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>
This commit is contained in:
Copilot
2025-12-24 18:02:17 +01:00
committed by GitHub
parent a3de71d939
commit 1453934e29
13 changed files with 868 additions and 2 deletions

View File

@@ -157,6 +157,116 @@ jobs:
body: markdown 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
run: ./scripts/uiTestsMobile.sh
- name: Post-processing
run: ./scripts/prepareVideoMobile.sh
- name: Generate unique base path
id: basepath
run: |
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BASEPATH="pr-${{ github.event.pull_request.number }}-mobile-${TIMESTAMP}"
echo "basepath=${BASEPATH}" >> $GITHUB_OUTPUT
- name: Install AWS CLI v2
run: |
apt-get update && apt-get install -y unzip
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
./aws/install
rm -rf aws awscliv2.zip
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ vars.AWS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: 'eu-central-1'
- name: Upload full mobile video to S3
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
BASEPATH: ${{ steps.basepath.outputs.basepath }}
run: |
# Upload GIF
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/ui-test-mobile.gif \
--body ./ui-test-mobile.gif \
--content-type image/gif
# Upload MP4
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/ui-test-mobile.mp4 \
--body ./ui-test-mobile.mp4 \
--content-type video/mp4
- name: Upload mobile video segments to S3
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
BASEPATH: ${{ steps.basepath.outputs.basepath }}
shell: bash
run: |
# Upload all mobile GIF segment files if they exist
shopt -s nullglob # Make glob return empty list if no matches
for segment in segment-mobile-*.gif; do
echo "Uploading $segment..."
aws s3api put-object \
--bucket ${AWS_BUCKET} \
--key artifacts/${BASEPATH}/${segment} \
--body ./${segment} \
--content-type image/gif
done
shopt -u nullglob # Restore default behavior
- name: Generate file URLs
id: fileurl
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
BASEPATH: ${{ steps.basepath.outputs.basepath }}
run: |
BASE_URL="https://${AWS_BUCKET}.s3.eu-central-1.amazonaws.com/artifacts/${BASEPATH}"
echo "base-url=${BASE_URL}" >> $GITHUB_OUTPUT
echo "Uploaded to: ${BASE_URL}"
- name: Generate markdown summary
id: markdown
env:
BASE_URL: ${{ steps.fileurl.outputs.base-url }}
run: |
MARKDOWN=$(node ./scripts/generateMarkdownSummaryMobile.js "${BASE_URL}")
echo "markdown<<EOF" >> $GITHUB_OUTPUT
echo "$MARKDOWN" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Add to workflow summary
env:
MARKDOWN: ${{ steps.markdown.outputs.markdown }}
run: |
echo "$MARKDOWN" >> $GITHUB_STEP_SUMMARY
- name: Post mobile video to PR
uses: actions/github-script@v7
env:
MARKDOWN: ${{ steps.markdown.outputs.markdown }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const markdown = process.env.MARKDOWN;
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: markdown
});
test-browser: test-browser:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:

156
MOBILE_COMPATIBILITY.md Normal file
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)

View File

@@ -187,6 +187,34 @@ yarn build
This script handles Xvfb setup, mosquitto startup, video recording, and cleanup. 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 release
Create a PR to `release` branch. Create a PR to `release` branch.

View File

@@ -18,6 +18,49 @@
outline: none; 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 { @keyframes updateDark {
0% { 0% {
background-color: none; background-color: none;

View File

@@ -20,8 +20,11 @@ interface Props {
} }
function ContentView(props: Props) { function ContentView(props: Props) {
// Use different defaults for mobile viewports (<=768px width)
// Use useState with lazy initialization to get initial mobile state
const [isMobile] = React.useState(() => typeof window !== 'undefined' && window.innerWidth <= 768)
const [height, setHeight] = React.useState<string | number>('100%') const [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 [detectedHeight, setDetectedHeight] = React.useState(0)
const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0) const [detectedSidebarWidth, setDetectedSidebarWidth] = React.useState(0)
@@ -109,7 +112,12 @@ function ContentView(props: Props) {
<div ref={widthRef} style={{ height: '100%' }}> <div ref={widthRef} style={{ height: '100%' }}>
<div <div
className={props.paneDefaults} className={props.paneDefaults}
style={{ minWidth: '250px', height: '100%', overflowY: 'auto', overflowX: 'hidden' }} style={{
minWidth: isMobile ? '100%' : '250px',
height: '100%',
overflowY: 'auto',
overflowX: 'hidden'
}}
> >
<Sidebar connectionId={props.connectionId} /> <Sidebar connectionId={props.connectionId} />
</div> </div>

View File

@@ -17,6 +17,7 @@
"test:electron": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js", "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:browser": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
"test:demo-video": "npx tsc && node dist/src/spec/demoVideo.js", "test:demo-video": "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": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
"test:ui:vnc": "tsc && ./scripts/uiTestsWithVnc.sh", "test:ui:vnc": "tsc && ./scripts/uiTestsWithVnc.sh",
"test:mcp": "tsc && node dist/src/spec/testMcpIntrospection.js", "test:mcp": "tsc && node dist/src/spec/testMcpIntrospection.js",

107
scripts/cutVideoSegmentsMobile.js Executable file
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);
});

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env node
const fs = require('fs');
// Read scenes-mobile.json
const scenes = JSON.parse(fs.readFileSync('scenes-mobile.json', 'utf8'));
// Get base URL from command line arguments
const baseUrl = process.argv[2];
if (!baseUrl) {
console.error('Usage: node generateMarkdownSummaryMobile.js <base-url>');
process.exit(1);
}
// Sanitize scene name to prevent path traversal
function sanitizeName(name) {
// Remove any characters that aren't alphanumeric, dash, or underscore
return name.replace(/[^a-zA-Z0-9_-]/g, '-');
}
// Generate markdown
let markdown = '## 📱 Mobile Demo Video Generated\n\n';
markdown += `### Full Mobile Video (Pixel 6 - 412x915)\n\n`;
markdown += `[📥 Download Mobile Video (MP4)](${baseUrl}/ui-test-mobile.mp4) | [GIF](${baseUrl}/ui-test-mobile.gif)\n\n`;
markdown += `---\n\n`;
markdown += `### 📑 Mobile Video Segments\n\n`;
markdown += `<details>\n`;
markdown += `<summary>Click to expand mobile segments</summary>\n\n`;
scenes.forEach((scene, index) => {
const safeName = sanitizeName(scene.name);
const segmentFile = `segment-mobile-${String(index + 1).padStart(2, '0')}-${safeName}.gif`;
const title = scene.title || scene.name;
const duration = (scene.duration / 1000).toFixed(1);
markdown += `<details>\n`;
markdown += `<summary><strong>${index + 1}. ${title}</strong> (${duration}s)</summary>\n\n`;
markdown += `![${title}](${baseUrl}/${segmentFile})\n\n`;
markdown += `</details>\n\n`;
});
markdown += `</details>\n\n`;
markdown += `_Mobile videos recorded at 412x915 (Pixel 6 viewport). Videos will expire in 90 days._`;
console.log(markdown);

38
scripts/prepareVideoMobile.sh Executable file
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="412x915"
GIF_SCALE="412"
ffmpeg -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv app2-mobile.mp4
# The video starts with a few blank frames, we want to know when they stop
ffprobe -f lavfi -i "movie=app2-mobile.mp4,blackdetect[out0]" -show_entries tags=lavfi.black_start,lavfi.black_end -of default=nw=1 -v quiet > ffmpeg_info_mobile
END_OF_BLACK=`cat ffmpeg_info_mobile | grep end | head -n1 | cut -d'=' -f2`
# Remove grey frames at the beginning (app start and splash screen)
END_OF_BLACK=`awk "BEGIN {print $END_OF_BLACK+0.8; exit}"`
# Trim black frames at start
ffmpeg -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv -ss $END_OF_BLACK app-mobile.mp4
# Generate gif palette
ffmpeg -y -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv -vf "fps=10,scale=$GIF_SCALE:-1:flags=lanczos,palettegen" palette-mobile.png
# Create gif
ffmpeg -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv -i palette-mobile.png -ss $END_OF_BLACK -filter_complex "fps=10,scale=$GIF_SCALE:-1:flags=lanczos[x];[x][1:v]paletteuse" app-mobile.gif
# Clean up
rm ffmpeg_info_mobile palette-mobile.png qrawvideorgb24-mobile.yuv app2-mobile.mp4
mv app-mobile.mp4 ui-test-mobile.mp4
mv app-mobile.gif ui-test-mobile.gif
# Cut video into segments based on scenes-mobile.json
echo "Cutting mobile video into segments..."
if [ -f "scenes-mobile.json" ]; then
node ./scripts/cutVideoSegmentsMobile.js
else
echo "Warning: scenes-mobile.json not found, skipping segment creation"
fi

79
scripts/uiTestsMobile.sh Executable file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
function finish {
set +e
echo "Exiting, cleaning up.."
echo "Stopping TMUX session (record-mobile).."
tmux kill-session -t record-mobile || echo "Already stopped"
if [[ ! -z "$PID_MOSQUITTO" ]]; then
echo "Stopping mosquitto ($PID_MOSQUITTO).."
kill "$PID_MOSQUITTO" || echo "Already stopped"
fi
if [[ ! -z "$PID_VNC" ]]; then
echo "Stopping VNC ($PID_VNC).."
kill "$PID_VNC" || echo "Already stopped"
fi
if [[ ! -z "$PID_XVFB" ]]; then
echo "Stopping XVFB ($PID_XVFB).."
kill "$PID_XVFB" || echo "Already stopped"
fi
if [[ ! -z "$PID_SERVER" ]]; then
echo "Stopping MQTT Explorer server ($PID_SERVER).."
kill "$PID_SERVER" || echo "Already stopped"
fi
}
trap finish EXIT
set -e
# Mobile viewport dimensions (Pixel 6)
DIMENSIONS="412x915"
SCR=99
# Start new window manager
Xvfb :$SCR -screen 0 "$DIMENSIONS"x24 -ac &
export PID_XVFB=$!
sleep 2
# Debug with VNC
x11vnc -localhost -rfbport 5900 -passwd "bierbier" -display :$SCR &
export PID_VNC=$!
# Start mqtt broker
mosquitto &
export PID_MOSQUITTO=$!
sleep 2
# Start MQTT Explorer in browser mode
export MQTT_EXPLORER_USERNAME=admin
export MQTT_EXPLORER_PASSWORD=password
export MQTT_EXPLORER_SKIP_AUTH=true
export DISPLAY=:$SCR
node dist/src/server.js &
export PID_SERVER=$!
sleep 5
# Delete old video
rm -f ./app-mobile*.mp4
rm -f ./qrawvideorgb24-mobile.yuv
# Start recording in tmux
tmux new-session -d -s record-mobile ffmpeg -f x11grab -draw_mouse 0 -video_size $DIMENSIONS -i :$SCR -r 20 -vcodec rawvideo -pix_fmt yuv420p qrawvideorgb24-mobile.yuv
# Start tests
export BROWSER_MODE_URL=http://localhost:3000
DISPLAY=:$SCR node dist/src/spec/demoVideoMobile.js
TEST_EXIT_CODE=$?
echo "Test script exited with $TEST_EXIT_CODE"
# Stop recording
tmux send-keys -t record-mobile q
# Ensure video is written
sleep 5
exit $TEST_EXIT_CODE

View File

@@ -22,6 +22,16 @@ export type SceneNames =
| 'keyboard_shortcuts' | 'keyboard_shortcuts'
| 'sparkplugb-decoding' | 'sparkplugb-decoding'
| 'end' | 'end'
| 'mobile_intro'
| 'mobile_connect'
| 'mobile_browse_topics'
| 'mobile_search'
| 'mobile_view_message'
| 'mobile_json_view'
| 'mobile_clipboard'
| 'mobile_plots'
| 'mobile_menu'
| 'mobile_end'
export const SCENE_TITLES: Record<SceneNames, string> = { export const SCENE_TITLES: Record<SceneNames, string> = {
connect: 'Connecting to MQTT Broker', connect: 'Connecting to MQTT Broker',
@@ -39,6 +49,16 @@ export const SCENE_TITLES: Record<SceneNames, string> = {
keyboard_shortcuts: 'Keyboard Shortcuts', keyboard_shortcuts: 'Keyboard Shortcuts',
'sparkplugb-decoding': 'SparkplugB Decoding', 'sparkplugb-decoding': 'SparkplugB Decoding',
end: 'The End', end: 'The End',
mobile_intro: 'MQTT Explorer on Mobile',
mobile_connect: 'Connect to MQTT Broker',
mobile_browse_topics: 'Browse Topic Tree',
mobile_search: 'Search Topics',
mobile_view_message: 'View Message Details',
mobile_json_view: 'JSON Message Formatting',
mobile_clipboard: 'Copy to Clipboard',
mobile_plots: 'View Numeric Plots',
mobile_menu: 'Settings & Menu',
mobile_end: 'Mobile-Friendly MQTT Explorer',
} }
export class SceneBuilder { export class SceneBuilder {

230
src/spec/demoVideoMobile.ts Normal file
View File

@@ -0,0 +1,230 @@
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { Browser, BrowserContext, Page, chromium } from 'playwright'
import mockMqtt, { stop as stopMqtt } from './mock-mqtt'
import { default as MockSparkplug } from './mock-sparkplugb'
import { clearSearch, searchTree } from './scenarios/searchTree'
import { clickOnHistory, createFakeMousePointer, hideText, showText, sleep } from './util'
import { connectTo } from './scenarios/connect'
import { copyTopicToClipboard } from './scenarios/copyTopicToClipboard'
import { copyValueToClipboard } from './scenarios/copyValueToClipboard'
import { disconnect } from './scenarios/disconnect'
import { publishTopic } from './scenarios/publishTopic'
import { Scene, SceneBuilder } from './SceneBuilder'
import { showAdvancedConnectionSettings } from './scenarios/showAdvancedConnectionSettings'
import { showJsonPreview } from './scenarios/showJsonPreview'
import { showMenu } from './scenarios/showMenu'
import { showNumericPlot } from './scenarios/showNumericPlot'
import { showOffDiffCapability } from './scenarios/showOffDiffCapability'
/**
* Mobile Demo Video - Pixel 6 viewport
*
* This demo showcases MQTT Explorer running in a mobile browser viewport
* simulating a Google Pixel 6 (412x915px portrait mode)
*/
/**
* A convenience method that handles gracefully cleaning up the test run.
*/
const cleanUp = async (scenes: SceneBuilder, browser: Browser) => {
// Exit app.
fs.writeFileSync('scenes-mobile.json', JSON.stringify(scenes.scenes, undefined, ' '))
await browser.close()
}
process.on('unhandledRejection' as any, (error: Error | any) => {
console.error('unhandledRejection', error.message, error.stack)
process.exit(1)
})
setTimeout(
() => {
console.error('Timeout reached')
process.exit(1)
},
60 * 10 * 1000
)
async function doStuff() {
const brokerHost = process.env.TESTS_MQTT_BROKER_HOST || '127.0.0.1'
const brokerPort = process.env.TESTS_MQTT_BROKER_PORT || '1883'
console.log(`Waiting for MQTT Broker at ${brokerHost}:${brokerPort} (no auth)`)
await mockMqtt()
console.log('Starting playwright/chromium in mobile mode (Pixel 6)')
// Launch Chromium browser with mobile emulation
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
})
// Create browser context with Pixel 6 viewport
const context = await browser.newContext({
viewport: {
width: 412,
height: 915,
},
deviceScaleFactor: 2.625,
isMobile: true,
hasTouch: true,
userAgent: 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36',
})
const page = await context.newPage()
// Navigate to the browser mode server
const serverUrl = process.env.BROWSER_MODE_URL || 'http://localhost:3000'
console.log(`Navigating to ${serverUrl}`)
await page.goto(serverUrl, { waitUntil: 'networkidle' })
// Print the title
console.log(await page.title())
// Capture a screenshot
await page.screenshot({ path: 'intro-mobile.png' })
// Direct console to Node terminal
page.on('console', console.log)
// Handle authentication if required
const username = process.env.MQTT_EXPLORER_USERNAME || 'admin'
const password = process.env.MQTT_EXPLORER_PASSWORD || 'password'
console.log('Waiting for page to initialize...')
await sleep(3000)
// Check for login dialog
const loginDialog = page.locator('h2:has-text("Login to MQTT Explorer")')
let loginDialogVisible = false
try {
loginDialogVisible = await loginDialog.isVisible({ timeout: 5000 })
} catch (error) {
console.log('Login dialog not found - auth may be disabled')
}
if (loginDialogVisible) {
console.log('Handling authentication...')
const usernameInput = page.locator('input[name="username"]')
const passwordInput = page.locator('input[name="password"]')
const loginButton = page.locator('button:has-text("Login")')
await usernameInput.fill(username)
await passwordInput.fill(password)
await loginButton.click()
await sleep(2000)
}
// Wait for the connection UI to be visible
await page.locator('//label[contains(text(), "Host")]/..//input').waitFor({ timeout: 10000 })
const scenes = new SceneBuilder()
await scenes.record('mobile_intro', async () => {
await showText('MQTT Explorer on Mobile', 2000, page, 'middle')
await sleep(2500)
await showText('Google Pixel 6 (412x915)', 1500, page, 'middle')
await sleep(2000)
await hideText(page)
})
await scenes.record('mobile_connect', async () => {
await showText('Connect to MQTT Broker', 1500, page, 'top')
await connectTo(brokerHost, page)
await MockSparkplug.run() // Start sparkplug client after connect
await sleep(2000)
await hideText(page)
})
await scenes.record('mobile_browse_topics', async () => {
await showText('Browse Topic Tree', 1500, page, 'top')
await sleep(1500)
// Try to expand a topic in the tree
const firstTopic = page.locator('[data-testid="tree-node"]').first()
if (await firstTopic.isVisible()) {
await firstTopic.click()
await sleep(1000)
}
await sleep(1500)
await hideText(page)
})
await scenes.record('mobile_search', async () => {
await showText('Search Topics', 1500, page, 'top')
await searchTree('temp', page)
await sleep(1500)
await showText('Filter Results', 1000, page, 'top')
await sleep(1500)
await clearSearch(page)
await sleep(1000)
await hideText(page)
})
await scenes.record('mobile_view_message', async () => {
await showText('View Message Details', 1500, page, 'top')
await sleep(1000)
// Click on a topic to view details in sidebar
const topicNode = page.locator('[data-testid="tree-node"]').first()
if (await topicNode.isVisible()) {
await topicNode.click()
await sleep(2000)
}
await hideText(page)
})
await scenes.record('mobile_json_view', async () => {
await showText('JSON Message Formatting', 1500, page, 'top')
await showJsonPreview(page)
await sleep(2000)
await hideText(page)
})
await scenes.record('mobile_clipboard', async () => {
await showText('Copy to Clipboard', 1500, page, 'top')
await copyTopicToClipboard(page)
await sleep(1000)
await copyValueToClipboard(page)
await sleep(1500)
await hideText(page)
})
await scenes.record('mobile_plots', async () => {
await showText('View Numeric Plots', 1500, page, 'top')
await showNumericPlot(page)
await sleep(2500)
await hideText(page)
})
await scenes.record('mobile_menu', async () => {
await showText('Settings & Menu', 1500, page, 'top')
await showMenu(page)
await sleep(2000)
await hideText(page)
})
await scenes.record('mobile_end', async () => {
await showText('Mobile-Friendly MQTT Explorer', 2000, page, 'middle')
await sleep(2500)
await showText('Ready for Optimization', 1500, page, 'middle')
await sleep(2000)
})
setTimeout(() => {
console.log('Forced quit')
process.exit(0)
}, 10 * 1000)
stopMqtt()
console.log('Stopped mqtt client')
await cleanUp(scenes, browser)
// Force exit since there appear to be open handles
process.exit(0)
}
doStuff()

View File

@@ -22,6 +22,7 @@
"src/AuthManager.ts", "src/AuthManager.ts",
"src/spec/electron.ts", "src/spec/electron.ts",
"src/spec/demoVideo.ts", "src/spec/demoVideo.ts",
"src/spec/demoVideoMobile.ts",
"src/spec/leakTest.ts", "src/spec/leakTest.ts",
"src/spec/testMcpIntrospection.ts", "src/spec/testMcpIntrospection.ts",
"src/spec/ui-tests.spec.ts", "src/spec/ui-tests.spec.ts",