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:
42
.github/copilot-instructions.md
vendored
42
.github/copilot-instructions.md
vendored
@@ -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
|
||||
```
|
||||
|
||||
|
||||
23
.github/workflows/tests.yml
vendored
23
.github/workflows/tests.yml
vendored
@@ -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
32
.gitignore
vendored
@@ -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
|
||||
|
||||
49
LICENSE.md
49
LICENSE.md
@@ -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 licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then 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 Adapter’s 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 Adapter’s 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
180
MOBILE_TESTING.md
Normal 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/)
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
48
app/src/autoConnectHandler.ts
Normal file
48
app/src/autoConnectHandler.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
125
app/src/components/ConnectionSetup/MobileConnectionSelector.tsx
Normal file
125
app/src/components/ConnectionSetup/MobileConnectionSelector.tsx
Normal 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))
|
||||
@@ -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'
|
||||
|
||||
69
app/src/components/Layout/MobileTabs.tsx
Normal file
69
app/src/components/Layout/MobileTabs.tsx
Normal 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)
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,17 +2,22 @@ import * as React from 'react'
|
||||
import BooleanSwitch from './BooleanSwitch'
|
||||
import BrokerStatistics from './BrokerStatistics'
|
||||
import ChevronRight from '@mui/icons-material/ChevronRight'
|
||||
import CloudOff from '@mui/icons-material/CloudOff'
|
||||
import Logout from '@mui/icons-material/Logout'
|
||||
import TimeLocale from './TimeLocale'
|
||||
import { AppState } from '../../reducers'
|
||||
import { bindActionCreators } from 'redux'
|
||||
import { connect } from 'react-redux'
|
||||
import { globalActions, settingsActions } from '../../actions'
|
||||
import { globalActions, settingsActions, connectionActions } from '../../actions'
|
||||
import { shell } from 'electron'
|
||||
import { Theme } from '@mui/material/styles'
|
||||
import { withStyles } from '@mui/styles'
|
||||
import { TopicOrder } from '../../reducers/Settings'
|
||||
import { isBrowserMode } from '../../utils/browserMode'
|
||||
import { useAuth } from '../../contexts/AuthContext'
|
||||
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
@@ -75,12 +80,26 @@ const styles = (theme: Theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
cursor: 'pointer' as 'pointer',
|
||||
},
|
||||
mobileButtons: {
|
||||
padding: theme.spacing(1),
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as 'column',
|
||||
gap: theme.spacing(1),
|
||||
// Only show on mobile
|
||||
[theme.breakpoints.up('md')]: {
|
||||
display: 'none' as 'none',
|
||||
},
|
||||
},
|
||||
mobileButton: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
actions: {
|
||||
settings: typeof settingsActions
|
||||
global: typeof globalActions
|
||||
connection: typeof connectionActions
|
||||
}
|
||||
autoExpandLimit: number
|
||||
classes: any
|
||||
@@ -219,6 +238,7 @@ class Settings extends React.PureComponent<Props, {}> {
|
||||
</Typography>
|
||||
<Divider style={{ userSelect: 'none' }} />
|
||||
</div>
|
||||
<MobileActionButtons classes={classes} actions={actions} />
|
||||
<div>
|
||||
{this.renderAutoExpand()}
|
||||
{this.renderNodeOrder()}
|
||||
@@ -238,6 +258,52 @@ class Settings extends React.PureComponent<Props, {}> {
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile action buttons component (disconnect/logout)
|
||||
function MobileActionButtons({ classes, actions }: { classes: any; actions: any }) {
|
||||
const { authDisabled } = useAuth()
|
||||
|
||||
const handleLogout = async () => {
|
||||
// Disconnect first
|
||||
actions.connection.disconnect()
|
||||
|
||||
// Clear credentials from sessionStorage
|
||||
if (typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.removeItem('mqtt-explorer-username')
|
||||
sessionStorage.removeItem('mqtt-explorer-password')
|
||||
}
|
||||
|
||||
// Reload page to reset all state and show login dialog
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.mobileButtons}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<CloudOff />}
|
||||
onClick={actions.connection.disconnect}
|
||||
className={classes.mobileButton}
|
||||
data-testid="mobile-disconnect-button"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
{isBrowserMode && !authDisabled && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Logout />}
|
||||
onClick={handleLogout}
|
||||
className={classes.mobileButton}
|
||||
data-testid="mobile-logout-button"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
autoExpandLimit: state.settings.get('autoExpandLimit'),
|
||||
@@ -254,6 +320,7 @@ const mapDispatchToProps = (dispatch: any) => {
|
||||
actions: {
|
||||
settings: bindActionCreators(settingsActions, dispatch),
|
||||
global: bindActionCreators(globalActions, dispatch),
|
||||
connection: bindActionCreators(connectionActions, dispatch),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
|
||||
|
||||
const selectOption = useCallback(
|
||||
(decoder: MessageDecoder, format: string) => {
|
||||
if (!node) {
|
||||
if (!node || !node.viewModel) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export const TopicTypeButton = (props: { node?: q.TreeNode<any> }) => {
|
||||
|
||||
return (
|
||||
<Button onClick={handleToggle}>
|
||||
{props.node?.viewModel.decoder?.format ?? props.node?.type}
|
||||
{props.node?.viewModel?.decoder?.format ?? props.node?.type}
|
||||
<Popper open={open} anchorEl={anchorEl} role={undefined} transition>
|
||||
{({ TransitionProps, placement }) => (
|
||||
<Grow
|
||||
|
||||
@@ -52,8 +52,21 @@ export const TreeNodeTitle = (props: TreeNodeProps) => {
|
||||
return null
|
||||
}
|
||||
|
||||
// On mobile, the expand button has its own click handler separate from topic selection
|
||||
// On desktop, clicking anywhere (including expander) selects and toggles via didClickTitle
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth <= 768
|
||||
const onClick = isMobile ? props.toggleCollapsed : undefined
|
||||
|
||||
return (
|
||||
<span key="expander" className={props.classes.expander} onClick={props.toggleCollapsed}>
|
||||
<span
|
||||
key="expander"
|
||||
className={props.classes.expander}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
aria-label={props.collapsed ? 'Expand topic' : 'Collapse topic'}
|
||||
aria-expanded={!props.collapsed}
|
||||
tabIndex={isMobile ? 0 : -1}
|
||||
>
|
||||
{props.collapsed ? '▶' : '▼'}
|
||||
</span>
|
||||
)
|
||||
@@ -83,27 +96,39 @@ export const TreeNodeTitle = (props: TreeNodeProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => ({
|
||||
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))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
8
app/src/store.ts
Normal 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
mobile-before-connect.png
Normal file
BIN
mobile-before-connect.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
mobile-debug-1-before-connect.png
Normal file
BIN
mobile-debug-1-before-connect.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
mobile-debug-2-after-connect.png
Normal file
BIN
mobile-debug-2-after-connect.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
mobile-expand-1-before.png
Normal file
BIN
mobile-expand-1-before.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
mobile-expand-2-after-livingroom.png
Normal file
BIN
mobile-expand-2-after-livingroom.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
mobile-expand-3-after-lamp.png
Normal file
BIN
mobile-expand-3-after-lamp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=$?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('/')}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from 'fs'
|
||||
import { Page, Locator } from 'playwright'
|
||||
|
||||
export { expandTopic } from './expandTopic'
|
||||
export { selectTopic } from './selectTopic'
|
||||
|
||||
let fast = false
|
||||
export function setFast() {
|
||||
@@ -85,8 +86,10 @@ export async function moveToCenterOfElement(element: Locator) {
|
||||
try {
|
||||
const js = `window.demo.moveMouse(${targetX}, ${targetY}, ${duration});`
|
||||
await runJavascript(js, element.page())
|
||||
await sleep(duration)
|
||||
await sleep(250, true)
|
||||
// IMPORTANT: Wait for animation to complete before returning
|
||||
// The animation duration + a small buffer for frame rendering
|
||||
await sleep(duration, true) // Use required=true to ensure we actually wait
|
||||
await sleep(100, true) // Extra buffer for the last frame
|
||||
} catch (error) {
|
||||
// window.demo.moveMouse might not be available in all test environments
|
||||
// This is fine - we'll proceed with the click anyway
|
||||
@@ -115,17 +118,26 @@ export async function clickOn(
|
||||
// Ensure element is visible before trying to interact
|
||||
await element.waitFor({ state: 'visible', timeout: 30000 })
|
||||
|
||||
// Scroll element into view first (important for mobile viewports)
|
||||
await element.scrollIntoViewIfNeeded()
|
||||
await sleep(100)
|
||||
|
||||
// Skip hover when force is true (used when modal backdrop might intercept)
|
||||
if (!force) {
|
||||
try {
|
||||
// Move the simulated mouse cursor and wait for animation to complete
|
||||
await moveToCenterOfElement(element)
|
||||
// Now hover with the real cursor (this is instant but comes after animation)
|
||||
await element.hover()
|
||||
// Small delay after hover for visual smoothness
|
||||
await sleep(50, true)
|
||||
} catch (error) {
|
||||
// If custom mouse movement fails, we can still proceed with the click
|
||||
// Playwright's click will handle scrolling into view automatically
|
||||
console.log('Custom mouse movement failed, proceeding with direct click')
|
||||
}
|
||||
}
|
||||
// Click happens after simulated cursor has reached its destination
|
||||
await element.click({ delay, button, force, clickCount: clicks })
|
||||
await sleep(50)
|
||||
}
|
||||
|
||||
68
src/spec/util/selectTopic.ts
Normal file
68
src/spec/util/selectTopic.ts
Normal 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}"`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user