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>
This commit is contained in:
Copilot
2025-12-27 17:02:49 +01:00
committed by GitHub
parent 8f86d272c7
commit 4de52aba7c
45 changed files with 1381 additions and 224 deletions

View File

@@ -91,3 +91,45 @@ When modifying or creating UI components, follow the styling patterns documented
- 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
```

View File

@@ -176,8 +176,12 @@ jobs:
- 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
@@ -199,24 +203,32 @@ jobs:
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
# 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
# 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 }}
@@ -234,6 +246,7 @@ jobs:
done
shopt -u nullglob # Restore default behavior
- name: Generate file URLs
if: always()
id: fileurl
env:
AWS_BUCKET: ${{ vars.AWS_BUCKET }}
@@ -243,20 +256,24 @@ jobs:
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}")
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 }}

32
.gitignore vendored
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
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

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.

180
MOBILE_TESTING.md Normal file
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/)

View File

@@ -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())
}
}

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)
}

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

View File

@@ -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>
<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',

View File

@@ -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))

View File

@@ -8,6 +8,7 @@ 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
@@ -21,13 +22,27 @@ interface Props {
function ContentView(props: Props) {
// Use different defaults for mobile viewports (<=768px width)
// Use useState with lazy initialization to get initial mobile state
const [isMobile] = React.useState(() => typeof window !== 'undefined' && window.innerWidth <= 768)
// 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>(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()
@@ -71,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>
@@ -113,7 +205,7 @@ function ContentView(props: Props) {
<div
className={props.paneDefaults}
style={{
minWidth: isMobile ? '100%' : '250px',
minWidth: '250px',
height: '100%',
overflowY: 'auto',
overflowX: 'hidden'

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)

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',
},
},
})

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,

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),
},
}
}

View File

@@ -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

View File

@@ -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) => ({
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: theme.spacing(0.25),
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))

View File

@@ -61,8 +61,22 @@ function TreeNodeComponent(props: Props) {
const didClickTitle = React.useCallback(
(event: React.MouseEvent) => {
event.stopPropagation()
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}

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',
},
},
}
}

View File

@@ -2,19 +2,16 @@ import * as React from 'react'
import * as ReactDOM from 'react-dom/client'
import App from './components/App'
import Demo from './components/Demo'
import reducers, { AppState } from './reducers'
import { thunk as reduxThunk } from 'redux-thunk'
import { applyMiddleware, compose, createStore } from 'redux'
import { batchDispatchMiddleware } from 'redux-batched-actions'
import { AppState } from './reducers'
import { connect, Provider } from 'react-redux'
import { ThemeProvider } from '@mui/material/styles'
import { ThemeProvider as LegacyThemeProvider } from '@mui/styles'
import './utils/tracking'
import { themes } from './theme'
import { BrowserAuthWrapper } from './components/BrowserAuthWrapper'
import { store } from './store'
import './autoConnectHandler' // Initialize auto-connect handling
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const store = createStore(reducers, composeEnhancers(applyMiddleware(reduxThunk, batchDispatchMiddleware)))
function ApplicationRenderer(props: { theme: 'light' | 'dark' }) {
const theme = props.theme === 'light' ? themes.lightTheme : themes.darkTheme

8
app/src/store.ts Normal file
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)))

BIN
icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

BIN
mobile-before-connect.png Normal file

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

BIN
mobile-expand-1-before.png Normal file

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

View File

@@ -16,6 +16,7 @@
"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",

View File

@@ -1,28 +1,46 @@
#!/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
// 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>');
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
let markdown = '## 📱 Mobile Demo Video Generated\n\n';
markdown += `### Full Mobile Video (Pixel 6 - 412x915)\n\n`;
// 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`;
@@ -40,6 +58,10 @@ scenes.forEach((scene, index) => {
});
markdown += `</details>\n\n`;
markdown += `_Mobile videos recorded at 412x915 (Pixel 6 viewport). Videos will expire in 90 days._`;
} 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);

View File

@@ -2,7 +2,7 @@
# Mobile demo video post-processing script
# Converts raw mobile video to MP4 and GIF, then cuts into segments
DIMENSIONS="412x915"
DIMENSIONS="412x914"
GIF_SCALE="412"
ffmpeg -s:v $DIMENSIONS -r 20 -f rawvideo -pix_fmt yuv420p -i qrawvideorgb24-mobile.yuv app2-mobile.mp4

View File

@@ -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
@@ -65,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=$?

View File

@@ -30,12 +30,17 @@ function finish {
trap finish EXIT
set -e
# Mobile viewport dimensions (Pixel 6)
DIMENSIONS="412x915"
# 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
Xvfb :$SCR -screen 0 "$DIMENSIONS"x24 -ac &
# Start new window manager with extra height for Chrome header
Xvfb :$SCR -screen 0 "$XVFB_DIMENSIONS"x24 -ac &
export PID_XVFB=$!
sleep 2
@@ -47,6 +52,7 @@ export PID_VNC=$!
mosquitto &
export PID_MOSQUITTO=$!
sleep 2
npx -y playwright install
# Start MQTT Explorer in browser mode
export MQTT_EXPLORER_USERNAME=admin
@@ -61,8 +67,9 @@ sleep 5
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 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

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

View File

@@ -25,12 +25,10 @@ export type SceneNames =
| 'mobile_intro'
| 'mobile_connect'
| 'mobile_browse_topics'
| 'mobile_search'
| 'mobile_view_message'
| 'mobile_search'
| 'mobile_json_view'
| 'mobile_clipboard'
| 'mobile_plots'
| 'mobile_menu'
| 'mobile_settings'
| 'mobile_end'
export const SCENE_TITLES: Record<SceneNames, string> = {
@@ -52,12 +50,10 @@ export const SCENE_TITLES: Record<SceneNames, string> = {
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_search: 'Search Topics',
mobile_json_view: 'JSON Message Formatting',
mobile_clipboard: 'Copy to Clipboard',
mobile_plots: 'View Numeric Plots',
mobile_menu: 'Settings & Menu',
mobile_settings: 'Settings with Disconnect/Logout',
mobile_end: 'Mobile-Friendly MQTT Explorer',
}

View File

@@ -7,7 +7,7 @@ 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 { clickOn, clickOnHistory, createFakeMousePointer, hideText, showText, sleep } from './util'
import { connectTo } from './scenarios/connect'
import { copyTopicToClipboard } from './scenarios/copyTopicToClipboard'
import { copyValueToClipboard } from './scenarios/copyValueToClipboard'
@@ -19,6 +19,8 @@ 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
@@ -58,16 +60,29 @@ async function doStuff() {
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: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
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: 915,
height: 914, // Changed from 915 to 914 (must be even for h264)
},
deviceScaleFactor: 2.625,
isMobile: true,
@@ -85,12 +100,19 @@ async function doStuff() {
// Print the title
console.log(await page.title())
// Capture a screenshot
// 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'
@@ -136,25 +158,55 @@ async function doStuff() {
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 sleep(3000) // Give more time for topics to load
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 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')
@@ -164,48 +216,55 @@ async function doStuff() {
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)
})
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)
}
await scenes.record('mobile_menu', async () => {
await showText('Settings & Menu', 1500, page, 'top')
await showMenu(page)
// 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)

View File

@@ -4,7 +4,12 @@ import { Page } from 'playwright'
export async function connectTo(host: string, browser: Page) {
await setTextInInput('Host', host, browser)
// Try to capture screenshot (may fail in headed mode)
try {
await browser.screenshot({ path: 'screen1.png' })
} catch (error) {
// Screenshot may fail in headed mode, that's ok
}
// Use data-testid for reliable button location
const connectButton = browser.locator('[data-testid="connect-button"]')

View File

@@ -3,6 +3,10 @@ import { expandTopic, sleep } from '../util'
export async function showJsonPreview(browser: Page) {
await expandTopic('actuality/showcase', browser)
try {
await browser.screenshot({ path: 'screen3.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
await sleep(1000)
}

View File

@@ -9,7 +9,11 @@ export async function showMenu(browser: Page) {
// moveToCenterOfElement(brokerStatistics, browser)
await sleep(2000)
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)
try {
await browser.screenshot({ path: 'screen_dark_mode.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
await clickOn(themeSwitch)
await clickOn(menuButton)

View File

@@ -2,6 +2,8 @@ import { Page } from 'playwright'
import { moveToCenterOfElement, clickOn, clickOnHistory, expandTopic, sleep } from '../util'
export async function showNumericPlot(browser: Page) {
// On desktop, expandTopic will also select the topic (original behavior restored)
// This shows the JSON properties in the details panel where chart icons are located
await expandTopic('kitchen/coffee_maker', browser)
let heater = await valuePreviewGuttersShowChartIcon('heater', browser)
await moveToCenterOfElement(heater)
@@ -30,7 +32,11 @@ export async function showNumericPlot(browser: Page) {
await clickAway('temperature', browser)
await sleep(2500)
try {
await browser.screenshot({ path: 'screen_chart_panel.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
await removeChart('heater', browser)
await sleep(750)

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)
try {
await browser.screenshot({ path: 'screen_sparkplugb_decoding.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
await sleep(1000)
}

View File

@@ -62,15 +62,39 @@ describe('MQTT Explorer UI Tests', function () {
}
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({
headless: true,
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

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"]')
// Scroll the element into view to ensure it's clickable
await locator.scrollIntoViewIfNeeded()
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 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))
} 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('/')}"`)
}
}
}

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)
}

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}"`)
}
}