Fix expandTopic selector, restore and streamline comprehensive UI tests (#938)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ test-mcp-introspection.js
|
||||
|
||||
/data
|
||||
test-screenshot-*.png
|
||||
test-expand-*.png
|
||||
|
||||
207
src/spec/expandTopic.spec.ts
Normal file
207
src/spec/expandTopic.spec.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,7 @@ export interface MockSparkplugClient {
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
let sample = (function () {
|
||||
const sample = (function () {
|
||||
let config = {
|
||||
serverUrl: 'tcp://127.0.0.1:1883',
|
||||
username: '',
|
||||
@@ -169,12 +169,12 @@ let sample = (function () {
|
||||
// Create node command handler
|
||||
// spell-checker: disable-next-line
|
||||
sparkplugClient.on('ncmd', function (payload: UPayload) {
|
||||
let timestamp = payload.timestamp,
|
||||
const timestamp = payload.timestamp,
|
||||
metrics = payload.metrics
|
||||
|
||||
if (metrics !== undefined && metrics !== null) {
|
||||
for (let i = 0; i < metrics.length; i++) {
|
||||
let metric = metrics[i]
|
||||
const metric = metrics[i]
|
||||
if (metric.name == 'Node Control/Rebirth' && metric.value) {
|
||||
console.log("Received 'Rebirth' command")
|
||||
// Publish Node BIRTH certificate
|
||||
@@ -200,7 +200,7 @@ let sample = (function () {
|
||||
// Loop over the metrics and store them in a map
|
||||
if (metrics !== undefined && metrics !== null) {
|
||||
for (let i = 0; i < metrics.length; i++) {
|
||||
let metric = metrics[i]
|
||||
const metric = metrics[i]
|
||||
if (metric.name !== undefined && metric.name !== null) {
|
||||
inboundMetricMap[metric.name] = metric.value
|
||||
}
|
||||
|
||||
285
src/spec/ui-tests-comprehensive.spec.ts
Normal file
285
src/spec/ui-tests-comprehensive.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -10,16 +10,11 @@ import { expandTopic } from './util/expandTopic'
|
||||
import type { MqttClient } from 'mqtt'
|
||||
|
||||
/**
|
||||
* MQTT Explorer UI Tests - Fully Isolated Test Suite
|
||||
* MQTT Explorer UI Tests
|
||||
*
|
||||
* Each test:
|
||||
* 1. Gets fresh page
|
||||
* 2. Mocks only the MQTT messages it needs (no timers)
|
||||
* 3. Connects to broker
|
||||
* 4. Tests functionality using expandTopic
|
||||
* 5. Reloads page for next test
|
||||
*
|
||||
* This ensures complete test isolation with no state carryover.
|
||||
* Tests the core UI functionality using a single connection.
|
||||
* All topics are published before connecting, and tests run sequentially
|
||||
* on the same connected application instance.
|
||||
*/
|
||||
// tslint:disable:only-arrow-functions ter-prefer-arrow-callback no-unused-expression
|
||||
describe('MQTT Explorer UI Tests', function () {
|
||||
@@ -27,6 +22,7 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
|
||||
let electronApp: ElectronApplication
|
||||
let testMock: MqttClient
|
||||
let page: Page
|
||||
|
||||
before(async function () {
|
||||
this.timeout(90000)
|
||||
@@ -34,11 +30,37 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
console.log('Creating test-specific MQTT mock (no timers)...')
|
||||
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...')
|
||||
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 () {
|
||||
@@ -51,99 +73,53 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
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', () => {
|
||||
it('should connect and expand livingroom/lamp topic', async function () {
|
||||
// Given: Fresh page and mocked topic
|
||||
const page = await getFreshPage()
|
||||
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)
|
||||
// Given: Connected to broker with topics loaded
|
||||
// When: Expand topic
|
||||
await expandTopic('livingroom/lamp', page)
|
||||
|
||||
// Then: Should see lamp state
|
||||
const stateTopic = await page.locator('span[data-test-topic="state"]')
|
||||
// Then: Should see lamp state topic
|
||||
const stateTopic = page.locator('span[data-test-topic="state"]').first()
|
||||
await stateTopic.waitFor({ state: 'visible', timeout: 5000 })
|
||||
expect(await stateTopic.isVisible()).to.be.true
|
||||
|
||||
await page.screenshot({ path: 'test-screenshot-connection.png' })
|
||||
|
||||
// Clean up: Reload page
|
||||
await page.reload()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Topic Tree Structure', () => {
|
||||
it('should expand and display kitchen/coffee_maker with JSON payload', async function () {
|
||||
// Given: Fresh page and mocked JSON message
|
||||
const page = await getFreshPage()
|
||||
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)
|
||||
// Given: Connected to broker with kitchen/coffee_maker topic
|
||||
// When: Expand topic
|
||||
await expandTopic('kitchen/coffee_maker', page)
|
||||
|
||||
// Then: JSON content should be visible (check for heater key)
|
||||
const valueDisplay = await page.locator('text="heater"')
|
||||
await valueDisplay.waitFor({ state: 'visible', timeout: 5000 })
|
||||
expect(await valueDisplay.isVisible()).to.be.true
|
||||
// Then: The topic should be visible and selected
|
||||
const coffeeMakerTopic = page.locator('span[data-test-topic="coffee_maker"]').first()
|
||||
await coffeeMakerTopic.waitFor({ state: 'visible', timeout: 5000 })
|
||||
expect(await coffeeMakerTopic.isVisible()).to.be.true
|
||||
|
||||
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 () {
|
||||
// Given: Fresh page and nested mocked topic
|
||||
const page = await getFreshPage()
|
||||
testMock.publish('livingroom/lamp/brightness', '128', { retain: true, qos: 0 })
|
||||
await sleep(500)
|
||||
it('should expand nested topic livingroom/lamp/state', async function () {
|
||||
// Given: Connected to broker with nested topics
|
||||
// When: Expand to nested topic
|
||||
await expandTopic('livingroom/lamp/state', page)
|
||||
|
||||
// When: Connect and expand to nested topic
|
||||
await connectTo('127.0.0.1', page)
|
||||
await sleep(2000)
|
||||
await expandTopic('livingroom/lamp/brightness', page)
|
||||
|
||||
// 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
|
||||
// Then: State topic should be visible and selected
|
||||
const stateTopic = page.locator('span[data-test-topic="state"]').first()
|
||||
await stateTopic.waitFor({ state: 'visible', timeout: 5000 })
|
||||
expect(await stateTopic.isVisible()).to.be.true
|
||||
|
||||
await page.screenshot({ path: 'test-screenshot-nested-topic.png' })
|
||||
|
||||
// Clean up: Reload page
|
||||
await page.reload()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should search for temperature and expand kitchen/temperature', async function () {
|
||||
// Given: Fresh page and mocked temperature topics
|
||||
const page = await getFreshPage()
|
||||
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)
|
||||
// Given: Connected to broker with temperature topics
|
||||
// When: Search and expand
|
||||
await searchTree('temp', page)
|
||||
await sleep(1000)
|
||||
await clearSearch(page)
|
||||
@@ -151,26 +127,16 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
await expandTopic('kitchen/temperature', page)
|
||||
|
||||
// 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 })
|
||||
expect(await tempTopic.isVisible()).to.be.true
|
||||
|
||||
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 () {
|
||||
// Given: Fresh page and mocked lamp topics
|
||||
const page = await getFreshPage()
|
||||
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)
|
||||
// Given: Connected to broker with lamp topics
|
||||
// When: Search and expand
|
||||
await searchTree('kitchen/lamp', page)
|
||||
await sleep(1000)
|
||||
await clearSearch(page)
|
||||
@@ -178,14 +144,11 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
await expandTopic('kitchen/lamp', page)
|
||||
|
||||
// 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 })
|
||||
expect(await lampTopic.isVisible()).to.be.true
|
||||
|
||||
await page.screenshot({ path: 'test-screenshot-search-lamp.png' })
|
||||
|
||||
// Clean up: Reload page
|
||||
await page.reload()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,35 +1,95 @@
|
||||
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) {
|
||||
const topics = path.split('/')
|
||||
console.log('expandTopic', path)
|
||||
|
||||
// Build hierarchical selector and expand one level at a time
|
||||
for (let i = 0; i < topics.length; i++) {
|
||||
// Build a hierarchical selector for the current level
|
||||
// e.g., "kitchen" then "kitchen coffee_maker"
|
||||
// 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
|
||||
for (let i = 0; i < topics.length; i += 1) {
|
||||
const topicName = topics[i]
|
||||
const currentPath = topics.slice(0, i + 1)
|
||||
const selectors = currentPath.map(v => `span[data-test-topic='${v}']`)
|
||||
const hierarchicalSelector = selectors.join(' ')
|
||||
const nextTopicName = i < topics.length - 1 ? topics[i + 1] : null
|
||||
|
||||
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 {
|
||||
await locator.waitFor({ state: 'visible', timeout: 30000 })
|
||||
console.log(`found topics`, currentPath, topics)
|
||||
// Increased timeout to 3000ms to handle slower UI after many test runs
|
||||
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)
|
||||
|
||||
// Reduced delay for UI to expand - 200ms is sufficient for most cases
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
// 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))
|
||||
|
||||
// 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) {
|
||||
console.error(`Failed to find topic path: ${currentPath.join('/')}`, error)
|
||||
throw new Error(`Could not find topic "${currentPath.join('/')}" in path "${path}"`)
|
||||
console.error(`Failed to click topic "${topicName}" in path "${currentPath.join('/')}"`, error)
|
||||
throw new Error(`Could not click topic "${topicName}" in path "${currentPath.join('/')}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
"src/spec/leakTest.ts",
|
||||
"src/spec/testMcpIntrospection.ts",
|
||||
"src/spec/ui-tests.spec.ts",
|
||||
"src/spec/ui-tests-comprehensive.spec.ts",
|
||||
"src/spec/expandTopic.spec.ts",
|
||||
"scripts/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
Reference in New Issue
Block a user