mirror of
https://github.com/thomasnordquist/MQTT-Explorer.git
synced 2026-09-12 01:23:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee5fd58341 | ||
|
|
1047f5f8a8 | ||
|
|
24c503bc98 |
@@ -81,13 +81,3 @@ node dist/src/server.js 2>&1 | tee server.log
|
||||
- `app/src/browserEventBus.ts` - Socket.io client
|
||||
- `app/src/components/BrowserAuthWrapper.tsx` - Auth dialog
|
||||
- `app/src/index.tsx` - React entry, theme providers
|
||||
|
||||
## Styling Conventions
|
||||
|
||||
When modifying or creating UI components, follow the styling patterns documented in <a>STYLING.md</a>.
|
||||
|
||||
**Key points for AI agents:**
|
||||
- Use Material-UI (MUI) v7 components with `withStyles` HOC for styling
|
||||
- Access theme colors via `theme.palette.*`, spacing via `theme.spacing()`, typography via `theme.typography.*`
|
||||
- Support both light and dark modes with theme-conditional styling
|
||||
- Import Material-UI colors: `import { blueGrey, amber, green, red } from '@mui/material/colors'`
|
||||
|
||||
@@ -38,20 +38,17 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install and Start Mosquitto
|
||||
- name: Setup and start mosquitto
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y mosquitto mosquitto-clients
|
||||
|
||||
# Create a minimal configuration file for testing
|
||||
sudo tee /etc/mosquitto/conf.d/test.conf > /dev/null <<EOF
|
||||
listener 1883
|
||||
allow_anonymous true
|
||||
persistence false
|
||||
EOF
|
||||
|
||||
# Start mosquitto in detached mode
|
||||
sudo mosquitto -c /etc/mosquitto/mosquitto.conf -d
|
||||
sudo apt-get install -y mosquitto
|
||||
sudo mkdir -p /etc/mosquitto/conf.d
|
||||
echo "listener 1883" | sudo tee /etc/mosquitto/conf.d/default.conf
|
||||
echo "allow_anonymous true" | sudo tee -a /etc/mosquitto/conf.d/default.conf
|
||||
sudo systemctl start mosquitto
|
||||
sudo systemctl status mosquitto
|
||||
# Wait for mosquitto to be ready
|
||||
timeout 10 bash -c 'until mosquitto_sub -t "\$SYS/#" -C 1 > /dev/null 2>&1; do sleep 1; done'
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
+39
-117
@@ -27,10 +27,16 @@ jobs:
|
||||
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
|
||||
volumes:
|
||||
- ./:/app
|
||||
options: --user root
|
||||
env:
|
||||
TESTS_MQTT_BROKER_HOST: mosquitto
|
||||
TESTS_MQTT_BROKER_PORT: 1883
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Start mosquitto
|
||||
run: mosquitto -c /etc/mosquitto/conf.d/default.conf -d
|
||||
- name: Install Packages
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build Browser Mode
|
||||
@@ -53,10 +59,16 @@ jobs:
|
||||
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
|
||||
volumes:
|
||||
- ./:/app
|
||||
options: --user root
|
||||
env:
|
||||
TESTS_MQTT_BROKER_HOST: localhost
|
||||
TESTS_MQTT_BROKER_PORT: 1883
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Start mosquitto
|
||||
run: mosquitto -c /etc/mosquitto/conf.d/default.conf -d
|
||||
- name: Install Packages
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build
|
||||
@@ -157,118 +169,13 @@ jobs:
|
||||
body: markdown
|
||||
});
|
||||
|
||||
demo-video-mobile:
|
||||
test-browser:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
|
||||
volumes:
|
||||
- ./:/app
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Install Packages
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build Browser Mode
|
||||
run: yarn build:server
|
||||
- name: Generate Mobile Demo Video
|
||||
run: ./scripts/uiTestsMobile.sh
|
||||
- name: Post-processing
|
||||
run: ./scripts/prepareVideoMobile.sh
|
||||
- name: Generate unique base path
|
||||
id: basepath
|
||||
run: |
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
BASEPATH="pr-${{ github.event.pull_request.number }}-mobile-${TIMESTAMP}"
|
||||
echo "basepath=${BASEPATH}" >> $GITHUB_OUTPUT
|
||||
- name: Install AWS CLI v2
|
||||
run: |
|
||||
apt-get update && apt-get install -y unzip
|
||||
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
unzip -q awscliv2.zip
|
||||
./aws/install
|
||||
rm -rf aws awscliv2.zip
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
aws-access-key-id: ${{ vars.AWS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: 'eu-central-1'
|
||||
- name: Upload full mobile video to S3
|
||||
env:
|
||||
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
|
||||
BASEPATH: ${{ steps.basepath.outputs.basepath }}
|
||||
run: |
|
||||
# Upload GIF
|
||||
aws s3api put-object \
|
||||
--bucket ${AWS_BUCKET} \
|
||||
--key artifacts/${BASEPATH}/ui-test-mobile.gif \
|
||||
--body ./ui-test-mobile.gif \
|
||||
--content-type image/gif
|
||||
|
||||
# Upload MP4
|
||||
aws s3api put-object \
|
||||
--bucket ${AWS_BUCKET} \
|
||||
--key artifacts/${BASEPATH}/ui-test-mobile.mp4 \
|
||||
--body ./ui-test-mobile.mp4 \
|
||||
--content-type video/mp4
|
||||
- name: Upload mobile video segments to S3
|
||||
env:
|
||||
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
|
||||
BASEPATH: ${{ steps.basepath.outputs.basepath }}
|
||||
shell: bash
|
||||
run: |
|
||||
# Upload all mobile GIF segment files if they exist
|
||||
shopt -s nullglob # Make glob return empty list if no matches
|
||||
for segment in segment-mobile-*.gif; do
|
||||
echo "Uploading $segment..."
|
||||
aws s3api put-object \
|
||||
--bucket ${AWS_BUCKET} \
|
||||
--key artifacts/${BASEPATH}/${segment} \
|
||||
--body ./${segment} \
|
||||
--content-type image/gif
|
||||
done
|
||||
shopt -u nullglob # Restore default behavior
|
||||
- name: Generate file URLs
|
||||
id: fileurl
|
||||
env:
|
||||
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
|
||||
BASEPATH: ${{ steps.basepath.outputs.basepath }}
|
||||
run: |
|
||||
BASE_URL="https://${AWS_BUCKET}.s3.eu-central-1.amazonaws.com/artifacts/${BASEPATH}"
|
||||
echo "base-url=${BASE_URL}" >> $GITHUB_OUTPUT
|
||||
echo "Uploaded to: ${BASE_URL}"
|
||||
- name: Generate markdown summary
|
||||
id: markdown
|
||||
env:
|
||||
BASE_URL: ${{ steps.fileurl.outputs.base-url }}
|
||||
run: |
|
||||
MARKDOWN=$(node ./scripts/generateMarkdownSummaryMobile.js "${BASE_URL}")
|
||||
echo "markdown<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$MARKDOWN" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
- name: Add to workflow summary
|
||||
env:
|
||||
MARKDOWN: ${{ steps.markdown.outputs.markdown }}
|
||||
run: |
|
||||
echo "$MARKDOWN" >> $GITHUB_STEP_SUMMARY
|
||||
- name: Post mobile video to PR
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
MARKDOWN: ${{ steps.markdown.outputs.markdown }}
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const markdown = process.env.MARKDOWN;
|
||||
github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: markdown
|
||||
});
|
||||
|
||||
test-browser:
|
||||
runs-on: ubuntu-latest
|
||||
options: --user root
|
||||
env:
|
||||
TESTS_MQTT_BROKER_HOST: localhost
|
||||
TESTS_MQTT_BROKER_PORT: 1883
|
||||
@@ -276,14 +183,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Install System Dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y mosquitto mosquitto-clients
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
- name: Start mosquitto
|
||||
run: mosquitto -c /etc/mosquitto/conf.d/default.conf -d
|
||||
- name: Install Dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Install Playwright Browsers
|
||||
@@ -294,5 +195,26 @@ jobs:
|
||||
run: yarn test:app
|
||||
- name: Test Backend
|
||||
run: yarn test:backend
|
||||
- name: Run Browser UI Tests
|
||||
run: ./scripts/runBrowserTests.sh
|
||||
- name: Start Server in Background
|
||||
run: |
|
||||
yarn start:server &
|
||||
echo $! > server.pid
|
||||
env:
|
||||
MQTT_EXPLORER_USERNAME: test
|
||||
MQTT_EXPLORER_PASSWORD: test123
|
||||
PORT: 3000
|
||||
- name: Wait for Server
|
||||
run: |
|
||||
timeout 30 bash -c 'until curl -f http://localhost:3000; do sleep 1; done'
|
||||
- name: Browser Smoke Test
|
||||
run: |
|
||||
# Test server is running
|
||||
curl -f http://localhost:3000 || exit 1
|
||||
echo "Browser mode server is running successfully"
|
||||
- name: Stop Server
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f server.pid ]; then
|
||||
kill $(cat server.pid) || true
|
||||
rm server.pid
|
||||
fi
|
||||
|
||||
@@ -93,21 +93,20 @@ Tests the traditional Electron desktop application:
|
||||
Tests the new browser/server mode:
|
||||
|
||||
- **Environment**: Ubuntu latest with Node.js 24
|
||||
- **MQTT Broker**: Mosquitto v2 on port 1883
|
||||
- Started detached with `-d` flag
|
||||
- Anonymous connections allowed
|
||||
- No persistence
|
||||
- **Services**:
|
||||
- **Mosquitto MQTT Broker**: Eclipse Mosquitto v2 on port 1883
|
||||
- Health checks enabled
|
||||
- Anonymous connections allowed
|
||||
- **Steps**:
|
||||
1. Install and start Mosquitto in detached mode
|
||||
2. Setup Node.js 24
|
||||
3. Install dependencies
|
||||
4. Install Playwright browsers (`npx playwright install --with-deps chromium`)
|
||||
5. Build browser mode (`yarn build:server`)
|
||||
6. Run unit tests (app + backend)
|
||||
7. Start server in background with test credentials
|
||||
8. Wait for server to be ready
|
||||
9. Run browser smoke tests
|
||||
10. Clean up server process
|
||||
1. Setup Node.js 24
|
||||
2. Install dependencies
|
||||
3. Install Playwright browsers (`npx playwright install --with-deps chromium`)
|
||||
4. Build browser mode (`yarn build:server`)
|
||||
5. Run unit tests (app + backend)
|
||||
6. Start server in background with test credentials
|
||||
7. Wait for server to be ready
|
||||
8. Run browser smoke tests
|
||||
9. Clean up server process
|
||||
|
||||
**Environment Variables**:
|
||||
- `MQTT_EXPLORER_USERNAME=test`
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Multi-stage build for MQTT Explorer Browser Mode
|
||||
# Stage 1: Build and prepare production dependencies
|
||||
FROM node:22-alpine AS builder
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
# Mobile Compatibility Concept
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the mobile compatibility strategy for MQTT Explorer, focusing on providing a good mobile experience without requiring a complete UI rewrite.
|
||||
|
||||
## Target Device
|
||||
|
||||
**Reference Device:** Google Pixel 6
|
||||
- Viewport: 412x915 pixels (portrait)
|
||||
- Typical modern smartphone dimensions
|
||||
- Good representation of common mobile browsers
|
||||
|
||||
## Strategy
|
||||
|
||||
### 1. Browser Mode First
|
||||
Mobile compatibility focuses on the browser mode (`yarn dev:server`) rather than native mobile apps, as:
|
||||
- Browser mode already supports any device with a modern web browser
|
||||
- No app store deployment complexities
|
||||
- Users can access via mobile browser or save as PWA
|
||||
|
||||
### 2. Responsive Design Enhancements
|
||||
|
||||
Without rewriting the UI, we implement strategic responsive improvements:
|
||||
|
||||
#### Viewport Configuration
|
||||
- Ensure proper viewport meta tag for mobile scaling
|
||||
- Already present: `<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />`
|
||||
|
||||
#### Layout Adaptations
|
||||
- **Tree Panel**: Make touch-friendly (larger tap targets, better scrolling)
|
||||
- **Sidebar**: Collapsible by default on mobile, swipe-friendly
|
||||
- **Chart Panel**: Stack vertically instead of side-by-side
|
||||
- **Split Panes**: Adjust minimum sizes and default positions for mobile
|
||||
|
||||
#### Touch Interactions
|
||||
- Increase tap target sizes for mobile (minimum 44x44px)
|
||||
- Improve scrolling performance
|
||||
- Add touch-friendly gestures where applicable
|
||||
|
||||
### 3. Minimal CSS Changes
|
||||
|
||||
Use CSS media queries to adapt the UI for mobile viewports:
|
||||
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
/* Mobile-specific overrides */
|
||||
}
|
||||
```
|
||||
|
||||
Key areas for CSS adjustments:
|
||||
- Typography sizing (ensure readability on small screens)
|
||||
- Padding and margins (optimize for touch)
|
||||
- Button and icon sizes (larger for touch targets)
|
||||
- Navigation (hamburger menu, collapsible sections)
|
||||
|
||||
### 4. Feature Prioritization
|
||||
|
||||
On mobile devices, prioritize:
|
||||
1. **Core Functionality**: View topics, read messages, basic navigation
|
||||
2. **Search**: Easy topic filtering and search
|
||||
3. **Connection Management**: Connect/disconnect, basic settings
|
||||
4. **Publishing**: Simple message publishing
|
||||
|
||||
Less critical on mobile (can be de-emphasized):
|
||||
- Advanced connection settings (can use smaller text/collapse)
|
||||
- Extensive keyboard shortcuts
|
||||
- Multi-panel simultaneous viewing
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Phase 1: Foundation (Current)
|
||||
- Document mobile compatibility concept ✓
|
||||
- Create mobile demo video showing current experience
|
||||
- Identify pain points and opportunities
|
||||
|
||||
### Phase 2: Quick Wins (Minimal Changes)
|
||||
- Adjust default split pane positions for mobile
|
||||
- Increase touch target sizes in critical areas
|
||||
- Improve sidebar collapse behavior on small screens
|
||||
- Optimize tree node spacing for touch
|
||||
|
||||
### Phase 3: Enhanced Experience (Future)
|
||||
- Add PWA manifest for "add to home screen"
|
||||
- Implement swipe gestures
|
||||
- Optimize connection dialog for mobile
|
||||
- Add mobile-specific keyboard (numeric for ports, etc.)
|
||||
|
||||
## Demo Video
|
||||
|
||||
### Purpose
|
||||
Create a demonstration video showing MQTT Explorer running on a mobile viewport (Pixel 6 dimensions) to:
|
||||
- Showcase current mobile experience
|
||||
- Identify UX issues
|
||||
- Demonstrate the feasibility of mobile usage
|
||||
- Guide future improvements
|
||||
|
||||
### Technical Implementation
|
||||
- Use Playwright with Chromium in mobile emulation mode
|
||||
- Viewport size: 412x915 (Pixel 6 portrait)
|
||||
- Record typical mobile use cases:
|
||||
- Connecting to broker
|
||||
- Browsing topic tree (with touch gestures)
|
||||
- Viewing message details
|
||||
- Searching topics
|
||||
- Publishing messages
|
||||
|
||||
### Script Location
|
||||
`src/spec/demoVideoMobile.ts` - Mobile-specific demo video script
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Manual Testing
|
||||
- Test on real mobile devices (iOS Safari, Android Chrome)
|
||||
- Use Chrome DevTools device emulation during development
|
||||
- Verify touch interactions work smoothly
|
||||
|
||||
### Automated Testing
|
||||
- Create mobile-specific UI tests
|
||||
- Run demo video generation with mobile viewport
|
||||
- Validate responsive breakpoints
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Progressive Web App (PWA)
|
||||
Add PWA capabilities:
|
||||
- Service worker for offline support
|
||||
- App manifest for installability
|
||||
- App icon and splash screen
|
||||
|
||||
### Platform-Specific Optimizations
|
||||
- iOS: Handle safe areas, notch
|
||||
- Android: Material Design guidelines
|
||||
- Dark mode (already supported via theme)
|
||||
|
||||
### Performance
|
||||
- Optimize bundle size for mobile networks
|
||||
- Implement lazy loading for large topic trees
|
||||
- Add connection retry logic for unreliable mobile networks
|
||||
|
||||
## Metrics for Success
|
||||
|
||||
A successful mobile experience should provide:
|
||||
- ✅ All core features accessible on mobile
|
||||
- ✅ No horizontal scrolling required
|
||||
- ✅ Touch targets minimum 44x44px
|
||||
- ✅ Readable text without zooming
|
||||
- ✅ Smooth scrolling and interactions
|
||||
- ✅ Quick load times (<3s on 3G)
|
||||
|
||||
## Resources
|
||||
|
||||
- [Google Mobile-Friendly Test](https://search.google.com/test/mobile-friendly)
|
||||
- [Material Design Touch Target Guidelines](https://material.io/design/usability/accessibility.html#layout-typography)
|
||||
- [MDN Responsive Design](https://developer.mozilla.org/en-US/docs/Learn/CSS/CSS_layout/Responsive_Design)
|
||||
- [Playwright Device Emulation](https://playwright.dev/docs/emulation)
|
||||
@@ -18,17 +18,6 @@ Downloads can be found at the link above.
|
||||
This page is dedicated to its development.
|
||||
Pull-Requests and error reports are welcome.
|
||||
|
||||
## Platform Support
|
||||
|
||||
MQTT Explorer supports multiple platforms and architectures:
|
||||
|
||||
- **Windows**: x64
|
||||
- **macOS**: x64 (Intel) and ARM64 (Apple Silicon)
|
||||
- **Linux**: x64, ARM64 (Raspberry Pi 5), and ARMv7l (Raspberry Pi 4 and older)
|
||||
- Available formats: AppImage, Deb, Snap
|
||||
|
||||
ARM64 builds are perfect for running on Raspberry Pi 5 and other ARM64-based single-board computers.
|
||||
|
||||
## Quick Start with GitHub Codespaces
|
||||
|
||||
The fastest way to start developing is with GitHub Codespaces:
|
||||
@@ -110,8 +99,6 @@ yarn dev:server
|
||||
|
||||
The `app` directory contains all the rendering logic, the `backend` directory currently contains the models, tests, connection management, `src` contains all the electron bindings. [mqttjs](https://github.com/mqttjs/MQTT.js) is used to facilitate communication to MQTT brokers.
|
||||
|
||||
For information on styling conventions and visual design patterns, see [STYLING.md](STYLING.md).
|
||||
|
||||
## Automated Tests
|
||||
|
||||
MQTT Explorer uses multiple test suites to ensure reliability and quality:
|
||||
@@ -198,34 +185,6 @@ yarn build
|
||||
|
||||
This script handles Xvfb setup, mosquitto startup, video recording, and cleanup.
|
||||
|
||||
### Mobile Demo Video
|
||||
|
||||
A mobile-focused demo video showcases MQTT Explorer in a mobile viewport (Pixel 6: 412x915px):
|
||||
|
||||
```bash
|
||||
yarn build
|
||||
yarn test:demo-video:mobile
|
||||
```
|
||||
|
||||
Or with full recording setup:
|
||||
```bash
|
||||
yarn build
|
||||
./scripts/uiTestsMobile.sh
|
||||
```
|
||||
|
||||
This demonstrates the mobile compatibility features and responsive design improvements. See [MOBILE_COMPATIBILITY.md](MOBILE_COMPATIBILITY.md) for the mobile strategy and implementation details.
|
||||
|
||||
## Mobile Compatibility
|
||||
|
||||
MQTT Explorer supports mobile devices through its browser mode with responsive design enhancements:
|
||||
|
||||
- **Target Device**: Google Pixel 6 (412x915px viewport)
|
||||
- **Touch-Friendly UI**: Minimum 44px tap targets for better mobile UX
|
||||
- **Responsive Layout**: Sidebar and panels adapt to mobile viewports
|
||||
- **Browser Mode**: Access via mobile browser or install as PWA
|
||||
|
||||
For the complete mobile compatibility concept, implementation phases, and future roadmap, see [MOBILE_COMPATIBILITY.md](MOBILE_COMPATIBILITY.md).
|
||||
|
||||
## Create a release
|
||||
|
||||
Create a PR to `release` branch.
|
||||
|
||||
-212
@@ -1,212 +0,0 @@
|
||||
# MQTT Explorer Styling Conventions
|
||||
|
||||
This document outlines the styling conventions used in MQTT Explorer for visual consistency and maintainable code.
|
||||
|
||||
## UI Framework
|
||||
|
||||
Material-UI (MUI) v7 with JSS styling via `withStyles` HOC.
|
||||
|
||||
**Stack:**
|
||||
- `@mui/material` (v7) - Core components and theming
|
||||
- `@mui/icons-material` (v7) - Icons
|
||||
- `@mui/styles` (v6) - JSS styling with `withStyles`
|
||||
- `@emotion/react` & `@emotion/styled` - CSS-in-JS foundation
|
||||
|
||||
## Theming
|
||||
|
||||
**Location:** `app/src/theme.ts`
|
||||
|
||||
**Configuration:**
|
||||
- Light and dark modes supported
|
||||
- Primary color: `#335C67` (teal/blue-green)
|
||||
- Secondary: Material-UI `amber` palette
|
||||
- Base typography: `0.9rem`, `userSelect: 'none'`
|
||||
|
||||
**Application:**
|
||||
```typescript
|
||||
<ThemeProvider theme={theme}>
|
||||
<LegacyThemeProvider theme={theme}>
|
||||
<App />
|
||||
</LegacyThemeProvider>
|
||||
</ThemeProvider>
|
||||
```
|
||||
|
||||
## Colors
|
||||
|
||||
**Access theme colors:**
|
||||
```typescript
|
||||
backgroundColor: theme.palette.background.default
|
||||
color: theme.palette.text.primary
|
||||
borderColor: theme.palette.divider
|
||||
```
|
||||
|
||||
**Material-UI palettes:**
|
||||
```typescript
|
||||
import { blueGrey, amber, green, red, orange } from '@mui/material/colors'
|
||||
backgroundColor: blueGrey[100] // Light shade
|
||||
backgroundColor: blueGrey[700] // Dark shade
|
||||
```
|
||||
|
||||
**Theme-conditional:**
|
||||
```typescript
|
||||
const color = theme.palette.mode === 'light' ? blueGrey[300] : theme.palette.primary.main
|
||||
```
|
||||
|
||||
**Code editor colors:** Defined in `app/src/components/Sidebar/CodeBlockColors.ts`
|
||||
|
||||
## Typography
|
||||
|
||||
**Variants:**
|
||||
```typescript
|
||||
<Typography variant="h6">Heading</Typography>
|
||||
<Typography variant="body1">Body text</Typography>
|
||||
<Typography variant="caption">Caption</Typography>
|
||||
```
|
||||
|
||||
**Font sizes:**
|
||||
```typescript
|
||||
fontSize: theme.typography.pxToRem(15)
|
||||
```
|
||||
|
||||
**Monospace:** `"12px/normal 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace"`
|
||||
|
||||
## Spacing
|
||||
|
||||
**8px grid system:**
|
||||
```typescript
|
||||
margin: theme.spacing(1) // 8px
|
||||
padding: theme.spacing(2) // 16px
|
||||
marginLeft: theme.spacing(1.5) // 12px (tree indentation)
|
||||
```
|
||||
|
||||
**Border radius:**
|
||||
```typescript
|
||||
borderRadius: theme.shape.borderRadius // 4px default
|
||||
```
|
||||
|
||||
## Component Styling
|
||||
|
||||
**Primary approach - withStyles HOC:**
|
||||
```typescript
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
root: {
|
||||
backgroundColor: theme.palette.background.default,
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
})
|
||||
|
||||
export default withStyles(styles)(MyComponent)
|
||||
```
|
||||
|
||||
**Type assertions:**
|
||||
```typescript
|
||||
display: 'block' as 'block'
|
||||
whiteSpace: 'nowrap' as 'nowrap'
|
||||
overflow: 'hidden' as 'hidden'
|
||||
```
|
||||
|
||||
**Responsive:**
|
||||
```typescript
|
||||
[theme.breakpoints.up(750)]: {
|
||||
display: 'block',
|
||||
}
|
||||
```
|
||||
|
||||
**sx prop (simple cases):**
|
||||
```typescript
|
||||
<Button sx={{ color: 'primary.contrastText' }}>Text</Button>
|
||||
```
|
||||
|
||||
## Animations
|
||||
|
||||
**CSS animations:**
|
||||
```typescript
|
||||
animation: 'updateLight 0.5s'
|
||||
```
|
||||
|
||||
**Theme transitions:**
|
||||
```typescript
|
||||
transition: theme.transitions.create('transform', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
})
|
||||
```
|
||||
|
||||
## Interactive States
|
||||
|
||||
**Hover:**
|
||||
```typescript
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.mode === 'light'
|
||||
? blueGrey[100]
|
||||
: theme.palette.primary.light,
|
||||
}
|
||||
```
|
||||
|
||||
**Selection:**
|
||||
```typescript
|
||||
selected: {
|
||||
backgroundColor: (theme.palette.mode === 'light'
|
||||
? blueGrey[300]
|
||||
: theme.palette.primary.main) + ' !important',
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Tree nodes:**
|
||||
```typescript
|
||||
node: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
subnodes: {
|
||||
marginLeft: theme.spacing(1.5),
|
||||
}
|
||||
```
|
||||
|
||||
**Buttons:**
|
||||
```typescript
|
||||
<Button variant="contained" color="primary">Submit</Button>
|
||||
<Button variant="outlined" color="primary">Cancel</Button>
|
||||
<Button>Learn More</Button>
|
||||
```
|
||||
|
||||
**Icons:**
|
||||
```typescript
|
||||
<Icon fontSize="inherit" />
|
||||
<Icon style={{ fontSize: '16px' }} />
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
**DO:**
|
||||
✅ Use theme variables (`theme.palette.*`, `theme.spacing()`, `theme.typography.*`)
|
||||
✅ Use `withStyles` HOC for component styles
|
||||
✅ Use `theme.palette.mode` for light/dark conditional styling
|
||||
✅ Import Material-UI color palettes for extended colors
|
||||
✅ Keep styles co-located with components
|
||||
|
||||
**DON'T:**
|
||||
❌ Hardcode colors or spacing values
|
||||
❌ Create global CSS files
|
||||
❌ Duplicate style definitions
|
||||
❌ Use inline styles for complex patterns
|
||||
|
||||
## Resources
|
||||
|
||||
- [Material-UI Documentation](https://mui.com/material-ui/getting-started/)
|
||||
- [Color System](https://mui.com/material-ui/customization/color/)
|
||||
- [Theming Guide](https://mui.com/material-ui/customization/theming/)
|
||||
|
||||
**Testing:** Verify in both light/dark themes, check responsive behavior, ensure accessibility.
|
||||
|
||||
## Related
|
||||
|
||||
- [README.md](./Readme.md) - Project overview
|
||||
- [BROWSER_MODE.md](./BROWSER_MODE.md) - Browser mode
|
||||
- [.github/copilot-instructions.md](./.github/copilot-instructions.md) - Copilot instructions
|
||||
@@ -18,49 +18,6 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Mobile-specific responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
/* Increase touch target sizes for better mobile UX */
|
||||
button {
|
||||
min-height: 44px !important;
|
||||
min-width: 44px !important;
|
||||
}
|
||||
|
||||
/* Make icons larger on mobile */
|
||||
svg {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
|
||||
/* Improve tree node tap targets */
|
||||
[data-testid="tree-node"] {
|
||||
min-height: 44px !important;
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
/* Better mobile typography */
|
||||
body {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* Prevent text selection on mobile taps - applied to interactive elements */
|
||||
button, a, [role="button"], [data-testid] {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* Improve scrolling performance for scrollable containers */
|
||||
[style*="overflow"], .MuiDrawer-root, [data-testid="tree-container"] {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Make resizers more visible on mobile */
|
||||
.Resizer.vertical::before,
|
||||
.Resizer.horizontal::before {
|
||||
font-size: 1.5rem !important;
|
||||
opacity: 0.8 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes updateDark {
|
||||
0% {
|
||||
background-color: none;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { globalActions } from '.'
|
||||
import { resetStore as resetTreeStore, showTree } from './Tree'
|
||||
import { showError } from './Global'
|
||||
import { TopicViewModel } from '../model/TopicViewModel'
|
||||
import { addMqttConnectionEvent, makeConnectionStateEvent, removeConnection, rendererEvents } from '../eventBus'
|
||||
import { addMqttConnectionEvent, makeConnectionStateEvent, removeConnection, rendererEvents } from '../../../events'
|
||||
|
||||
export const connect =
|
||||
(options: MqttOptions, connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as path from 'path'
|
||||
import { ActionTypes, Action } from '../reducers/ConnectionManager'
|
||||
import { Subscription } from '../../../backend/src/DataSource/MqttSource'
|
||||
import { connectionsMigrator } from './migrations/Connection'
|
||||
import { rendererRpc, readFromFile } from '../eventBus'
|
||||
import { rendererRpc, readFromFile } from '../../../events'
|
||||
import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest'
|
||||
|
||||
export interface ConnectionDictionary {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Action, ActionTypes } from '../reducers/Publish'
|
||||
import { AppState } from '../reducers'
|
||||
import { Base64Message } from '../../../backend/src/Model/Base64Message'
|
||||
import { Dispatch } from 'redux'
|
||||
import { MqttMessage, makePublishEvent, rendererEvents, rendererRpc, readFromFile } from '../eventBus'
|
||||
import { MqttMessage, makePublishEvent, rendererEvents, rendererRpc, readFromFile } from '../../../events'
|
||||
import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest'
|
||||
import { showError } from './Global'
|
||||
import { Base64 } from 'js-base64'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as q from '../../../backend/src/Model'
|
||||
import { AppState } from '../reducers'
|
||||
import { Dispatch } from 'redux'
|
||||
import { makePublishEvent, rendererEvents } from '../eventBus'
|
||||
import { makePublishEvent, rendererEvents } from '../../../events'
|
||||
import { moveSelectionUpOrDownwards } from './visibleTreeTraversal'
|
||||
import { globalActions } from '.'
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CertificateTypes } from '../../actions/ConnectionManager'
|
||||
import { connect } from 'react-redux'
|
||||
import { connectionManagerActions } from '../../actions'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { rendererRpc } from '../../eventBus'
|
||||
import { rendererRpc } from '../../../../events'
|
||||
import { RpcEvents } from '../../../../events/EventsV2'
|
||||
|
||||
function BrowserCertificateFileSelection(props: {
|
||||
|
||||
@@ -188,7 +188,6 @@ function ConnectionSettings(props: Props) {
|
||||
value={connection.host}
|
||||
onChange={handleChange('host')}
|
||||
margin="normal"
|
||||
inputProps={{ 'data-testid': 'host-input' }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item={true} xs={3}>
|
||||
|
||||
@@ -20,11 +20,8 @@ interface Props {
|
||||
}
|
||||
|
||||
function ContentView(props: Props) {
|
||||
// Use different defaults for mobile viewports (<=768px width)
|
||||
// Use useState with lazy initialization to get initial mobile state
|
||||
const [isMobile] = React.useState(() => typeof window !== 'undefined' && window.innerWidth <= 768)
|
||||
const [height, setHeight] = React.useState<string | number>('100%')
|
||||
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>(isMobile ? '100%' : '40%')
|
||||
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>('40%')
|
||||
const [detectedHeight, setDetectedHeight] = React.useState(0)
|
||||
const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0)
|
||||
|
||||
@@ -112,12 +109,7 @@ function ContentView(props: Props) {
|
||||
<div ref={widthRef} style={{ height: '100%' }}>
|
||||
<div
|
||||
className={props.paneDefaults}
|
||||
style={{
|
||||
minWidth: isMobile ? '100%' : '250px',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
style={{ minWidth: '250px', height: '100%', overflowY: 'auto', overflowX: 'hidden' }}
|
||||
>
|
||||
<Sidebar connectionId={props.connectionId} />
|
||||
</div>
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* LoginDialog Security Tests
|
||||
*
|
||||
* Security-focused tests for the Login Page:
|
||||
* - Error message visibility to users
|
||||
* - Rate limiting enforcement (anti-brute force)
|
||||
* - Credential requirement validation
|
||||
* - Information disclosure prevention
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { expect } from 'chai'
|
||||
import { describe, it } from 'mocha'
|
||||
import { LoginDialog } from './LoginDialog'
|
||||
import { renderWithProviders, waitFor } from '../utils/spec/testUtils'
|
||||
|
||||
// Helper to get elements
|
||||
const getByText = (text: string) => {
|
||||
const elements = Array.from(document.querySelectorAll('*'))
|
||||
return elements.find(el => el.textContent?.includes(text))
|
||||
}
|
||||
const getByTestId = (testId: string) => document.querySelector(`[data-testid="${testId}"]`)
|
||||
|
||||
describe('LoginDialog Security Tests', () => {
|
||||
describe('Error Message Visibility (Security)', () => {
|
||||
it('should display "Invalid credentials" error message to user', () => {
|
||||
const mockLogin = () => {}
|
||||
const errorMessage = 'Invalid credentials'
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify error is visible to user
|
||||
const errorElement = getByText(errorMessage)
|
||||
expect(errorElement).to.exist
|
||||
})
|
||||
|
||||
it('should display rate limiting error message to user', () => {
|
||||
const mockLogin = () => {}
|
||||
const errorMessage = 'Too many failed authentication attempts. Please wait 30 seconds before trying again.'
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify rate limiting error is visible to user
|
||||
expect(getByText('Too many failed authentication attempts')).to.exist
|
||||
})
|
||||
|
||||
it('should display "Authentication required" error message to user', () => {
|
||||
const mockLogin = () => {}
|
||||
const errorMessage = 'Please enter your username and password.'
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify auth required message is visible to user
|
||||
expect(getByText('Please enter your username and password.')).to.exist
|
||||
})
|
||||
|
||||
it('should display generic authentication failure message to user', () => {
|
||||
const mockLogin = () => {}
|
||||
const errorMessage = 'Authentication failed. Please try again.'
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify generic error is visible to user
|
||||
expect(getByText('Authentication failed. Please try again.')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rate Limiting Enforcement (Anti-Brute Force)', () => {
|
||||
it('should disable login button during rate limit countdown', () => {
|
||||
const mockLogin = () => {}
|
||||
const waitTime = 30
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify button is disabled to prevent further attempts
|
||||
const buttons = Array.from(document.querySelectorAll('button'))
|
||||
const loginButton = buttons.find(b => b.textContent?.match(/Wait \d+s/))
|
||||
expect(loginButton).to.exist
|
||||
expect(loginButton?.hasAttribute('disabled')).to.be.true
|
||||
})
|
||||
|
||||
it('should disable input fields during rate limit countdown', () => {
|
||||
const mockLogin = () => {}
|
||||
const waitTime = 30
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify inputs are disabled to prevent modification during lockout
|
||||
const usernameInput = getByTestId('username-input')?.querySelector('input')
|
||||
const passwordInput = getByTestId('password-input')?.querySelector('input')
|
||||
|
||||
expect(usernameInput?.hasAttribute('disabled')).to.be.true
|
||||
expect(passwordInput?.hasAttribute('disabled')).to.be.true
|
||||
})
|
||||
|
||||
it('should display countdown timer to user during rate limiting', () => {
|
||||
const mockLogin = () => {}
|
||||
const waitTime = 30
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} waitTimeSeconds={waitTime} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify countdown is visible to inform user of lockout duration
|
||||
const countdownElement = getByText('Please wait')
|
||||
expect(countdownElement).to.exist
|
||||
expect(countdownElement?.textContent).to.match(/Please wait \d+ seconds before trying again/i)
|
||||
})
|
||||
|
||||
it('should display both rate limit error and countdown to user', () => {
|
||||
const mockLogin = () => {}
|
||||
const errorMessage = 'Too many failed authentication attempts. Please wait 30 seconds before trying again.'
|
||||
const waitTime = 30
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} waitTimeSeconds={waitTime} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify both error and countdown are visible
|
||||
expect(getByText(errorMessage)).to.exist
|
||||
expect(getByText('Please wait 30 seconds before trying again')).to.exist
|
||||
})
|
||||
})
|
||||
|
||||
describe('Credential Requirement Validation (Prevent Unauthorized Access)', () => {
|
||||
it('should require both username and password fields to be present', () => {
|
||||
const mockLogin = () => {}
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify both credential fields exist and are required
|
||||
const usernameInput = getByTestId('username-input')
|
||||
const passwordInput = getByTestId('password-input')
|
||||
|
||||
expect(usernameInput).to.exist
|
||||
expect(passwordInput).to.exist
|
||||
})
|
||||
|
||||
it('should require password field to be masked', () => {
|
||||
const mockLogin = () => {}
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify password is masked (type="password") for security
|
||||
const passwordInput = getByTestId('password-input')?.querySelector('input')
|
||||
expect(passwordInput?.getAttribute('type')).to.equal('password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Information Disclosure Prevention', () => {
|
||||
it('should use generic "Invalid credentials" error (no username enumeration)', () => {
|
||||
const mockLogin = () => {}
|
||||
// Error doesn't distinguish between invalid username vs invalid password
|
||||
const errorMessage = 'Invalid credentials'
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify error doesn't leak whether username or password was wrong
|
||||
const errorElement = getByText(errorMessage)
|
||||
expect(errorElement).to.exist
|
||||
expect(errorElement?.textContent).to.not.include('username')
|
||||
expect(errorElement?.textContent).to.not.include('password')
|
||||
})
|
||||
|
||||
it('should not display sensitive information in error messages', () => {
|
||||
const mockLogin = () => {}
|
||||
const errorMessage = 'Invalid credentials'
|
||||
|
||||
renderWithProviders(
|
||||
<LoginDialog open={true} onLogin={mockLogin} error={errorMessage} />,
|
||||
{ withTheme: true }
|
||||
)
|
||||
|
||||
// Verify error doesn't contain sensitive data
|
||||
const errorElement = getByText(errorMessage)
|
||||
expect(errorElement?.textContent).to.not.include('database')
|
||||
expect(errorElement?.textContent).to.not.include('server')
|
||||
expect(errorElement?.textContent).to.not.include('SQL')
|
||||
expect(errorElement?.textContent).to.not.include('error code')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import { withStyles } from '@mui/styles'
|
||||
import { updateNotifierActions } from '../actions'
|
||||
|
||||
import { Button, IconButton, Modal, Paper, Snackbar, SnackbarContent, Typography } from '@mui/material'
|
||||
import { rendererRpc, getAppVersion } from '../eventBus'
|
||||
import { rendererRpc, getAppVersion } from '../../../events'
|
||||
|
||||
interface Props {
|
||||
showUpdateNotification: boolean
|
||||
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
*/
|
||||
function ClearAdornment(props: Props) {
|
||||
const theme = useTheme()
|
||||
|
||||
|
||||
if (!props.value) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import FileCopy from '@mui/icons-material/FileCopy'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { globalActions } from '../../actions'
|
||||
import copyTextFallback from 'copy-text-to-clipboard'
|
||||
|
||||
// Fallback for older browsers or when clipboard API is not available
|
||||
const copyTextFallback = require('copy-text-to-clipboard')
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
@@ -17,7 +19,7 @@ async function copyToClipboard(text: string): Promise<boolean> {
|
||||
} catch (error) {
|
||||
console.warn('Clipboard API failed, using fallback:', error)
|
||||
}
|
||||
|
||||
|
||||
// Fallback to copy-text-to-clipboard library
|
||||
return copyTextFallback(text)
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ class CustomIconButton extends React.PureComponent<Props, {}> {
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<IconButton
|
||||
className={this.props.classes.button}
|
||||
style={this.props.style}
|
||||
<IconButton
|
||||
className={this.props.classes.button}
|
||||
style={this.props.style}
|
||||
onClick={this.onClick}
|
||||
data-testid={this.props['data-testid']}
|
||||
>
|
||||
|
||||
@@ -5,58 +5,16 @@ import CustomIconButton from './CustomIconButton'
|
||||
|
||||
import { SaveAlt } from '@mui/icons-material'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { rendererRpc, writeToFile } from '../../eventBus'
|
||||
import { rendererRpc, writeToFile } from '../../../../events'
|
||||
import { makeSaveDialogRpc } from '../../../../events/OpenDialogRequest'
|
||||
import { isBrowserMode } from '../../utils/browserMode'
|
||||
|
||||
import { globalActions } from '../../actions'
|
||||
|
||||
/**
|
||||
* Download a file in browser mode using blob URL
|
||||
* @param data Base64-encoded file data
|
||||
* @param filename Filename for the download
|
||||
* @returns The filename that was downloaded
|
||||
*/
|
||||
function downloadFileInBrowser(data: string, filename: string): string {
|
||||
// Decode base64 data
|
||||
const binaryString = atob(data)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([bytes], { type: 'application/octet-stream' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
return filename
|
||||
}
|
||||
|
||||
export async function saveToFile(data: string): Promise<string | undefined> {
|
||||
const rejectReasons = {
|
||||
errorWritingFile: 'Error writing file',
|
||||
}
|
||||
|
||||
// In browser mode, use browser download
|
||||
if (isBrowserMode) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `mqtt-message-${timestamp}.bin`
|
||||
try {
|
||||
downloadFileInBrowser(data, filename)
|
||||
return filename
|
||||
} catch (error) {
|
||||
throw rejectReasons.errorWritingFile
|
||||
}
|
||||
}
|
||||
|
||||
// In Electron mode, use native file dialog
|
||||
const { canceled, filePath } = await rendererRpc.call(makeSaveDialogRpc(), {
|
||||
securityScopedBookmarks: true,
|
||||
})
|
||||
@@ -109,7 +67,7 @@ class Save extends React.PureComponent<Props, State> {
|
||||
)
|
||||
|
||||
return (
|
||||
<CustomIconButton onClick={this.handleClick} tooltip="Save to file" data-testid="save-button">
|
||||
<CustomIconButton onClick={this.handleClick} tooltip="Save to file">
|
||||
<div style={{ marginTop: '2px' }}>{icon}</div>
|
||||
</CustomIconButton>
|
||||
)
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Event bus abstraction layer
|
||||
* Provides the correct rendererRpc and rendererEvents implementation based on runtime environment
|
||||
* - In browser mode: uses Socket.IO-based event bus
|
||||
* - In Electron mode: uses IPC-based event bus
|
||||
*
|
||||
* This module uses dynamic imports to avoid bundling unused dependencies.
|
||||
*/
|
||||
|
||||
import { isBrowserMode } from './utils/browserMode'
|
||||
import type { Rpc } from '../../events/EventSystem/Rpc'
|
||||
import type { EventBusInterface } from '../../events/EventSystem/EventBusInterface'
|
||||
|
||||
let rendererRpcInstance: Rpc<any> | null = null
|
||||
let rendererEventsInstance: EventBusInterface | null = null
|
||||
let backendRpcInstance: Rpc<any> | null = null
|
||||
let backendEventsInstance: EventBusInterface | null = null
|
||||
|
||||
/**
|
||||
* Get the renderer RPC instance
|
||||
* Lazy-loads the appropriate implementation based on environment
|
||||
*/
|
||||
export function getRendererRpc(): Rpc<any> {
|
||||
if (rendererRpcInstance) {
|
||||
return rendererRpcInstance
|
||||
}
|
||||
|
||||
if (isBrowserMode) {
|
||||
// Dynamic import for browser mode
|
||||
const browserEventBus = require('./browserEventBus')
|
||||
rendererRpcInstance = browserEventBus.rendererRpc
|
||||
} else {
|
||||
// Dynamic import for Electron mode
|
||||
const electronEventBus = require('../../events/EventSystem/EventBus')
|
||||
rendererRpcInstance = electronEventBus.rendererRpc
|
||||
}
|
||||
|
||||
return rendererRpcInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the renderer events instance
|
||||
* Lazy-loads the appropriate implementation based on environment
|
||||
*/
|
||||
export function getRendererEvents(): EventBusInterface {
|
||||
if (rendererEventsInstance) {
|
||||
return rendererEventsInstance
|
||||
}
|
||||
|
||||
if (isBrowserMode) {
|
||||
// Dynamic import for browser mode
|
||||
const browserEventBus = require('./browserEventBus')
|
||||
rendererEventsInstance = browserEventBus.rendererEvents
|
||||
} else {
|
||||
// Dynamic import for Electron mode
|
||||
const electronEventBus = require('../../events/EventSystem/EventBus')
|
||||
rendererEventsInstance = electronEventBus.rendererEvents
|
||||
}
|
||||
|
||||
return rendererEventsInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backend RPC instance (for compatibility)
|
||||
*/
|
||||
export function getBackendRpc(): Rpc<any> {
|
||||
if (backendRpcInstance) {
|
||||
return backendRpcInstance
|
||||
}
|
||||
|
||||
if (isBrowserMode) {
|
||||
// In browser mode, backend is accessed via socket.io
|
||||
const browserEventBus = require('./browserEventBus')
|
||||
backendRpcInstance = browserEventBus.backendRpc
|
||||
} else {
|
||||
// In Electron mode, backend RPC uses IPC
|
||||
const electronEventBus = require('../../events/EventSystem/EventBus')
|
||||
backendRpcInstance = electronEventBus.backendRpc
|
||||
}
|
||||
|
||||
return backendRpcInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the backend events instance (for compatibility)
|
||||
*/
|
||||
export function getBackendEvents(): EventBusInterface {
|
||||
if (backendEventsInstance) {
|
||||
return backendEventsInstance
|
||||
}
|
||||
|
||||
if (isBrowserMode) {
|
||||
// In browser mode, backend is accessed via socket.io
|
||||
const browserEventBus = require('./browserEventBus')
|
||||
backendEventsInstance = browserEventBus.backendEvents
|
||||
} else {
|
||||
// In Electron mode, backend events use IPC
|
||||
const electronEventBus = require('../../events/EventSystem/EventBus')
|
||||
backendEventsInstance = electronEventBus.backendEvents
|
||||
}
|
||||
|
||||
return backendEventsInstance
|
||||
}
|
||||
|
||||
// Export as named constants for convenience (lazy-loaded on first access)
|
||||
export const rendererRpc = new Proxy({} as Rpc<any>, {
|
||||
get(target, prop) {
|
||||
return getRendererRpc()[prop as keyof Rpc<any>]
|
||||
}
|
||||
})
|
||||
|
||||
export const rendererEvents = new Proxy({} as EventBusInterface, {
|
||||
get(target, prop) {
|
||||
return getRendererEvents()[prop as keyof EventBusInterface]
|
||||
}
|
||||
})
|
||||
|
||||
export const backendRpc = new Proxy({} as Rpc<any>, {
|
||||
get(target, prop) {
|
||||
return getBackendRpc()[prop as keyof Rpc<any>]
|
||||
}
|
||||
})
|
||||
|
||||
export const backendEvents = new Proxy({} as EventBusInterface, {
|
||||
get(target, prop) {
|
||||
return getBackendEvents()[prop as keyof EventBusInterface]
|
||||
}
|
||||
})
|
||||
|
||||
// Re-export all event definitions that are shared
|
||||
export * from '../../events/Events'
|
||||
export * from '../../events/EventsV2'
|
||||
export * from '../../events/EventSystem/EventDispatcher'
|
||||
export * from '../../events/EventSystem/EventBusInterface'
|
||||
@@ -1,4 +1,4 @@
|
||||
import { rendererRpc } from '../eventBus'
|
||||
import { rendererRpc } from '../../../events'
|
||||
|
||||
import { storageStoreEvent, storageLoadEvent, storageClearEvent } from '../../../events/StorageEvents'
|
||||
|
||||
|
||||
+3
-2
@@ -2,6 +2,7 @@ export * from './Events'
|
||||
export * from './EventsV2'
|
||||
export * from './EventSystem/EventDispatcher'
|
||||
// EventBus exports removed - this file contains Electron-specific imports
|
||||
// In Electron mode, webpack replaces '../../../events' to use './EventSystem/EventBus'
|
||||
// In browser mode, webpack replaces '../../../events' to use browserEventBus.ts
|
||||
// which should not be loaded in server/browser mode
|
||||
// Electron code should import directly from './EventSystem/EventBus'
|
||||
// export * from './EventSystem/EventBus'
|
||||
export * from './EventSystem/EventBusInterface'
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"test:electron": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
|
||||
"test:browser": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
|
||||
"test:demo-video": "npx tsc && node dist/src/spec/demoVideo.js",
|
||||
"test:demo-video:mobile": "npx tsc && node dist/src/spec/demoVideoMobile.js",
|
||||
"test:ui": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
|
||||
"test:ui:vnc": "tsc && ./scripts/uiTestsWithVnc.sh",
|
||||
"test:mcp": "tsc && node dist/src/spec/testMcpIntrospection.js",
|
||||
|
||||
+2
-13
@@ -3,13 +3,11 @@ import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as dotProp from 'dot-prop'
|
||||
|
||||
// Linux AppImage build for multiple architectures
|
||||
// Builds for: x64, ARM64 (Raspberry Pi 5), and ARMv7l (Raspberry Pi 4 and older)
|
||||
const linuxAppImage: builder.CliOptions = {
|
||||
x64: true,
|
||||
ia32: false,
|
||||
armv7l: true,
|
||||
arm64: true, // Raspberry Pi 5 support
|
||||
arm64: true,
|
||||
projectDir: './build/clean',
|
||||
publish: 'always',
|
||||
}
|
||||
@@ -23,13 +21,11 @@ const linuxSnap: builder.CliOptions = {
|
||||
publish: 'always',
|
||||
}
|
||||
|
||||
// Linux Deb package build for multiple architectures
|
||||
// Builds for: amd64 (x64), arm64 (Raspberry Pi 5), and armhf (Raspberry Pi 4 and older)
|
||||
const linuxDeb: builder.CliOptions = {
|
||||
x64: true,
|
||||
ia32: false,
|
||||
armv7l: true,
|
||||
arm64: true, // Raspberry Pi 5 support
|
||||
arm64: true,
|
||||
projectDir: './build/clean',
|
||||
publish: 'always',
|
||||
}
|
||||
@@ -80,7 +76,6 @@ async function executeBuild() {
|
||||
await buildWithOptions(winAppx, { platform: 'win', package: 'appx' })
|
||||
break
|
||||
case 'linux':
|
||||
console.log('Building Linux packages for architectures: x64, arm64 (Raspberry Pi 5), armv7l')
|
||||
await buildWithOptions(linuxAppImage, { platform: 'linux', package: 'AppImage' })
|
||||
await buildWithOptions(linuxSnap, { platform: 'linux', package: 'snap' })
|
||||
await buildWithOptions(linuxDeb, { platform: 'linux', package: 'deb' })
|
||||
@@ -110,12 +105,6 @@ async function buildWithOptions(options: builder.CliOptions, buildInfo: BuildInf
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(jsonLocation).toString())
|
||||
|
||||
// Log architectures being built
|
||||
const architectures = []
|
||||
if (options.x64) architectures.push('x64')
|
||||
if (options.arm64) architectures.push('arm64')
|
||||
console.log(`Building ${buildInfo.package} for architectures: ${architectures.join(', ')}`)
|
||||
|
||||
// AppX must have a different name since the store name is already taken (but not used)
|
||||
if (buildInfo.package === 'appx') {
|
||||
dotProp.set(packageJson, 'build.productName', 'MQTT-Explorer')
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
/**
|
||||
* Cut video into segments as GIFs based on scenes.json
|
||||
*
|
||||
* This script reads scenes.json and uses ffmpeg to create GIF segments
|
||||
* from the ui-test.mp4 video file.
|
||||
*/
|
||||
# Read scenes.json and cut video into segments as GIFs
|
||||
if [ ! -f "scenes.json" ]; then
|
||||
echo "scenes.json not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "ui-test.mp4" ]; then
|
||||
echo "ui-test.mp4 not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cutting video into GIF segments based on scenes.json..."
|
||||
|
||||
GIF_SCALE="1024"
|
||||
|
||||
# Parse scenes.json and cut video segments as GIFs
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Check required files exist
|
||||
if (!fs.existsSync('scenes.json')) {
|
||||
console.error('scenes.json not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync('ui-test.mp4')) {
|
||||
console.error('ui-test.mp4 not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Cutting video into GIF segments based on scenes.json...');
|
||||
|
||||
const scenes = JSON.parse(fs.readFileSync('scenes.json', 'utf8'));
|
||||
|
||||
const GIF_SCALE = process.env.GIF_SCALE || '1024';
|
||||
|
||||
console.log('Creating GIF segments...');
|
||||
|
||||
// Sanitize scene name to prevent path traversal and command injection
|
||||
@@ -37,13 +33,13 @@ function sanitizeName(name) {
|
||||
|
||||
async function cutSegmentAsGif(scene, index) {
|
||||
const safeName = sanitizeName(scene.name);
|
||||
const segmentName = `segment-${String(index + 1).padStart(2, '0')}-${safeName}`;
|
||||
const paletteFile = `${segmentName}-palette.png`;
|
||||
const outputFile = `${segmentName}.gif`;
|
||||
const segmentName = \`segment-\${String(index + 1).padStart(2, '0')}-\${safeName}\`;
|
||||
const paletteFile = \`\${segmentName}-palette.png\`;
|
||||
const outputFile = \`\${segmentName}.gif\`;
|
||||
const startTime = scene.start / 1000; // Convert ms to seconds
|
||||
const duration = scene.duration / 1000; // Convert ms to seconds
|
||||
|
||||
console.log(`Creating ${outputFile} (start: ${startTime}s, duration: ${duration}s)`);
|
||||
console.log(\`Creating \${outputFile} (start: \${startTime}s, duration: \${duration}s)\`);
|
||||
|
||||
// Step 1: Generate palette for this segment
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -52,7 +48,7 @@ async function cutSegmentAsGif(scene, index) {
|
||||
'-ss', startTime.toString(),
|
||||
'-t', duration.toString(),
|
||||
'-i', 'ui-test.mp4',
|
||||
'-vf', `fps=10,scale=${GIF_SCALE}:-1:flags=lanczos,palettegen`,
|
||||
'-vf', 'fps=10,scale=${process.env.GIF_SCALE || 1024}:-1:flags=lanczos,palettegen',
|
||||
paletteFile
|
||||
]);
|
||||
|
||||
@@ -60,8 +56,8 @@ async function cutSegmentAsGif(scene, index) {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`Failed to create palette for ${outputFile}`);
|
||||
reject(new Error(`ffmpeg palette generation exited with code ${code}`));
|
||||
console.error(\`Failed to create palette for \${outputFile}\`);
|
||||
reject(new Error(\`ffmpeg palette generation exited with code \${code}\`));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -74,7 +70,7 @@ async function cutSegmentAsGif(scene, index) {
|
||||
'-t', duration.toString(),
|
||||
'-i', 'ui-test.mp4',
|
||||
'-i', paletteFile,
|
||||
'-filter_complex', `fps=10,scale=${GIF_SCALE}:-1:flags=lanczos[x];[x][1:v]paletteuse`,
|
||||
'-filter_complex', 'fps=10,scale=${process.env.GIF_SCALE || 1024}:-1:flags=lanczos[x];[x][1:v]paletteuse',
|
||||
outputFile
|
||||
]);
|
||||
|
||||
@@ -89,8 +85,8 @@ async function cutSegmentAsGif(scene, index) {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`Failed to create ${outputFile}`);
|
||||
reject(new Error(`ffmpeg GIF creation exited with code ${code}`));
|
||||
console.error(\`Failed to create \${outputFile}\`);
|
||||
reject(new Error(\`ffmpeg GIF creation exited with code \${code}\`));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -100,8 +96,11 @@ async function cutSegmentAsGif(scene, index) {
|
||||
for (let i = 0; i < scenes.length; i++) {
|
||||
await cutSegmentAsGif(scenes[i], i);
|
||||
}
|
||||
console.log('Video segments created successfully');
|
||||
console.log('All GIF segments created successfully');
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
"
|
||||
|
||||
echo "Video segments created successfully"
|
||||
@@ -1,107 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Cut mobile demo video into segments as GIFs based on scenes-mobile.json
|
||||
*
|
||||
* This script reads scenes-mobile.json and uses ffmpeg to create GIF segments
|
||||
* from the ui-test-mobile.mp4 video file.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
// Check required files exist
|
||||
if (!fs.existsSync('scenes-mobile.json')) {
|
||||
console.error('scenes-mobile.json not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync('ui-test-mobile.mp4')) {
|
||||
console.error('ui-test-mobile.mp4 not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Cutting mobile video into GIF segments based on scenes-mobile.json...');
|
||||
|
||||
const scenes = JSON.parse(fs.readFileSync('scenes-mobile.json', 'utf8'));
|
||||
|
||||
const GIF_SCALE = process.env.GIF_SCALE || '412';
|
||||
|
||||
console.log('Creating mobile GIF segments...');
|
||||
|
||||
// Sanitize scene name to prevent path traversal and command injection
|
||||
function sanitizeName(name) {
|
||||
// Remove any characters that aren't alphanumeric, dash, or underscore
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
}
|
||||
|
||||
async function cutSegmentAsGif(scene, index) {
|
||||
const safeName = sanitizeName(scene.name);
|
||||
const segmentName = `segment-mobile-${String(index + 1).padStart(2, '0')}-${safeName}`;
|
||||
const paletteFile = `${segmentName}-palette.png`;
|
||||
const outputFile = `${segmentName}.gif`;
|
||||
const startTime = scene.start / 1000; // Convert ms to seconds
|
||||
const duration = scene.duration / 1000; // Convert ms to seconds
|
||||
|
||||
console.log(`Creating ${outputFile} (start: ${startTime}s, duration: ${duration}s)`);
|
||||
|
||||
// Step 1: Generate palette for this segment
|
||||
await new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn('ffmpeg', [
|
||||
'-y',
|
||||
'-ss', startTime.toString(),
|
||||
'-t', duration.toString(),
|
||||
'-i', 'ui-test-mobile.mp4',
|
||||
'-vf', `fps=10,scale=${GIF_SCALE}:-1:flags=lanczos,palettegen`,
|
||||
paletteFile
|
||||
]);
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`Failed to create palette for ${outputFile}`);
|
||||
reject(new Error(`ffmpeg palette generation exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Step 2: Create GIF using the palette
|
||||
await new Promise((resolve, reject) => {
|
||||
const ffmpeg = spawn('ffmpeg', [
|
||||
'-y',
|
||||
'-ss', startTime.toString(),
|
||||
'-t', duration.toString(),
|
||||
'-i', 'ui-test-mobile.mp4',
|
||||
'-i', paletteFile,
|
||||
'-filter_complex', `fps=10,scale=${GIF_SCALE}:-1:flags=lanczos[x];[x][1:v]paletteuse`,
|
||||
outputFile
|
||||
]);
|
||||
|
||||
ffmpeg.on('close', (code) => {
|
||||
// Clean up palette file
|
||||
try {
|
||||
fs.unlinkSync(paletteFile);
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`Failed to create ${outputFile}`);
|
||||
reject(new Error(`ffmpeg GIF creation exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
for (let i = 0; i < scenes.length; i++) {
|
||||
await cutSegmentAsGif(scenes[i], i);
|
||||
}
|
||||
console.log('Mobile video segments created successfully');
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
|
||||
// Read scenes-mobile.json
|
||||
const scenes = JSON.parse(fs.readFileSync('scenes-mobile.json', 'utf8'));
|
||||
|
||||
// Get base URL from command line arguments
|
||||
const baseUrl = process.argv[2];
|
||||
|
||||
if (!baseUrl) {
|
||||
console.error('Usage: node generateMarkdownSummaryMobile.js <base-url>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Sanitize scene name to prevent path traversal
|
||||
function sanitizeName(name) {
|
||||
// Remove any characters that aren't alphanumeric, dash, or underscore
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '-');
|
||||
}
|
||||
|
||||
// Generate markdown
|
||||
let markdown = '## 📱 Mobile Demo Video Generated\n\n';
|
||||
markdown += `### Full Mobile Video (Pixel 6 - 412x915)\n\n`;
|
||||
markdown += `[📥 Download Mobile Video (MP4)](${baseUrl}/ui-test-mobile.mp4) | [GIF](${baseUrl}/ui-test-mobile.gif)\n\n`;
|
||||
markdown += `---\n\n`;
|
||||
markdown += `### 📑 Mobile Video Segments\n\n`;
|
||||
markdown += `<details>\n`;
|
||||
markdown += `<summary>Click to expand mobile segments</summary>\n\n`;
|
||||
|
||||
scenes.forEach((scene, index) => {
|
||||
const safeName = sanitizeName(scene.name);
|
||||
const segmentFile = `segment-mobile-${String(index + 1).padStart(2, '0')}-${safeName}.gif`;
|
||||
const title = scene.title || scene.name;
|
||||
const duration = (scene.duration / 1000).toFixed(1);
|
||||
|
||||
markdown += `<details>\n`;
|
||||
markdown += `<summary><strong>${index + 1}. ${title}</strong> (${duration}s)</summary>\n\n`;
|
||||
markdown += `\n\n`;
|
||||
markdown += `</details>\n\n`;
|
||||
});
|
||||
|
||||
markdown += `</details>\n\n`;
|
||||
markdown += `_Mobile videos recorded at 412x915 (Pixel 6 viewport). Videos will expire in 90 days._`;
|
||||
|
||||
console.log(markdown);
|
||||
@@ -29,7 +29,7 @@ mv app720.gif ui-test.gif
|
||||
# Cut video into segments based on scenes.json
|
||||
echo "Cutting video into segments..."
|
||||
if [ -f "scenes.json" ]; then
|
||||
node ./scripts/cutVideoSegments.js
|
||||
./scripts/cutVideoSegments.sh
|
||||
else
|
||||
echo "Warning: scenes.json not found, skipping segment creation"
|
||||
fi
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Mobile demo video post-processing script
|
||||
# Converts raw mobile video to MP4 and GIF, then cuts into segments
|
||||
|
||||
DIMENSIONS="412x915"
|
||||
GIF_SCALE="412"
|
||||
|
||||
ffmpeg -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv app2-mobile.mp4
|
||||
|
||||
# The video starts with a few blank frames, we want to know when they stop
|
||||
ffprobe -f lavfi -i "movie=app2-mobile.mp4,blackdetect[out0]" -show_entries tags=lavfi.black_start,lavfi.black_end -of default=nw=1 -v quiet > ffmpeg_info_mobile
|
||||
END_OF_BLACK=`cat ffmpeg_info_mobile | grep end | head -n1 | cut -d'=' -f2`
|
||||
|
||||
# Remove grey frames at the beginning (app start and splash screen)
|
||||
END_OF_BLACK=`awk "BEGIN {print $END_OF_BLACK+0.8; exit}"`
|
||||
|
||||
# Trim black frames at start
|
||||
ffmpeg -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv -ss $END_OF_BLACK app-mobile.mp4
|
||||
|
||||
# Generate gif palette
|
||||
ffmpeg -y -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv -vf "fps=10,scale=$GIF_SCALE:-1:flags=lanczos,palettegen" palette-mobile.png
|
||||
|
||||
# Create gif
|
||||
ffmpeg -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv -i palette-mobile.png -ss $END_OF_BLACK -filter_complex "fps=10,scale=$GIF_SCALE:-1:flags=lanczos[x];[x][1:v]paletteuse" app-mobile.gif
|
||||
|
||||
# Clean up
|
||||
rm ffmpeg_info_mobile palette-mobile.png qrawvideorgb24-mobile.yuv app2-mobile.mp4
|
||||
|
||||
mv app-mobile.mp4 ui-test-mobile.mp4
|
||||
mv app-mobile.gif ui-test-mobile.gif
|
||||
|
||||
# Cut video into segments based on scenes-mobile.json
|
||||
echo "Cutting mobile video into segments..."
|
||||
if [ -f "scenes-mobile.json" ]; then
|
||||
node ./scripts/cutVideoSegmentsMobile.js
|
||||
else
|
||||
echo "Warning: scenes-mobile.json not found, skipping segment creation"
|
||||
fi
|
||||
@@ -2,7 +2,7 @@
|
||||
# Browser Mode Test Runner
|
||||
#
|
||||
# This script runs UI tests against the browser mode server (instead of Electron).
|
||||
# It starts a mosquitto MQTT broker automatically and cleans it up on exit.
|
||||
# It expects a mosquitto MQTT broker to be running (via service or manually started).
|
||||
# The broker address is configured via environment variables.
|
||||
#
|
||||
# Environment Variables:
|
||||
@@ -23,21 +23,10 @@ function finish {
|
||||
echo "Stopping server ($PID_SERVER).."
|
||||
kill "$PID_SERVER" || echo "Already stopped"
|
||||
fi
|
||||
|
||||
if [[ ! -z "$PID_MOSQUITTO" ]]; then
|
||||
echo "Stopping mosquitto ($PID_MOSQUITTO).."
|
||||
kill "$PID_MOSQUITTO" || echo "Already stopped"
|
||||
fi
|
||||
}
|
||||
|
||||
trap finish EXIT
|
||||
|
||||
# Start mqtt broker
|
||||
mosquitto &
|
||||
export PID_MOSQUITTO=$!
|
||||
sleep 1
|
||||
npx -y playwright install
|
||||
|
||||
# Set credentials for browser authentication (tests will use these to login)
|
||||
export MQTT_EXPLORER_USERNAME=${MQTT_EXPLORER_USERNAME:-test}
|
||||
export MQTT_EXPLORER_PASSWORD=${MQTT_EXPLORER_PASSWORD:-test123}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
#!/bin/bash
|
||||
function finish {
|
||||
set +e
|
||||
echo "Exiting, cleaning up.."
|
||||
|
||||
echo "Stopping TMUX session (record-mobile).."
|
||||
tmux kill-session -t record-mobile || echo "Already stopped"
|
||||
|
||||
if [[ ! -z "$PID_MOSQUITTO" ]]; then
|
||||
echo "Stopping mosquitto ($PID_MOSQUITTO).."
|
||||
kill "$PID_MOSQUITTO" || echo "Already stopped"
|
||||
fi
|
||||
|
||||
if [[ ! -z "$PID_VNC" ]]; then
|
||||
echo "Stopping VNC ($PID_VNC).."
|
||||
kill "$PID_VNC" || echo "Already stopped"
|
||||
fi
|
||||
|
||||
if [[ ! -z "$PID_XVFB" ]]; then
|
||||
echo "Stopping XVFB ($PID_XVFB).."
|
||||
kill "$PID_XVFB" || echo "Already stopped"
|
||||
fi
|
||||
|
||||
if [[ ! -z "$PID_SERVER" ]]; then
|
||||
echo "Stopping MQTT Explorer server ($PID_SERVER).."
|
||||
kill "$PID_SERVER" || echo "Already stopped"
|
||||
fi
|
||||
}
|
||||
|
||||
trap finish EXIT
|
||||
set -e
|
||||
|
||||
# Mobile viewport dimensions (Pixel 6)
|
||||
DIMENSIONS="412x915"
|
||||
SCR=99
|
||||
|
||||
# Start new window manager
|
||||
Xvfb :$SCR -screen 0 "$DIMENSIONS"x24 -ac &
|
||||
export PID_XVFB=$!
|
||||
sleep 2
|
||||
|
||||
# Debug with VNC
|
||||
x11vnc -localhost -rfbport 5900 -passwd "bierbier" -display :$SCR &
|
||||
export PID_VNC=$!
|
||||
|
||||
# Start mqtt broker
|
||||
mosquitto &
|
||||
export PID_MOSQUITTO=$!
|
||||
sleep 1
|
||||
npx -y playwright install
|
||||
|
||||
# Start MQTT Explorer in browser mode
|
||||
export MQTT_EXPLORER_USERNAME=admin
|
||||
export MQTT_EXPLORER_PASSWORD=password
|
||||
export MQTT_EXPLORER_SKIP_AUTH=true
|
||||
export DISPLAY=:$SCR
|
||||
node dist/src/server.js &
|
||||
export PID_SERVER=$!
|
||||
sleep 5
|
||||
|
||||
# Delete old video
|
||||
rm -f ./app-mobile*.mp4
|
||||
rm -f ./qrawvideorgb24-mobile.yuv
|
||||
|
||||
# Start recording in tmux
|
||||
tmux new-session -d -s record-mobile ffmpeg -f x11grab -draw_mouse 0 -video_size $DIMENSIONS -i :$SCR -r 20 -vcodec rawvideo -pix_fmt yuv420p qrawvideorgb24-mobile.yuv
|
||||
|
||||
# Start tests
|
||||
export BROWSER_MODE_URL=http://localhost:3000
|
||||
DISPLAY=:$SCR node dist/src/spec/demoVideoMobile.js
|
||||
TEST_EXIT_CODE=$?
|
||||
echo "Test script exited with $TEST_EXIT_CODE"
|
||||
|
||||
# Stop recording
|
||||
tmux send-keys -t record-mobile q
|
||||
|
||||
# Ensure video is written
|
||||
sleep 5
|
||||
|
||||
exit $TEST_EXIT_CODE
|
||||
@@ -22,16 +22,6 @@ export type SceneNames =
|
||||
| 'keyboard_shortcuts'
|
||||
| 'sparkplugb-decoding'
|
||||
| 'end'
|
||||
| 'mobile_intro'
|
||||
| 'mobile_connect'
|
||||
| 'mobile_browse_topics'
|
||||
| 'mobile_search'
|
||||
| 'mobile_view_message'
|
||||
| 'mobile_json_view'
|
||||
| 'mobile_clipboard'
|
||||
| 'mobile_plots'
|
||||
| 'mobile_menu'
|
||||
| 'mobile_end'
|
||||
|
||||
export const SCENE_TITLES: Record<SceneNames, string> = {
|
||||
connect: 'Connecting to MQTT Broker',
|
||||
@@ -49,16 +39,6 @@ export const SCENE_TITLES: Record<SceneNames, string> = {
|
||||
keyboard_shortcuts: 'Keyboard Shortcuts',
|
||||
'sparkplugb-decoding': 'SparkplugB Decoding',
|
||||
end: 'The End',
|
||||
mobile_intro: 'MQTT Explorer on Mobile',
|
||||
mobile_connect: 'Connect to MQTT Broker',
|
||||
mobile_browse_topics: 'Browse Topic Tree',
|
||||
mobile_search: 'Search Topics',
|
||||
mobile_view_message: 'View Message Details',
|
||||
mobile_json_view: 'JSON Message Formatting',
|
||||
mobile_clipboard: 'Copy to Clipboard',
|
||||
mobile_plots: 'View Numeric Plots',
|
||||
mobile_menu: 'Settings & Menu',
|
||||
mobile_end: 'Mobile-Friendly MQTT Explorer',
|
||||
}
|
||||
|
||||
export class SceneBuilder {
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
|
||||
import { Browser, BrowserContext, Page, chromium } from 'playwright'
|
||||
|
||||
import mockMqtt, { stop as stopMqtt } from './mock-mqtt'
|
||||
import { default as MockSparkplug } from './mock-sparkplugb'
|
||||
import { clearSearch, searchTree } from './scenarios/searchTree'
|
||||
import { clickOnHistory, createFakeMousePointer, hideText, showText, sleep } from './util'
|
||||
import { connectTo } from './scenarios/connect'
|
||||
import { copyTopicToClipboard } from './scenarios/copyTopicToClipboard'
|
||||
import { copyValueToClipboard } from './scenarios/copyValueToClipboard'
|
||||
import { disconnect } from './scenarios/disconnect'
|
||||
import { publishTopic } from './scenarios/publishTopic'
|
||||
import { Scene, SceneBuilder } from './SceneBuilder'
|
||||
import { showAdvancedConnectionSettings } from './scenarios/showAdvancedConnectionSettings'
|
||||
import { showJsonPreview } from './scenarios/showJsonPreview'
|
||||
import { showMenu } from './scenarios/showMenu'
|
||||
import { showNumericPlot } from './scenarios/showNumericPlot'
|
||||
import { showOffDiffCapability } from './scenarios/showOffDiffCapability'
|
||||
|
||||
/**
|
||||
* Mobile Demo Video - Pixel 6 viewport
|
||||
*
|
||||
* This demo showcases MQTT Explorer running in a mobile browser viewport
|
||||
* simulating a Google Pixel 6 (412x915px portrait mode)
|
||||
*/
|
||||
|
||||
/**
|
||||
* A convenience method that handles gracefully cleaning up the test run.
|
||||
*/
|
||||
const cleanUp = async (scenes: SceneBuilder, browser: Browser) => {
|
||||
// Exit app.
|
||||
fs.writeFileSync('scenes-mobile.json', JSON.stringify(scenes.scenes, undefined, ' '))
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
process.on('unhandledRejection' as any, (error: Error | any) => {
|
||||
console.error('unhandledRejection', error.message, error.stack)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
console.error('Timeout reached')
|
||||
process.exit(1)
|
||||
},
|
||||
60 * 10 * 1000
|
||||
)
|
||||
|
||||
async function doStuff() {
|
||||
const brokerHost = process.env.TESTS_MQTT_BROKER_HOST || '127.0.0.1'
|
||||
const brokerPort = process.env.TESTS_MQTT_BROKER_PORT || '1883'
|
||||
console.log(`Waiting for MQTT Broker at ${brokerHost}:${brokerPort} (no auth)`)
|
||||
await mockMqtt()
|
||||
|
||||
console.log('Starting playwright/chromium in mobile mode (Pixel 6)')
|
||||
|
||||
// Launch Chromium browser with mobile emulation
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
})
|
||||
|
||||
// Create browser context with Pixel 6 viewport
|
||||
const context = await browser.newContext({
|
||||
viewport: {
|
||||
width: 412,
|
||||
height: 915,
|
||||
},
|
||||
deviceScaleFactor: 2.625,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36',
|
||||
})
|
||||
|
||||
const page = await context.newPage()
|
||||
|
||||
// Navigate to the browser mode server
|
||||
const serverUrl = process.env.BROWSER_MODE_URL || 'http://localhost:3000'
|
||||
console.log(`Navigating to ${serverUrl}`)
|
||||
await page.goto(serverUrl, { waitUntil: 'networkidle' })
|
||||
|
||||
// Print the title
|
||||
console.log(await page.title())
|
||||
|
||||
// Capture a screenshot
|
||||
await page.screenshot({ path: 'intro-mobile.png' })
|
||||
|
||||
// Direct console to Node terminal
|
||||
page.on('console', console.log)
|
||||
|
||||
// Handle authentication if required
|
||||
const username = process.env.MQTT_EXPLORER_USERNAME || 'admin'
|
||||
const password = process.env.MQTT_EXPLORER_PASSWORD || 'password'
|
||||
|
||||
console.log('Waiting for page to initialize...')
|
||||
await sleep(3000)
|
||||
|
||||
// Check for login dialog
|
||||
const loginDialog = page.locator('h2:has-text("Login to MQTT Explorer")')
|
||||
let loginDialogVisible = false
|
||||
try {
|
||||
loginDialogVisible = await loginDialog.isVisible({ timeout: 5000 })
|
||||
} catch (error) {
|
||||
console.log('Login dialog not found - auth may be disabled')
|
||||
}
|
||||
|
||||
if (loginDialogVisible) {
|
||||
console.log('Handling authentication...')
|
||||
const usernameInput = page.locator('input[name="username"]')
|
||||
const passwordInput = page.locator('input[name="password"]')
|
||||
const loginButton = page.locator('button:has-text("Login")')
|
||||
|
||||
await usernameInput.fill(username)
|
||||
await passwordInput.fill(password)
|
||||
await loginButton.click()
|
||||
await sleep(2000)
|
||||
}
|
||||
|
||||
// Wait for the connection UI to be visible
|
||||
await page.locator('//label[contains(text(), "Host")]/..//input').waitFor({ timeout: 10000 })
|
||||
|
||||
const scenes = new SceneBuilder()
|
||||
|
||||
await scenes.record('mobile_intro', async () => {
|
||||
await showText('MQTT Explorer on Mobile', 2000, page, 'middle')
|
||||
await sleep(2500)
|
||||
await showText('Google Pixel 6 (412x915)', 1500, page, 'middle')
|
||||
await sleep(2000)
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_connect', async () => {
|
||||
await showText('Connect to MQTT Broker', 1500, page, 'top')
|
||||
await connectTo(brokerHost, page)
|
||||
await MockSparkplug.run() // Start sparkplug client after connect
|
||||
await sleep(2000)
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_browse_topics', async () => {
|
||||
await showText('Browse Topic Tree', 1500, page, 'top')
|
||||
await sleep(1500)
|
||||
// Try to expand a topic in the tree
|
||||
const firstTopic = page.locator('[data-testid="tree-node"]').first()
|
||||
if (await firstTopic.isVisible()) {
|
||||
await firstTopic.click()
|
||||
await sleep(1000)
|
||||
}
|
||||
await sleep(1500)
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_search', async () => {
|
||||
await showText('Search Topics', 1500, page, 'top')
|
||||
await searchTree('temp', page)
|
||||
await sleep(1500)
|
||||
await showText('Filter Results', 1000, page, 'top')
|
||||
await sleep(1500)
|
||||
await clearSearch(page)
|
||||
await sleep(1000)
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_view_message', async () => {
|
||||
await showText('View Message Details', 1500, page, 'top')
|
||||
await sleep(1000)
|
||||
// Click on a topic to view details in sidebar
|
||||
const topicNode = page.locator('[data-testid="tree-node"]').first()
|
||||
if (await topicNode.isVisible()) {
|
||||
await topicNode.click()
|
||||
await sleep(2000)
|
||||
}
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_json_view', async () => {
|
||||
await showText('JSON Message Formatting', 1500, page, 'top')
|
||||
await showJsonPreview(page)
|
||||
await sleep(2000)
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_clipboard', async () => {
|
||||
await showText('Copy to Clipboard', 1500, page, 'top')
|
||||
await copyTopicToClipboard(page)
|
||||
await sleep(1000)
|
||||
await copyValueToClipboard(page)
|
||||
await sleep(1500)
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_plots', async () => {
|
||||
await showText('View Numeric Plots', 1500, page, 'top')
|
||||
await showNumericPlot(page)
|
||||
await sleep(2500)
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_menu', async () => {
|
||||
await showText('Settings & Menu', 1500, page, 'top')
|
||||
await showMenu(page)
|
||||
await sleep(2000)
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_end', async () => {
|
||||
await showText('Mobile-Friendly MQTT Explorer', 2000, page, 'middle')
|
||||
await sleep(2500)
|
||||
await showText('Ready for Optimization', 1500, page, 'middle')
|
||||
await sleep(2000)
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
console.log('Forced quit')
|
||||
process.exit(0)
|
||||
}, 10 * 1000)
|
||||
|
||||
stopMqtt()
|
||||
console.log('Stopped mqtt client')
|
||||
|
||||
await cleanUp(scenes, browser)
|
||||
|
||||
// Force exit since there appear to be open handles
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
doStuff()
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Page } from 'playwright'
|
||||
import { clickOn } from '../util'
|
||||
|
||||
export async function saveMessageToFile(browser: Page) {
|
||||
// Select the save button specifically in the Value panel
|
||||
const saveButton = browser.getByRole('button', { name: /Value/i }).getByTestId('save-button')
|
||||
await clickOn(saveButton, 1)
|
||||
}
|
||||
+4
-140
@@ -15,7 +15,7 @@ import type { MqttClient } from 'mqtt'
|
||||
* Tests the core UI functionality using a single connection.
|
||||
* All topics are published before connecting, and tests run sequentially
|
||||
* on the same connected application instance.
|
||||
*
|
||||
*
|
||||
* Supports both Electron and Browser modes:
|
||||
* - Electron mode: Default behavior, launches Electron app
|
||||
* - Browser mode: Set BROWSER_MODE_URL environment variable to the server URL
|
||||
@@ -68,9 +68,7 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage'],
|
||||
})
|
||||
|
||||
browserContext = await browser.newContext({
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
})
|
||||
browserContext = await browser.newContext()
|
||||
page = await browserContext.newPage()
|
||||
|
||||
// Listen for console messages
|
||||
@@ -96,13 +94,10 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
// Timeout is expected if dialog is not shown, not an error
|
||||
console.log('Login dialog not found (timeout) - checking if auth is disabled')
|
||||
}
|
||||
|
||||
|
||||
// Debug: print page content to see what's rendered
|
||||
if (!loginDialogVisible) {
|
||||
const body = await page
|
||||
.locator('body')
|
||||
.textContent()
|
||||
.catch(() => 'Unable to read body')
|
||||
const body = await page.locator('body').textContent().catch(() => 'Unable to read body')
|
||||
console.log('Page body text:', body?.substring(0, 300))
|
||||
}
|
||||
|
||||
@@ -242,135 +237,4 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
await page.screenshot({ path: 'test-screenshot-search-lamp.png' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Clipboard Operations', () => {
|
||||
it('should copy topic path to clipboard in both Electron and browser modes', async function () {
|
||||
// Given: A topic is selected
|
||||
await clearSearch(page)
|
||||
await sleep(1000)
|
||||
await expandTopic('livingroom/lamp/state', page)
|
||||
await sleep(1000)
|
||||
|
||||
// When: Copy topic button is clicked
|
||||
const copyTopicButton = page.getByRole('button', { name: /Topic/i }).getByTestId('copy-button')
|
||||
await copyTopicButton.click()
|
||||
await sleep(500)
|
||||
|
||||
// Then: Clipboard should contain the topic path
|
||||
const clipboardText = await page.evaluate(async () => {
|
||||
try {
|
||||
// Try to read from clipboard using the Clipboard API
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText()
|
||||
}
|
||||
// Fallback: try to paste into a temporary input element
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
document.execCommand('paste')
|
||||
const text = input.value
|
||||
document.body.removeChild(input)
|
||||
return text
|
||||
} catch (error) {
|
||||
// If clipboard access fails, return empty string
|
||||
console.warn('Clipboard read failed:', error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// Verify clipboard contains expected topic path
|
||||
if (clipboardText) {
|
||||
expect(clipboardText).to.equal('livingroom/lamp/state')
|
||||
} else {
|
||||
// If clipboard reading is not available, at least verify the button was clicked
|
||||
console.warn('Clipboard verification not available in this environment')
|
||||
const copyButton = await copyTopicButton.isVisible()
|
||||
expect(copyButton).to.be.true
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-screenshot-copy-topic.png' })
|
||||
})
|
||||
|
||||
it('should copy message value to clipboard in both Electron and browser modes', async function () {
|
||||
// Given: A topic with a value is selected (reuse already expanded topic)
|
||||
// When: Copy value button is clicked
|
||||
const copyValueButton = page.getByRole('button', { name: /Value/i }).getByTestId('copy-button')
|
||||
await copyValueButton.click()
|
||||
await sleep(500)
|
||||
|
||||
// Then: Clipboard should contain the message value
|
||||
const clipboardText = await page.evaluate(async () => {
|
||||
try {
|
||||
// Try to read from clipboard using the Clipboard API
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText()
|
||||
}
|
||||
// Fallback: try to paste into a temporary input element
|
||||
const input = document.createElement('input')
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
document.execCommand('paste')
|
||||
const text = input.value
|
||||
document.body.removeChild(input)
|
||||
return text
|
||||
} catch (error) {
|
||||
// If clipboard access fails, return empty string
|
||||
console.warn('Clipboard read failed:', error)
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// Verify clipboard contains expected value (should be "on" from livingroom/lamp/state)
|
||||
if (clipboardText) {
|
||||
expect(clipboardText).to.equal('on')
|
||||
} else {
|
||||
// If clipboard reading is not available, at least verify the button was clicked
|
||||
console.warn('Clipboard verification not available in this environment')
|
||||
const copyButton = await copyValueButton.isVisible()
|
||||
expect(copyButton).to.be.true
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-screenshot-copy-value.png' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('File Save/Download Operations', () => {
|
||||
it('should save/download message to file in both Electron and browser modes', async function () {
|
||||
// Given: A topic with a message is already selected from previous test
|
||||
await sleep(500)
|
||||
|
||||
if (isBrowserMode) {
|
||||
// In browser mode, set up download handling
|
||||
const downloadPromise = page.waitForEvent('download', { timeout: 10000 })
|
||||
|
||||
// When: Save button is clicked
|
||||
const saveButton = page.getByRole('button', { name: /Value/i }).getByTestId('save-button')
|
||||
await saveButton.click()
|
||||
|
||||
// Then: Download should be triggered
|
||||
const download = await downloadPromise
|
||||
expect(download).to.not.be.undefined
|
||||
|
||||
// Verify download has a filename
|
||||
const filename = download.suggestedFilename()
|
||||
expect(filename).to.include('mqtt-message-')
|
||||
console.log('Browser mode: File downloaded:', filename)
|
||||
|
||||
// Save to verify (optional, but helps with debugging)
|
||||
await download.saveAs(`/tmp/${filename}`)
|
||||
} else {
|
||||
// In Electron mode, the file dialog would open
|
||||
// We can't easily test the native file dialog, but we can verify the button works
|
||||
const saveButton = page.getByRole('button', { name: /Value/i }).getByTestId('save-button')
|
||||
const isVisible = await saveButton.isVisible()
|
||||
expect(isVisible).to.be.true
|
||||
|
||||
// Note: In Electron, clicking this would open a native dialog which we can't easily automate
|
||||
// For now, just verify the button exists
|
||||
console.log('Electron mode: Save button is visible (native dialog not tested)')
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-screenshot-save-message.png' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"src/AuthManager.ts",
|
||||
"src/spec/electron.ts",
|
||||
"src/spec/demoVideo.ts",
|
||||
"src/spec/demoVideoMobile.ts",
|
||||
"src/spec/leakTest.ts",
|
||||
"src/spec/testMcpIntrospection.ts",
|
||||
"src/spec/ui-tests.spec.ts",
|
||||
|
||||
Reference in New Issue
Block a user