Fix expandTopic selector, restore and streamline comprehensive UI tests (#938)

This commit is contained in:
Copilot
2025-12-20 23:26:15 +01:00
committed by GitHub
parent c55c3a8245
commit e725b1d012
8 changed files with 635 additions and 117 deletions

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ test-mcp-introspection.js
/data /data
test-screenshot-*.png test-screenshot-*.png
test-expand-*.png

View File

@@ -0,0 +1,207 @@
import 'mocha'
import { expect } from 'chai'
import { ElectronApplication, Page, _electron as electron } from 'playwright'
import { createTestMock, stopTestMock } from './mock-mqtt-test'
import { sleep } from './util'
import { connectTo } from './scenarios/connect'
import { expandTopic } from './util/expandTopic'
import type { MqttClient } from 'mqtt'
/**
* Isolated Test for expandTopic UI Helper
*
* This test validates that the expandTopic function correctly:
* 1. Finds topics in the tree hierarchy
* 2. Expands nested topics correctly
* 3. Handles multiple levels of nesting
* 4. Differentiates between topics with the same name in different branches
*/
// tslint:disable:only-arrow-functions ter-prefer-arrow-callback no-unused-expression
// Disabled rules:
// - only-arrow-functions, ter-prefer-arrow-callback: Mocha test style uses traditional functions for proper `this` binding
// - no-unused-expression: Chai assertions like `expect(x).to.be.true` are expressions
describe('expandTopic UI Helper - Isolated Test', function () {
this.timeout(90000)
let electronApp: ElectronApplication
let testMock: MqttClient
let page: Page
before(async function () {
this.timeout(120000)
console.log('Creating test-specific MQTT mock...')
testMock = await createTestMock()
console.log('Publishing test topics...')
// Create a diverse topic structure for testing
testMock.publish('kitchen/lamp/state', 'on', { retain: true, qos: 0 })
testMock.publish('kitchen/lamp/brightness', '75', { retain: true, qos: 0 })
testMock.publish('kitchen/temperature', '22.5', { retain: true, qos: 0 })
testMock.publish('livingroom/lamp/state', 'off', { retain: true, qos: 0 })
testMock.publish('livingroom/lamp/brightness', '50', { retain: true, qos: 0 })
testMock.publish('livingroom/temperature', '21.0', { retain: true, qos: 0 })
testMock.publish('garage/door/status', 'closed', { retain: true, qos: 0 })
await sleep(1000) // Let messages propagate
console.log('Launching Electron application...')
electronApp = await electron.launch({
args: [`${__dirname}/../../..`, '--runningUiTestOnCi', '--no-sandbox', '--disable-dev-shm-usage'],
timeout: 60000,
})
console.log('Getting application window...')
page = await electronApp.firstWindow({ timeout: 30000 })
await page.locator('//label[contains(text(), "Host")]/..//input').waitFor({ timeout: 10000 })
console.log('Connecting to MQTT broker...')
await connectTo('127.0.0.1', page)
await sleep(3000) // Give time for topics to load
console.log('Setup complete')
})
after(async function () {
this.timeout(10000)
if (electronApp) {
await electronApp.close()
}
stopTestMock()
})
describe('Single Level Topics', () => {
it('should expand a single-level topic (kitchen)', async function () {
// Given: Topics are loaded
// When: Expand single-level topic
await expandTopic('kitchen', page)
// Then: Kitchen topic should be visible and clickable
const kitchenTopic = await page.locator('span[data-test-topic="kitchen"]')
await kitchenTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await kitchenTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-expand-single-level.png' })
})
it('should expand another single-level topic (livingroom)', async function () {
// Given: Topics are loaded
// When: Expand single-level topic
await expandTopic('livingroom', page)
// Then: Livingroom topic should be visible
const livingroomTopic = await page.locator('span[data-test-topic="livingroom"]')
await livingroomTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await livingroomTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-expand-another-single-level.png' })
})
})
describe('Two Level Topics', () => {
it('should expand a two-level topic (kitchen/lamp)', async function () {
// Given: Topics are loaded
// When: Expand two-level topic
await expandTopic('kitchen/lamp', page)
// Then: Both kitchen and lamp should be visible
const kitchenTopic = await page.locator('span[data-test-topic="kitchen"]')
const lampTopic = await page.locator('span[data-test-topic="lamp"]')
expect(await kitchenTopic.isVisible()).to.be.true
expect(await lampTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-expand-two-level-kitchen-lamp.png' })
})
it('should expand a different two-level topic (kitchen/temperature)', async function () {
// Given: Topics are loaded
// When: Expand two-level topic
await expandTopic('kitchen/temperature', page)
// Then: Temperature topic under kitchen 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-expand-two-level-kitchen-temp.png' })
})
})
describe('Three Level Topics', () => {
it('should expand a three-level topic (kitchen/lamp/state)', async function () {
// Given: Topics are loaded
// When: Expand three-level topic
await expandTopic('kitchen/lamp/state', page)
// Then: All three levels should be visible
const kitchenTopic = await page.locator('span[data-test-topic="kitchen"]')
const lampTopic = await page.locator('span[data-test-topic="lamp"]')
const stateTopic = await page.locator('span[data-test-topic="state"]')
expect(await kitchenTopic.isVisible()).to.be.true
expect(await lampTopic.isVisible()).to.be.true
expect(await stateTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-expand-three-level.png' })
})
it('should expand kitchen/lamp/brightness (different leaf same parent)', async function () {
// Given: Topics are loaded
// When: Expand another three-level topic under same parent
await expandTopic('kitchen/lamp/brightness', page)
// Then: Brightness topic should be visible
const brightnessTopic = await page.locator('span[data-test-topic="brightness"]')
await brightnessTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await brightnessTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-expand-three-level-brightness.png' })
})
})
describe('Different Branches', () => {
it('should correctly expand livingroom/lamp (different branch, same name)', async function () {
// Given: Topics are loaded (kitchen/lamp also exists)
// When: Expand livingroom/lamp (note: lamp exists under both kitchen and livingroom)
await expandTopic('livingroom/lamp', page)
// Then: Should expand the lamp under livingroom, not kitchen
// We verify this by checking that after clicking, we're in the livingroom branch
const livingroomTopic = await page.locator('span[data-test-topic="livingroom"]')
const lampTopic = await page.locator('span[data-test-topic="lamp"]')
expect(await livingroomTopic.isVisible()).to.be.true
expect(await lampTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-expand-different-branch.png' })
})
it('should expand livingroom/lamp/state (full path in different branch)', async function () {
// Given: kitchen/lamp/state also exists
// When: Expand livingroom/lamp/state
await expandTopic('livingroom/lamp/state', page)
// Then: Should see state under livingroom/lamp
const stateTopic = await page.locator('span[data-test-topic="state"]')
await stateTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await stateTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-expand-different-branch-full.png' })
})
})
describe('Error Handling', () => {
it('should throw error for non-existent topic', async function () {
// Given: Topics are loaded
// When: Try to expand non-existent topic
// Then: Should throw error
try {
await expandTopic('nonexistent/topic/path', page)
expect.fail('Should have thrown an error for non-existent topic')
} catch (error: any) {
expect(error.message).to.include('Could not find topic')
}
})
})
})

View File

@@ -22,7 +22,7 @@ export interface MockSparkplugClient {
stop: () => void stop: () => void
} }
let sample = (function () { const sample = (function () {
let config = { let config = {
serverUrl: 'tcp://127.0.0.1:1883', serverUrl: 'tcp://127.0.0.1:1883',
username: '', username: '',
@@ -169,12 +169,12 @@ let sample = (function () {
// Create node command handler // Create node command handler
// spell-checker: disable-next-line // spell-checker: disable-next-line
sparkplugClient.on('ncmd', function (payload: UPayload) { sparkplugClient.on('ncmd', function (payload: UPayload) {
let timestamp = payload.timestamp, const timestamp = payload.timestamp,
metrics = payload.metrics metrics = payload.metrics
if (metrics !== undefined && metrics !== null) { if (metrics !== undefined && metrics !== null) {
for (let i = 0; i < metrics.length; i++) { for (let i = 0; i < metrics.length; i++) {
let metric = metrics[i] const metric = metrics[i]
if (metric.name == 'Node Control/Rebirth' && metric.value) { if (metric.name == 'Node Control/Rebirth' && metric.value) {
console.log("Received 'Rebirth' command") console.log("Received 'Rebirth' command")
// Publish Node BIRTH certificate // Publish Node BIRTH certificate
@@ -200,7 +200,7 @@ let sample = (function () {
// Loop over the metrics and store them in a map // Loop over the metrics and store them in a map
if (metrics !== undefined && metrics !== null) { if (metrics !== undefined && metrics !== null) {
for (let i = 0; i < metrics.length; i++) { for (let i = 0; i < metrics.length; i++) {
let metric = metrics[i] const metric = metrics[i]
if (metric.name !== undefined && metric.name !== null) { if (metric.name !== undefined && metric.name !== null) {
inboundMetricMap[metric.name] = metric.value inboundMetricMap[metric.name] = metric.value
} }

View File

@@ -0,0 +1,285 @@
import 'mocha'
import { expect } from 'chai'
import { ElectronApplication, Page, _electron as electron } from 'playwright'
import { createTestMock, stopTestMock } from './mock-mqtt-test'
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'
import type { MqttClient } from 'mqtt'
/**
* Comprehensive UI Test Suite for MQTT Explorer
*
* These tests validate the core UI functionality of MQTT Explorer.
* All topics are published before connecting, and tests run sequentially
* on the same connected application instance.
*
* 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 Comprehensive UI Tests', function () {
this.timeout(60000)
let electronApp: ElectronApplication
let page: Page
let testMock: MqttClient
/**
* Setup: Start MQTT broker mock and launch Electron app
*/
before(async function () {
this.timeout(120000) // Increased timeout for comprehensive setup
console.log('Creating test-specific MQTT mock (no timers)...')
testMock = await createTestMock()
console.log('Publishing all test topics...')
// Publish all test topics before connecting - simulating what mock-mqtt does with timers
// Basic topics
testMock.publish('livingroom/lamp/state', 'on', { retain: true, qos: 0 })
testMock.publish('livingroom/lamp/brightness', '128', { retain: true, qos: 0 })
testMock.publish('livingroom/lamp-1/state', 'on', { retain: true, qos: 0 })
testMock.publish('livingroom/lamp-1/brightness', '48', { retain: true, qos: 0 })
testMock.publish('livingroom/lamp-2/state', 'off', { retain: true, qos: 0 })
testMock.publish('livingroom/lamp-2/brightness', '48', { retain: true, qos: 0 })
testMock.publish('livingroom/temperature', '21.0', { retain: true, qos: 0 })
testMock.publish('livingroom/humidity', '60', { retain: true, qos: 0 })
testMock.publish('livingroom/thermostat/targetTemperature', '20°C', { retain: true, qos: 0 })
// Kitchen topics
const coffeeData = {
heater: 'on',
temperature: 92.5,
waterLevel: 0.5,
update: new Date().toISOString(),
}
testMock.publish('kitchen/coffee_maker', JSON.stringify(coffeeData), { retain: true, qos: 2 })
testMock.publish('kitchen/lamp/state', 'off', { retain: true, qos: 0 })
testMock.publish('kitchen/temperature', '22.5', { retain: true, qos: 0 })
testMock.publish('kitchen/humidity', '55', { retain: true, qos: 0 })
// Garden topics
testMock.publish('garden/pump/state', 'off', { retain: true, qos: 0 })
testMock.publish('garden/water/level', '70%', { retain: true, qos: 0 })
testMock.publish('garden/lamps/state', 'off', { retain: true, qos: 0 })
// Bridge topics
testMock.publish('zigbee2mqtt/bridge/state', 'online', { retain: true, qos: 0 })
testMock.publish('ble2mqtt/bridge/state', 'online', { retain: true, qos: 0 })
// Special character topics
testMock.publish('01-80-C2-00-00-0F/LWT', 'offline', { retain: true, qos: 0 })
// 3D printer topics
testMock.publish('3d-printer/OctoPrint/temperature/bed', '{"_timestamp":1548589083,"actual":25.9,"target":0}', {
retain: true,
qos: 0,
})
testMock.publish('3d-printer/OctoPrint/temperature/tool0', '{"_timestamp":1548589093,"actual":26.4,"target":0}', {
retain: true,
qos: 0,
})
// Actuality showcase for JSON display
const actualityData = {
tags: {
entityId: 33512,
entityType: 'person',
host: 'd44ad81e10f9',
server: 'http://localhost/dataActuality',
status: 'live',
},
timestamp: Date.now(),
}
testMock.publish('actuality/showcase', JSON.stringify(actualityData), { retain: true, qos: 0 })
await sleep(2000) // Let MQTT messages propagate and get retained
console.log('Launching Electron application...')
electronApp = await electron.launch({
args: [`${__dirname}/../../..`, '--runningUiTestOnCi', '--no-sandbox', '--disable-dev-shm-usage'],
timeout: 60000,
})
console.log('Waiting for application window...')
page = await electronApp.firstWindow({ timeout: 30000 })
await page.locator('//label[contains(text(), "Host")]/..//input').waitFor({ timeout: 10000 })
console.log('Connecting to MQTT broker...')
await connectTo('127.0.0.1', page)
await sleep(3000) // Give time for all topics to load
// Start Sparkplug client after connection
console.log('Starting SparkplugB mock...')
await MockSparkplug.run()
await sleep(2000)
console.log('Application ready for testing')
})
/**
* Teardown: Close app and stop MQTT mock
*/
after(async function () {
this.timeout(10000)
if (electronApp) {
await electronApp.close()
}
stopTestMock()
})
describe('Connection Management', () => {
it('should connect to MQTT broker successfully', async function () {
// Given: Application is connected (from before hook)
// Then: Disconnect button should be visible (indicating connected state)
const disconnectButton = page.locator('//button/span[contains(text(),"Disconnect")]')
await disconnectButton.waitFor({ state: 'visible', timeout: 5000 })
const isVisible = await disconnectButton.isVisible()
expect(isVisible).to.be.true
await page.screenshot({ path: 'test-screenshot-connection.png' })
})
})
describe('Topic Tree Structure', () => {
it('should display the correct number of root topics from mock data', async function () {
// Then: We should see expected root topics (livingroom, kitchen, garden, etc.)
const rootTopics = ['livingroom', 'kitchen', 'garden']
for (const topicName of rootTopics) {
const topic = page.locator(`span[data-test-topic="${topicName}"]`).first()
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' })
})
})
describe('Topic Navigation and Search', () => {
it('should search and filter topics containing "temp"', async function () {
// When: User searches for "temp"
await searchTree('temp', page)
await sleep(1000)
// Then: Search field should contain the search term
const searchField = page.locator('//input[contains(@placeholder, "Search")]')
const searchValue = await searchField.inputValue()
expect(searchValue).to.equal('temp')
// And: Temperature topics should be visible
const tempTopic = 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' })
// Clean up: Clear search
await clearSearch(page)
await sleep(500)
})
})
describe('Message Visualization', () => {
it('Given a JSON message on topic actuality/showcase, should display formatted JSON', async function () {
// showJsonPreview internally calls expandTopic, so we don't need to call it here
await showJsonPreview(page)
await sleep(1000)
await page.screenshot({ path: 'test-screenshot-json-display.png' })
})
it('should show numeric plots for topics with numeric values', async function () {
// When: We navigate to a numeric topic and show plot
await expandTopic('livingroom/temperature', page)
await sleep(500)
await showNumericPlot(page)
await sleep(2000)
await page.screenshot({ path: 'test-screenshot-numeric-plot.png' })
})
})
describe('Clipboard Operations', () => {
it('should copy topic path to clipboard', async function () {
// When: We copy topic to clipboard
await expandTopic('livingroom/lamp/state', page)
await sleep(500)
await copyTopicToClipboard(page)
await sleep(500)
await page.screenshot({ path: 'test-screenshot-copy-topic.png' })
})
})
describe('SparkplugB Support', () => {
it('Given SparkplugB messages, should decode and display the payload', async function () {
// When: We show SparkplugB decoding
await showSparkPlugDecoding(page)
await sleep(1000)
await page.screenshot({ path: 'test-screenshot-sparkplugb.png' })
})
})
describe('Settings and Configuration', () => {
it('should show settings menu', async function () {
// When: We open settings menu
await showMenu(page)
await sleep(1000)
await page.screenshot({ path: 'test-screenshot-menu.png' })
})
})
describe('Retained Messages', () => {
it('Given retained messages on multiple topics, should display retained indicator', async function () {
// When: We navigate to a topic with retained message
await expandTopic('garden/pump/state', page)
await sleep(500)
// Then: The topic should be visible (retained messages are shown)
const pumpTopic = page.locator('span[data-test-topic="state"]').first()
expect(await pumpTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-retained.png' })
})
})
describe('Special Topic Names and Characters', () => {
it('Given topic with MAC address format (01-80-C2-00-00-0F/LWT), should display correctly', async function () {
// When: We look for the MAC address topic
const macTopic = page.locator('span[data-test-topic="01-80-C2-00-00-0F"]').first()
await macTopic.waitFor({ state: 'visible', timeout: 5000 })
// Then: It should be visible
expect(await macTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-special-chars.png' })
})
})
})

View File

@@ -10,16 +10,11 @@ import { expandTopic } from './util/expandTopic'
import type { MqttClient } from 'mqtt' import type { MqttClient } from 'mqtt'
/** /**
* MQTT Explorer UI Tests - Fully Isolated Test Suite * MQTT Explorer UI Tests
* *
* Each test: * Tests the core UI functionality using a single connection.
* 1. Gets fresh page * All topics are published before connecting, and tests run sequentially
* 2. Mocks only the MQTT messages it needs (no timers) * on the same connected application instance.
* 3. Connects to broker
* 4. Tests functionality using expandTopic
* 5. Reloads page for next test
*
* This ensures complete test isolation with no state carryover.
*/ */
// tslint:disable:only-arrow-functions ter-prefer-arrow-callback no-unused-expression // tslint:disable:only-arrow-functions ter-prefer-arrow-callback no-unused-expression
describe('MQTT Explorer UI Tests', function () { describe('MQTT Explorer UI Tests', function () {
@@ -27,6 +22,7 @@ describe('MQTT Explorer UI Tests', function () {
let electronApp: ElectronApplication let electronApp: ElectronApplication
let testMock: MqttClient let testMock: MqttClient
let page: Page
before(async function () { before(async function () {
this.timeout(90000) this.timeout(90000)
@@ -34,11 +30,37 @@ describe('MQTT Explorer UI Tests', function () {
console.log('Creating test-specific MQTT mock (no timers)...') console.log('Creating test-specific MQTT mock (no timers)...')
testMock = await createTestMock() testMock = await createTestMock()
console.log('Publishing test topics...')
// Publish all test topics before connecting
testMock.publish('livingroom/lamp/state', 'on', { retain: true, qos: 0 })
testMock.publish('livingroom/lamp/brightness', '128', { retain: true, qos: 0 })
testMock.publish('livingroom/temperature', '21.0', { retain: true, qos: 0 })
const coffeeData = {
heater: 'on',
temperature: 92.5,
waterLevel: 0.5,
}
testMock.publish('kitchen/coffee_maker', JSON.stringify(coffeeData), { retain: true, qos: 2 })
testMock.publish('kitchen/lamp/state', 'off', { retain: true, qos: 0 })
testMock.publish('kitchen/temperature', '22.5', { retain: true, qos: 0 })
await sleep(2000) // Let MQTT messages propagate and get retained
console.log('Launching Electron application...') console.log('Launching Electron application...')
electronApp = await electron.launch({ electronApp = await electron.launch({
args: [`${__dirname}/../../..`, '--runningUiTestOnCi', '--no-sandbox', '--disable-dev-shm-usage'], args: [`${__dirname}/../../..`, '--runningUiTestOnCi', '--no-sandbox', '--disable-dev-shm-usage'],
timeout: 60000, timeout: 60000,
}) })
console.log('Getting application window...')
page = await electronApp.firstWindow({ timeout: 30000 })
await page.locator('//label[contains(text(), "Host")]/..//input').waitFor({ timeout: 10000 })
console.log('Connecting to MQTT broker...')
await connectTo('127.0.0.1', page)
await sleep(3000) // Give time for topics to load
console.log('Setup complete')
}) })
after(async function () { after(async function () {
@@ -51,99 +73,53 @@ describe('MQTT Explorer UI Tests', function () {
stopTestMock() stopTestMock()
}) })
// Helper function to get a fresh page
async function getFreshPage(): Promise<Page> {
const page = await electronApp.firstWindow({ timeout: 30000 })
await page.locator('//label[contains(text(), "Host")]/..//input').waitFor({ timeout: 10000 })
return page
}
describe('Connection Management', () => { describe('Connection Management', () => {
it('should connect and expand livingroom/lamp topic', async function () { it('should connect and expand livingroom/lamp topic', async function () {
// Given: Fresh page and mocked topic // Given: Connected to broker with topics loaded
const page = await getFreshPage() // When: Expand topic
testMock.publish('livingroom/lamp/state', 'on', { retain: true, qos: 0 })
await sleep(500) // Let MQTT message propagate
// When: Connect and expand topic
await connectTo('127.0.0.1', page)
await sleep(2000)
await expandTopic('livingroom/lamp', page) await expandTopic('livingroom/lamp', page)
// Then: Should see lamp state // Then: Should see lamp state topic
const stateTopic = await page.locator('span[data-test-topic="state"]') const stateTopic = page.locator('span[data-test-topic="state"]').first()
await stateTopic.waitFor({ state: 'visible', timeout: 5000 }) await stateTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await stateTopic.isVisible()).to.be.true expect(await stateTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-connection.png' }) await page.screenshot({ path: 'test-screenshot-connection.png' })
// Clean up: Reload page
await page.reload()
}) })
}) })
describe('Topic Tree Structure', () => { describe('Topic Tree Structure', () => {
it('should expand and display kitchen/coffee_maker with JSON payload', async function () { it('should expand and display kitchen/coffee_maker with JSON payload', async function () {
// Given: Fresh page and mocked JSON message // Given: Connected to broker with kitchen/coffee_maker topic
const page = await getFreshPage() // When: Expand topic
const coffeeData = {
heater: 'on',
temperature: 92.5,
waterLevel: 0.5,
}
testMock.publish('kitchen/coffee_maker', JSON.stringify(coffeeData), { retain: true, qos: 2 })
await sleep(500)
// When: Connect and expand topic
await connectTo('127.0.0.1', page)
await sleep(2000)
await expandTopic('kitchen/coffee_maker', page) await expandTopic('kitchen/coffee_maker', page)
// Then: JSON content should be visible (check for heater key) // Then: The topic should be visible and selected
const valueDisplay = await page.locator('text="heater"') const coffeeMakerTopic = page.locator('span[data-test-topic="coffee_maker"]').first()
await valueDisplay.waitFor({ state: 'visible', timeout: 5000 }) await coffeeMakerTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await valueDisplay.isVisible()).to.be.true expect(await coffeeMakerTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-kitchen-json.png' }) await page.screenshot({ path: 'test-screenshot-kitchen-json.png' })
// Clean up: Reload page
await page.reload()
}) })
it('should expand nested topic livingroom/lamp/brightness', async function () { it('should expand nested topic livingroom/lamp/state', async function () {
// Given: Fresh page and nested mocked topic // Given: Connected to broker with nested topics
const page = await getFreshPage() // When: Expand to nested topic
testMock.publish('livingroom/lamp/brightness', '128', { retain: true, qos: 0 }) await expandTopic('livingroom/lamp/state', page)
await sleep(500)
// When: Connect and expand to nested topic // Then: State topic should be visible and selected
await connectTo('127.0.0.1', page) const stateTopic = page.locator('span[data-test-topic="state"]').first()
await sleep(2000) await stateTopic.waitFor({ state: 'visible', timeout: 5000 })
await expandTopic('livingroom/lamp/brightness', page) expect(await stateTopic.isVisible()).to.be.true
// Then: Brightness topic should be visible and selected
const brightnessTopic = await page.locator('span[data-test-topic="brightness"]')
await brightnessTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await brightnessTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-nested-topic.png' }) await page.screenshot({ path: 'test-screenshot-nested-topic.png' })
// Clean up: Reload page
await page.reload()
}) })
}) })
describe('Search Functionality', () => { describe('Search Functionality', () => {
it('should search for temperature and expand kitchen/temperature', async function () { it('should search for temperature and expand kitchen/temperature', async function () {
// Given: Fresh page and mocked temperature topics // Given: Connected to broker with temperature topics
const page = await getFreshPage() // When: Search and expand
testMock.publish('kitchen/temperature', '22.5', { retain: true, qos: 0 })
testMock.publish('livingroom/temperature', '21.0', { retain: true, qos: 0 })
await sleep(500)
// When: Connect, search, and expand
await connectTo('127.0.0.1', page)
await sleep(2000)
await searchTree('temp', page) await searchTree('temp', page)
await sleep(1000) await sleep(1000)
await clearSearch(page) await clearSearch(page)
@@ -151,26 +127,16 @@ describe('MQTT Explorer UI Tests', function () {
await expandTopic('kitchen/temperature', page) await expandTopic('kitchen/temperature', page)
// Then: Temperature topic should be visible // Then: Temperature topic should be visible
const tempTopic = await page.locator('span[data-test-topic="temperature"]') const tempTopic = page.locator('span[data-test-topic="temperature"]').first()
await tempTopic.waitFor({ state: 'visible', timeout: 5000 }) await tempTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await tempTopic.isVisible()).to.be.true expect(await tempTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-search-temp.png' }) await page.screenshot({ path: 'test-screenshot-search-temp.png' })
// Clean up: Reload page
await page.reload()
}) })
it('should search for lamp and expand kitchen/lamp', async function () { it('should search for lamp and expand kitchen/lamp', async function () {
// Given: Fresh page and mocked lamp topics // Given: Connected to broker with lamp topics
const page = await getFreshPage() // When: Search and expand
testMock.publish('kitchen/lamp/state', 'off', { retain: true, qos: 0 })
testMock.publish('livingroom/lamp/state', 'on', { retain: true, qos: 0 })
await sleep(500)
// When: Connect, search, and expand
await connectTo('127.0.0.1', page)
await sleep(2000)
await searchTree('kitchen/lamp', page) await searchTree('kitchen/lamp', page)
await sleep(1000) await sleep(1000)
await clearSearch(page) await clearSearch(page)
@@ -178,14 +144,11 @@ describe('MQTT Explorer UI Tests', function () {
await expandTopic('kitchen/lamp', page) await expandTopic('kitchen/lamp', page)
// Then: Lamp topic should be visible // Then: Lamp topic should be visible
const lampTopic = await page.locator('span[data-test-topic="lamp"]') const lampTopic = page.locator('span[data-test-topic="lamp"]').first()
await lampTopic.waitFor({ state: 'visible', timeout: 5000 }) await lampTopic.waitFor({ state: 'visible', timeout: 5000 })
expect(await lampTopic.isVisible()).to.be.true expect(await lampTopic.isVisible()).to.be.true
await page.screenshot({ path: 'test-screenshot-search-lamp.png' }) await page.screenshot({ path: 'test-screenshot-search-lamp.png' })
// Clean up: Reload page
await page.reload()
}) })
}) })
}) })

View File

@@ -1,35 +1,95 @@
import { clickOn } from './' import { clickOn } from './'
import { Page } from 'playwright' import { Page, Locator } from 'playwright'
// Time to wait after clicking a topic for the tree to expand and render children
// Increased to 1000ms to handle sequential test execution where UI might be slower
const TREE_EXPANSION_DELAY_MS = 1000
// Additional wait time to ensure child topics are rendered after expansion
const CHILD_RENDER_DELAY_MS = 500
export async function expandTopic(path: string, browser: Page) { export async function expandTopic(path: string, browser: Page) {
const topics = path.split('/') const topics = path.split('/')
console.log('expandTopic', path) console.log('expandTopic', path)
// Build hierarchical selector and expand one level at a time // Expand each level of the topic tree one at a time
for (let i = 0; i < topics.length; i++) { // Strategy: Click on each topic level individually, relying on the fact that
// Build a hierarchical selector for the current level // after clicking a parent, its children become visible and we can find the next level
// e.g., "kitchen" then "kitchen coffee_maker" for (let i = 0; i < topics.length; i += 1) {
const topicName = topics[i]
const currentPath = topics.slice(0, i + 1) const currentPath = topics.slice(0, i + 1)
const selectors = currentPath.map(v => `span[data-test-topic='${v}']`) const nextTopicName = i < topics.length - 1 ? topics[i + 1] : null
const hierarchicalSelector = selectors.join(' ')
console.log(`topic matches`, currentPath, `locator('${hierarchicalSelector}')`) console.log(`Expanding level ${i + 1}/${topics.length}: ${topicName}`)
const locator = browser.locator(hierarchicalSelector).first() // 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}']`
// Wait for the topic to be visible with a reasonable timeout console.log(`Using selector: ${selector}`)
// Get all matching elements (there may be multiple topics with the same name)
const allMatches = browser.locator(selector)
// 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
for (let j = 0; j < count; j += 1) {
const candidate = allMatches.nth(j)
try { try {
await locator.waitFor({ state: 'visible', timeout: 30000 }) // Increased timeout to 3000ms to handle slower UI after many test runs
console.log(`found topics`, currentPath, topics) await candidate.waitFor({ state: 'visible', timeout: 3000 })
locator = candidate
console.log(`Using match #${j} for '${topicName}'`)
break
} catch {
// This candidate is not visible, try the next one
continue
}
}
// Click to expand this level if (!locator) {
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}`)
// Scroll the element into view to ensure it's clickable
await locator.scrollIntoViewIfNeeded()
await new Promise(resolve => setTimeout(resolve, 200))
// Click to expand/select this level
await clickOn(locator) await clickOn(locator)
// Reduced delay for UI to expand - 200ms is sufficient for most cases // Give the UI time to expand and render child topics
await new Promise(resolve => setTimeout(resolve, 200)) // This is important for MQTT async operations and tree rendering
await new Promise(resolve => setTimeout(resolve, TREE_EXPANSION_DELAY_MS))
// If this is not the last topic in the path, verify that children rendered
if (nextTopicName) {
console.log(`Waiting for children of '${topicName}' to render...`)
await new Promise(resolve => setTimeout(resolve, CHILD_RENDER_DELAY_MS))
// Check if the next topic is now visible
const nextSelector = `span[data-test-topic='${nextTopicName}']`
const nextMatches = browser.locator(nextSelector)
const nextCount = await nextMatches.count()
console.log(`After expanding '${topicName}', found ${nextCount} elements for next topic '${nextTopicName}'`)
// If we don't find the next topic, wait a bit longer
if (nextCount === 0) {
console.log(`No children found yet, waiting additional time...`)
await new Promise(resolve => setTimeout(resolve, TREE_EXPANSION_DELAY_MS))
}
}
} catch (error) { } catch (error) {
console.error(`Failed to find topic path: ${currentPath.join('/')}`, error) console.error(`Failed to click topic "${topicName}" in path "${currentPath.join('/')}"`, error)
throw new Error(`Could not find topic "${currentPath.join('/')}" in path "${path}"`) throw new Error(`Could not click topic "${topicName}" in path "${currentPath.join('/')}"`)
} }
} }
} }

View File

@@ -25,6 +25,8 @@
"src/spec/leakTest.ts", "src/spec/leakTest.ts",
"src/spec/testMcpIntrospection.ts", "src/spec/testMcpIntrospection.ts",
"src/spec/ui-tests.spec.ts", "src/spec/ui-tests.spec.ts",
"src/spec/ui-tests-comprehensive.spec.ts",
"src/spec/expandTopic.spec.ts",
"scripts/*.ts" "scripts/*.ts"
], ],
"exclude": ["node_modules"] "exclude": ["node_modules"]