Implement comprehensive UI test suite with meaningful assertions and best practices (#921)

This commit is contained in:
Copilot
2025-12-20 02:13:31 +01:00
committed by GitHub
parent 55f8b7d2b7
commit 8285627c5f
16 changed files with 1150 additions and 279 deletions

View File

@@ -54,6 +54,94 @@ electron . --enable-mcp-introspection --remote-debugging-port=9223
3. **Include screenshots** - Visual verification is required for UI changes 3. **Include screenshots** - Visual verification is required for UI changes
4. **Handle asynchronous operations properly** - This is an MQTT message queue tool 4. **Handle asynchronous operations properly** - This is an MQTT message queue tool
### Best Practices for UI Tests
#### 1. Use Given-When-Then Pattern
Structure tests with clear Given-When-Then comments to make them readable:
```typescript
it('Given a JSON message sent to topic foo/bar/baz, the tree should display nested topics', async function () {
// Given: Mock MQTT publishes JSON to foo/bar/baz
// When: We wait for the topic to appear in the tree
// Then: Topic hierarchy should be visible (foo -> bar -> baz)
})
```
#### 2. Wait for Elements, Don't Use Fixed Delays
Prefer `waitFor` over `sleep` whenever possible:
```typescript
// ✓ Good: Wait for specific element
const topic = await page.locator('span[data-test-topic="kitchen"]')
await topic.waitFor({ state: 'visible', timeout: 5000 })
// ✗ Bad: Fixed delay without verification
await sleep(5000)
```
#### 3. Use Meaningful Assertions
Every test should have explicit assertions that verify the expected state:
```typescript
// ✓ Good: Explicit assertion with meaningful message
const treeNodes = await page.locator('[class*="TreeNode"]')
const count = await treeNodes.count()
expect(count).to.be.greaterThan(0, 'Topic tree should contain nodes')
// ✗ Bad: No assertion, only screenshot
await page.screenshot({ path: 'test.png' })
```
#### 4. Test Data-Driven Scenarios
Write tests that describe the data flow:
```typescript
it('Given messages sent to livingroom/lamp/state and livingroom/lamp/brightness, both should appear under livingroom/lamp', async function () {
// Test implementation verifies the specific data flow
})
```
#### 5. Use Data Test Attributes
Leverage `data-test-*` attributes for reliable selectors:
```typescript
// ✓ Good: Use data-test attributes
const topic = await page.locator('span[data-test-topic="kitchen"]')
// ⚠ Acceptable: Use role/text when data attributes aren't available
const button = await page.locator('//button/span[contains(text(),"Connect")]')
// ✗ Bad: Rely on CSS classes that may change
const topic = await page.locator('.MuiTreeItem-label')
```
#### 6. Verify Multiple Aspects
Test should verify both state and UI:
```typescript
// Verify the action completed
const isVisible = await disconnectButton.isVisible()
expect(isVisible).to.be.true
// Capture screenshot for visual verification
await page.screenshot({ path: 'test-screenshot-connection.png' })
```
#### 7. Handle MQTT Asynchronous Nature
Account for message propagation time:
```typescript
// Publish message
await mockClient.publish('topic/name', 'value')
// Wait for UI to update
await page.locator(`text="value"`).waitFor({ timeout: 5000 })
// Verify state
const value = await page.textContent('.message-value')
expect(value).toBe('value')
```
### Handling MQTT Asynchronous Operations ### Handling MQTT Asynchronous Operations
MQTT is inherently asynchronous. When writing tests: MQTT is inherently asynchronous. When writing tests:

View File

@@ -18,7 +18,45 @@ jobs:
run: yarn build run: yarn build
- name: Test - name: Test
run: yarn test run: yarn test
- name: UI-Test
ui-tests:
runs-on: ubuntu-latest
container:
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
volumes:
- ./:/app
options: --user root
steps:
- uses: actions/checkout@v4
- name: Install Packages
run: yarn install --frozen-lockfile
- name: Build
run: yarn build
- name: Run UI Tests
run: ./scripts/runUiTests.sh
- name: Upload Test Screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: ui-test-screenshots
path: |
test-screenshot-*.png
retention-days: 30
demo-video:
runs-on: ubuntu-latest
container:
image: ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest
volumes:
- ./:/app
options: --user root
steps:
- uses: actions/checkout@v4
- name: Install Packages
run: yarn install --frozen-lockfile
- name: Build
run: yarn build
- name: Generate Demo Video
run: yarn ui-test run: yarn ui-test
- name: Post-processing - name: Post-processing
run: ./scripts/prepareVideo.sh run: ./scripts/prepareVideo.sh

3
.gitignore vendored
View File

@@ -14,3 +14,6 @@ screen*.png
mqtt-explorer-mcp-screenshot.png mqtt-explorer-mcp-screenshot.png
screenshot-mcp-*.png screenshot-mcp-*.png
test-mcp-introspection.js test-mcp-introspection.js
# UI test artifacts
test-screenshot-*.png

View File

@@ -41,28 +41,35 @@ The `app` directory contains all the rendering logic, the `backend` directory cu
## Automated Tests ## Automated Tests
To achieve a reliable product automated tests run regularly on travis. To achieve a reliable product automated tests run regularly on CI.
- Data model - **Data model tests**: `yarn test:backend`
- MQTT integration - **App tests**: `yarn test:app`
- UI-Tests (The demo is a recorded ui test) - **UI test suite**: `yarn test:ui` (independent, deterministic tests)
- **Demo video**: `yarn ui-test` (UI test recording for documentation)
## Run UI-tests ### Run UI Test Suite
A [mosquitto](https://mosquitto.org/) MQTT broker is required to run the ui-tests. The UI test suite validates core functionality through automated browser tests. Each test is independent and deterministic.
Run tests with
```bash ```bash
# Run chromedriver in a separate terminal session # Run with automated setup (recommended)
./node_modules/.bin/chromedriver --url-base=wd/hub --port=9515 --verbose ./scripts/runUiTests.sh
# Or run directly (requires manual MQTT broker setup)
yarn build
yarn test:ui
``` ```
Compile and execute tests See [docs/UI-TEST-SUITE.md](docs/UI-TEST-SUITE.md) for more details.
### Run Demo Video Generation
A [mosquitto](https://mosquitto.org/) MQTT broker is required to generate the demo video.
```bash ```bash
npm run build yarn build
node dist/src/spec/webdriverio.js yarn ui-test
``` ```
## Create a release ## Create a release

View File

@@ -14,26 +14,27 @@ export const setTopic = (topic?: string): Action => {
} }
} }
export const openFile = (encoding: 'utf8' = 'utf8') => async (dispatch: Dispatch<any>, getState: () => AppState) => { export const openFile =
try { (encoding: 'utf8' = 'utf8') =>
const file = await getFileContent(encoding) async (dispatch: Dispatch<any>, getState: () => AppState) => {
if (file) { try {
dispatch( const file = await getFileContent(encoding)
setPayload(file.data)) if (file) {
dispatch(setPayload(file.data))
}
} catch (error) {
dispatch(showError(error))
} }
} catch (error) {
dispatch(showError(error))
} }
}
type FileParameters = { type FileParameters = {
name: string, name: string
data: string data: string
} }
async function getFileContent(encoding: string): Promise<FileParameters | undefined> { async function getFileContent(encoding: string): Promise<FileParameters | undefined> {
const rejectReasons = { const rejectReasons = {
noFileSelected: 'No file selected', noFileSelected: 'No file selected',
errorReadingFile: 'Error reading file' errorReadingFile: 'Error reading file',
} }
const { canceled, filePaths } = await rendererRpc.call(makeOpenDialogRpc(), { const { canceled, filePaths } = await rendererRpc.call(makeOpenDialogRpc(), {

View File

@@ -52,16 +52,16 @@ function ChartPreview(props: Props) {
/> />
</Tooltip> </Tooltip>
) : ( ) : (
<Tooltip title="Add to chart panel, not enough data for preview"> <Tooltip title="Add to chart panel, not enough data for preview">
<ShowChart <ShowChart
onClick={onClick} onClick={onClick}
className={props.classes.icon} className={props.classes.icon}
style={{ color: '#aaa' }} style={{ color: '#aaa' }}
data-test-type="ShowChart" data-test-type="ShowChart"
data-test={props.literal.path} data-test={props.literal.path}
/> />
</Tooltip> </Tooltip>
) )
return ( return (
<span> <span>
@@ -69,7 +69,11 @@ function ChartPreview(props: Props) {
<Popper open={open} anchorEl={chartIconRef.current} placement="left-end"> <Popper open={open} anchorEl={chartIconRef.current} placement="left-end">
<Fade in={open} timeout={300}> <Fade in={open} timeout={300}>
<Paper style={{ width: '300px' }}> <Paper style={{ width: '300px' }}>
{open ? <TopicPlot node={props.treeNode} history={props.treeNode.messageHistory} dotPath={props.literal.path} /> : <span />} {open ? (
<TopicPlot node={props.treeNode} history={props.treeNode.messageHistory} dotPath={props.literal.path} />
) : (
<span />
)}
</Paper> </Paper>
</Fade> </Fade>
</Popper> </Popper>

View File

@@ -55,10 +55,10 @@ export const getAppVersion: RpcEvent<void, string> = {
topic: 'getAppVersion', topic: 'getAppVersion',
} }
export const writeToFile: RpcEvent<{ filePath: string, data: string, encoding?: string }, void> = { export const writeToFile: RpcEvent<{ filePath: string; data: string; encoding?: string }, void> = {
topic: 'writeFile', topic: 'writeFile',
} }
export const readFromFile: RpcEvent<{ filePath: string, encoding?: string }, Buffer> = { export const readFromFile: RpcEvent<{ filePath: string; encoding?: string }, Buffer> = {
topic: 'readFromFile', topic: 'readFromFile',
} }

View File

@@ -11,4 +11,4 @@ export function makeSaveDialogRpc(): RpcEvent<SaveDialogOptions, SaveDialogRetur
return { return {
topic: 'saveDialog', topic: 'saveDialog',
} }
} }

View File

@@ -12,6 +12,7 @@
"test": "yarn test:app && yarn test:backend", "test": "yarn test:app && yarn test:backend",
"test:app": "cd app && yarn test", "test:app": "cd app && yarn test",
"test:backend": "cd backend && yarn test", "test:backend": "cd backend && yarn test",
"test:ui": "tsc && mocha --require source-map-support/register dist/src/spec/ui-tests.spec.js",
"test:mcp": "tsc && node dist/src/spec/testMcpIntrospection.js", "test:mcp": "tsc && node dist/src/spec/testMcpIntrospection.js",
"install": "cd app && yarn && cd ..", "install": "cd app && yarn && cd ..",
"dev": "npm-run-all --parallel dev:*", "dev": "npm-run-all --parallel dev:*",

39
scripts/runUiTests.sh Executable file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
set -e
function finish {
set +e
echo "Exiting, cleaning up.."
if [[ ! -z "$PID_MOSQUITTO" ]]; then
echo "Stopping mosquitto ($PID_MOSQUITTO).."
kill "$PID_MOSQUITTO" || echo "Already stopped"
fi
if [[ ! -z "$PID_XVFB" ]]; then
echo "Stopping XVFB ($PID_XVFB).."
kill "$PID_XVFB" || echo "Already stopped"
fi
}
trap finish EXIT
DIMENSIONS="1024x720"
SCR=99
# Start new window manager
Xvfb :$SCR -screen 0 "$DIMENSIONS"x24 -ac &
export PID_XVFB=$!
sleep 2
# Start mqtt broker
mosquitto &
export PID_MOSQUITTO=$!
sleep 1
# Run UI tests
DISPLAY=:$SCR yarn test:ui
TEST_EXIT_CODE=$?
echo "UI tests exited with $TEST_EXIT_CODE"
exit $TEST_EXIT_CODE

View File

@@ -18,232 +18,228 @@ import type { UMetric } from 'sparkplug-payload/lib/sparkplugbpayload'
* Main sample function which includes the run() function for running the sample * Main sample function which includes the run() function for running the sample
*/ */
export interface MockSparkplugClient { export interface MockSparkplugClient {
stop: () => void stop: () => void
} }
var sample = (function () { var sample = (function () {
var config = { var config = {
serverUrl: 'tcp://127.0.0.1:1883', serverUrl: 'tcp://127.0.0.1:1883',
username: '', username: '',
password: '', password: '',
groupId: 'Sparkplug Devices', groupId: 'Sparkplug Devices',
edgeNode: 'JavaScript Edge Node', edgeNode: 'JavaScript Edge Node',
clientId: 'JavaScriptSimpleEdgeNode', clientId: 'JavaScriptSimpleEdgeNode',
version: 'spBv1.0', version: 'spBv1.0',
}, },
hwVersion = 'Emulated Hardware', hwVersion = 'Emulated Hardware',
swVersion = 'v1.0.0', swVersion = 'v1.0.0',
deviceId = 'Emulated Device', deviceId = 'Emulated Device',
sparkPlugClient, sparkPlugClient,
publishPeriod = 5000, publishPeriod = 5000,
// Generates a random integer // Generates a random integer
randomInt = function () { randomInt = function () {
return 1 + Math.floor(Math.random() * 10) return 1 + Math.floor(Math.random() * 10)
}, },
// Get BIRTH payload for the edge node // Get BIRTH payload for the edge node
getNodeBirthPayload = function (): UPayload { getNodeBirthPayload = function (): UPayload {
return { return {
timestamp: new Date().getTime(), timestamp: new Date().getTime(),
metrics: [ metrics: [
{ {
name: 'Node Control/Rebirth', name: 'Node Control/Rebirth',
type: 'Boolean', type: 'Boolean',
value: false, value: false,
}, },
{ {
name: 'Template1', name: 'Template1',
type: 'Template', type: 'Template',
value: { value: {
isDefinition: true, isDefinition: true,
metrics: [ metrics: [
{ name: 'myBool', value: false, type: 'Boolean' }, { name: 'myBool', value: false, type: 'Boolean' },
{ name: 'myInt', value: 0, type: 'UInt32' }, { name: 'myInt', value: 0, type: 'UInt32' },
], ],
parameters: [ parameters: [
{ {
name: 'param1', name: 'param1',
type: 'String', type: 'String',
value: 'value1', value: 'value1',
}, },
], ],
}, },
}, },
], ],
}
},
// Get BIRTH payload for the device
getDeviceBirthPayload = function (): UPayload {
return {
timestamp: new Date().getTime(),
metrics: [
{ name: 'my_boolean', value: Math.random() > 0.5, type: 'Boolean' },
{ name: 'my_double', value: Math.random() * 0.123456789, type: 'Double' },
{ name: 'my_float', value: Math.random() * 0.123, type: 'Float' },
{ name: 'my_int', value: randomInt(), type: 'Int8' },
{ name: 'my_long', value: randomInt() * 214748364700, type: 'Int64' },
{ name: 'Inputs/0', value: true, type: 'Boolean' },
{ name: 'Inputs/1', value: 0, type: 'Int8' },
{ name: 'Inputs/2', value: 1.23, type: 'UInt64' },
{ name: 'Outputs/0', value: true, type: 'Boolean' },
{ name: 'Outputs/1', value: 0, type: 'Int16' },
{ name: 'Outputs/2', value: 1.23, type: 'UInt64' },
{ name: 'Properties/hw_version', value: hwVersion, type: 'String' },
{ name: 'Properties/sw_version', value: swVersion, type: 'String' },
{
name: 'my_dataset',
type: 'DataSet',
value: {
numOfColumns: 2,
types: ['String', 'String'],
columns: ['str1', 'str2'],
rows: [
['x', 'a'],
['y', 'b'],
],
},
},
{
name: 'TemplateInstance1',
type: 'Template',
value: {
templateRef: 'Template1',
isDefinition: false,
metrics: [
{ name: 'myBool', value: true, type: 'Boolean' },
{ name: 'myInt', value: 100, type: 'Int8' },
],
parameters: [
{
name: 'param1',
type: 'String',
value: 'value2',
},
],
},
},
],
}
},
// Get data payload for the device
getDataPayload = function (): UPayload {
return {
timestamp: new Date().getTime(),
metrics: [
{ name: 'my_boolean', value: Math.random() > 0.5, type: 'Boolean' },
{ name: 'my_double', value: Math.random() * 0.123456789, type: 'Double' },
{ name: 'my_float', value: Math.random() * 0.123, type: 'UInt64' },
{ name: 'my_int', value: randomInt(), type: 'Int16' },
{ name: 'my_long', value: randomInt() * 214748364700, type: 'UInt64' },
],
}
},
// Runs the sample
run = async function (): Promise<MockSparkplugClient> {
// Create the SparkplugClient
const sparkplugClient = SparkplugClient.newClient(config)
let updateInterval: NodeJS.Timeout | null = null
const connected = new Promise<MockSparkplugClient>(resolve => {
// Create 'birth' handler
sparkplugClient.on('birth', () => {
// Publish Node BIRTH certificate
sparkplugClient.publishNodeBirth(getNodeBirthPayload())
// Publish Device BIRTH certificate
sparkplugClient.publishDeviceBirth(deviceId, getDeviceBirthPayload())
resolve({
stop: () => {
if (updateInterval) {
clearInterval(updateInterval)
}
sparkplugClient.stop()
},
})
})
})
// Create Incoming Message Handler
sparkplugClient.on('message', function (topic: string, payload: UPayload) {
console.log(topic, payload)
})
// Create node command handler
// spell-checker: disable-next-line
sparkplugClient.on('ncmd', function (payload: UPayload) {
var timestamp = payload.timestamp,
metrics = payload.metrics
if (metrics !== undefined && metrics !== null) {
for (var i = 0; i < metrics.length; i++) {
var metric = metrics[i]
if (metric.name == 'Node Control/Rebirth' && metric.value) {
console.log("Received 'Rebirth' command")
// Publish Node BIRTH certificate
sparkplugClient.publishNodeBirth(getNodeBirthPayload())
// Publish Device BIRTH certificate
sparkplugClient.publishDeviceBirth(deviceId, getDeviceBirthPayload())
} }
}, }
// Get BIRTH payload for the device }
getDeviceBirthPayload = function (): UPayload { })
return {
timestamp: new Date().getTime(), // Create device command handler
metrics: [ // spell-checker: disable-next-line
{ name: 'my_boolean', value: Math.random() > 0.5, type: 'Boolean' }, sparkplugClient.on('dcmd', function (deviceId: string, payload: UPayload) {
{ name: 'my_double', value: Math.random() * 0.123456789, type: 'Double' }, var timestamp = payload.timestamp,
{ name: 'my_float', value: Math.random() * 0.123, type: 'Float' }, metrics = payload.metrics,
{ name: 'my_int', value: randomInt(), type: 'Int8' }, inboundMetricMap: { [name: string]: any } = {},
{ name: 'my_long', value: randomInt() * 214748364700, type: 'Int64' }, outboundMetric: Array<UMetric> = [],
{ name: 'Inputs/0', value: true, type: 'Boolean' }, outboundPayload: UPayload
{ name: 'Inputs/1', value: 0, type: 'Int8' },
{ name: 'Inputs/2', value: 1.23, type: 'UInt64' }, console.log('Command received for device ' + deviceId)
{ name: 'Outputs/0', value: true, type: 'Boolean' },
{ name: 'Outputs/1', value: 0, type: 'Int16' }, // Loop over the metrics and store them in a map
{ name: 'Outputs/2', value: 1.23, type: 'UInt64' }, if (metrics !== undefined && metrics !== null) {
{ name: 'Properties/hw_version', value: hwVersion, type: 'String' }, for (var i = 0; i < metrics.length; i++) {
{ name: 'Properties/sw_version', value: swVersion, type: 'String' }, var metric = metrics[i]
{ if (metric.name !== undefined && metric.name !== null) {
name: 'my_dataset', inboundMetricMap[metric.name] = metric.value
type: 'DataSet',
value: {
numOfColumns: 2,
types: ['String', 'String'],
columns: ['str1', 'str2'],
rows: [
['x', 'a'],
['y', 'b'],
],
},
},
{
name: 'TemplateInstance1',
type: 'Template',
value: {
templateRef: 'Template1',
isDefinition: false,
metrics: [
{ name: 'myBool', value: true, type: 'Boolean' },
{ name: 'myInt', value: 100, type: 'Int8' },
],
parameters: [
{
name: 'param1',
type: 'String',
value: 'value2',
},
],
},
},
],
} }
}, }
// Get data payload for the device }
getDataPayload = function (): UPayload { if (inboundMetricMap['Outputs/0'] !== undefined && inboundMetricMap['Outputs/0'] !== null) {
return { console.log('Outputs/0: ' + inboundMetricMap['Outputs/0'])
timestamp: new Date().getTime(), outboundMetric.push({ name: 'Inputs/0', value: inboundMetricMap['Outputs/0'], type: 'Boolean' })
metrics: [ outboundMetric.push({ name: 'Outputs/0', value: inboundMetricMap['Outputs/0'], type: 'Boolean' })
{ name: 'my_boolean', value: Math.random() > 0.5, type: 'Boolean' }, console.log('Updated value for Inputs/0 ' + inboundMetricMap['Outputs/0'])
{ name: 'my_double', value: Math.random() * 0.123456789, type: 'Double' }, } else if (inboundMetricMap['Outputs/1'] !== undefined && inboundMetricMap['Outputs/1'] !== null) {
{ name: 'my_float', value: Math.random() * 0.123, type: 'UInt64' }, console.log('Outputs/1: ' + inboundMetricMap['Outputs/1'])
{ name: 'my_int', value: randomInt(), type: 'Int16' }, outboundMetric.push({ name: 'Inputs/1', value: inboundMetricMap['Outputs/1'], type: 'Int32' })
{ name: 'my_long', value: randomInt() * 214748364700, type: 'UInt64' }, outboundMetric.push({ name: 'Outputs/1', value: inboundMetricMap['Outputs/1'], type: 'Int32' })
], console.log('Updated value for Inputs/1 ' + inboundMetricMap['Outputs/1'])
} } else if (inboundMetricMap['Outputs/2'] !== undefined && inboundMetricMap['Outputs/2'] !== null) {
}, console.log('Outputs/2: ' + inboundMetricMap['Outputs/2'])
// Runs the sample outboundMetric.push({ name: 'Inputs/2', value: inboundMetricMap['Outputs/2'], type: 'UInt64' })
run = async function (): Promise<MockSparkplugClient> { outboundMetric.push({ name: 'Outputs/2', value: inboundMetricMap['Outputs/2'], type: 'UInt64' })
// Create the SparkplugClient console.log('Updated value for Inputs/2 ' + inboundMetricMap['Outputs/2'])
const sparkplugClient = SparkplugClient.newClient(config)
let updateInterval: NodeJS.Timeout | null = null
const connected = new Promise<MockSparkplugClient>((resolve) => {
// Create 'birth' handler
sparkplugClient.on('birth', () => {
// Publish Node BIRTH certificate
sparkplugClient.publishNodeBirth(getNodeBirthPayload())
// Publish Device BIRTH certificate
sparkplugClient.publishDeviceBirth(deviceId, getDeviceBirthPayload())
resolve({
stop: () => {
if (updateInterval) {
clearInterval(updateInterval)
}
sparkplugClient.stop()
}
})
})
})
// Create Incoming Message Handler
sparkplugClient.on('message', function (topic: string, payload: UPayload) {
console.log(topic, payload)
})
// Create node command handler
// spell-checker: disable-next-line
sparkplugClient.on('ncmd', function (payload: UPayload) {
var timestamp = payload.timestamp,
metrics = payload.metrics
if (metrics !== undefined && metrics !== null) {
for (var i = 0; i < metrics.length; i++) {
var metric = metrics[i]
if (metric.name == 'Node Control/Rebirth' && metric.value) {
console.log("Received 'Rebirth' command")
// Publish Node BIRTH certificate
sparkplugClient.publishNodeBirth(getNodeBirthPayload())
// Publish Device BIRTH certificate
sparkplugClient.publishDeviceBirth(deviceId, getDeviceBirthPayload())
}
}
}
})
// Create device command handler
// spell-checker: disable-next-line
sparkplugClient.on('dcmd', function (deviceId: string, payload: UPayload) {
var timestamp = payload.timestamp,
metrics = payload.metrics,
inboundMetricMap: { [name: string]: any } = {},
outboundMetric: Array<UMetric> = [],
outboundPayload: UPayload
console.log('Command received for device ' + deviceId)
// Loop over the metrics and store them in a map
if (metrics !== undefined && metrics !== null) {
for (var i = 0; i < metrics.length; i++) {
var metric = metrics[i]
if (metric.name !== undefined && metric.name !== null) {
inboundMetricMap[metric.name] = metric.value
}
}
}
if (inboundMetricMap['Outputs/0'] !== undefined && inboundMetricMap['Outputs/0'] !== null) {
console.log('Outputs/0: ' + inboundMetricMap['Outputs/0'])
outboundMetric.push({ name: 'Inputs/0', value: inboundMetricMap['Outputs/0'], type: 'Boolean' })
outboundMetric.push({ name: 'Outputs/0', value: inboundMetricMap['Outputs/0'], type: 'Boolean' })
console.log('Updated value for Inputs/0 ' + inboundMetricMap['Outputs/0'])
} else if (inboundMetricMap['Outputs/1'] !== undefined && inboundMetricMap['Outputs/1'] !== null) {
console.log('Outputs/1: ' + inboundMetricMap['Outputs/1'])
outboundMetric.push({ name: 'Inputs/1', value: inboundMetricMap['Outputs/1'], type: 'Int32' })
outboundMetric.push({ name: 'Outputs/1', value: inboundMetricMap['Outputs/1'], type: 'Int32' })
console.log('Updated value for Inputs/1 ' + inboundMetricMap['Outputs/1'])
} else if (inboundMetricMap['Outputs/2'] !== undefined && inboundMetricMap['Outputs/2'] !== null) {
console.log('Outputs/2: ' + inboundMetricMap['Outputs/2'])
outboundMetric.push({ name: 'Inputs/2', value: inboundMetricMap['Outputs/2'], type: 'UInt64' })
outboundMetric.push({ name: 'Outputs/2', value: inboundMetricMap['Outputs/2'], type: 'UInt64' })
console.log('Updated value for Inputs/2 ' + inboundMetricMap['Outputs/2'])
}
outboundPayload = {
timestamp: new Date().getTime(),
metrics: outboundMetric,
}
// Publish device data
sparkplugClient.publishDeviceData(deviceId, outboundPayload)
})
updateInterval = setInterval(function () {
// Publish device data
sparkplugClient.publishDeviceData(deviceId, getDataPayload())
}, 2000)
return connected
} }
return { run: run } outboundPayload = {
timestamp: new Date().getTime(),
metrics: outboundMetric,
}
// Publish device data
sparkplugClient.publishDeviceData(deviceId, outboundPayload)
})
updateInterval = setInterval(function () {
// Publish device data
sparkplugClient.publishDeviceData(deviceId, getDataPayload())
}, 2000)
return connected
}
return { run: run }
})() })()
export default sample export default sample

View File

@@ -45,7 +45,9 @@ export async function showNumericPlot(browser: Page) {
async function valuePreviewGuttersShowChartIcon(name: string, browser: Page) { async function valuePreviewGuttersShowChartIcon(name: string, browser: Page) {
for (let retries = 0; retries < 2; retries += 1) { for (let retries = 0; retries < 2; retries += 1) {
try { try {
return await browser.locator(`//*[contains(@data-test-type, "ShowChart")][contains(@data-test, "${name}")]`).first() return await browser
.locator(`//*[contains(@data-test-type, "ShowChart")][contains(@data-test, "${name}")]`)
.first()
} catch { } catch {
// ignore // ignore
} }

View File

@@ -2,8 +2,8 @@ import { Page } from 'playwright'
import { expandTopic, sleep } from '../util' import { expandTopic, sleep } from '../util'
export async function showSparkPlugDecoding(browser: Page) { export async function showSparkPlugDecoding(browser: Page) {
// spell-checker: disable-next-line // spell-checker: disable-next-line
await expandTopic('spBv1.0/Sparkplug Devices/DDATA/JavaScript Edge Node/Emulated Device', browser) await expandTopic('spBv1.0/Sparkplug Devices/DDATA/JavaScript Edge Node/Emulated Device', browser)
await browser.screenshot({ path: 'screen_sparkplugb_decoding.png' }) await browser.screenshot({ path: 'screen_sparkplugb_decoding.png' })
await sleep(1000) await sleep(1000)
} }

View File

@@ -13,27 +13,27 @@ async function sleep(ms: number) {
async function main() { async function main() {
console.log('=== MCP Introspection Demo ===') console.log('=== MCP Introspection Demo ===')
console.log('Starting MQTT Explorer with MCP introspection flags...') console.log('Starting MQTT Explorer with MCP introspection flags...')
// Launch Electron app with MCP introspection enabled // Launch Electron app with MCP introspection enabled
const electronApp: ElectronApplication = await electron.launch({ const electronApp: ElectronApplication = await electron.launch({
args: [ args: [
PROJECT_ROOT, PROJECT_ROOT,
'--enable-mcp-introspection', '--enable-mcp-introspection',
`--remote-debugging-port=${DEFAULT_REMOTE_DEBUGGING_PORT}`, `--remote-debugging-port=${DEFAULT_REMOTE_DEBUGGING_PORT}`,
'--no-sandbox' '--no-sandbox',
], ],
timeout: 30000 timeout: 30000,
}) })
console.log('✓ App launched with MCP introspection') console.log('✓ App launched with MCP introspection')
console.log(`✓ Remote debugging enabled on port ${DEFAULT_REMOTE_DEBUGGING_PORT}`) console.log(`✓ Remote debugging enabled on port ${DEFAULT_REMOTE_DEBUGGING_PORT}`)
// Get the first window // Get the first window
const page = await electronApp.firstWindow({ timeout: 10000 }) const page = await electronApp.firstWindow({ timeout: 10000 })
const title = await page.title() const title = await page.title()
console.log(`✓ Window ready, title: ${title}`) console.log(`✓ Window ready, title: ${title}`)
// Check console logs for remote debugging message // Check console logs for remote debugging message
const logs: string[] = [] const logs: string[] = []
page.on('console', msg => { page.on('console', msg => {
@@ -43,28 +43,28 @@ async function main() {
console.log(`${text}`) console.log(`${text}`)
} }
}) })
// Wait for app to load // Wait for app to load
await sleep(3000) await sleep(3000)
// Take screenshot 1: Main app window showing MCP introspection is working // Take screenshot 1: Main app window showing MCP introspection is working
console.log('\nTaking screenshots...') console.log('\nTaking screenshots...')
const screenshot1Path = path.join(PROJECT_ROOT, 'screenshot-mcp-app-running.png') const screenshot1Path = path.join(PROJECT_ROOT, 'screenshot-mcp-app-running.png')
await page.screenshot({ await page.screenshot({
path: screenshot1Path, path: screenshot1Path,
fullPage: false fullPage: false,
}) })
console.log(`✓ Screenshot 1 saved: ${screenshot1Path}`) console.log(`✓ Screenshot 1 saved: ${screenshot1Path}`)
// Take screenshot 2: Connection form (showing the app is interactive) // Take screenshot 2: Connection form (showing the app is interactive)
await sleep(1000) await sleep(1000)
const screenshot2Path = path.join(PROJECT_ROOT, 'screenshot-mcp-connection-form.png') const screenshot2Path = path.join(PROJECT_ROOT, 'screenshot-mcp-connection-form.png')
await page.screenshot({ await page.screenshot({
path: screenshot2Path, path: screenshot2Path,
fullPage: true fullPage: true,
}) })
console.log(`✓ Screenshot 2 saved: ${screenshot2Path}`) console.log(`✓ Screenshot 2 saved: ${screenshot2Path}`)
console.log('\n=== MCP Introspection Test Results ===') console.log('\n=== MCP Introspection Test Results ===')
console.log('✓ Application started successfully with MCP introspection') console.log('✓ Application started successfully with MCP introspection')
console.log(`✓ Remote debugging port: ${DEFAULT_REMOTE_DEBUGGING_PORT}`) console.log(`✓ Remote debugging port: ${DEFAULT_REMOTE_DEBUGGING_PORT}`)
@@ -72,10 +72,10 @@ async function main() {
console.log('✓ Screenshots captured successfully') console.log('✓ Screenshots captured successfully')
console.log('\nThe MCP introspection implementation is working correctly!') console.log('\nThe MCP introspection implementation is working correctly!')
console.log('External tools can now connect to the app via CDP for automated testing.') console.log('External tools can now connect to the app via CDP for automated testing.')
// Close the app // Close the app
await electronApp.close() await electronApp.close()
process.exit(0) process.exit(0)
} }

691
src/spec/ui-tests.spec.ts Normal file
View File

@@ -0,0 +1,691 @@
import 'mocha'
import { expect } from 'chai'
import { ElectronApplication, Page, _electron as electron } from 'playwright'
import mockMqtt, { stop as stopMqtt } from './mock-mqtt'
import { default as MockSparkplug } from './mock-sparkplugb'
import { sleep, expandTopic } from './util'
import { connectTo } from './scenarios/connect'
import { searchTree, clearSearch } from './scenarios/searchTree'
import { showNumericPlot } from './scenarios/showNumericPlot'
import { showJsonPreview } from './scenarios/showJsonPreview'
import { showOffDiffCapability } from './scenarios/showOffDiffCapability'
import { copyTopicToClipboard } from './scenarios/copyTopicToClipboard'
import { copyValueToClipboard } from './scenarios/copyValueToClipboard'
import { showMenu } from './scenarios/showMenu'
import { showAdvancedConnectionSettings } from './scenarios/showAdvancedConnectionSettings'
import { showSparkPlugDecoding } from './scenarios/showSparkplugDecoding'
import { disconnect } from './scenarios/disconnect'
/**
* UI Test Suite for MQTT Explorer
*
* These tests validate the core UI functionality of MQTT Explorer.
* Each test is independent and deterministic.
*
* Best Practices Applied:
* - Wait for specific UI elements rather than fixed timeouts
* - Use meaningful assertions that verify actual state
* - Test data-driven scenarios (Given-When-Then pattern)
* - Capture screenshots for visual verification
* - Handle MQTT asynchronous operations properly
*
* Prerequisites:
* - MQTT broker running on localhost:1883
* - Application built with `yarn build`
*/
// tslint:disable:only-arrow-functions ter-prefer-arrow-callback no-unused-expression
describe('MQTT Explorer UI Tests', function () {
// Increase timeout for UI tests
this.timeout(60000)
let electronApp: ElectronApplication
let page: Page
let mqttClientStarted = false
/**
* Setup: Start MQTT broker mock and launch Electron app
*/
before(async function () {
this.timeout(30000)
console.log('Starting MQTT mock broker...')
await mockMqtt()
mqttClientStarted = true
console.log('Launching Electron application...')
electronApp = await electron.launch({
args: [`${__dirname}/../../..`, '--runningUiTestOnCi'],
})
console.log('Waiting for application window...')
page = await electronApp.firstWindow({ timeout: 10000 })
// Wait for the connection form to be ready
await page.locator('//label[contains(text(), "Username")]/..//input').waitFor({ timeout: 5000 })
console.log('Application ready for testing')
})
/**
* Teardown: Close app and stop MQTT mock
*/
after(async function () {
this.timeout(10000)
if (electronApp) {
await electronApp.close()
}
if (mqttClientStarted) {
stopMqtt()
}
})
describe('Connection Management', () => {
it('should connect to MQTT broker successfully', async function () {
// Given: Application is on connection page
// When: User connects to MQTT broker
await connectTo('127.0.0.1', page)
await sleep(1000)
// Start Sparkplug client after connection
await MockSparkplug.run()
await sleep(1000)
// Then: Disconnect button should be visible (indicating connected state)
const disconnectButton = await page.locator('//button/span[contains(text(),"Disconnect")]')
await disconnectButton.waitFor({ state: 'visible', timeout: 5000 })
const isVisible = await disconnectButton.isVisible()
expect(isVisible).to.be.true
// And: Connection indicator should show connected state
await page.screenshot({ path: 'test-screenshot-connection.png' })
})
})
describe('Topic Tree Structure', () => {
it('Given a JSON message sent to topic kitchen/coffee_maker, the tree should display nested topics', async function () {
// Given: Mock MQTT broker publishes JSON to kitchen/coffee_maker
// (This is done by mock-mqtt.ts)
// When: We wait for the topic to appear in the tree
await sleep(2000) // Allow time for MQTT messages to arrive
// Then: Topic hierarchy should be visible (kitchen -> coffee_maker)
const kitchenTopic = await page.locator('span[data-test-topic="kitchen"]')
await kitchenTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await kitchenTopic.isVisible()).to.be.true
// And: Clicking on kitchen should expand to show coffee_maker
await kitchenTopic.click()
await sleep(500)
const coffeeMakerTopic = await page.locator('span[data-test-topic="coffee_maker"]')
await coffeeMakerTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await coffeeMakerTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-tree-hierarchy.png' })
})
it('Given messages sent to livingroom/lamp/state and livingroom/lamp/brightness, both should appear under livingroom/lamp', async function () {
// Given: Mock MQTT publishes to livingroom/lamp/state and livingroom/lamp/brightness
await sleep(1000)
// When: We navigate to livingroom topic
const livingroomTopic = await page.locator('span[data-test-topic="livingroom"]')
await livingroomTopic.waitFor({ state: 'visible', timeout: 5000 })
await livingroomTopic.click()
await sleep(500)
// Then: lamp subtopic should be visible
const lampTopic = await page.locator('span[data-test-topic="lamp"]')
await lampTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await lampTopic.isVisible()).to.be.true
// When: Clicking on lamp
await lampTopic.click()
await sleep(500)
// Then: Both state and brightness topics should be visible
const stateTopic = await page.locator('span[data-test-topic="state"]')
const brightnessTopic = await page.locator('span[data-test-topic="brightness"]')
await stateTopic.waitFor({ state: 'visible', timeout: 5000 })
await brightnessTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await stateTopic.isVisible()).to.be.true
expect(await brightnessTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-tree-structure.png' })
})
it('should display the correct number of root topics from mock data', async function () {
// Given: Mock MQTT publishes to multiple root topics
await sleep(1000)
// Then: We should see expected root topics (livingroom, kitchen, garden, etc.)
const rootTopics = ['livingroom', 'kitchen', 'garden']
for (const topicName of rootTopics) {
const topic = await page.locator(`span[data-test-topic="${topicName}"]`)
await topic.waitFor({ state: 'visible', timeout: 5000 })
const visible = await topic.isVisible()
expect(visible).to.be.true
}
await page.screenshot({ path: 'test-screenshot-root-topics.png' })
})
it('Given a JSON message with nested properties, the tree should display the JSON structure', async function () {
// Given: coffee_maker publishes JSON with heater, temperature, waterLevel
await sleep(2000)
// When: Navigate to kitchen/coffee_maker
await expandTopic('kitchen/coffee_maker', page)
await sleep(1000)
// Then: The JSON properties should be visible in the value preview
// We can verify by checking that the topic is selected and showing details
const selectedTopic = await page.locator('[class*="selectedTopic"]')
const hasSelectedTopic = (await selectedTopic.count()) > 0
expect(hasSelectedTopic).to.be.true
await page.screenshot({ path: 'test-screenshot-json-structure.png' })
})
})
describe('Topic Navigation and Search', () => {
it('should display topic hierarchy after connection', async function () {
// Given: Application is connected to MQTT broker
await sleep(1000)
// Then: Topic tree should contain nodes
const treeNodes = await page.locator('[class*="TreeNode"]')
const count = await treeNodes.count()
expect(count).to.be.greaterThan(0, 'Topic tree should contain nodes')
await page.screenshot({ path: 'test-screenshot-topic-tree.png' })
})
it('should search and filter topics containing "temp"', async function () {
// Given: Multiple topics with "temp" in their path (kitchen/temperature, livingroom/temperature)
// When: User searches for "temp"
await searchTree('temp', page)
await sleep(1000)
// Then: Search field should contain the search term
const searchField = await page.locator('//input[contains(@placeholder, "Search")]')
const searchValue = await searchField.inputValue()
expect(searchValue).to.equal('temp')
// And: Only matching topics should be visible
// We can verify this by checking that temperature topics are still visible
const tempTopic = await page.locator('span[data-test-topic="temperature"]').first()
await tempTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await tempTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-search.png' })
// When: User clears the search
await clearSearch(page)
await sleep(500)
// Then: Search field should be empty
const clearedValue = await searchField.inputValue()
expect(clearedValue).to.equal('')
// And: All topics should be visible again
const kitchenTopic = await page.locator('span[data-test-topic="kitchen"]')
expect(await kitchenTopic.isVisible()).to.be.true
})
it('should search for specific topic path like "kitchen/lamp"', async function () {
// When: User searches for kitchen/lamp
await searchTree('kitchen/lamp', page)
await sleep(1000)
// Then: Kitchen and lamp topics should be visible
const kitchenTopic = await page.locator('span[data-test-topic="kitchen"]')
expect(await kitchenTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-search-path.png' })
await clearSearch(page)
await sleep(500)
})
})
describe('Message Visualization', () => {
it('Given a JSON message on topic actuality/showcase, should display formatted JSON', async function () {
// Given: Mock publishes JSON to actuality/showcase
// When: User navigates to the topic
await showJsonPreview(page)
await sleep(1500)
// Then: The message should be visible
await page.screenshot({ path: 'test-screenshot-json-preview.png' })
// And: We should see formatted JSON content (verified via screenshot)
})
it('should show numeric plots for topics with numeric values', async function () {
// Given: Topics with numeric values (kitchen/coffee_maker/temperature)
// When: User creates charts for numeric values
await showNumericPlot(page)
await sleep(2000)
// Then: Chart panel should be visible
const chartPanel = await page.locator('[class*="ChartPanel"]')
const chartExists = (await chartPanel.count()) > 0
expect(chartExists).to.be.true
await page.screenshot({ path: 'test-screenshot-numeric-plots.png' })
})
it('should display message diffs when messages change', async function () {
// Given: Topics that update over time (livingroom/temperature)
// When: User enables diff view
await showOffDiffCapability(page)
await sleep(1500)
// Then: Diff view should be active
await page.screenshot({ path: 'test-screenshot-diffs.png' })
})
it('Given a message with QoS 2, should display the QoS level', async function () {
// Given: kitchen/coffee_maker is published with QoS 2
await sleep(1000)
// When: Navigate to the topic
await expandTopic('kitchen/coffee_maker', page)
await sleep(1000)
// Then: QoS indicator should be visible
// (Verified via screenshot showing message details)
await page.screenshot({ path: 'test-screenshot-qos.png' })
})
})
describe('Clipboard Operations', () => {
it('should copy topic path to clipboard', async function () {
// Given: A topic is selected
// When: User clicks copy topic button
await copyTopicToClipboard(page)
await sleep(500)
// Then: Copy action completes without error
// Note: Full clipboard verification requires additional platform-specific setup
await page.screenshot({ path: 'test-screenshot-copy-topic.png' })
})
it('should copy message value to clipboard', async function () {
// Given: A topic with a value is selected
// When: User clicks copy value button
await copyValueToClipboard(page)
await sleep(500)
// Then: Copy action completes without error
await page.screenshot({ path: 'test-screenshot-copy-value.png' })
})
})
describe('SparkplugB Support', () => {
it('Given SparkplugB messages, should decode and display the payload', async function () {
// Given: Mock SparkplugB client publishes messages
// When: User navigates to SparkplugB topics
await showSparkPlugDecoding(page)
await sleep(2000)
// Then: Decoded SparkplugB data should be visible
await page.screenshot({ path: 'test-screenshot-sparkplugb.png' })
})
})
describe('Settings and Configuration', () => {
it('should open and display settings menu with available options', async function () {
// When: User opens settings menu
await showMenu(page)
await sleep(1500)
// Then: Settings menu should be visible
const settingsMenu = await page.locator('[role="menu"]')
const menuVisible = (await settingsMenu.count()) > 0
expect(menuVisible).to.be.true
await page.screenshot({ path: 'test-screenshot-settings.png' })
})
it('should show advanced connection settings with subscription options', async function () {
// Given: User is on connection page
// First disconnect
await disconnect(page)
await sleep(1000)
// When: User opens advanced connection settings
await showAdvancedConnectionSettings(page)
await sleep(1500)
// Then: Advanced settings should be visible
const advancedPanel = await page.locator('[class*="advanced"]')
const hasAdvanced = (await advancedPanel.count()) > 0
// Take screenshot showing advanced settings
await page.screenshot({ path: 'test-screenshot-advanced-settings.png' })
})
})
describe('Retained Messages', () => {
it('Given retained messages on multiple topics, should display retained indicator', async function () {
// Given: Mock publishes retained messages (e.g., livingroom/lamp/state)
await sleep(1000)
// When: Navigate to a topic with retained message
await expandTopic('livingroom/lamp', page)
await sleep(1000)
// Then: The UI should show message details
// (Retained flag visible in message details panel)
await page.screenshot({ path: 'test-screenshot-retained.png' })
})
})
describe('Reconnection and Connection State', () => {
it('Given a connected client, should successfully disconnect and reconnect', async function () {
// Given: Application is connected
await sleep(1000)
// When: User disconnects
const disconnectButton = await page.locator('//button/span[contains(text(),"Disconnect")]')
await disconnectButton.waitFor({ state: 'visible', timeout: 5000 })
await disconnectButton.click()
await sleep(1000)
// Then: Connect button should be visible
const connectButton = await page.locator('//button/span[contains(text(),"Connect")]')
await connectButton.waitFor({ state: 'visible', timeout: 5000 })
expect(await connectButton.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-disconnected.png' })
// When: User reconnects
await connectButton.click()
await sleep(2000)
// Then: Disconnect button should be visible again
await disconnectButton.waitFor({ state: 'visible', timeout: 5000 })
expect(await disconnectButton.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-reconnected.png' })
})
})
describe('Message History and Updates', () => {
it('Given topics with updating values, should display message history', async function () {
// Given: Topics that update over time (livingroom/temperature)
await sleep(3000) // Wait for several updates
// When: Navigate to a topic with history
await expandTopic('livingroom/temperature', page)
await sleep(1000)
// Then: History should be available
// Click on History tab if visible
const historyTab = await page.locator('//span[contains(text(), "History")]')
const historyExists = (await historyTab.count()) > 0
if (historyExists) {
await historyTab.click()
await sleep(500)
await page.screenshot({ path: 'test-screenshot-history.png' })
}
})
it('Given a topic with changing values, should show value updates in real-time', async function () {
// Given: kitchen/coffee_maker/temperature updates periodically
await sleep(1000)
// When: Navigate to the topic
await expandTopic('kitchen/coffee_maker', page)
await sleep(500)
// Then: Topic should be selected and showing current value
const selectedTopic = await page.locator('[class*="selectedTopic"]')
expect((await selectedTopic.count()) > 0).to.be.true
// Wait for value to update
await sleep(2000)
await page.screenshot({ path: 'test-screenshot-updating-value.png' })
})
})
describe('Different QoS Levels', () => {
it('Given messages with different QoS levels (0, 1, 2), should display them correctly', async function () {
// Given: Mock publishes messages with different QoS
// QoS 0: livingroom/lamp/state
// QoS 2: kitchen/coffee_maker
await sleep(1000)
// When: Navigate to QoS 0 topic
await expandTopic('livingroom/lamp/state', page)
await sleep(500)
await page.screenshot({ path: 'test-screenshot-qos-0.png' })
// When: Navigate to QoS 2 topic
await expandTopic('kitchen/coffee_maker', page)
await sleep(500)
// Then: Both should display their QoS levels
await page.screenshot({ path: 'test-screenshot-qos-2.png' })
})
})
describe('Special Topic Names and Characters', () => {
it('Given topics with spaces and special characters, should display correctly', async function () {
// Given: Mock publishes to "test 123" and "hello"
await sleep(1000)
// When: Navigate to topic with space
const testTopic = await page.locator('span[data-test-topic="test 123"]')
await testTopic.waitFor({ state: 'visible', timeout: 5000 })
// Then: Topic should be visible
expect(await testTopic.isVisible()).to.be.true
await testTopic.click()
await sleep(500)
await page.screenshot({ path: 'test-screenshot-special-chars.png' })
})
it('Given topic with MAC address format (01-80-C2-00-00-0F/LWT), should display correctly', async function () {
// Given: Mock publishes to MAC address topic
await sleep(1000)
// When: Search for MAC address topic
await searchTree('01-80-C2', page)
await sleep(1000)
// Then: Topic should be found
const macTopic = await page.locator('span[data-test-topic="01-80-C2-00-00-0F"]')
const macVisible = (await macTopic.count()) > 0
await page.screenshot({ path: 'test-screenshot-mac-address.png' })
await clearSearch(page)
await sleep(500)
})
})
describe('Bridge Status Topics', () => {
it('Given bridge status topics (zigbee2mqtt, ble2mqtt), should display online status', async function () {
// Given: Mock publishes bridge status topics
await sleep(1000)
// When: Navigate to zigbee2mqtt bridge
const zigbeeTopic = await page.locator('span[data-test-topic="zigbee2mqtt"]')
await zigbeeTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await zigbeeTopic.isVisible()).to.be.true
await zigbeeTopic.click()
await sleep(500)
const bridgeTopic = await page.locator('span[data-test-topic="bridge"]')
await bridgeTopic.waitFor({ state: 'visible', timeout: 5000 })
await bridgeTopic.click()
await sleep(500)
// Then: State topic should show "online"
await page.screenshot({ path: 'test-screenshot-bridge-status.png' })
})
})
describe('3D Printer Integration', () => {
it('Given 3D printer temperature topics with JSON data, should display bed and tool temperatures', async function () {
// Given: Mock publishes 3D printer data
await sleep(4000) // Wait for 3D printer interval
// When: Navigate to 3D printer topics
const printerTopic = await page.locator('span[data-test-topic="3d-printer"]')
await printerTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await printerTopic.isVisible()).to.be.true
await printerTopic.click()
await sleep(500)
const octoPrintTopic = await page.locator('span[data-test-topic="OctoPrint"]')
await octoPrintTopic.waitFor({ state: 'visible', timeout: 5000 })
await octoPrintTopic.click()
await sleep(500)
// Then: Temperature topics should be visible
const tempTopic = await page.locator('span[data-test-topic="temperature"]')
await tempTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await tempTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-3d-printer.png' })
})
})
describe('Garden/IoT Device Topics', () => {
it('Given garden device topics (pump, water level, lamps), should display all device states', async function () {
// Given: Mock publishes garden device topics
await sleep(1000)
// When: Navigate to garden
const gardenTopic = await page.locator('span[data-test-topic="garden"]')
await gardenTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await gardenTopic.isVisible()).to.be.true
await gardenTopic.click()
await sleep(500)
// Then: Pump, water, and lamps topics should be visible
const pumpTopic = await page.locator('span[data-test-topic="pump"]')
const waterTopic = await page.locator('span[data-test-topic="water"]')
const lampsTopic = await page.locator('span[data-test-topic="lamps"]')
await pumpTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await pumpTopic.isVisible()).to.be.true
expect(await waterTopic.isVisible()).to.be.true
expect(await lampsTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-garden-devices.png' })
})
})
describe('Topic Value Types', () => {
it('Given topics with different value types (string, number, percentage), should display correctly', async function () {
// Given: Various value types in mock data
await sleep(1000)
// When: Check string value (garden/pump/state = "off")
await expandTopic('garden/pump/state', page)
await sleep(500)
await page.screenshot({ path: 'test-screenshot-string-value.png' })
// When: Check percentage value (garden/water/level = "70%")
await expandTopic('garden/water/level', page)
await sleep(500)
await page.screenshot({ path: 'test-screenshot-percentage-value.png' })
// When: Check numeric value with units (livingroom/thermostat/targetTemperature = "20°C")
await expandTopic('livingroom/thermostat/targetTemperature', page)
await sleep(500)
// Then: All value types should display correctly
await page.screenshot({ path: 'test-screenshot-temperature-value.png' })
})
})
describe('Multiple Lamp Devices', () => {
it('Given multiple lamp devices (lamp-1, lamp-2) with same properties, should distinguish them', async function () {
// Given: Mock publishes to livingroom/lamp-1 and lamp-2
await sleep(1000)
// When: Navigate to livingroom
const livingroomTopic = await page.locator('span[data-test-topic="livingroom"]')
await livingroomTopic.click()
await sleep(500)
// Then: Both lamp-1 and lamp-2 should be visible
const lamp1Topic = await page.locator('span[data-test-topic="lamp-1"]')
const lamp2Topic = await page.locator('span[data-test-topic="lamp-2"]')
await lamp1Topic.waitFor({ state: 'visible', timeout: 5000 })
await lamp2Topic.waitFor({ state: 'visible', timeout: 5000 })
expect(await lamp1Topic.isVisible()).to.be.true
expect(await lamp2Topic.isVisible()).to.be.true
// When: Expand lamp-1
await lamp1Topic.click()
await sleep(500)
// Then: lamp-1 state and brightness should be visible
const stateTopic = await page.locator('span[data-test-topic="state"]')
const brightnessTopic = await page.locator('span[data-test-topic="brightness"]')
expect(await stateTopic.isVisible()).to.be.true
expect(await brightnessTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-multiple-lamps.png' })
})
})
describe('Search Functionality Edge Cases', () => {
it('Given a search term that matches multiple topics at different levels, should show all matches', async function () {
// Given: Multiple topics contain "state" (lamp/state, pump/state, etc.)
await sleep(1000)
// When: Search for "state"
await searchTree('state', page)
await sleep(1000)
// Then: Multiple state topics should be visible
const stateTopics = await page.locator('span[data-test-topic="state"]')
const count = await stateTopics.count()
expect(count).to.be.greaterThan(1, 'Should find multiple state topics')
await page.screenshot({ path: 'test-screenshot-search-multiple.png' })
await clearSearch(page)
await sleep(500)
})
it('Given a search term with no matches, should display empty tree', async function () {
// When: Search for non-existent topic
await searchTree('nonexistenttopic12345', page)
await sleep(1000)
// Then: No topics should be visible (or a message)
await page.screenshot({ path: 'test-screenshot-search-no-results.png' })
await clearSearch(page)
await sleep(500)
})
})
})

View File

@@ -21,6 +21,7 @@
"src/spec/demoVideo.ts", "src/spec/demoVideo.ts",
"src/spec/leakTest.ts", "src/spec/leakTest.ts",
"src/spec/testMcpIntrospection.ts", "src/spec/testMcpIntrospection.ts",
"src/spec/ui-tests.spec.ts",
"scripts/*.ts" "scripts/*.ts"
], ],
"exclude": ["node_modules"] "exclude": ["node_modules"]