Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]andthomasnordquist 5a8f0f6e2e Revert TreeNodeTitle expander onClick change - focus only on dependency updates
Per user feedback, reverting the TreeNodeTitle expander onClick fix as it's
unrelated to the core dependency update task. Focusing only on updating
dependencies to their latest compatible versions.

Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-28 12:17:44 +00:00
copilot-swe-agent[bot]andthomasnordquist 787de916fe Fix TreeNodeTitle expander onClick to always be present
The mobile PR (#1008) conditionally removed onClick from the expander button
on desktop (window.innerWidth > 768), assuming clicking the topic text would
handle expansion. However, test scenarios specifically click the expander button
and need it to work.

Fixed by always attaching onClick={props.toggleCollapsed} to the expander,
not conditionally based on viewport size. This ensures the expander works
in both mobile and desktop modes, including in tests.

This partially addresses the kitchen topic test failure - the expander now
works when clicked, but topics still aren't appearing (separate issue to investigate).

Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-28 11:31:44 +00:00
copilot-swe-agent[bot]andthomasnordquist febed64653 Revert test timing changes and downgrade @react-spring/web for @visx compatibility
Investigation findings:
- Reverted demoVideo.ts and leakTest.ts test timing changes
- Kept --no-sandbox and --disable-dev-shm-usage flags (required for Xvfb)
- Added 2000ms wait before connect scene for dialog rendering
- Downgraded @react-spring/web from 10.0.3 to 9.7.5 (peer dependency conflict with @visx/xychart)
- Downgraded mocha from 11.7.5 to 10.8.2 (tested - not root cause, but safer)

Testing showed @react-spring/web 10.0.3 has peer dependency conflicts with @visx/xychart which expects ^9.4.5.
With 9.7.5, connection dialog renders properly. The kitchen topic issue persists but is unrelated to these dependencies.

Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-28 11:22:03 +00:00
copilot-swe-agent[bot]andthomasnordquist d608c8e143 Fix demoVideo test: add explicit waitFor on connection dialog and increase wait time
After investigation using Playwright, found the actual issue:
1. Line 74 was creating a locator but not waiting for it, causing race conditions
2. Topics weren't appearing because 3000ms was insufficient
3. Increased wait to 10000ms to ensure MQTT topics are published and rendered

Evidence: Ran test with Xvfb and captured screenshots showing the connection
dialog state. Topics from mock-mqtt were not appearing even after 3000ms wait.

Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-28 10:28:30 +00:00
copilot-swe-agent[bot]andthomasnordquist 51bd794bdb Add --no-sandbox and --disable-dev-shm-usage flags to Electron test launches for Xvfb compatibility
These flags are required for Electron to work properly in headless/Xvfb
environments. Without them, Electron fails to launch or has rendering issues
which cause topic selection and UI interaction failures in CI tests.

Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-28 07:02:37 +00:00
copilot-swe-agent[bot]andthomasnordquist 500e45f4ba Fix demo video timing: increase wait after MQTT connect to 3000ms
The demo video was failing with "Could not find topic kitchen" because
the 1000ms wait after connecting was insufficient for MQTT topics to
be received and rendered. This aligns with other test files that use
3000ms wait time.

Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-27 18:37:45 +00:00
copilot-swe-agent[bot]andthomasnordquist d8987f144a Update additional packages to latest versions
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-27 16:11:42 +00:00
copilot-swe-agent[bot] 4473709dac Merge branch 'master' into copilot/update-all-dependencies
Resolved conflicts in package.json files:
- Merged ace-builds update (1.43.5) with new visx dependencies
- Kept updated versions: electron-builder 26.4.0, mocha 11.7.5
- Regenerated yarn.lock files with merged dependencies
2025-12-27 16:10:32 +00:00
4de52aba7c Implement mobile-first navigation with tabs, server-side auto-connect, improve mobile UX (#1008)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
Co-authored-by: Thomas Nordquist <thomasnordquist@users.noreply.github.com>
2025-12-27 17:02:49 +01:00
Thomas Nordquist 8f86d272c7 Change base image from node:24-alpine to node:22-alpine 2025-12-24 19:40:15 +01:00
Copilotandthomasnordquist 2abba74e1c Add concurrency groups to workflows to cancel superseded runs (#1007)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-24 18:38:22 +01:00
dependabot[bot] 10d0b0fa77 chore(deps): bump ws from 7.5.9 to 7.5.10 (#815)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-24 18:25:40 +01:00
Copilotandthomasnordquist 7e79a7601e Add UI tests for clipboard copy and file download in Electron and browser modes (#1004)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-24 18:13:43 +01:00
Thomas Nordquist ef3343a912 Change base image from node:24-alpine to node:22-alpine
For armv7 copatibility
2025-12-24 18:11:30 +01:00
Copilotandthomasnordquist ee1171f7af Add security-focused tests for Login Page error messages (#1005)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-24 18:02:44 +01:00
Copilotandthomasnordquist 1453934e29 Add mobile compatibility concept, Pixel 6 demo video infrastructure, and CI/CD workflow (#1006)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-24 18:02:17 +01:00
a3de71d939 Fix RPC import issue preventing Host input field from appearing in Electron mode (#991)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
Co-authored-by: Thomas Nordquist <thomasnordquist@users.noreply.github.com>
2025-12-24 16:57:08 +01:00
Thomas Nordquist 7e5b013448 Install Playwright before running browser tests 2025-12-24 16:49:46 +01:00
Copilotandthomasnordquist fe790ecd39 Consolidate mosquitto startup into test scripts (#1003)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-24 16:41:19 +01:00
Copilotandthomasnordquist b5f6c7fa3f Convert cutVideoSegments.sh from shell wrapper to native Node.js script (#1002)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-24 16:07:55 +01:00
Thomas Nordquist c6831212b4 Clean up test workflow configuration
Removed unnecessary options and environment variables for test containers.
2025-12-24 15:06:14 +01:00
Copilotandthomasnordquist 6612e10a66 Replace mosquitto service containers with detached process startup (#1000)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-24 14:59:11 +01:00
Copilotandthomasnordquist 4eb58df233 Fix: Export GIF_SCALE in cutVideoSegments.sh (#998)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-24 14:28:25 +01:00
Copilotandthomasnordquist f854e12824 Fix mosquitto service container entrypoint argument syntax (#999)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-24 14:27:07 +01:00
92fe005e3b docs: Add comprehensive styling conventions guide (#968)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
Co-authored-by: Thomas Nordquist <thomasnordquist@users.noreply.github.com>
2025-12-24 14:03:37 +01:00
Copilotandthomasnordquist 5f703695bb Fix invalid cmd: syntax in GitHub Actions mosquitto service containers (#996)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-24 13:28:55 +01:00
Thomas Nordquist 33c1b0258d Merge branch 'master' into copilot/update-all-dependencies 2025-12-23 19:19:42 +01:00
copilot-swe-agent[bot]andthomasnordquist 13445842a4 Remove @electron/node-gyp from dependencies (only needs to be in resolutions)
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-23 17:15:45 +00:00
copilot-swe-agent[bot]andthomasnordquist f0fa96ba2e Update @electron/node-gyp resolution to latest version
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-23 17:13:31 +00:00
copilot-swe-agent[bot]andthomasnordquist 28b9e9a48c Fix react-split-pane import and keep compatible dependency versions
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-23 17:10:24 +00:00
copilot-swe-agent[bot]andthomasnordquist 9e75541ed5 Update GitHub Actions and npm dependencies to latest versions
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
2025-12-23 17:06:14 +00:00
copilot-swe-agent[bot] 95fd6826fa Initial plan 2025-12-23 16:59:39 +00:00
77 changed files with 5906 additions and 3872 deletions
+52
View File
@@ -81,3 +81,55 @@ 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'`
## Mobile Testing Workflow
**Prerequisites for mobile testing:**
```bash
# Install Playwright browsers
npx playwright install --with-deps chromium
# Configure mosquitto to allow anonymous connections (for local testing)
echo "listener 1883
allow_anonymous true" | sudo tee /etc/mosquitto/conf.d/allow-anonymous.conf
sudo systemctl restart mosquitto
```
**Interactive testing with mobile viewport:**
```bash
# Set up environment
export MQTT_EXPLORER_SKIP_AUTH=true
export MQTT_AUTO_CONNECT_HOST=127.0.0.1
# Build and start server
yarn build:server
node dist/src/server.js
# In another terminal, run Playwright test with mobile viewport
# Create test script with viewport: { width: 412, height: 914 }
# Always INSPECT the rendered output, don't rely on assumptions
```
**Key lesson**: Mobile tree visibility issues often stem from:
1. CSS flex/absolute positioning conflicts
2. Missing Redux state updates (connection not propagated to frontend)
3. MQTT broker authentication (mosquitto requires `allow_anonymous true` for testing)
4. Timing issues (frontend subscribing to events after backend emits them)
**Server-side auto-connect** (for testing):
```bash
export MQTT_AUTO_CONNECT_HOST=127.0.0.1
export MQTT_AUTO_CONNECT_PORT=1883 # optional
export MQTT_AUTO_CONNECT_PROTOCOL=mqtt # optional
```
+4
View File
@@ -8,6 +8,10 @@ on:
- Dockerfile
- .github
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
create-image:
runs-on: ubuntu-latest
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Install system dependencies
run: |
+21 -16
View File
@@ -22,6 +22,10 @@ on:
- cron: '0 2 1,15 * *'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
runs-on: ubuntu-latest
@@ -34,24 +38,25 @@ jobs:
TESTS_MQTT_BROKER_HOST: localhost
TESTS_MQTT_BROKER_PORT: 1883
services:
# MQTT broker for testing
mosquitto:
image: eclipse-mosquitto:2
ports:
- 1883:1883
options: >-
--health-cmd "mosquitto_sub -t '$SYS/#' -C 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
--entrypoint sh
cmd: -c "mkdir -p /mosquitto/config && echo 'listener 1883' > /mosquitto/config/mosquitto.conf && echo 'allow_anonymous true' >> /mosquitto/config/mosquitto.conf && exec mosquitto -c /mosquitto/config/mosquitto.conf"
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install 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
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -73,7 +78,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Build Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.browser
@@ -207,7 +212,7 @@ jobs:
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.browser
+137 -47
View File
@@ -2,6 +2,10 @@ on:
pull_request_target: # Use pull_request_target
branches: [master, beta, release]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
@@ -27,16 +31,10 @@ 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
@@ -59,16 +57,10 @@ 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
@@ -169,27 +161,146 @@ jobs:
body: markdown
});
demo-video-mobile:
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
id: generate_video
continue-on-error: true
run: ./scripts/uiTestsMobile.sh
- name: Post-processing
if: always()
continue-on-error: true
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
if: always()
continue-on-error: true
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
BASEPATH: ${{ steps.basepath.outputs.basepath }}
run: |
# Upload GIF if it exists
if [ -f ./ui-test-mobile.gif ]; then
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/ui-test-mobile.gif \
--body ./ui-test-mobile.gif \
--content-type image/gif
fi
# Upload MP4 if it exists
if [ -f ./ui-test-mobile.mp4 ]; then
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/ui-test-mobile.mp4 \
--body ./ui-test-mobile.mp4 \
--content-type video/mp4
fi
- name: Upload mobile video segments to S3
if: always()
continue-on-error: true
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
if: always()
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
if: always()
id: markdown
env:
BASE_URL: ${{ steps.fileurl.outputs.base-url }}
TEST_STATUS: ${{ steps.generate_video.outcome }}
run: |
MARKDOWN=$(node ./scripts/generateMarkdownSummaryMobile.js "${BASE_URL}" "${TEST_STATUS}")
echo "markdown<<EOF" >> $GITHUB_OUTPUT
echo "$MARKDOWN" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Add to workflow summary
if: always()
env:
MARKDOWN: ${{ steps.markdown.outputs.markdown }}
run: |
echo "$MARKDOWN" >> $GITHUB_STEP_SUMMARY
- name: Post mobile video to PR
if: always()
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
env:
TESTS_MQTT_BROKER_HOST: localhost
TESTS_MQTT_BROKER_PORT: 1883
services:
mosquitto:
image: eclipse-mosquitto:2
ports:
- 1883:1883
options: >-
--health-cmd "mosquitto_sub -t '$SYS/#' -C 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
--entrypoint sh
cmd: -c "mkdir -p /mosquitto/config && echo 'listener 1883' > /mosquitto/config/mosquitto.conf && echo 'allow_anonymous true' >> /mosquitto/config/mosquitto.conf && exec mosquitto -c /mosquitto/config/mosquitto.conf"
steps:
- 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:
@@ -204,26 +315,5 @@ jobs:
run: yarn test:app
- name: Test Backend
run: yarn test:backend
- 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
- name: Run Browser UI Tests
run: ./scripts/runBrowserTests.sh
+4
View File
@@ -2,6 +2,10 @@ name: Update Website
on: [release, workflow_dispatch]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
update-website:
runs-on: ubuntu-latest
+33 -1
View File
@@ -27,12 +27,44 @@ app/.webpack-cache
# Demo video artifacts
scenes.json
scenes-mobile.json
segment-*.mp4
segment-*.gif
ui-test.mp4
ui-test.gif
ui-test-mobile.mp4
ui-test-mobile.gif
app.mp4
app2.mp4
app-mobile.mp4
app2-mobile.mp4
app720.gif
qrawvideorgb24.yuv
intro.png
qrawvideorgb24-mobile.yuv
intro.png
intro-mobile.png
palette.png
palette-mobile.png
ffmpeg_info
ffmpeg_info_mobile# Mobile test artifacts
qrawvideorgb24-mobile.yuv
*.yuv
segment-mobile-*.gif
mobile-demo.mp4
mobile-demo.gif
final-mobile-tree.png
mobile-tree-debug.png
mobile-render-debug.png
tree-state-check.png
server*.log
# Test scripts
test-mobile-tree.js
check-*.js
debug-*.js
inspect-*.js
final-*.js
publish-test*.js
verify-mobile-tree.js
interactive-mobile-test.js
long-wait-test.js
+14 -13
View File
@@ -93,20 +93,21 @@ Tests the traditional Electron desktop application:
Tests the new browser/server mode:
- **Environment**: Ubuntu latest with Node.js 24
- **Services**:
- **Mosquitto MQTT Broker**: Eclipse Mosquitto v2 on port 1883
- Health checks enabled
- Anonymous connections allowed
- **MQTT Broker**: Mosquitto v2 on port 1883
- Started detached with `-d` flag
- Anonymous connections allowed
- No persistence
- **Steps**:
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
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
**Environment Variables**:
- `MQTT_EXPLORER_USERNAME=test`
+2 -2
View File
@@ -1,6 +1,6 @@
# Multi-stage build for MQTT Explorer Browser Mode
# Stage 1: Build and prepare production dependencies
FROM node:24-alpine AS builder
FROM node:22-alpine AS builder
WORKDIR /build
@@ -24,7 +24,7 @@ RUN yarn install --production --frozen-lockfile --network-timeout 100000 && \
rm -rf /tmp/*
# Stage 2: Production
FROM node:24-alpine
FROM node:22-alpine
# Install dumb-init in a single layer
RUN apk add --no-cache dumb-init
+17 -32
View File
@@ -1,6 +1,6 @@
When distributing, the attribution and donation page may not be altered or made less accessible without explicit approval.
# Creative Commons Attribution-ShareAlike 4.0 International
# Creative Commons Attribution-NoDerivatives 4.0 International
Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.
@@ -12,37 +12,33 @@ Creative Commons public licenses provide a standard set of terms and conditions
* __Considerations for the public:__ By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensors permission is not necessary for any reasonfor example, because of any applicable exception or limitation to copyrightthen that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. [More considerations for the public](http://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensees).
## Creative Commons Attribution-ShareAlike 4.0 International Public License
## Creative Commons Attribution-NoDerivatives 4.0 International Public License
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-ShareAlike 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NoDerivatives 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.
### Section 1 Definitions.
a. __Adapted Material__ means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.
b. __Adapter's License__ means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.
b. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
c. __BY-SA Compatible License__ means a license listed at [creativecommons.org/compatiblelicenses](http://creativecommons.org/compatiblelicenses), approved by Creative Commons as essentially the equivalent of this Public License.
c. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
d. __Copyright and Similar Rights__ means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.
d. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
e. __Effective Technological Measures__ means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.
e. __License Elements__ means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and NoDerivatives.
f. __Exceptions and Limitations__ means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.
f. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
g. __License Elements__ means the license attributes listed in the name of a Creative Commons Public License. The License Elements of this Public License are Attribution and ShareAlike.
g. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
h. __Licensed Material__ means the artistic or literary work, database, or other material to which the Licensor applied this Public License.
h. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License.
i. __Licensed Rights__ means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.
i. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
j. __Licensor__ means the individual(s) or entity(ies) granting rights under this Public License.
j. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
k. __Share__ means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.
l. __Sui Generis Database Rights__ means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.
m. __You__ means the individual or entity exercising the Licensed Rights under this Public License. __Your__ has a corresponding meaning.
k. __You__ means the individual or entity exercising the Licensed Rights under this Public License. __Your__ has a corresponding meaning.
### Section 2 Scope.
@@ -50,9 +46,9 @@ a. ___License grant.___
1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:
A. reproduce and Share the Licensed Material, in whole or in part; and
A. reproduce and Share the Licensed Material, in whole or in part; but not
B. produce, reproduce, and Share Adapted Material.
B. produce, reproduce, or Share Adapted Material.
2. __Exceptions and Limitations.__ For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.
@@ -64,9 +60,8 @@ a. ___License grant.___
A. __Offer from the Licensor Licensed Material.__ Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.
B. __Additional offer from the Licensor Adapted Material.__ Every recipient of Adapted Material from You automatically receives an offer from the Licensor to exercise the Licensed Rights in the Adapted Material under the conditions of the Adapters License You apply.
B. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
C. __No downstream restrictions.__ You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.
6. __No endorsement.__ Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).
@@ -106,23 +101,13 @@ a. ___Attribution.___
3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.
b. ___ShareAlike.___
In addition to the conditions in Section 3(a), if You Share Adapted Material You produce, the following conditions also apply.
1. The Adapters License You apply must be a Creative Commons license with the same License Elements, this version or later, or a BY-SA Compatible License.
2. You must include the text of, or the URI or hyperlink to, the Adapter's License You apply. You may satisfy this condition in any reasonable manner based on the medium, means, and context in which You Share Adapted Material.
3. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, Adapted Material that restrict exercise of the rights granted under the Adapter's License You apply.
### Section 4 Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material, including for purposes of Section 3(b); and
b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.
+156
View File
@@ -0,0 +1,156 @@
# 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)
+180
View File
@@ -0,0 +1,180 @@
# Mobile Testing Guide
This document describes how to run and debug mobile UI tests for MQTT Explorer.
## Overview
The mobile tests simulate MQTT Explorer running in a mobile browser (Google Pixel 6 viewport: 412x914px) and generate demo videos showing the mobile user experience.
## Prerequisites
### System Dependencies
```bash
sudo apt-get install -y ffmpeg tmux xvfb x11vnc mosquitto
```
### Node Dependencies
```bash
yarn install
npx playwright install --with-deps chromium
```
## Running Mobile Tests
### 1. Build the Application
```bash
yarn build:server # For browser mode
```
### 2. Run Tests
```bash
./scripts/uiTestsMobile.sh
```
This will:
- Start Xvfb (virtual framebuffer)
- Start mosquitto MQTT broker
- Start MQTT Explorer server in browser mode
- Run Playwright tests with mobile viewport
- Record video of the test session
### 3. Post-Process Video
```bash
./scripts/prepareVideoMobile.sh
```
This converts the raw video to MP4 and GIF formats and creates individual segments for each test scene.
## Output Files
- `ui-test-mobile.mp4` - Full mobile test video (MP4)
- `ui-test-mobile.gif` - Full mobile test video (GIF)
- `segment-mobile-*.gif` - Individual scene segments
- `scenes-mobile.json` - Scene timing metadata
All video files are automatically excluded from git (see `.gitignore`).
## Debugging
### View Test in VNC
During test execution, you can connect with VNC to watch in real-time:
```bash
# Password: bierbier
vncviewer localhost:5900
```
### Common Issues
#### 1. Playwright Browsers Not Installed
**Error:** `Executable doesn't exist at .../chromium_headless_shell-1200/chrome-headless-shell`
**Solution:**
```bash
npx playwright install --with-deps chromium
```
#### 2. Video Encoding Fails
**Error:** `height not divisible by 2`
**Solution:** Ensure viewport height is even. Mobile viewport is set to 412x914 (not 915).
#### 3. Elements Outside Viewport
**Error:** `element is outside of the viewport`
**Solution:** The fix adds `scrollIntoViewIfNeeded()` before clicking elements. For modal/dialog elements that intercept clicks, use `force: true`.
#### 4. MQTT Broker Already Running
**Error:** `Address already in use` on port 1883
**Solution:** Kill existing mosquitto process:
```bash
pkill mosquitto
```
## Mobile UI Enhancements
The tests revealed several mobile UI improvements that were implemented:
### 1. Connection Dialog Responsiveness
Added responsive CSS to `ConnectionSetup.tsx`:
- Mobile viewports use 95vw width and 85vh height
- Enabled scrolling on the right panel
- Hide profile list on mobile to save space
### 2. Click Handling
Enhanced `clickOn` helper in `util/index.ts`:
- Added `scrollIntoViewIfNeeded()` to ensure elements are in viewport
- Support for `force: true` to bypass overlay elements
### 3. Tree Node Expansion
Updated `expandTopic` helper:
- Use `force: true` for tree clicks to bypass accordion overlays
- Better handling of nested topic expansion
## Test Scenes
The mobile demo includes these scenes:
1. **mobile_intro** - Introduction screen
2. **mobile_connect** - Connect to MQTT broker
3. **mobile_browse_topics** - Browse topic tree
4. **mobile_search** - Search and filter topics
5. **mobile_view_message** - View message details
6. **mobile_json_view** - JSON formatting display
7. **mobile_clipboard** - Copy operations
8. **mobile_plots** - Numeric data visualization
9. **mobile_menu** - Settings and menu
10. **mobile_end** - Conclusion screen
## CI Integration
Mobile tests run in the `demo-video-mobile` job in `.github/workflows/tests.yml`:
```yaml
demo-video-mobile:
runs-on: ubuntu-latest
container:
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
steps:
- name: Generate Mobile Demo Video
run: ./scripts/uiTestsMobile.sh
- name: Post-processing
run: ./scripts/prepareVideoMobile.sh
```
Videos are uploaded to S3 and linked in PR comments.
## Technical Notes
### Viewport Configuration
- **Width:** 412px (Pixel 6)
- **Height:** 914px (must be even for h264 encoding)
- **Device Scale Factor:** 2.625
- **Mobile Mode:** Enabled with touch events
### Video Recording
- Raw video: YUV420P format
- Frame rate: 20 fps
- Recording tool: ffmpeg via tmux
### Post-Processing
- MP4 encoding: h264 codec
- GIF palette: 256 colors optimized per segment
- Segment creation: Based on scene timing in `scenes-mobile.json`
## Future Improvements
Potential areas for enhancement:
1. **Touch Gestures** - Add swipe and pinch interactions
2. **Performance** - Optimize for slower mobile networks
3. **Accessibility** - Larger touch targets, better contrast
4. **PWA Support** - Add manifest for "add to home screen"
5. **Orientation** - Test landscape mode
## References
- [MOBILE_COMPATIBILITY.md](./MOBILE_COMPATIBILITY.md) - Mobile compatibility strategy
- [Playwright Device Emulation](https://playwright.dev/docs/emulation)
- [Material-UI Responsive Design](https://mui.com/material-ui/customization/breakpoints/)
+30
View File
@@ -99,6 +99,8 @@ 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:
@@ -185,6 +187,34 @@ 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
@@ -0,0 +1,212 @@
# 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,6 +18,49 @@
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;
+24 -24
View File
@@ -21,23 +21,23 @@
"@mui/lab": "^7.0.1-beta.20",
"@mui/material": "^7.3.6",
"@mui/styles": "^6.4.8",
"@react-spring/web": "^9.7.5",
"@react-spring/web": "^10.0.3",
"@types/react-transition-group": "^4.4.11",
"@visx/axis": "^3.10.1",
"@visx/grid": "^3.5.0",
"@visx/tooltip": "^3.3.0",
"@visx/xychart": "^3.10.2",
"ace-builds": "^1.4.11",
"ace-builds": "^1.43.5",
"axios": "^1.13.2",
"compare-versions": "^6.1.1",
"copy-text-to-clipboard": "^3.2.0",
"d3": "^7.9.0",
"d3-shape": "^3.2.0",
"diff": "^7.0.0",
"dot-prop": "^5.3.0",
"diff": "^8.0.2",
"dot-prop": "5",
"events": "^3.3.0",
"get-value": "^3.0.1",
"immutable": "^4.3.7",
"get-value": "^4.0.1",
"immutable": "^5.1.4",
"in-viewport": "^3.6.0",
"js-base64": "^3.7.8",
"json-to-ast": "^2.1.0",
@@ -46,32 +46,32 @@
"moving-average": "^1.0.0",
"number-abbreviate": "^2.0.0",
"os-browserify": "^0.3.0",
"parse-duration": "^0.1.1",
"parse-duration": "^2.1.5",
"path-browserify": "^1.0.1",
"prismjs": "^1.29.0",
"react": "^19.2.3",
"react-ace": "^14.0.1",
"react-dom": "^19.2.3",
"react-redux": "^9.2.0",
"react-resize-detector": "^11.0.1",
"react-split-pane": "^0.1.92",
"react-resize-detector": "^12.3.0",
"react-split-pane": "^3.0.4",
"react-transition-group": "^4.4.5",
"redux": "^5.0.1",
"redux-batched-actions": "^0.5.0",
"redux-thunk": "^3.1.0",
"sha1": "^1.1.1",
"socket.io-client": "^4.8.1",
"socket.io-client": "^4.8.3",
"url": "^0.11.4",
"uuid": "^11.0.0"
"uuid": "^13.0.0"
},
"devDependencies": {
"@babel/runtime": "^7.28.4",
"@reduxjs/toolkit": "2.5.0",
"@testing-library/dom": "10.4.0",
"@testing-library/react": "16.1.0",
"@testing-library/user-event": "14.5.2",
"@reduxjs/toolkit": "^2.11.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/d3": "^7.4.3",
"@types/diff": "^7.0.0",
"@types/diff": "^8.0.0",
"@types/get-value": "^3.0.5",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^25.0.3",
@@ -79,28 +79,28 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-redux": "^7.1.34",
"@types/react-resize-detector": "^4.0.3",
"@types/react-resize-detector": "^6.1.0",
"@types/sha1": "^1.1.1",
"@types/socket.io-client": "^3.0.0",
"@types/uuid": "^11.0.0",
"@types/vis": "^4.21.24",
"chai": "^4.5.0",
"cross-env": "^7.0.3",
"chai": "^6.2.2",
"cross-env": "^10.1.0",
"css-loader": "^7.1.2",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.6.3",
"jsdom": "25.0.1",
"jsdom": "^27.4.0",
"jsdom-global": "3.0.2",
"lodash": "^4.17.21",
"mocha": "^10.8.2",
"mocha": "^11.7.5",
"moment": "^2.30.1",
"node-loader": "^2.0.0",
"source-map-loader": "^5.0.0",
"style-loader": "^4.0.0",
"ts-loader": "^9.5.1",
"ts-loader": "^9.5.4",
"typescript": "^5.9.3",
"webpack": "^5.98.0",
"webpack-bundle-analyzer": "^4.10.2",
"webpack": "^5.104.1",
"webpack-bundle-analyzer": "^5.1.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.2.0"
},
+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 '../../../events'
import { addMqttConnectionEvent, makeConnectionStateEvent, removeConnection, rendererEvents } from '../eventBus'
export const connect =
(options: MqttOptions, connectionId: string) => (dispatch: Dispatch<any>, getState: () => AppState) => {
+4 -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 '../../../events'
import { rendererRpc, readFromFile } from '../eventBus'
import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest'
export interface ConnectionDictionary {
@@ -46,6 +46,9 @@ export const loadConnectionSettings = () => async (dispatch: Dispatch<any>, getS
const firstKey = Object.keys(connections)[0]
if (firstKey) {
dispatch(selectConnection(firstKey))
} else {
// No connections exist - create a default one
dispatch(createConnection())
}
}
+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 '../../../events'
import { MqttMessage, makePublishEvent, rendererEvents, rendererRpc, readFromFile } from '../eventBus'
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 '../../../events'
import { makePublishEvent, rendererEvents } from '../eventBus'
import { moveSelectionUpOrDownwards } from './visibleTreeTraversal'
import { globalActions } from '.'
+48
View File
@@ -0,0 +1,48 @@
// Auto-connect handler for browser mode
// This file is loaded early in the app initialization to handle server-initiated auto-connect
import { store } from './store'
import * as q from '../../backend/src/Model'
import { TopicViewModel } from './model/TopicViewModel'
import { showTree } from './actions/Tree'
import { connecting, connected } from './actions/Connection'
import { makeConnectionStateEvent, rendererEvents } from './eventBus'
import { DataSourceState } from '../../backend/src/DataSource'
// Listen for auto-connect-initiated event from server
if (typeof window !== 'undefined') {
window.addEventListener('mqtt-auto-connect-initiated', ((event: CustomEvent) => {
const { connectionId } = event.detail
console.log('Auto-connect initiated from server, connectionId:', connectionId)
// Dispatch connecting action
store.dispatch(connecting(connectionId) as any)
console.log('Dispatched connecting action')
// Subscribe to connection state events
const stateEvent = makeConnectionStateEvent(connectionId)
console.log('Subscribing to connection state event:', stateEvent)
rendererEvents.subscribe(stateEvent, (dataSourceState: DataSourceState) => {
console.log('Auto-connect state update:', JSON.stringify(dataSourceState, null, 2))
if (dataSourceState.connected) {
console.log('Auto-connect: connection established!')
const state = store.getState()
const didReconnect = Boolean(state.connection.tree)
if (!didReconnect) {
// Create tree and update with connection
console.log('Creating tree for connection:', connectionId)
const tree = new q.Tree<TopicViewModel>()
tree.updateWithConnection(rendererEvents, connectionId)
store.dispatch(showTree(tree) as any)
store.dispatch(connected(tree, 'auto-connect') as any)
console.log('Auto-connect successful, tree created and dispatched')
}
} else if (dataSourceState.error) {
console.error('Auto-connect error:', dataSourceState.error)
}
})
console.log('Auto-connect handler setup complete')
}) as EventListener)
}
+24
View File
@@ -72,6 +72,30 @@ socket.on('auth-status', (data: { authDisabled: boolean }) => {
}
})
// Listen for auto-connect configuration from server
socket.on('auto-connect-config', (config: any) => {
console.log('Auto-connect configuration received from server')
// Dispatch custom event with auto-connect config
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mqtt-auto-connect-config', {
detail: config
}))
}
})
// Listen for auto-connect-initiated event from server
socket.on('auto-connect-initiated', (data: { connectionId: string }) => {
console.log('Auto-connect initiated by server, connectionId:', data.connectionId)
// Dispatch custom event to trigger connection flow
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('mqtt-auto-connect-initiated', {
detail: data
}))
}
})
/**
* Update socket authentication credentials and attempt to reconnect
* @param newUsername New username
@@ -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 '../../../../events'
import { rendererRpc } from '../../eventBus'
import { RpcEvents } from '../../../../events/EventsV2'
function BrowserCertificateFileSelection(props: {
@@ -188,6 +188,7 @@ function ConnectionSettings(props: Props) {
value={connection.host}
onChange={handleChange('host')}
margin="normal"
inputProps={{ 'data-testid': 'host-input' }}
/>
</Grid>
<Grid item={true} xs={3}>
@@ -2,6 +2,7 @@ import * as React from 'react'
import ConnectionSettings from './ConnectionSettings'
const ConnectionSettingsAny = ConnectionSettings as any
import ProfileList from './ProfileList'
import MobileConnectionSelector from './MobileConnectionSelector'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
@@ -66,10 +67,15 @@ class ConnectionSetup extends React.PureComponent<Props, {}> {
</div>
<div className={classes.right} key={connection && connection.id}>
<Toolbar>
<Typography className={classes.title} variant="h6" color="inherit">
MQTT Connection
</Typography>
<Typography className={classes.connectionUri}>{mqttConnection && mqttConnection.url}</Typography>
<div className={classes.toolbarContent}>
<div className={classes.desktopTitle}>
<Typography className={classes.title} variant="h6" color="inherit">
MQTT Connection
</Typography>
<Typography className={classes.connectionUri}>{mqttConnection && mqttConnection.url}</Typography>
</div>
<MobileConnectionSelector />
</div>
</Toolbar>
{this.renderSettings()}
</div>
@@ -86,6 +92,20 @@ const styles = (theme: Theme) => ({
color: theme.palette.text.primary,
whiteSpace: 'nowrap' as 'nowrap',
},
toolbarContent: {
width: '100%',
display: 'flex',
alignItems: 'center',
},
desktopTitle: {
display: 'flex',
alignItems: 'center',
flex: 1,
// Hide on mobile - connection selector will take its place
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
},
},
root: {
margin: `calc((100vh - ${connectionHeight}) / 2) auto 0 auto`,
minWidth: '800px',
@@ -93,6 +113,14 @@ const styles = (theme: Theme) => ({
height: connectionHeight,
outline: 'none' as 'none',
display: 'flex' as 'flex',
// Mobile responsive adjustments
[theme.breakpoints.down('md')]: {
minWidth: '95vw',
maxWidth: '95vw',
height: '85vh',
margin: '7.5vh auto 0 auto',
flexDirection: 'column' as 'column',
},
},
left: {
borderRightStyle: 'dotted' as 'dotted',
@@ -103,12 +131,21 @@ const styles = (theme: Theme) => ({
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
overflowY: 'auto' as 'auto',
// Mobile: hide profile list to save space
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
},
},
right: {
borderRadius: `0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0`,
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(2),
flex: 10,
// Mobile: enable scrolling
[theme.breakpoints.down('md')]: {
borderRadius: `${theme.shape.borderRadius}px`,
overflowY: 'auto' as 'auto',
},
},
connectionUri: {
width: '27em',
@@ -0,0 +1,125 @@
import * as React from 'react'
import Add from '@mui/icons-material/Add'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { IconButton, MenuItem, Select, SelectChangeEvent } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
const styles = (theme: Theme) => ({
container: {
display: 'none',
// Only show on mobile, takes full width
[theme.breakpoints.down('md')]: {
display: 'flex',
flex: 1,
alignItems: 'center',
gap: theme.spacing(1),
},
},
select: {
flex: 1,
fontSize: '1rem',
'& .MuiSelect-select': {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
},
},
addButton: {
padding: theme.spacing(1),
},
})
interface Props {
classes: any
connections: Array<{ id: string; name?: string; host?: string }>
currentConnectionId?: string
actions: typeof connectionManagerActions
}
class MobileConnectionSelector extends React.PureComponent<Props, {}> {
private handleConnectionChange = (event: SelectChangeEvent<string>) => {
const connectionId = event.target.value
this.props.actions.selectConnection(connectionId)
}
private handleCreateConnection = () => {
this.props.actions.createConnection()
}
private getConnectionDisplayName = (connection: { name?: string; host?: string }) => {
return connection.name || connection.host || 'Unnamed Connection'
}
public render() {
const { classes, connections, currentConnectionId } = this.props
if (!connections || connections.length === 0) {
return null
}
return (
<div className={classes.container}>
<Select
className={classes.select}
value={currentConnectionId || ''}
onChange={this.handleConnectionChange}
aria-label="Select MQTT connection"
displayEmpty
MenuProps={{
PaperProps: {
style: {
maxHeight: '60vh',
},
},
}}
>
{connections.map(conn => {
const isConnected = conn.id === currentConnectionId
const displayName = this.getConnectionDisplayName(conn)
return (
<MenuItem key={conn.id} value={conn.id}>
{displayName}
{isConnected && ' (Connected)'}
</MenuItem>
)
})}
</Select>
<IconButton
className={classes.addButton}
onClick={this.handleCreateConnection}
aria-label="Create new connection"
size="medium"
>
<Add />
</IconButton>
</div>
)
}
}
const mapStateToProps = (state: AppState) => {
const connectionManager = state.connectionManager
const connections = connectionManager && connectionManager.connections
? Object.values(connectionManager.connections).map(conn => ({
id: conn.id,
name: conn.name,
host: conn.host,
}))
: []
return {
connections,
currentConnectionId: state.connectionManager?.selected,
}
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: bindActionCreators(connectionManagerActions, dispatch),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(withStyles(styles)(MobileConnectionSelector))
+105 -5
View File
@@ -1,6 +1,6 @@
import * as React from 'react'
import ChartPanel from '../ChartPanel'
import ReactSplitPaneImport from 'react-split-pane'
import { SplitPane } from 'react-split-pane'
import Tree from '../Tree'
import { AppState } from '../../reducers'
import { ChartParameters } from '../../reducers/Charts'
@@ -8,9 +8,10 @@ import { connect } from 'react-redux'
import { List } from 'immutable'
import { Sidebar } from '../Sidebar'
import { useResizeDetector } from 'react-resize-detector'
import MobileTabs from './MobileTabs'
// Type cast to any to work around React 18 compatibility issues with react-split-pane 0.1.x
const ReactSplitPane = ReactSplitPaneImport as any
// Alias for compatibility
const ReactSplitPane = SplitPane
interface Props {
heightProperty: any
@@ -20,11 +21,28 @@ interface Props {
}
function ContentView(props: Props) {
// Use different defaults for mobile viewports (<=768px width)
// Use state for mobile detection that updates on resize
const [isMobile, setIsMobile] = React.useState(() => typeof window !== 'undefined' && window.innerWidth <= 768)
const [mobileTab, setMobileTab] = React.useState(0) // 0 = topics, 1 = details
const [height, setHeight] = React.useState<string | number>('100%')
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>('40%')
const [sidebarWidth, setSidebarWidth] = React.useState<string | number>(isMobile ? '100%' : '40%')
const [detectedHeight, setDetectedHeight] = React.useState(0)
const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0)
// Update mobile state on resize
React.useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768)
}
// Set initial state
handleResize()
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
const { height: resizeHeight, ref: heightRef } = useResizeDetector()
const { width: resizeWidth, ref: widthRef } = useResizeDetector()
@@ -68,6 +86,83 @@ function ContentView(props: Props) {
}
}, [props.chartPanelItems])
// Mobile view with tab switcher
if (isMobile) {
// Expose tab switching functions for other components to call
React.useEffect(() => {
if (typeof window !== 'undefined') {
(window as any).switchToDetailsTab = () => setMobileTab(1)
(window as any).switchToTopicsTab = () => setMobileTab(0)
}
return () => {
if (typeof window !== 'undefined') {
delete (window as any).switchToDetailsTab
delete (window as any).switchToTopicsTab
}
}
}, [])
const mobileContainerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 64px)', // Full viewport minus titlebar
width: '100%',
}
const tabContentStyle: React.CSSProperties = {
flex: 1,
display: 'flex',
flexDirection: 'column',
minHeight: 0, // Critical for flex children with overflow
width: '100%',
overflow: 'hidden',
position: 'relative',
}
// Tree container needs explicit height for the Tree component's height: 100% to work
const treeContainerStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
}
const sidebarContainerStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
width: '100%',
height: '100%',
overflow: 'auto',
}
return (
<div style={mobileContainerStyle}>
<MobileTabs value={mobileTab} onChange={setMobileTab} />
<div style={tabContentStyle}>
{/* Topics tab */}
{mobileTab === 0 && (
<div style={treeContainerStyle}>
<Tree />
</div>
)}
{/* Details tab */}
{mobileTab === 1 && (
<div style={sidebarContainerStyle}>
<Sidebar connectionId={props.connectionId} />
</div>
)}
</div>
</div>
)
}
// Desktop view with split panes
return (
<div className={props.paneDefaults}>
<span>
@@ -109,7 +204,12 @@ function ContentView(props: Props) {
<div ref={widthRef} style={{ height: '100%' }}>
<div
className={props.paneDefaults}
style={{ minWidth: '250px', height: '100%', overflowY: 'auto', overflowX: 'hidden' }}
style={{
minWidth: '250px',
height: '100%',
overflowY: 'auto',
overflowX: 'hidden'
}}
>
<Sidebar connectionId={props.connectionId} />
</div>
+69
View File
@@ -0,0 +1,69 @@
import * as React from 'react'
import { Tabs, Tab, Box } from '@mui/material'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
interface Props {
classes: any
value: number
onChange: (value: number) => void
}
function MobileTabs(props: Props) {
const handleChange = (_event: React.SyntheticEvent, newValue: number) => {
props.onChange(newValue)
}
return (
<Box className={props.classes.root} role="navigation" aria-label="Mobile navigation tabs">
<Tabs
value={props.value}
onChange={handleChange}
variant="fullWidth"
indicatorColor="primary"
textColor="primary"
aria-label="Topics and Details tabs"
>
<Tab
label="Topics"
data-testid="mobile-tab-topics"
aria-label="View topics tree"
id="mobile-tab-0"
aria-controls="mobile-tabpanel-0"
/>
<Tab
label="Details"
data-testid="mobile-tab-details"
aria-label="View topic details"
id="mobile-tab-1"
aria-controls="mobile-tabpanel-1"
/>
</Tabs>
</Box>
)
}
const styles = (theme: Theme) => ({
root: {
borderBottom: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
position: 'relative' as 'relative',
zIndex: 1,
minHeight: '56px', // Touch-friendly tab height
'& .MuiTab-root': {
minHeight: '56px', // 48px minimum + padding
fontSize: '16px', // Prevent iOS zoom
fontWeight: 500,
padding: theme.spacing(1.5, 2),
textTransform: 'none' as 'none', // Better readability
'&:active': {
opacity: 0.7, // Touch feedback
},
},
'& .MuiTabs-indicator': {
height: '3px', // Thicker indicator for better visibility
},
},
})
export default withStyles(styles)(MobileTabs)
+35 -5
View File
@@ -23,7 +23,15 @@ function SearchBar(props: {
const [hasFocus, setHasFocus] = useState(false)
const inputRef = useRef<HTMLInputElement>()
const onFocus = useCallback(() => setHasFocus(true), [])
const onFocus = useCallback(() => {
setHasFocus(true)
// On mobile, switch to Topics tab when search is focused
if (typeof window !== 'undefined' && window.innerWidth <= 768) {
if ((window as any).switchToTopicsTab) {
(window as any).switchToTopicsTab()
}
}
}, [])
const onBlur = useCallback(() => setHasFocus(false), [])
const clearFilter = useCallback(() => {
@@ -57,8 +65,8 @@ function SearchBar(props: {
})
return (
<div className={classes.search}>
<div className={classes.searchIcon}>
<div className={classes.search} role="search">
<div className={classes.searchIcon} aria-hidden="true">
<Search />
</div>
<InputBase
@@ -67,6 +75,7 @@ function SearchBar(props: {
onFocus,
onBlur,
ref: inputRef,
'aria-label': 'Search topics',
}}
onChange={onFilterChange}
placeholder="Search…"
@@ -130,16 +139,37 @@ const styles = (theme: Theme) => ({
justifyContent: 'center' as 'center',
},
inputRoot: {
color: 'inherit' as 'inherit',
color: `${theme.palette.common.white} !important`, // Ensure white text color with high specificity
width: '100%',
'& input': {
color: `${theme.palette.common.white} !important`, // Target input element directly
},
},
inputInput: {
paddingTop: theme.spacing(1),
paddingRight: theme.spacing(1),
paddingBottom: theme.spacing(1),
paddingLeft: theme.spacing(6),
paddingLeft: `${theme.spacing(6)} !important`, // Ensure padding is applied (48px)
transition: theme.transitions.create('width'),
width: '100%',
color: `${theme.palette.common.white} !important`, // High contrast white text with priority
fontSize: '16px', // Prevent iOS zoom on focus
'&::placeholder': {
color: `${fade(theme.palette.common.white, 0.7)} !important`, // Semi-transparent white placeholder
opacity: 1,
},
'&::-webkit-input-placeholder': {
color: `${fade(theme.palette.common.white, 0.7)} !important`,
},
'&::-moz-placeholder': {
color: `${fade(theme.palette.common.white, 0.7)} !important`,
},
// Improve mobile input handling
[theme.breakpoints.down('md')]: {
fontSize: '16px', // Prevent zoom
WebkitAppearance: 'none',
touchAction: 'manipulation',
},
},
})
+11
View File
@@ -22,6 +22,9 @@ const styles = (theme: Theme) => ({
[theme.breakpoints.up(750)]: {
display: 'block' as 'block',
},
[theme.breakpoints.up('md')]: {
display: 'block' as 'block',
},
whiteSpace: 'nowrap' as 'nowrap',
},
disconnectIcon: {
@@ -37,9 +40,17 @@ const styles = (theme: Theme) => ({
},
disconnect: {
margin: 'auto 8px auto auto',
// Hide on mobile (<=768px)
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
},
},
logout: {
margin: 'auto 0 auto 8px',
// Hide on mobile (<=768px)
[theme.breakpoints.down('md')]: {
display: 'none' as 'none',
},
},
disconnectLabel: {
color: theme.palette.primary.contrastText,
@@ -0,0 +1,211 @@
/**
* 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')
})
})
})
+68 -1
View File
@@ -2,17 +2,22 @@ import * as React from 'react'
import BooleanSwitch from './BooleanSwitch'
import BrokerStatistics from './BrokerStatistics'
import ChevronRight from '@mui/icons-material/ChevronRight'
import CloudOff from '@mui/icons-material/CloudOff'
import Logout from '@mui/icons-material/Logout'
import TimeLocale from './TimeLocale'
import { AppState } from '../../reducers'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { globalActions, settingsActions } from '../../actions'
import { globalActions, settingsActions, connectionActions } from '../../actions'
import { shell } from 'electron'
import { Theme } from '@mui/material/styles'
import { withStyles } from '@mui/styles'
import { TopicOrder } from '../../reducers/Settings'
import { isBrowserMode } from '../../utils/browserMode'
import { useAuth } from '../../contexts/AuthContext'
import {
Button,
Divider,
Drawer,
IconButton,
@@ -75,12 +80,26 @@ const styles = (theme: Theme) => ({
color: theme.palette.text.secondary,
cursor: 'pointer' as 'pointer',
},
mobileButtons: {
padding: theme.spacing(1),
display: 'flex',
flexDirection: 'column' as 'column',
gap: theme.spacing(1),
// Only show on mobile
[theme.breakpoints.up('md')]: {
display: 'none' as 'none',
},
},
mobileButton: {
justifyContent: 'flex-start',
},
})
interface Props {
actions: {
settings: typeof settingsActions
global: typeof globalActions
connection: typeof connectionActions
}
autoExpandLimit: number
classes: any
@@ -219,6 +238,7 @@ class Settings extends React.PureComponent<Props, {}> {
</Typography>
<Divider style={{ userSelect: 'none' }} />
</div>
<MobileActionButtons classes={classes} actions={actions} />
<div>
{this.renderAutoExpand()}
{this.renderNodeOrder()}
@@ -238,6 +258,52 @@ class Settings extends React.PureComponent<Props, {}> {
}
}
// Mobile action buttons component (disconnect/logout)
function MobileActionButtons({ classes, actions }: { classes: any; actions: any }) {
const { authDisabled } = useAuth()
const handleLogout = async () => {
// Disconnect first
actions.connection.disconnect()
// Clear credentials from sessionStorage
if (typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem('mqtt-explorer-username')
sessionStorage.removeItem('mqtt-explorer-password')
}
// Reload page to reset all state and show login dialog
if (typeof window !== 'undefined') {
window.location.reload()
}
}
return (
<div className={classes.mobileButtons}>
<Button
variant="outlined"
startIcon={<CloudOff />}
onClick={actions.connection.disconnect}
className={classes.mobileButton}
data-testid="mobile-disconnect-button"
>
Disconnect
</Button>
{isBrowserMode && !authDisabled && (
<Button
variant="outlined"
startIcon={<Logout />}
onClick={handleLogout}
className={classes.mobileButton}
data-testid="mobile-logout-button"
>
Logout
</Button>
)}
</div>
)
}
const mapStateToProps = (state: AppState) => {
return {
autoExpandLimit: state.settings.get('autoExpandLimit'),
@@ -254,6 +320,7 @@ const mapDispatchToProps = (dispatch: any) => {
actions: {
settings: bindActionCreators(settingsActions, dispatch),
global: bindActionCreators(globalActions, dispatch),
connection: bindActionCreators(connectionActions, dispatch),
},
}
}
@@ -24,7 +24,7 @@ export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
const selectOption = useCallback(
(decoder: MessageDecoder, format: string) => {
if (!node) {
if (!node || !node.viewModel) {
return
}
@@ -55,7 +55,7 @@ export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
return (
<Button onClick={handleToggle}>
{props.node?.viewModel.decoder?.format ?? props.node?.type}
{props.node?.viewModel?.decoder?.format ?? props.node?.type}
<Popper open={open} anchorEl={anchorEl} role={undefined} transition>
{({ TransitionProps, placement }) => (
<Grow
@@ -52,8 +52,21 @@ export const TreeNodeTitle = (props: TreeNodeProps) => {
return null
}
// On mobile, the expand button has its own click handler separate from topic selection
// On desktop, clicking anywhere (including expander) selects and toggles via didClickTitle
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
const onClick = isMobile ? props.toggleCollapsed : undefined
return (
<span key="expander" className={props.classes.expander} onClick={props.toggleCollapsed}>
<span
key="expander"
className={props.classes.expander}
onClick={onClick}
role="button"
aria-label={props.collapsed ? 'Expand topic' : 'Collapse topic'}
aria-expanded={!props.collapsed}
tabIndex={isMobile ? 0 : -1}
>
{props.collapsed ? '▶' : '▼'}
</span>
)
@@ -83,27 +96,39 @@ export const TreeNodeTitle = (props: TreeNodeProps) => {
)
}
const styles = (theme: Theme) => ({
value: {
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
padding: '0',
},
sourceEdge: {
fontWeight: 'bold' as 'bold',
overflow: 'hidden' as 'hidden',
},
expander: {
color: theme.palette.mode === 'light' ? '#222' : '#eee',
cursor: 'pointer' as 'pointer',
paddingRight: theme.spacing(0.25),
userSelect: 'none' as 'none',
},
collapsedSubnodes: {
color: theme.palette.text.secondary,
userSelect: 'none' as 'none',
},
})
const styles = (theme: Theme) => {
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
return {
value: {
whiteSpace: 'nowrap' as 'nowrap',
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
padding: '0',
fontSize: isMobile ? '15px' : 'inherit', // Slightly larger on mobile
},
sourceEdge: {
fontWeight: 'bold' as 'bold',
overflow: 'hidden' as 'hidden',
fontSize: isMobile ? '16px' : 'inherit', // Base 16px on mobile to prevent zoom
},
expander: {
color: theme.palette.mode === 'light' ? '#222' : '#eee',
cursor: 'pointer' as 'pointer',
paddingRight: isMobile ? theme.spacing(1) : theme.spacing(0.25), // Larger touch area
paddingLeft: isMobile ? theme.spacing(0.5) : 0,
minWidth: isMobile ? '32px' : 'auto', // 40px total width on mobile for touch
display: 'inline-block' as 'inline-block',
textAlign: 'center' as 'center',
userSelect: 'none' as 'none',
fontSize: isMobile ? '18px' : 'inherit', // Larger icon on mobile
},
collapsedSubnodes: {
color: theme.palette.text.secondary,
userSelect: 'none' as 'none',
fontSize: isMobile ? '14px' : 'inherit',
},
}
}
export default withStyles(styles)(memo(TreeNodeTitle))
+20 -2
View File
@@ -61,8 +61,22 @@ function TreeNodeComponent(props: Props) {
const didClickTitle = React.useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
didSelectTopic()
setCollapsedOverride(!isCollapsed)
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
if (isMobile) {
// Mobile: Only select the topic (no toggle)
// Expanding is handled by the separate expand button click
didSelectTopic()
// Switch to details tab on mobile after selecting a topic
if (typeof window !== 'undefined' && (window as any).switchToDetailsTab) {
(window as any).switchToDetailsTab()
}
} else {
// Desktop: Original behavior - select AND toggle (click anywhere works)
didSelectTopic()
setCollapsedOverride(!isCollapsed)
}
},
[isCollapsed, didSelectTopic]
)
@@ -122,6 +136,10 @@ function TreeNodeComponent(props: Props) {
onClick={didClickTitle}
tabIndex={-1}
onKeyDown={deleteTopicCallback}
role="treeitem"
aria-selected={selected}
aria-expanded={!isCollapsed}
aria-label={`Topic: ${name || treeNode.sourceEdge?.name || 'root'}`}
>
<TreeNodeTitle
lastUpdate={treeNode.lastUpdate}
+15 -5
View File
@@ -2,6 +2,8 @@ import { blueGrey } from '@mui/material/colors'
import { Theme } from '@mui/material/styles'
export const styles = (theme: Theme) => {
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
return {
animationLight: {
willChange: 'auto',
@@ -25,7 +27,7 @@ export const styles = (theme: Theme) => {
overflow: 'hidden' as 'hidden',
textOverflow: 'ellipsis' as 'ellipsis',
whiteSpace: 'nowrap' as 'nowrap',
padding: '1px 0px 0px 0px',
padding: isMobile ? '1px 0px' : '1px 0px 0px 0px',
},
topicSelect: {
float: 'right' as 'right',
@@ -34,7 +36,7 @@ export const styles = (theme: Theme) => {
marginTop: '-1px',
},
subnodes: {
marginLeft: theme.spacing(1.5),
marginLeft: isMobile ? theme.spacing(2) : theme.spacing(1.5), // Increased indentation on mobile
},
selected: {
backgroundColor: (theme.palette.mode === 'light' ? blueGrey[300] : theme.palette.primary.main) + ' !important',
@@ -42,15 +44,23 @@ export const styles = (theme: Theme) => {
hover: {},
title: {
borderRadius: '4px',
lineHeight: '1em',
lineHeight: isMobile ? '1.3em' : '1em',
display: 'inline-block' as 'inline-block',
whiteSpace: 'nowrap' as 'nowrap',
height: '14px',
padding: '1px 4px 0 4px',
minHeight: isMobile ? '40px' : '14px', // 44px touch target on mobile (WCAG AA minimum)
height: 'auto' as 'auto',
padding: isMobile ? '8px 8px' : '1px 4px 0 4px', // Reduced padding, still touch-friendly
margin: '1px 0px',
fontSize: isMobile ? '16px' : 'inherit', // Prevent iOS zoom on focus
cursor: 'pointer' as 'pointer',
'&:hover': {
backgroundColor: theme.palette.mode === 'light' ? blueGrey[100] : theme.palette.primary.light,
},
// Better touch feedback on mobile
[theme.breakpoints.down('md')]: {
WebkitTapHighlightColor: 'transparent',
touchAction: 'manipulation',
},
},
}
}
+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 '../../../events'
import { rendererRpc, getAppVersion } from '../eventBus'
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
}
+2 -4
View File
@@ -5,9 +5,7 @@ import FileCopy from '@mui/icons-material/FileCopy'
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import { globalActions } from '../../actions'
// Fallback for older browsers or when clipboard API is not available
const copyTextFallback = require('copy-text-to-clipboard')
import copyTextFallback from 'copy-text-to-clipboard'
async function copyToClipboard(text: string): Promise<boolean> {
try {
@@ -19,7 +17,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']}
>
+44 -2
View File
@@ -5,16 +5,58 @@ import CustomIconButton from './CustomIconButton'
import { SaveAlt } from '@mui/icons-material'
import { bindActionCreators } from 'redux'
import { rendererRpc, writeToFile } from '../../../../events'
import { rendererRpc, writeToFile } from '../../eventBus'
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,
})
@@ -67,7 +109,7 @@ class Save extends React.PureComponent<Props, State> {
)
return (
<CustomIconButton onClick={this.handleClick} tooltip="Save to file">
<CustomIconButton onClick={this.handleClick} tooltip="Save to file" data-testid="save-button">
<div style={{ marginTop: '2px' }}>{icon}</div>
</CustomIconButton>
)
+134
View File
@@ -0,0 +1,134 @@
/**
* 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'
+8
View File
@@ -0,0 +1,8 @@
// Export store singleton for use in other modules
import reducers from './reducers'
import { thunk as reduxThunk } from 'redux-thunk'
import { applyMiddleware, compose, createStore } from 'redux'
import { batchDispatchMiddleware } from 'redux-batched-actions'
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
export const store = createStore(reducers, composeEnhancers(applyMiddleware(reduxThunk, batchDispatchMiddleware)))
+1 -1
View File
@@ -1,4 +1,4 @@
import { rendererRpc } from '../../../events'
import { rendererRpc } from '../eventBus'
import { storageStoreEvent, storageLoadEvent, storageClearEvent } from '../../../events/StorageEvents'
+716 -773
View File
File diff suppressed because it is too large Load Diff
+7 -8
View File
@@ -16,28 +16,27 @@
"author": "",
"license": "CC-BY-SA-4.0",
"dependencies": {
"@types/sha1": "^1.1.5",
"builder-util-runtime": "^9.3.1",
"fs-extra": "^11.3.3",
"js-base64": "^3.7.8",
"lowdb": "^1.0.0",
"lowdb": "1",
"mqtt": "^5.14.1",
"protobufjs": "^8.0.0",
"sha1": "^1.1.1",
"uuid": "^11.0.0"
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/chai": "^4.3.20",
"@types/chai": "^5.2.3",
"@types/fs-extra": "^11.0.4",
"@types/lowdb": "^1.0.15",
"@types/lowdb": "1",
"@types/mocha": "^10.0.10",
"@types/node": "^25.0.3",
"@types/sha1": "^1.1.1",
"@types/sha1": "^1.1.5",
"@types/uuid": "^11.0.0",
"c8": "^10.1.3",
"chai": "^4.5.0",
"chai": "^6.2.2",
"electron": "39.2.7",
"mocha": "^10.8.2",
"mocha": "^11.7.5",
"source-map-support": "^0.5.9",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
+667 -1159
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -2,7 +2,6 @@ export * from './Events'
export * from './EventsV2'
export * from './EventSystem/EventDispatcher'
// EventBus exports removed - this file contains Electron-specific imports
// which should not be loaded in server/browser mode
// Electron code should import directly from './EventSystem/EventBus'
// export * from './EventSystem/EventBus'
// In Electron mode, webpack replaces '../../../events' to use './EventSystem/EventBus'
// In browser mode, webpack replaces '../../../events' to use browserEventBus.ts
export * from './EventSystem/EventBusInterface'
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+12 -10
View File
@@ -16,7 +16,9 @@
"test:backend": "(cd backend && yarn test)",
"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:mobile-ui": "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",
@@ -47,7 +49,7 @@
"url": "https://github.com/thomasnordquist/MQTT-Explorer.git"
},
"resolutions": {
"@electron/node-gyp": "10.2.0-electron.1"
"@electron/node-gyp": "10.2.0-electron.2"
},
"build": {
"appId": "mqtt-explorer",
@@ -99,13 +101,13 @@
"@semantic-release/commit-analyzer": "^13.0.1",
"@semantic-release/git": "^10.0.1",
"@types/bcryptjs": "^3.0.0",
"@types/chai": "^4.3.20",
"@types/chai": "^5.2.3",
"@types/express": "^5.0.6",
"@types/express-rate-limit": "^6.0.2",
"@types/fs-extra": "^11.0.4",
"@types/helmet": "^4.0.0",
"@types/json-to-ast": "^2.1.4",
"@types/lowdb": "^1.0.15",
"@types/lowdb": "1",
"@types/mime": "^4.0.0",
"@types/mocha": "^10.0.10",
"@types/mustache": "^4.2.5",
@@ -113,10 +115,10 @@
"@types/semver": "^7.7.1",
"@types/sha1": "^1.1.1",
"@types/socket.io": "^3.0.2",
"@types/uuid": "^8.3.4",
"@types/uuid": "^11.0.0",
"builder-util-runtime": "^9.3.1",
"chai": "^4.5.0",
"cspell": "^8.19.4",
"chai": "^6.2.2",
"cspell": "^9.4.0",
"electron": "39.2.7",
"electron-builder": "^26.4.0",
"mocha": "^10.8.2",
@@ -142,7 +144,7 @@
"axios": "^1.13.2",
"bcryptjs": "^3.0.3",
"debug": "^4.4.3",
"dot-prop": "^5.3.0",
"dot-prop": "5",
"electron-log": "^5.4.3",
"electron-updater": "^6.7.3",
"express": "^5.2.1",
@@ -152,14 +154,14 @@
"helmet": "^8.1.0",
"js-base64": "^3.7.8",
"json-to-ast": "^2.1.0",
"lowdb": "^1.0.0",
"lowdb": "1",
"mime": "^4.1.0",
"mqtt": "^5.14.1",
"protobufjs": "^8.0.0",
"sha1": "^1.1.1",
"socket.io": "^4.8.1",
"socket.io": "^4.8.3",
"sparkplug-payload": "^1.0.3",
"uuid": "^11.0.0",
"uuid": "^13.0.0",
"yarn-run-all": "^3.1.1"
}
}
@@ -1,28 +1,32 @@
#!/bin/bash
set -e
#!/usr/bin/env node
# Read scenes.json and cut video into segments as GIFs
if [ ! -f "scenes.json" ]; then
echo "scenes.json not found"
exit 1
fi
/**
* 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.
*/
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
@@ -33,13 +37,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) => {
@@ -48,7 +52,7 @@ async function cutSegmentAsGif(scene, index) {
'-ss', startTime.toString(),
'-t', duration.toString(),
'-i', 'ui-test.mp4',
'-vf', 'fps=10,scale=${process.env.GIF_SCALE || 1024}:-1:flags=lanczos,palettegen',
'-vf', `fps=10,scale=${GIF_SCALE}:-1:flags=lanczos,palettegen`,
paletteFile
]);
@@ -56,8 +60,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}`));
}
});
});
@@ -70,7 +74,7 @@ async function cutSegmentAsGif(scene, index) {
'-t', duration.toString(),
'-i', 'ui-test.mp4',
'-i', paletteFile,
'-filter_complex', 'fps=10,scale=${process.env.GIF_SCALE || 1024}:-1:flags=lanczos[x];[x][1:v]paletteuse',
'-filter_complex', `fps=10,scale=${GIF_SCALE}:-1:flags=lanczos[x];[x][1:v]paletteuse`,
outputFile
]);
@@ -85,8 +89,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}`));
}
});
});
@@ -96,11 +100,8 @@ async function cutSegmentAsGif(scene, index) {
for (let i = 0; i < scenes.length; i++) {
await cutSegmentAsGif(scenes[i], i);
}
console.log('All GIF segments created successfully');
console.log('Video segments created successfully');
})().catch(err => {
console.error(err);
process.exit(1);
});
"
echo "Video segments created successfully"
+107
View File
@@ -0,0 +1,107 @@
#!/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);
});
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
const fs = require('fs');
// Get base URL and test status from command line arguments
const baseUrl = process.argv[2];
const testStatus = process.argv[3] || 'success'; // Default to success if not provided
if (!baseUrl) {
console.error('Usage: node generateMarkdownSummaryMobile.js <base-url> [test-status]');
process.exit(1);
}
// Read scenes-mobile.json if it exists
let scenes = [];
try {
if (fs.existsSync('scenes-mobile.json')) {
scenes = JSON.parse(fs.readFileSync('scenes-mobile.json', 'utf8'));
}
} catch (error) {
console.error('Warning: Could not read scenes-mobile.json:', error.message);
}
// 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 with status indication
const statusIcon = testStatus === 'success' ? '✅' : '⚠️';
const statusText = testStatus === 'success' ? 'Generated Successfully' : 'Generated (Test Failed)';
let markdown = `## ${statusIcon} Mobile Demo Video ${statusText}\n\n`;
if (testStatus !== 'success') {
markdown += `> ⚠️ **Note**: The mobile demo test encountered errors but videos were still uploaded for debugging. Check the logs for details.\n\n`;
}
markdown += `### Full Mobile Video (Pixel 6 - 412x914)\n\n`;
markdown += `[📥 Download Mobile Video (MP4)](${baseUrl}/ui-test-mobile.mp4) | [GIF](${baseUrl}/ui-test-mobile.gif)\n\n`;
markdown += `---\n\n`;
if (scenes.length > 0) {
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`;
} else {
markdown += `*Scene information not available - check if video processing completed*\n\n`;
}
markdown += `_Mobile videos recorded at 412x914 (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
./scripts/cutVideoSegments.sh
node ./scripts/cutVideoSegments.js
else
echo "Warning: scenes.json not found, skipping segment creation"
fi
+38
View File
@@ -0,0 +1,38 @@
#!/bin/bash
# Mobile demo video post-processing script
# Converts raw mobile video to MP4 and GIF, then cuts into segments
DIMENSIONS="412x914"
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
+20 -1
View File
@@ -2,7 +2,7 @@
# Browser Mode Test Runner
#
# This script runs UI tests against the browser mode server (instead of Electron).
# It expects a mosquitto MQTT broker to be running (via service or manually started).
# It starts a mosquitto MQTT broker automatically and cleans it up on exit.
# The broker address is configured via environment variables.
#
# Environment Variables:
@@ -12,6 +12,7 @@
# BROWSER_MODE_URL - URL for browser tests (set automatically)
# TESTS_MQTT_BROKER_HOST - MQTT broker host for tests (required, default: 127.0.0.1)
# TESTS_MQTT_BROKER_PORT - MQTT broker port for tests (default: 1883)
# USE_MOBILE_VIEWPORT - Enable mobile viewport (default: false, set to 'true' for mobile tests)
#
set -e
@@ -23,10 +24,21 @@ 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}
@@ -54,8 +66,15 @@ done
export BROWSER_MODE_URL="http://localhost:${PORT}"
export TESTS_MQTT_BROKER_HOST="${TESTS_MQTT_BROKER_HOST:-127.0.0.1}"
export TESTS_MQTT_BROKER_PORT="${TESTS_MQTT_BROKER_PORT:-1883}"
# Enable mobile viewport for mobile UI tests
export USE_MOBILE_VIEWPORT="${USE_MOBILE_VIEWPORT:-false}"
echo "Using MQTT broker at $TESTS_MQTT_BROKER_HOST:$TESTS_MQTT_BROKER_PORT"
if [ "$USE_MOBILE_VIEWPORT" = "true" ]; then
echo "Mobile viewport: ENABLED (412x914)"
else
echo "Mobile viewport: DISABLED (desktop 1280x720)"
fi
yarn test:browser
TEST_EXIT_CODE=$?
+86
View File
@@ -0,0 +1,86 @@
#!/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 - height must be even for h264)
DIMENSIONS="412x914"
# Chrome header in --app mode is 88px tall
# Add 88px to Xvfb height to accommodate the Chrome header
CHROME_HEADER_HEIGHT=88
XVFB_HEIGHT=$((914 + CHROME_HEADER_HEIGHT))
XVFB_DIMENSIONS="412x${XVFB_HEIGHT}"
SCR=99
# Start new window manager with extra height for Chrome header
Xvfb :$SCR -screen 0 "$XVFB_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 2
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 with vertical offset to exclude Chrome header
# Record only the actual mobile viewport (412x914), skipping the 88px Chrome header at top
tmux new-session -d -s record-mobile ffmpeg -f x11grab -draw_mouse 0 -video_size $DIMENSIONS -i :$SCR+0,$CHROME_HEADER_HEIGHT -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
+54 -12
View File
@@ -12,7 +12,7 @@ import ConfigStorage from '../backend/src/ConfigStorage'
import { SocketIOServerEventBus } from '../events/EventSystem/SocketIOServerEventBus'
import { Rpc } from '../events/EventSystem/Rpc'
import { makeOpenDialogRpc, makeSaveDialogRpc } from '../events/OpenDialogRequest'
import { getAppVersion, writeToFile, readFromFile } from '../events'
import { getAppVersion, writeToFile, readFromFile, addMqttConnectionEvent } from '../events'
import { RpcEvents } from '../events/EventsV2'
const PORT = process.env.PORT || 3000
@@ -215,17 +215,6 @@ async function startServer() {
next()
})
// Send auth status to clients on connection
io.on('connection', (socket) => {
// Inform client about auth status
const authDisabled = (socket as any).authDisabled === true
socket.emit('auth-status', { authDisabled })
if (!isProduction) {
console.log(`Client connected, auth disabled: ${authDisabled}`)
}
})
// Initialize backend event bus with Socket.io
const backendEvents = new SocketIOServerEventBus(io)
const backendRpc = new Rpc(backendEvents)
@@ -238,6 +227,59 @@ async function startServer() {
const configStorage = new ConfigStorage(path.join(process.cwd(), 'data', 'settings.json'), backendRpc)
configStorage.init()
// Send auth status to clients on connection
io.on('connection', (socket) => {
// Inform client about auth status
const authDisabled = (socket as any).authDisabled === true
socket.emit('auth-status', { authDisabled })
if (!isProduction) {
console.log(`Client connected, auth disabled: ${authDisabled}`)
}
// Auto-connect to MQTT broker if configured via environment variables
const autoConnectHost = process.env.MQTT_AUTO_CONNECT_HOST
if (autoConnectHost) {
const connectionId = 'auto-connect-' + Date.now()
// Notify client immediately that auto-connect will happen
socket.emit('auto-connect-initiated', { connectionId })
// Delay auto-connect to give client time to subscribe to events
setTimeout(() => {
const protocol = process.env.MQTT_AUTO_CONNECT_PROTOCOL || 'mqtt'
const port = parseInt(process.env.MQTT_AUTO_CONNECT_PORT || '1883')
const tls = protocol.endsWith('s') // mqtts or wss
const url = `${protocol}://${autoConnectHost}:${port}`
const autoConnectConfig = {
id: connectionId,
options: {
url,
username: process.env.MQTT_AUTO_CONNECT_USERNAME,
password: process.env.MQTT_AUTO_CONNECT_PASSWORD,
tls,
certValidation: false,
clientId: process.env.MQTT_AUTO_CONNECT_CLIENT_ID || 'mqtt-explorer-' + Math.random().toString(16).substr(2, 8),
subscriptions: [{ topic: '#', qos: 0 as 0 | 1 | 2 }], // Subscribe to all topics
}
}
if (!isProduction) {
console.log('Auto-connecting to MQTT broker:', {
connectionId,
url: autoConnectConfig.options.url,
clientId: autoConnectConfig.options.clientId,
username: autoConnectConfig.options.username || '(none)',
})
}
// Trigger connection via backend events
backendEvents.emit(addMqttConnectionEvent, autoConnectConfig)
}, 1000) // 1 second delay to allow client to set up event subscriptions
}
})
// Setup RPC handlers for file operations
backendRpc.on(makeOpenDialogRpc(), async request => {
// In browser mode, file selection is handled client-side via upload
+16
View File
@@ -22,6 +22,14 @@ export type SceneNames =
| 'keyboard_shortcuts'
| 'sparkplugb-decoding'
| 'end'
| 'mobile_intro'
| 'mobile_connect'
| 'mobile_browse_topics'
| 'mobile_view_message'
| 'mobile_search'
| 'mobile_json_view'
| 'mobile_settings'
| 'mobile_end'
export const SCENE_TITLES: Record<SceneNames, string> = {
connect: 'Connecting to MQTT Broker',
@@ -39,6 +47,14 @@ 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_view_message: 'View Message Details',
mobile_search: 'Search Topics',
mobile_json_view: 'JSON Message Formatting',
mobile_settings: 'Settings with Disconnect/Logout',
mobile_end: 'Mobile-Friendly MQTT Explorer',
}
export class SceneBuilder {
+4 -1
View File
@@ -45,7 +45,7 @@ setTimeout(
60 * 10 * 1000
)
const runningUiTestOnCi = os.platform() === 'darwin' ? [] : ['--runningUiTestOnCi']
const runningUiTestOnCi = os.platform() === 'darwin' ? [] : ['--runningUiTestOnCi', '--no-sandbox', '--disable-dev-shm-usage']
async function doStuff() {
const brokerHost = process.env.TESTS_MQTT_BROKER_HOST || '127.0.0.1'
@@ -73,6 +73,9 @@ async function doStuff() {
// Wait for Username input to be visible
await page.locator('//label[contains(text(), "Username")]/..//input')
// Wait for the connection dialog to be fully rendered (especially after react-spring upgrade)
await sleep(2000)
const scenes = new SceneBuilder()
await scenes.record('connect', async () => {
await connectTo(brokerHost, page)
+289
View File
@@ -0,0 +1,289 @@
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 { clickOn, 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'
import { expandTopic } from './util/expandTopic'
import { selectTopic } from './util/selectTopic'
/**
* 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
// headless: false is required so the browser renders to the X display for video recording
const browser = await chromium.launch({
headless: false,
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--app=http://localhost:3000', // App mode - no browser UI
'--window-size=412,914', // Match the mobile viewport size
'--window-position=0,0',
'--disable-features=TranslateUI',
'--no-first-run',
'--no-default-browser-check',
'--disable-infobars',
'--disable-translate',
],
})
// Create browser context with Pixel 6 viewport
// Note: Height must be even for video encoding (h264 requirement)
const context = await browser.newContext({
viewport: {
width: 412,
height: 914, // Changed from 915 to 914 (must be even for h264)
},
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())
// Try to capture a screenshot (may fail in headed mode, but that's ok)
try {
await page.screenshot({ path: 'intro-mobile.png' })
} catch (error) {
console.log('Screenshot skipped (headed mode)')
}
// Direct console to Node terminal
page.on('console', console.log)
// Enable the fake mouse pointer for visual cursor tracking
await createFakeMousePointer(page)
// 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(3000) // Give more time for topics to load
await hideText(page)
})
await scenes.record('mobile_browse_topics', async () => {
await showText('Browse Topics - Topics Tab', 1500, page, 'top')
await sleep(2000)
// Wait for tree nodes to be visible
await page.waitForSelector('[data-test-topic]', { timeout: 10000 }).catch(() => {
console.log('Tree nodes not found, continuing...')
})
await sleep(1000)
try {
// Expand topics using the expandTopic utility
// On mobile, this clicks expand buttons (▶/▼) to navigate the tree
await showText('Expand Topic Tree', 1000, page, 'top')
await sleep(500)
await expandTopic('livingroom/lamp', page)
await sleep(1500)
} catch (error) {
console.log('Topic expansion failed, continuing...', error)
}
await hideText(page)
})
await scenes.record('mobile_view_message', async () => {
await showText('Tap Topic to View Details', 1500, page, 'top')
await sleep(1000)
try {
// Select a topic by clicking its text
// On mobile, this will switch to the Details tab automatically
await selectTopic('livingroom/lamp/state', page)
await sleep(2000)
// The mobile UI should now show the Details tab with the selected topic
await showText('Details Tab Activated', 1000, page, 'top')
await sleep(1500)
} catch (error) {
console.log('Topic selection failed, continuing...', error)
}
await hideText(page)
})
await scenes.record('mobile_search', async () => {
await showText('Search Topics', 1500, page, 'top')
await sleep(500)
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_json_view', async () => {
await showText('JSON Message Formatting', 1500, page, 'top')
await sleep(1000)
try {
// Navigate back to Topics tab to show tree navigation
const topicsTab = page.locator('button:has-text("TOPICS"), button:has-text("Topics")')
const topicsTabVisible = await topicsTab.isVisible().catch(() => false)
if (topicsTabVisible) {
await topicsTab.click()
await sleep(1000)
}
// Expand and select kitchen/coffee_maker to show JSON
await expandTopic('kitchen/coffee_maker', page)
await sleep(1000)
await selectTopic('kitchen/coffee_maker', page)
await sleep(2000)
await showText('JSON Payload View', 1000, page, 'top')
await sleep(1500)
} catch (error) {
console.log('JSON view navigation failed, continuing...', error)
}
await hideText(page)
})
await scenes.record('mobile_settings', async () => {
try {
await showText('Settings with Disconnect/Logout', 1500, page, 'top')
await sleep(2000)
// Just show that settings are available, don't click
await hideText(page)
} catch (error) {
console.log('Settings scene failed, continuing...', error)
// Try to dismiss any error dialogs
try {
const closeButton = page.locator('button:has-text("Close"), button[aria-label="close"]')
if (await closeButton.isVisible().catch(() => false)) {
await closeButton.click()
await sleep(500)
}
} catch (e) {
// Ignore if we can't close dialog
}
}
})
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
@@ -0,0 +1,8 @@
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)
}
+5 -1
View File
@@ -3,6 +3,10 @@ import { expandTopic, sleep } from '../util'
export async function showJsonPreview(browser: Page) {
await expandTopic('actuality/showcase', browser)
await browser.screenshot({ path: 'screen3.png' })
try {
await browser.screenshot({ path: 'screen3.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
await sleep(1000)
}
+10 -2
View File
@@ -9,7 +9,11 @@ export async function showMenu(browser: Page) {
// moveToCenterOfElement(brokerStatistics, browser)
await sleep(2000)
await browser.screenshot({ path: 'screen4.png' })
try {
await browser.screenshot({ path: 'screen4.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
const topicOrder = await browser.locator('//input[@name="node-order"]/../div')
await clickOn(topicOrder)
@@ -24,7 +28,11 @@ export async function showMenu(browser: Page) {
const themeSwitch = await browser.locator('[data-testid="dark-mode-toggle"]')
await clickOn(themeSwitch)
await sleep(3000)
await browser.screenshot({ path: 'screen_dark_mode.png' })
try {
await browser.screenshot({ path: 'screen_dark_mode.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
await clickOn(themeSwitch)
await clickOn(menuButton)
+5 -1
View File
@@ -4,6 +4,10 @@ import { expandTopic, sleep } from '../util'
export async function showSparkPlugDecoding(browser: Page) {
// spell-checker: disable-next-line
await expandTopic('spBv1.0/Sparkplug Devices/DDATA/JavaScript Edge Node/Emulated Device', browser)
await browser.screenshot({ path: 'screen_sparkplugb_decoding.png' })
try {
await browser.screenshot({ path: 'screen_sparkplugb_decoding.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
await sleep(1000)
}
+164 -4
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
@@ -61,6 +61,10 @@ describe('MQTT Explorer UI Tests', function () {
throw new Error('BROWSER_MODE_URL environment variable must be set when running in browser mode')
}
console.log(`Browser URL: ${browserUrl}`)
// Check if mobile viewport should be used
const useMobileViewport = process.env.USE_MOBILE_VIEWPORT === 'true'
console.log(`Mobile viewport: ${useMobileViewport}`)
// Launch Chromium browser
browser = await chromium.launch({
@@ -68,7 +72,29 @@ describe('MQTT Explorer UI Tests', function () {
args: ['--no-sandbox', '--disable-dev-shm-usage'],
})
browserContext = await browser.newContext()
// Create browser context with optional mobile viewport
const contextOptions: any = {
permissions: ['clipboard-read', 'clipboard-write'],
}
if (useMobileViewport) {
// Use same viewport as mobile demo (Pixel 6)
contextOptions.viewport = {
width: 412,
height: 914,
}
contextOptions.userAgent = 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36'
console.log('Using mobile viewport: 412x914 (Pixel 6)')
} else {
// Desktop viewport - ensure width > 768px so mobile UI doesn't activate
contextOptions.viewport = {
width: 1280,
height: 720,
}
console.log('Using desktop viewport: 1280x720')
}
browserContext = await browser.newContext(contextOptions)
page = await browserContext.newPage()
// Listen for console messages
@@ -94,10 +120,13 @@ 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))
}
@@ -237,4 +266,135 @@ 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' })
})
})
})
+90 -19
View File
@@ -12,9 +12,15 @@ export async function expandTopic(path: string, browser: Page) {
const topics = path.split('/')
console.log('expandTopic', path)
// Determine if we're in mobile viewport
// Desktop tests use 1280x720, mobile tests use 412x914
const viewport = browser.viewportSize()
const isMobileViewport = viewport && viewport.width <= 768
// Expand each level of the topic tree one at a time
// Strategy: Click on each topic level individually, relying on the fact that
// after clicking a parent, its children become visible and we can find the next level
// Strategy:
// - Desktop: Click topic text (selects + expands, original behavior)
// - Mobile: Click expand button only (doesn't select, mobile-specific behavior)
for (let i = 0; i < topics.length; i += 1) {
const topicName = topics[i]
const currentPath = topics.slice(0, i + 1)
@@ -24,25 +30,25 @@ export async function expandTopic(path: string, browser: Page) {
// Find the topic by its data-test-topic attribute
// After expanding previous levels, the current level should be visible
const selector = `span[data-test-topic='${topicName}']`
const topicSelector = `span[data-test-topic='${topicName}']`
console.log(`Using selector: ${selector}`)
console.log(`Using selector: ${topicSelector}`)
// Get all matching elements (there may be multiple topics with the same name)
const allMatches = browser.locator(selector)
const allMatches = browser.locator(topicSelector)
// Count how many matches we have
const count = await allMatches.count()
console.log(`Found ${count} elements matching '${topicName}'`)
// Find the first visible match
let locator: Locator | null = null
let topicLocator: Locator | null = null
for (let j = 0; j < count; j += 1) {
const candidate = allMatches.nth(j)
try {
// Increased timeout to 3000ms to handle slower UI after many test runs
await candidate.waitFor({ state: 'visible', timeout: 3000 })
locator = candidate
topicLocator = candidate
console.log(`Using match #${j} for '${topicName}'`)
break
} catch {
@@ -51,24 +57,89 @@ export async function expandTopic(path: string, browser: Page) {
}
}
if (!locator) {
if (!topicLocator) {
console.error(`Failed to find visible topic "${topicName}" in path "${currentPath.join('/')}"`)
throw new Error(`Could not find topic "${topicName}" in path "${currentPath.join('/')}"`)
}
try {
console.log(`Found and clicking topic: ${topicName}`)
if (isMobileViewport) {
// MOBILE: Click the expand button (▶/▼) only - doesn't select the topic
// The expand button is a sibling of the topic text within the same TreeNodeTitle
// Navigate to the parent span (TreeNodeTitle container) and find the expander
const parentSpan = topicLocator.locator('..')
const expandButton = parentSpan.locator('span.expander, span[class*="expander"]')
const expandButtonCount = await expandButton.count()
const isLastTopic = i === topics.length - 1
// Only click expand button if it exists (topics with children)
// Topics without children don't have an expand button
if (expandButtonCount > 0) {
console.log(`Found expand button for topic: ${topicName}`)
// Scroll the element into view to ensure it's clickable
await locator.scrollIntoViewIfNeeded()
await new Promise(resolve => setTimeout(resolve, 200))
// Scroll the expand button into view to ensure it's clickable
await expandButton.scrollIntoViewIfNeeded()
await new Promise(resolve => setTimeout(resolve, 200))
// Click to expand/select this level
await clickOn(locator)
// Check if already expanded (▼ means expanded, ▶ means collapsed)
const buttonText = await expandButton.textContent()
const isCollapsed = buttonText?.includes('▶')
if (isCollapsed) {
console.log(`Expanding topic: ${topicName}`)
// Click the expand button to expand this level
// Use force:true to bypass any overlays (e.g., accordions) that might intercept
await clickOn(expandButton, 1, 0, 'left', true)
// Give the UI time to expand and render child topics
// This is important for MQTT async operations and tree rendering
await new Promise(resolve => setTimeout(resolve, TREE_EXPANSION_DELAY_MS))
// Give the UI time to expand and render child topics
// This is important for MQTT async operations and tree rendering
await new Promise(resolve => setTimeout(resolve, TREE_EXPANSION_DELAY_MS))
} else {
console.log(`Topic ${topicName} is already expanded`)
}
} else {
console.log(`Topic ${topicName} has no expand button (leaf topic or empty)`)
}
} else {
// DESKTOP: Click the topic text (original behavior - selects + expands)
console.log(`Clicking topic text to expand: ${topicName}`)
// Scroll into view
await topicLocator.scrollIntoViewIfNeeded()
await new Promise(resolve => setTimeout(resolve, 200))
// Check if topic has children that can be expanded
const parentSpan = topicLocator.locator('..')
const expandButton = parentSpan.locator('span.expander, span[class*="expander"]')
const hasExpandButton = await expandButton.count() > 0
const isLastTopic = i === topics.length - 1
if (hasExpandButton) {
// Check if already expanded
const buttonText = await expandButton.textContent()
const isCollapsed = buttonText?.includes('▶')
if (isCollapsed) {
console.log(`Topic ${topicName} is collapsed, clicking to expand`)
// Click the topic text - on desktop this selects AND toggles expansion
await clickOn(topicLocator, 1, 0, 'left', false)
// Give the UI time to expand and render child topics
await new Promise(resolve => setTimeout(resolve, TREE_EXPANSION_DELAY_MS))
} else {
console.log(`Topic ${topicName} is already expanded, clicking to select`)
// Topic is already expanded, just click to select it
await clickOn(topicLocator, 1, 0, 'left', false)
await new Promise(resolve => setTimeout(resolve, 500))
}
} else {
// Leaf topic - click to select it (important for final topic in path)
console.log(`Topic ${topicName} has no children, clicking to select`)
await clickOn(topicLocator, 1, 0, 'left', false)
await new Promise(resolve => setTimeout(resolve, 500))
}
}
// If this is not the last topic in the path, verify that children rendered
if (nextTopicName) {
@@ -88,8 +159,8 @@ export async function expandTopic(path: string, browser: Page) {
}
}
} catch (error) {
console.error(`Failed to click topic "${topicName}" in path "${currentPath.join('/')}"`, error)
throw new Error(`Could not click topic "${topicName}" in path "${currentPath.join('/')}"`)
console.error(`Failed to expand topic "${topicName}" in path "${currentPath.join('/')}"`, error)
throw new Error(`Could not expand topic "${topicName}" in path "${currentPath.join('/')}"`)
}
}
}
+14 -2
View File
@@ -3,6 +3,7 @@ import * as fs from 'fs'
import { Page, Locator } from 'playwright'
export { expandTopic } from './expandTopic'
export { selectTopic } from './selectTopic'
let fast = false
export function setFast() {
@@ -85,8 +86,10 @@ export async function moveToCenterOfElement(element: Locator) {
try {
const js = `window.demo.moveMouse(${targetX}, ${targetY}, ${duration});`
await runJavascript(js, element.page())
await sleep(duration)
await sleep(250, true)
// IMPORTANT: Wait for animation to complete before returning
// The animation duration + a small buffer for frame rendering
await sleep(duration, true) // Use required=true to ensure we actually wait
await sleep(100, true) // Extra buffer for the last frame
} catch (error) {
// window.demo.moveMouse might not be available in all test environments
// This is fine - we'll proceed with the click anyway
@@ -115,17 +118,26 @@ export async function clickOn(
// Ensure element is visible before trying to interact
await element.waitFor({ state: 'visible', timeout: 30000 })
// Scroll element into view first (important for mobile viewports)
await element.scrollIntoViewIfNeeded()
await sleep(100)
// Skip hover when force is true (used when modal backdrop might intercept)
if (!force) {
try {
// Move the simulated mouse cursor and wait for animation to complete
await moveToCenterOfElement(element)
// Now hover with the real cursor (this is instant but comes after animation)
await element.hover()
// Small delay after hover for visual smoothness
await sleep(50, true)
} catch (error) {
// If custom mouse movement fails, we can still proceed with the click
// Playwright's click will handle scrolling into view automatically
console.log('Custom mouse movement failed, proceeding with direct click')
}
}
// Click happens after simulated cursor has reached its destination
await element.click({ delay, button, force, clickCount: clicks })
await sleep(50)
}
+68
View File
@@ -0,0 +1,68 @@
import { clickOn } from './'
import { Page, Locator } from 'playwright'
/**
* Selects a topic by clicking on its text (not the expand button)
* On mobile, this will also switch to the Details tab automatically
*
* @param path - Topic path like "mqtt/topic/name" or just "topicname"
* @param browser - Playwright Page object
*/
export async function selectTopic(path: string, browser: Page) {
const topics = path.split('/')
const topicName = topics[topics.length - 1] // Get the last topic in the path
console.log('selectTopic', topicName, 'from path', path)
// Find the topic by its data-test-topic attribute
const topicSelector = `span[data-test-topic='${topicName}']`
console.log(`Using selector: ${topicSelector}`)
// Get all matching elements (there may be multiple topics with the same name)
const allMatches = browser.locator(topicSelector)
// Count how many matches we have
const count = await allMatches.count()
console.log(`Found ${count} elements matching '${topicName}'`)
// Find the first visible match
let topicLocator: Locator | null = null
for (let j = 0; j < count; j += 1) {
const candidate = allMatches.nth(j)
try {
await candidate.waitFor({ state: 'visible', timeout: 3000 })
topicLocator = candidate
console.log(`Using match #${j} for '${topicName}'`)
break
} catch {
// This candidate is not visible, try the next one
continue
}
}
if (!topicLocator) {
console.error(`Failed to find visible topic "${topicName}"`)
throw new Error(`Could not find topic "${topicName}"`)
}
try {
console.log(`Selecting topic by clicking text: ${topicName}`)
// Scroll the element into view to ensure it's clickable
await topicLocator.scrollIntoViewIfNeeded()
await new Promise(resolve => setTimeout(resolve, 200))
// Click on the topic text to select it
// On mobile, this will also switch to the Details tab
await clickOn(topicLocator, 1, 0, 'left', false)
// Give the UI time to process the selection and tab switch
await new Promise(resolve => setTimeout(resolve, 500))
console.log(`Successfully selected topic: ${topicName}`)
} catch (error) {
console.error(`Failed to select topic "${topicName}"`, error)
throw new Error(`Could not select topic "${topicName}"`)
}
}
+1
View File
@@ -22,6 +22,7 @@
"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",
+1467 -1643
View File
File diff suppressed because it is too large Load Diff