Compare commits

..
39 changed files with 124 additions and 1750 deletions
-10
View File
@@ -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'`
+9 -12
View File
@@ -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
View File
@@ -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
+13 -14
View File
@@ -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
View File
@@ -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
-156
View File
@@ -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)
-41
View File
@@ -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
View File
@@ -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
-43
View File
@@ -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;
+1 -1
View File
@@ -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) => {
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 -1
View File
@@ -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}>
+2 -10
View File
@@ -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')
})
})
})
+1 -1
View File
@@ -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
+1 -1
View File
@@ -15,7 +15,7 @@ interface Props {
*/
function ClearAdornment(props: Props) {
const theme = useTheme()
if (!props.value) {
return null
}
+4 -2
View File
@@ -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']}
>
+2 -44
View File
@@ -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>
)
-134
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { rendererRpc } from '../eventBus'
import { rendererRpc } from '../../../events'
import { storageStoreEvent, storageLoadEvent, storageClearEvent } from '../../../events/StorageEvents'
+3 -2
View File
@@ -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'
-1
View File
@@ -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
View File
@@ -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"
-107
View File
@@ -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);
});
-45
View File
@@ -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 += `![${title}](${baseUrl}/${segmentFile})\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);
+1 -1
View File
@@ -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
-38
View File
@@ -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
+1 -12
View File
@@ -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}
-80
View File
@@ -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
-20
View File
@@ -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 {
-230
View File
@@ -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()
-8
View File
@@ -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
View File
@@ -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' })
})
})
})
-1
View File
@@ -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",