Add observability for LLM topic context inclusion (#1038)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com> Co-authored-by: Thomas Nordquist <thomasnordquist@users.noreply.github.com>
This commit is contained in:
@@ -11,7 +11,9 @@ export interface Credentials {
|
||||
|
||||
export class AuthManager {
|
||||
private credentialsPath: string
|
||||
|
||||
private credentials: Credentials | undefined
|
||||
|
||||
private skipAuth: boolean
|
||||
|
||||
constructor(credentialsPath: string) {
|
||||
|
||||
@@ -18,7 +18,7 @@ const applicationMenu: MenuItemConstructorOptions = {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'separator' as 'separator',
|
||||
type: 'separator' as const,
|
||||
},
|
||||
{
|
||||
label: 'Dev Tools',
|
||||
|
||||
@@ -40,13 +40,13 @@ if (remoteDebuggingPort) {
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
backendRpc.on(makeOpenDialogRpc(), async request => {
|
||||
return dialog.showOpenDialog(BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0], request)
|
||||
})
|
||||
backendRpc.on(makeOpenDialogRpc(), async request =>
|
||||
dialog.showOpenDialog(BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0], request)
|
||||
)
|
||||
|
||||
backendRpc.on(makeSaveDialogRpc(), async request => {
|
||||
return dialog.showSaveDialog(BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0], request)
|
||||
})
|
||||
backendRpc.on(makeSaveDialogRpc(), async request =>
|
||||
dialog.showSaveDialog(BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0], request)
|
||||
)
|
||||
|
||||
backendRpc.on(getAppVersion, async () => app.getVersion())
|
||||
|
||||
@@ -63,14 +63,14 @@ app.whenReady().then(() => {
|
||||
})
|
||||
|
||||
// Certificate upload handler - works for both Electron and browser mode via IPC
|
||||
backendRpc.on(RpcEvents.uploadCertificate, async ({ filename, data }) => {
|
||||
backendRpc.on(RpcEvents.uploadCertificate, async ({ filename, data }) =>
|
||||
// In Electron, we just return the data as-is since it's already read
|
||||
// The client will use it directly
|
||||
return {
|
||||
({
|
||||
name: filename,
|
||||
data,
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
autoUpdater.logger = log
|
||||
|
||||
298
src/server.ts
298
src/server.ts
@@ -3,9 +3,12 @@ import * as http from 'http'
|
||||
import * as path from 'path'
|
||||
import { Server } from 'socket.io'
|
||||
import { promises as fsPromise } from 'fs'
|
||||
import { inspect } from 'util'
|
||||
import helmet from 'helmet'
|
||||
import rateLimit from 'express-rate-limit'
|
||||
import { body, validationResult } from 'express-validator'
|
||||
import axios from 'axios'
|
||||
import OpenAI from 'openai'
|
||||
import { AuthManager } from './AuthManager'
|
||||
import { ConnectionManager } from '../backend/src/index'
|
||||
import ConfigStorage from '../backend/src/ConfigStorage'
|
||||
@@ -83,7 +86,7 @@ async function startServer() {
|
||||
// Create a copy to avoid mutating Helmet's defaults
|
||||
const defaultCspDirectives = { ...helmet.contentSecurityPolicy.getDefaultDirectives() }
|
||||
delete defaultCspDirectives['upgrade-insecure-requests']
|
||||
|
||||
|
||||
// Build custom CSP directives, overriding defaults as needed
|
||||
const cspDirectives = {
|
||||
...defaultCspDirectives,
|
||||
@@ -211,7 +214,11 @@ async function startServer() {
|
||||
|
||||
if (remainingWaitTime > 0) {
|
||||
const secondsRemaining = Math.ceil(remainingWaitTime / 1000)
|
||||
return next(new Error(`Too many failed authentication attempts. Please wait ${secondsRemaining} seconds before trying again.`))
|
||||
return next(
|
||||
new Error(
|
||||
`Too many failed authentication attempts. Please wait ${secondsRemaining} seconds before trying again.`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,11 +234,11 @@ async function startServer() {
|
||||
attempts.count++
|
||||
attempts.lastAttempt = now
|
||||
failedAttempts.set(clientIp, attempts)
|
||||
|
||||
|
||||
// Calculate next wait time for informational purposes
|
||||
const nextBackoff = calculateBackoffTime(attempts.count)
|
||||
const nextWaitSeconds = Math.ceil(nextBackoff / 1000)
|
||||
|
||||
|
||||
return next(new Error(`Invalid credentials. Next attempt allowed in ${nextWaitSeconds} seconds.`))
|
||||
}
|
||||
|
||||
@@ -257,30 +264,41 @@ async function startServer() {
|
||||
configStorage.init()
|
||||
|
||||
// Send auth status to clients on connection
|
||||
io.on('connection', (socket) => {
|
||||
io.on('connection', socket => {
|
||||
// Inform client about auth status
|
||||
const authDisabled = (socket as any).authDisabled === true
|
||||
socket.emit('auth-status', { authDisabled })
|
||||
|
||||
|
||||
if (!isProduction) {
|
||||
console.log(`Client connected, auth disabled: ${authDisabled}`)
|
||||
}
|
||||
|
||||
|
||||
// Send LLM availability status to clients (don't leak credentials)
|
||||
const llmAvailable = !!(process.env.OPENAI_API_KEY || process.env.GEMINI_API_KEY || process.env.LLM_API_KEY)
|
||||
|
||||
if (llmAvailable) {
|
||||
socket.emit('llm-available', { available: true })
|
||||
|
||||
if (!isProduction) {
|
||||
console.log('LLM service is available on backend')
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-connect to MQTT broker if configured via environment variables
|
||||
const autoConnectHost = process.env.MQTT_AUTO_CONNECT_HOST
|
||||
if (autoConnectHost) {
|
||||
const connectionId = 'auto-connect-' + Date.now()
|
||||
|
||||
const connectionId = `auto-connect-${Date.now()}`
|
||||
|
||||
// Notify client immediately that auto-connect will happen
|
||||
socket.emit('auto-connect-initiated', { connectionId })
|
||||
|
||||
|
||||
// Delay auto-connect to give client time to subscribe to events
|
||||
setTimeout(() => {
|
||||
const protocol = process.env.MQTT_AUTO_CONNECT_PROTOCOL || 'mqtt'
|
||||
const port = parseInt(process.env.MQTT_AUTO_CONNECT_PORT || '1883')
|
||||
const tls = protocol.endsWith('s') // mqtts or wss
|
||||
const url = `${protocol}://${autoConnectHost}:${port}`
|
||||
|
||||
|
||||
const autoConnectConfig = {
|
||||
id: connectionId,
|
||||
options: {
|
||||
@@ -289,11 +307,12 @@ async function startServer() {
|
||||
password: process.env.MQTT_AUTO_CONNECT_PASSWORD,
|
||||
tls,
|
||||
certValidation: false,
|
||||
clientId: process.env.MQTT_AUTO_CONNECT_CLIENT_ID || 'mqtt-explorer-' + Math.random().toString(16).substr(2, 8),
|
||||
clientId:
|
||||
process.env.MQTT_AUTO_CONNECT_CLIENT_ID || `mqtt-explorer-${Math.random().toString(16).substr(2, 8)}`,
|
||||
subscriptions: [{ topic: '#', qos: 0 as 0 | 1 | 2 }], // Subscribe to all topics
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if (!isProduction) {
|
||||
console.log('Auto-connecting to MQTT broker:', {
|
||||
connectionId,
|
||||
@@ -302,7 +321,7 @@ async function startServer() {
|
||||
username: autoConnectConfig.options.username || '(none)',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Trigger connection via backend events
|
||||
backendEvents.emit(addMqttConnectionEvent, autoConnectConfig)
|
||||
}, 1000) // 1 second delay to allow client to set up event subscriptions
|
||||
@@ -310,16 +329,16 @@ async function startServer() {
|
||||
})
|
||||
|
||||
// Setup RPC handlers for file operations
|
||||
backendRpc.on(makeOpenDialogRpc(), async request => {
|
||||
backendRpc.on(makeOpenDialogRpc(), async request =>
|
||||
// In browser mode, file selection is handled client-side via upload
|
||||
// Return empty result as this will be handled differently
|
||||
return { canceled: true, filePaths: [] }
|
||||
})
|
||||
({ canceled: true, filePaths: [] })
|
||||
)
|
||||
|
||||
backendRpc.on(makeSaveDialogRpc(), async request => {
|
||||
backendRpc.on(makeSaveDialogRpc(), async request =>
|
||||
// In browser mode, file saving is handled client-side via download
|
||||
return { canceled: true, filePath: '' }
|
||||
})
|
||||
({ canceled: true, filePath: '' })
|
||||
)
|
||||
|
||||
backendRpc.on(getAppVersion, async () => {
|
||||
// Return version from package.json
|
||||
@@ -434,6 +453,243 @@ async function startServer() {
|
||||
}
|
||||
})
|
||||
|
||||
// LLM Chat RPC handler - proxies requests to LLM providers via WebSocket
|
||||
backendRpc.on(RpcEvents.llmChat, async ({ messages, topicContext }) => {
|
||||
try {
|
||||
// Log received parameters
|
||||
console.log('\n' + '='.repeat(80))
|
||||
console.log('LLM RPC HANDLER - Received Request')
|
||||
console.log('='.repeat(80))
|
||||
console.log('Messages count:', messages?.length || 0)
|
||||
console.log('Topic context provided:', !!topicContext)
|
||||
if (topicContext) {
|
||||
console.log('Topic context length:', topicContext.length, 'characters')
|
||||
console.log('Topic context preview:', topicContext.substring(0, 200) + '...')
|
||||
}
|
||||
|
||||
// Log the last user message to verify context is included
|
||||
const lastUserMessage = messages?.filter((m: any) => m.role === 'user').slice(-1)[0]
|
||||
if (lastUserMessage) {
|
||||
console.log('\nLast user message:')
|
||||
console.log('Content length:', lastUserMessage.content.length, 'characters')
|
||||
console.log('Content starts with "Context:"?', lastUserMessage.content.startsWith('Context:'))
|
||||
console.log('Content preview:', lastUserMessage.content.substring(0, 500))
|
||||
}
|
||||
console.log('='.repeat(80) + '\n')
|
||||
|
||||
// Get LLM configuration from environment
|
||||
const envProvider = process.env.LLM_PROVIDER
|
||||
let provider: 'openai' | 'gemini' = 'openai'
|
||||
|
||||
// Validate provider
|
||||
if (envProvider === 'gemini' || envProvider === 'openai') {
|
||||
provider = envProvider
|
||||
} else if (envProvider) {
|
||||
// Invalid provider specified
|
||||
console.warn(`Invalid LLM_PROVIDER: ${envProvider}, using default: openai`)
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
provider === 'gemini'
|
||||
? process.env.GEMINI_API_KEY || process.env.LLM_API_KEY
|
||||
: process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('LLM service not configured')
|
||||
}
|
||||
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
throw new Error('Invalid request: messages required')
|
||||
}
|
||||
|
||||
// Call appropriate LLM provider
|
||||
let response: string
|
||||
let debugInfo: any = {}
|
||||
|
||||
if (provider === 'gemini') {
|
||||
// Gemini API
|
||||
const model = 'gemini-1.5-flash-latest'
|
||||
const contents = messages
|
||||
.filter((msg: any) => msg.role !== 'system')
|
||||
.map((msg: any) => ({
|
||||
role: msg.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: msg.content }],
|
||||
}))
|
||||
|
||||
// Prepend system message to first user message
|
||||
const systemMsg = messages.find((msg: any) => msg.role === 'system')
|
||||
if (systemMsg && contents.length > 0) {
|
||||
contents[0].parts.unshift({ text: systemMsg.content })
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
contents,
|
||||
generationConfig: {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 500,
|
||||
},
|
||||
}
|
||||
|
||||
// Log complete request to console (no truncation)
|
||||
console.log('\n' + '='.repeat(80))
|
||||
console.log('LLM REQUEST (Gemini)')
|
||||
console.log('='.repeat(80))
|
||||
console.log('Provider:', provider)
|
||||
console.log('Model:', model)
|
||||
console.log('Messages Count:', messages.length)
|
||||
console.log('\nFull Request Body:')
|
||||
console.log(inspect(requestBody, { depth: null, colors: true, maxArrayLength: null }))
|
||||
console.log('='.repeat(80) + '\n')
|
||||
|
||||
const startTime = Date.now()
|
||||
const geminiResponse = await axios.post(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
|
||||
requestBody,
|
||||
{
|
||||
timeout: 30000,
|
||||
}
|
||||
)
|
||||
const endTime = Date.now()
|
||||
|
||||
// Log complete response to console (no truncation)
|
||||
console.log('\n' + '='.repeat(80))
|
||||
console.log('LLM RESPONSE (Gemini)')
|
||||
console.log('='.repeat(80))
|
||||
console.log('Duration:', endTime - startTime, 'ms')
|
||||
console.log('\nFull Response:')
|
||||
console.log(inspect(geminiResponse.data, { depth: null, colors: true, maxArrayLength: null }))
|
||||
console.log('='.repeat(80) + '\n')
|
||||
|
||||
if (!geminiResponse.data.candidates || geminiResponse.data.candidates.length === 0) {
|
||||
throw new Error('No response from Gemini')
|
||||
}
|
||||
|
||||
response = geminiResponse.data.candidates[0].content.parts[0].text
|
||||
|
||||
// Capture debug info (remove API key from URL for security)
|
||||
debugInfo = {
|
||||
provider: 'gemini',
|
||||
model,
|
||||
request: {
|
||||
url: `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
|
||||
body: requestBody,
|
||||
},
|
||||
response: geminiResponse.data,
|
||||
timing: {
|
||||
duration_ms: endTime - startTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// OpenAI API using official SDK
|
||||
const model = 'gpt-5-mini'
|
||||
const openai = new OpenAI({
|
||||
apiKey,
|
||||
timeout: 30000,
|
||||
maxRetries: 2, // SDK handles retries with exponential backoff
|
||||
})
|
||||
|
||||
const requestBody = {
|
||||
model,
|
||||
messages,
|
||||
max_completion_tokens: 500,
|
||||
}
|
||||
|
||||
// Log complete request to console (no truncation)
|
||||
console.log('\n' + '='.repeat(80))
|
||||
console.log('LLM REQUEST (OpenAI)')
|
||||
console.log('='.repeat(80))
|
||||
console.log('Provider:', 'openai')
|
||||
console.log('Model:', model)
|
||||
console.log('Messages Count:', messages.length)
|
||||
console.log('\nFull Request Body:')
|
||||
console.log(inspect(requestBody, { depth: null, colors: true, maxArrayLength: null }))
|
||||
console.log('\nSystem Message:')
|
||||
const systemMsg = messages.find((msg: any) => msg.role === 'system')
|
||||
if (systemMsg) {
|
||||
console.log(inspect(systemMsg, { depth: null, colors: true }))
|
||||
}
|
||||
console.log('='.repeat(80) + '\n')
|
||||
|
||||
const startTime = Date.now()
|
||||
const openaiResponse = await openai.chat.completions.create(requestBody)
|
||||
const endTime = Date.now()
|
||||
|
||||
// Log complete response to console (no truncation)
|
||||
console.log('\n' + '='.repeat(80))
|
||||
console.log('LLM RESPONSE (OpenAI)')
|
||||
console.log('='.repeat(80))
|
||||
console.log('Duration:', endTime - startTime, 'ms')
|
||||
console.log('\nFull Response:')
|
||||
console.log(inspect(openaiResponse, { depth: null, colors: true, maxArrayLength: null }))
|
||||
console.log('='.repeat(80) + '\n')
|
||||
|
||||
if (!openaiResponse.choices || openaiResponse.choices.length === 0) {
|
||||
throw new Error('No response from OpenAI')
|
||||
}
|
||||
|
||||
response = openaiResponse.choices[0].message.content || ''
|
||||
|
||||
// Capture debug info
|
||||
debugInfo = {
|
||||
provider: 'openai',
|
||||
model,
|
||||
request: {
|
||||
url: 'https://api.openai.com/v1/chat/completions',
|
||||
body: requestBody,
|
||||
},
|
||||
response: {
|
||||
id: openaiResponse.id,
|
||||
model: openaiResponse.model,
|
||||
created: openaiResponse.created,
|
||||
choices: openaiResponse.choices,
|
||||
usage: openaiResponse.usage,
|
||||
system_fingerprint: openaiResponse.system_fingerprint,
|
||||
},
|
||||
timing: {
|
||||
duration_ms: endTime - startTime,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Return the response with debug info
|
||||
console.log('\n' + '='.repeat(80))
|
||||
console.log('LLM RPC HANDLER - Returning response')
|
||||
console.log('='.repeat(80))
|
||||
console.log('Response length:', response.length)
|
||||
console.log('Has debugInfo:', !!debugInfo)
|
||||
console.log('='.repeat(80) + '\n')
|
||||
|
||||
return { response, debugInfo }
|
||||
} catch (error: any) {
|
||||
console.error('\n' + '='.repeat(80))
|
||||
console.error('LLM RPC ERROR')
|
||||
console.error('='.repeat(80))
|
||||
console.error('Error message:', error.message)
|
||||
console.error('Error stack:', error.stack)
|
||||
console.error('Full error:', inspect(error, { depth: null, colors: true }))
|
||||
console.error('='.repeat(80) + '\n')
|
||||
|
||||
// Handle OpenAI SDK errors
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
throw new Error('Invalid API key configuration')
|
||||
} else if (error.status === 429) {
|
||||
throw new Error('Rate limit exceeded. Please try again later.')
|
||||
}
|
||||
// Handle axios errors (Gemini)
|
||||
else if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
throw new Error('Invalid API key configuration')
|
||||
} else if (error.response?.status === 429) {
|
||||
throw new Error('Rate limit exceeded. Please try again later.')
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
throw new Error('Request timeout. Please try again.')
|
||||
} else {
|
||||
throw new Error(error.message || 'Failed to get response from LLM service')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Serve static files
|
||||
app.use(express.static(path.join(__dirname, '..', '..', 'app', 'build')))
|
||||
|
||||
|
||||
@@ -4,46 +4,46 @@ import { SceneBuilder, SCENE_TITLES } from './SceneBuilder'
|
||||
describe('SceneBuilder', () => {
|
||||
it('should record scenes with titles', async () => {
|
||||
const builder = new SceneBuilder()
|
||||
|
||||
|
||||
await builder.record('connect', async () => {
|
||||
// Simulate some work
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
})
|
||||
|
||||
|
||||
expect(builder.scenes).to.have.length(1)
|
||||
expect(builder.scenes[0].name).to.equal('connect')
|
||||
expect(builder.scenes[0].title).to.equal('Connecting to MQTT Broker')
|
||||
expect(builder.scenes[0].duration).to.be.greaterThan(90)
|
||||
})
|
||||
|
||||
|
||||
it('should have titles for all scene types', () => {
|
||||
const sceneNames = Object.keys(SCENE_TITLES)
|
||||
expect(sceneNames.length).to.be.greaterThan(0)
|
||||
|
||||
|
||||
// Verify each scene has a non-empty title
|
||||
sceneNames.forEach(name => {
|
||||
expect(SCENE_TITLES[name as keyof typeof SCENE_TITLES]).to.be.a('string')
|
||||
expect(SCENE_TITLES[name as keyof typeof SCENE_TITLES].length).to.be.greaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
it('should record multiple scenes in sequence', async () => {
|
||||
const builder = new SceneBuilder()
|
||||
|
||||
|
||||
await builder.record('connect', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
|
||||
await builder.record('numeric_plots', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
|
||||
expect(builder.scenes).to.have.length(2)
|
||||
expect(builder.scenes[0].name).to.equal('connect')
|
||||
expect(builder.scenes[0].title).to.equal('Connecting to MQTT Broker')
|
||||
expect(builder.scenes[1].name).to.equal('numeric_plots')
|
||||
expect(builder.scenes[1].title).to.equal('Plot Topic History')
|
||||
|
||||
|
||||
// Second scene should start at or after first one ends
|
||||
expect(builder.scenes[1].start).to.be.at.least(builder.scenes[0].stop)
|
||||
})
|
||||
|
||||
@@ -59,6 +59,7 @@ export const SCENE_TITLES: Record<SceneNames, string> = {
|
||||
|
||||
export class SceneBuilder {
|
||||
public scenes: Array<Scene> = []
|
||||
|
||||
public offset = Date.now()
|
||||
|
||||
public async record(name: SceneNames, callback: () => Promise<any>): Promise<any> {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { selectTopic } from './util/selectTopic'
|
||||
|
||||
/**
|
||||
* Mobile Demo Video - Pixel 6 viewport
|
||||
*
|
||||
*
|
||||
* This demo showcases MQTT Explorer running in a mobile browser viewport
|
||||
* simulating a Google Pixel 6 (412x915px portrait mode)
|
||||
*/
|
||||
@@ -66,8 +66,8 @@ async function doStuff() {
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--app=http://localhost:3000', // App mode - no browser UI
|
||||
'--window-size=412,914', // Match the mobile viewport size
|
||||
'--app=http://localhost:3000', // App mode - no browser UI
|
||||
'--window-size=412,914', // Match the mobile viewport size
|
||||
'--window-position=0,0',
|
||||
'--disable-features=TranslateUI',
|
||||
'--no-first-run',
|
||||
@@ -87,7 +87,8 @@ async function doStuff() {
|
||||
deviceScaleFactor: 2.625,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36',
|
||||
})
|
||||
|
||||
const page = await context.newPage()
|
||||
@@ -99,17 +100,17 @@ async function doStuff() {
|
||||
|
||||
// Print the title
|
||||
console.log(await page.title())
|
||||
|
||||
|
||||
// Try to capture a screenshot (may fail in headed mode, but that's ok)
|
||||
try {
|
||||
await page.screenshot({ path: 'intro-mobile.png' })
|
||||
} catch (error) {
|
||||
console.log('Screenshot skipped (headed mode)')
|
||||
}
|
||||
|
||||
|
||||
// Direct console to Node terminal
|
||||
page.on('console', console.log)
|
||||
|
||||
|
||||
// Enable the fake mouse pointer for visual cursor tracking
|
||||
await createFakeMousePointer(page)
|
||||
|
||||
@@ -145,7 +146,7 @@ async function doStuff() {
|
||||
await page.locator('//label[contains(text(), "Host")]/..//input').waitFor({ timeout: 10000 })
|
||||
|
||||
const scenes = new SceneBuilder()
|
||||
|
||||
|
||||
await scenes.record('mobile_intro', async () => {
|
||||
await showText('MQTT Explorer on Mobile', 2000, page, 'middle')
|
||||
await sleep(2500)
|
||||
@@ -170,7 +171,7 @@ async function doStuff() {
|
||||
console.log('Tree nodes not found, continuing...')
|
||||
})
|
||||
await sleep(1000)
|
||||
|
||||
|
||||
try {
|
||||
// Expand topics using the expandTopic utility
|
||||
// On mobile, this clicks expand buttons (▶/▼) to navigate the tree
|
||||
@@ -181,14 +182,14 @@ async function doStuff() {
|
||||
} catch (error) {
|
||||
console.log('Topic expansion failed, continuing...', error)
|
||||
}
|
||||
|
||||
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
await scenes.record('mobile_view_message', async () => {
|
||||
await showText('Tap Topic to View Details', 1500, page, 'top')
|
||||
await sleep(1000)
|
||||
|
||||
|
||||
try {
|
||||
// Select a topic by clicking its text
|
||||
// On mobile, this will switch to the Details tab automatically
|
||||
@@ -200,7 +201,7 @@ async function doStuff() {
|
||||
} catch (error) {
|
||||
console.log('Topic selection failed, continuing...', error)
|
||||
}
|
||||
|
||||
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
@@ -219,7 +220,7 @@ async function doStuff() {
|
||||
await scenes.record('mobile_json_view', async () => {
|
||||
await showText('JSON Message Formatting', 1500, page, 'top')
|
||||
await sleep(1000)
|
||||
|
||||
|
||||
try {
|
||||
// Navigate back to Topics tab to show tree navigation
|
||||
const topicsTab = page.locator('button:has-text("TOPICS"), button:has-text("Topics")')
|
||||
@@ -228,19 +229,19 @@ async function doStuff() {
|
||||
await topicsTab.click()
|
||||
await sleep(1000)
|
||||
}
|
||||
|
||||
|
||||
// Expand and select kitchen/coffee_maker to show JSON
|
||||
await expandTopic('kitchen/coffee_maker', page)
|
||||
await sleep(1000)
|
||||
await selectTopic('kitchen/coffee_maker', page)
|
||||
await sleep(2000)
|
||||
|
||||
|
||||
await showText('JSON Payload View', 1000, page, 'top')
|
||||
await sleep(1500)
|
||||
} catch (error) {
|
||||
console.log('JSON view navigation failed, continuing...', error)
|
||||
}
|
||||
|
||||
|
||||
await hideText(page)
|
||||
})
|
||||
|
||||
@@ -276,7 +277,7 @@ async function doStuff() {
|
||||
console.log('Forced quit')
|
||||
process.exit(0)
|
||||
}, 10 * 1000)
|
||||
|
||||
|
||||
stopMqtt()
|
||||
console.log('Stopped mqtt client')
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { ElectronApplication, Page, _electron as electron } from 'playwright'
|
||||
import type { MqttClient } from 'mqtt'
|
||||
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
|
||||
@@ -72,7 +72,7 @@ describe('expandTopic UI Helper - Isolated Test', function () {
|
||||
})
|
||||
|
||||
describe('Single Level Topics', () => {
|
||||
it('should expand a single-level topic (kitchen)', async function () {
|
||||
it('should expand a single-level topic (kitchen)', async () => {
|
||||
// Given: Topics are loaded
|
||||
// When: Expand single-level topic
|
||||
await expandTopic('kitchen', page)
|
||||
@@ -85,7 +85,7 @@ describe('expandTopic UI Helper - Isolated Test', function () {
|
||||
await page.screenshot({ path: 'test-expand-single-level.png' })
|
||||
})
|
||||
|
||||
it('should expand another single-level topic (livingroom)', async function () {
|
||||
it('should expand another single-level topic (livingroom)', async () => {
|
||||
// Given: Topics are loaded
|
||||
// When: Expand single-level topic
|
||||
await expandTopic('livingroom', page)
|
||||
@@ -100,7 +100,7 @@ describe('expandTopic UI Helper - Isolated Test', function () {
|
||||
})
|
||||
|
||||
describe('Two Level Topics', () => {
|
||||
it('should expand a two-level topic (kitchen/lamp)', async function () {
|
||||
it('should expand a two-level topic (kitchen/lamp)', async () => {
|
||||
// Given: Topics are loaded
|
||||
// When: Expand two-level topic
|
||||
await expandTopic('kitchen/lamp', page)
|
||||
@@ -115,7 +115,7 @@ describe('expandTopic UI Helper - Isolated Test', function () {
|
||||
await page.screenshot({ path: 'test-expand-two-level-kitchen-lamp.png' })
|
||||
})
|
||||
|
||||
it('should expand a different two-level topic (kitchen/temperature)', async function () {
|
||||
it('should expand a different two-level topic (kitchen/temperature)', async () => {
|
||||
// Given: Topics are loaded
|
||||
// When: Expand two-level topic
|
||||
await expandTopic('kitchen/temperature', page)
|
||||
@@ -130,7 +130,7 @@ describe('expandTopic UI Helper - Isolated Test', function () {
|
||||
})
|
||||
|
||||
describe('Three Level Topics', () => {
|
||||
it('should expand a three-level topic (kitchen/lamp/state)', async function () {
|
||||
it('should expand a three-level topic (kitchen/lamp/state)', async () => {
|
||||
// Given: Topics are loaded
|
||||
// When: Expand three-level topic
|
||||
await expandTopic('kitchen/lamp/state', page)
|
||||
@@ -147,7 +147,7 @@ describe('expandTopic UI Helper - Isolated Test', function () {
|
||||
await page.screenshot({ path: 'test-expand-three-level.png' })
|
||||
})
|
||||
|
||||
it('should expand kitchen/lamp/brightness (different leaf same parent)', async function () {
|
||||
it('should expand kitchen/lamp/brightness (different leaf same parent)', async () => {
|
||||
// Given: Topics are loaded
|
||||
// When: Expand another three-level topic under same parent
|
||||
await expandTopic('kitchen/lamp/brightness', page)
|
||||
@@ -162,7 +162,7 @@ describe('expandTopic UI Helper - Isolated Test', function () {
|
||||
})
|
||||
|
||||
describe('Different Branches', () => {
|
||||
it('should correctly expand livingroom/lamp (different branch, same name)', async function () {
|
||||
it('should correctly expand livingroom/lamp (different branch, same name)', async () => {
|
||||
// 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)
|
||||
@@ -178,7 +178,7 @@ describe('expandTopic UI Helper - Isolated Test', function () {
|
||||
await page.screenshot({ path: 'test-expand-different-branch.png' })
|
||||
})
|
||||
|
||||
it('should expand livingroom/lamp/state (full path in different branch)', async function () {
|
||||
it('should expand livingroom/lamp/state (full path in different branch)', async () => {
|
||||
// Given: kitchen/lamp/state also exists
|
||||
// When: Expand livingroom/lamp/state
|
||||
await expandTopic('livingroom/lamp/state', page)
|
||||
@@ -193,7 +193,7 @@ describe('expandTopic UI Helper - Isolated Test', function () {
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should throw error for non-existent topic', async function () {
|
||||
it('should throw error for non-existent topic', async () => {
|
||||
// Given: Topics are loaded
|
||||
// When: Try to expand non-existent topic
|
||||
// Then: Should throw error
|
||||
|
||||
@@ -3,29 +3,29 @@ import { chromium, Page } from 'playwright'
|
||||
async function inspect() {
|
||||
const browser = await chromium.launch({ headless: true })
|
||||
const page = await browser.newPage()
|
||||
|
||||
|
||||
console.log('Navigating to localhost:3000...')
|
||||
await page.goto('http://localhost:3000')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
|
||||
// Login
|
||||
console.log('Logging in...')
|
||||
await page.fill('[name="username"]', 'test')
|
||||
await page.fill('[name="password"]', 'test123')
|
||||
await page.locator('[type="submit"]').click()
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
|
||||
// Expand kitchen/coffee_maker topic
|
||||
console.log('Expanding kitchen topic...')
|
||||
const kitchenTopic = page.locator('[data-test-topic="kitchen"]').first()
|
||||
await kitchenTopic.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
|
||||
console.log('Clicking coffee_maker topic...')
|
||||
const coffeeMakerTopic = page.locator('[data-test-topic="kitchen/coffee_maker"]').first()
|
||||
await coffeeMakerTopic.click()
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
|
||||
// Look for ShowChart icons
|
||||
console.log('\n=== ShowChart Elements ===')
|
||||
const showCharts = await page.locator('//*[contains(@data-test-type, "ShowChart")]').all()
|
||||
@@ -35,14 +35,16 @@ async function inspect() {
|
||||
const isVisible = await showCharts[i].isVisible()
|
||||
console.log(` [${i}] data-test="${dataTest}", visible=${isVisible}`)
|
||||
}
|
||||
|
||||
|
||||
// Click heater ShowChart icon
|
||||
console.log('\n=== Clicking heater ShowChart ===')
|
||||
const heaterChart = page.locator('//*[contains(@data-test-type, "ShowChart")][contains(@data-test, "heater")]').first()
|
||||
const heaterChart = page
|
||||
.locator('//*[contains(@data-test-type, "ShowChart")][contains(@data-test, "heater")]')
|
||||
.first()
|
||||
await heaterChart.waitFor({ state: 'visible', timeout: 5000 })
|
||||
await heaterChart.click()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
|
||||
// Check for ChartPanel
|
||||
console.log('\n=== Looking for ChartPanel ===')
|
||||
const chartPanels = await page.locator('[data-test-type="ChartPaper"]').all()
|
||||
@@ -51,7 +53,7 @@ async function inspect() {
|
||||
const dataTest = await chartPanels[i].getAttribute('data-test')
|
||||
console.log(` [${i}] data-test="${dataTest}"`)
|
||||
}
|
||||
|
||||
|
||||
// Look for ChartSettings buttons
|
||||
console.log('\n=== Looking for ChartSettings Elements ===')
|
||||
const allSettings = await page.locator('//*[@data-test-type="ChartSettings"]').all()
|
||||
@@ -62,7 +64,7 @@ async function inspect() {
|
||||
const box = await allSettings[i].boundingBox()
|
||||
console.log(` [${i}] data-test="${dataTest}", visible=${isVisible}, box=${JSON.stringify(box)}`)
|
||||
}
|
||||
|
||||
|
||||
// Try different locator strategies
|
||||
console.log('\n=== Trying contains query for ChartSettings with "heater" ===')
|
||||
const heaterSettings = page.locator('//*[contains(@data-test-type, "ChartSettings")][contains(@data-test, "heater")]')
|
||||
@@ -73,11 +75,11 @@ async function inspect() {
|
||||
const isVisible = await heaterSettings.first().isVisible()
|
||||
console.log(` data-test="${dataTest}", visible=${isVisible}`)
|
||||
}
|
||||
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: '/tmp/chart-settings-inspection.png', fullPage: true })
|
||||
console.log('\nScreenshot saved to /tmp/chart-settings-inspection.png')
|
||||
|
||||
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
|
||||
@@ -30,19 +30,19 @@ export async function createTestMock(): Promise<mqtt.MqttClient> {
|
||||
connectTimeout: 10000,
|
||||
reconnectPeriod: 0, // Disable reconnect in tests
|
||||
})
|
||||
|
||||
|
||||
client.once('connect', () => {
|
||||
console.log('Successfully connected to MQTT broker')
|
||||
mqttClient = client
|
||||
console.log(`Connected to MQTT broker at ${brokerUrl}`)
|
||||
resolve(client)
|
||||
})
|
||||
|
||||
client.once('error', (err) => {
|
||||
|
||||
client.once('error', err => {
|
||||
console.error('MQTT connection error:', err.message)
|
||||
reject(new Error(`Failed to connect to MQTT broker: ${err.message}`))
|
||||
})
|
||||
|
||||
|
||||
// Timeout after 15 seconds
|
||||
setTimeout(() => {
|
||||
if (!mqttClient) {
|
||||
|
||||
@@ -15,9 +15,9 @@ function connectMqtt(): Promise<mqtt.MqttClient> {
|
||||
const brokerHost = process.env.TESTS_MQTT_BROKER_HOST || '127.0.0.1'
|
||||
const brokerPort = process.env.TESTS_MQTT_BROKER_PORT || '1883'
|
||||
const brokerUrl = `mqtt://${brokerHost}:${brokerPort}`
|
||||
|
||||
|
||||
console.log(`Connecting to MQTT broker at ${brokerUrl}`)
|
||||
|
||||
|
||||
const client = mqtt.connect(brokerUrl, {
|
||||
username: '',
|
||||
password: '',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/********************************************************************************
|
||||
/** ******************************************************************************
|
||||
* Copyright (c) 2016-2018 Cirrus Link Solutions and others
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* Contributors:
|
||||
* Cirrus Link Solutions - initial implementation
|
||||
********************************************************************************/
|
||||
******************************************************************************* */
|
||||
import * as SparkplugClient from 'sparkplug-client'
|
||||
import type { UPayload } from 'sparkplug-client'
|
||||
import type { UMetric } from 'sparkplug-payload/lib/sparkplugbpayload'
|
||||
@@ -25,221 +25,221 @@ export interface MockSparkplugClient {
|
||||
const sample = (function () {
|
||||
const brokerHost = process.env.TESTS_MQTT_BROKER_HOST || '127.0.0.1'
|
||||
const brokerPort = process.env.TESTS_MQTT_BROKER_PORT || '1883'
|
||||
let config = {
|
||||
serverUrl: `tcp://${brokerHost}:${brokerPort}`,
|
||||
username: '',
|
||||
password: '',
|
||||
groupId: 'Sparkplug Devices',
|
||||
edgeNode: 'JavaScript Edge Node',
|
||||
clientId: 'JavaScriptSimpleEdgeNode',
|
||||
version: 'spBv1.0',
|
||||
},
|
||||
hwVersion = 'Emulated Hardware',
|
||||
swVersion = 'v1.0.0',
|
||||
deviceId = 'Emulated Device',
|
||||
sparkPlugClient,
|
||||
publishPeriod = 5000,
|
||||
// Generates a random integer
|
||||
randomInt = function () {
|
||||
return 1 + Math.floor(Math.random() * 10)
|
||||
},
|
||||
// Get BIRTH payload for the edge node
|
||||
getNodeBirthPayload = function (): UPayload {
|
||||
return {
|
||||
timestamp: new Date().getTime(),
|
||||
metrics: [
|
||||
{
|
||||
name: 'Node Control/Rebirth',
|
||||
type: 'Boolean',
|
||||
value: false,
|
||||
const config = {
|
||||
serverUrl: `tcp://${brokerHost}:${brokerPort}`,
|
||||
username: '',
|
||||
password: '',
|
||||
groupId: 'Sparkplug Devices',
|
||||
edgeNode: 'JavaScript Edge Node',
|
||||
clientId: 'JavaScriptSimpleEdgeNode',
|
||||
version: 'spBv1.0',
|
||||
}
|
||||
const hwVersion = 'Emulated Hardware'
|
||||
const swVersion = 'v1.0.0'
|
||||
const deviceId = 'Emulated Device'
|
||||
let sparkPlugClient
|
||||
const publishPeriod = 5000
|
||||
// Generates a random integer
|
||||
const randomInt = function () {
|
||||
return 1 + Math.floor(Math.random() * 10)
|
||||
}
|
||||
// Get BIRTH payload for the edge node
|
||||
const getNodeBirthPayload = function (): UPayload {
|
||||
return {
|
||||
timestamp: new Date().getTime(),
|
||||
metrics: [
|
||||
{
|
||||
name: 'Node Control/Rebirth',
|
||||
type: 'Boolean',
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
name: 'Template1',
|
||||
type: 'Template',
|
||||
value: {
|
||||
isDefinition: true,
|
||||
metrics: [
|
||||
{ name: 'myBool', value: false, type: 'Boolean' },
|
||||
{ name: 'myInt', value: 0, type: 'UInt32' },
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
name: 'param1',
|
||||
type: 'String',
|
||||
value: 'value1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Template1',
|
||||
type: 'Template',
|
||||
value: {
|
||||
isDefinition: true,
|
||||
metrics: [
|
||||
{ name: 'myBool', value: false, type: 'Boolean' },
|
||||
{ name: 'myInt', value: 0, type: 'UInt32' },
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
name: 'param1',
|
||||
type: 'String',
|
||||
value: 'value1',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
// Get BIRTH payload for the device
|
||||
const 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'],
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
// 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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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
|
||||
const 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
|
||||
const 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()
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
// 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 Incoming Message Handler
|
||||
sparkplugClient.on('message', (topic: string, payload: UPayload) => {
|
||||
console.log(topic, payload)
|
||||
})
|
||||
|
||||
// Create node command handler
|
||||
// spell-checker: disable-next-line
|
||||
sparkplugClient.on('ncmd', function (payload: UPayload) {
|
||||
const timestamp = payload.timestamp,
|
||||
metrics = payload.metrics
|
||||
// Create node command handler
|
||||
// spell-checker: disable-next-line
|
||||
sparkplugClient.on('ncmd', (payload: UPayload) => {
|
||||
const { timestamp } = payload
|
||||
const { metrics } = payload
|
||||
|
||||
if (metrics !== undefined && metrics !== null) {
|
||||
for (let i = 0; i < metrics.length; i++) {
|
||||
const 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())
|
||||
}
|
||||
if (metrics !== undefined && metrics !== null) {
|
||||
for (let i = 0; i < metrics.length; i++) {
|
||||
const 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) {
|
||||
let timestamp = payload.timestamp,
|
||||
metrics = payload.metrics,
|
||||
inboundMetricMap: { [name: string]: any } = {},
|
||||
outboundMetric: Array<UMetric> = [],
|
||||
outboundPayload: UPayload
|
||||
// Create device command handler
|
||||
// spell-checker: disable-next-line
|
||||
sparkplugClient.on('dcmd', (deviceId: string, payload: UPayload) => {
|
||||
const { timestamp } = payload
|
||||
const { metrics } = payload
|
||||
const inboundMetricMap: { [name: string]: any } = {}
|
||||
const outboundMetric: Array<UMetric> = []
|
||||
let outboundPayload: UPayload
|
||||
|
||||
console.log('Command received for device ' + deviceId)
|
||||
console.log(`Command received for device ${deviceId}`)
|
||||
|
||||
// Loop over the metrics and store them in a map
|
||||
if (metrics !== undefined && metrics !== null) {
|
||||
for (let i = 0; i < metrics.length; i++) {
|
||||
const metric = metrics[i]
|
||||
if (metric.name !== undefined && metric.name !== null) {
|
||||
inboundMetricMap[metric.name] = metric.value
|
||||
}
|
||||
// Loop over the metrics and store them in a map
|
||||
if (metrics !== undefined && metrics !== null) {
|
||||
for (let i = 0; i < metrics.length; i++) {
|
||||
const 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'])
|
||||
}
|
||||
}
|
||||
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,
|
||||
}
|
||||
outboundPayload = {
|
||||
timestamp: new Date().getTime(),
|
||||
metrics: outboundMetric,
|
||||
}
|
||||
|
||||
// Publish device data
|
||||
sparkplugClient.publishDeviceData(deviceId, outboundPayload)
|
||||
})
|
||||
// Publish device data
|
||||
sparkplugClient.publishDeviceData(deviceId, outboundPayload)
|
||||
})
|
||||
|
||||
updateInterval = setInterval(function () {
|
||||
// Publish device data
|
||||
sparkplugClient.publishDeviceData(deviceId, getDataPayload())
|
||||
}, 2000)
|
||||
return connected
|
||||
}
|
||||
updateInterval = setInterval(() => {
|
||||
// Publish device data
|
||||
sparkplugClient.publishDeviceData(deviceId, getDataPayload())
|
||||
}, 2000)
|
||||
return connected
|
||||
}
|
||||
|
||||
return { run }
|
||||
})()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clickOn, setTextInInput } from '../util'
|
||||
import { Page } from 'playwright'
|
||||
import { clickOn, setTextInInput } from '../util'
|
||||
|
||||
export async function connectTo(host: string, browser: Page) {
|
||||
await setTextInInput('Host', host, browser)
|
||||
|
||||
@@ -5,11 +5,11 @@ export async function showNumericPlot(browser: Page) {
|
||||
// On desktop, expandTopic will also select the topic (original behavior restored)
|
||||
// This shows the JSON properties in the details panel where chart icons are located
|
||||
await expandTopic('kitchen/coffee_maker', browser)
|
||||
|
||||
|
||||
// Switch to Details tab to ensure ShowChart icons are visible
|
||||
await switchToDetailsTab(browser)
|
||||
await sleep(500)
|
||||
|
||||
|
||||
let heater = await valuePreviewGuttersShowChartIcon('heater', browser)
|
||||
await moveToCenterOfElement(heater)
|
||||
await sleep(1000)
|
||||
@@ -57,7 +57,7 @@ async function valuePreviewGuttersShowChartIcon(name: string, browser: Page) {
|
||||
const locator = browser
|
||||
.locator(`//*[contains(@data-test-type, "ShowChart")][contains(@data-test, "${name}")]`)
|
||||
.first()
|
||||
|
||||
|
||||
await locator.waitFor({ state: 'visible', timeout: 30000 })
|
||||
return locator
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { ElectronApplication, Page, _electron as electron } from 'playwright'
|
||||
import type { MqttClient } from 'mqtt'
|
||||
import { createTestMock, stopTestMock } from './mock-mqtt-test'
|
||||
import { default as MockSparkplug } from './mock-sparkplugb'
|
||||
import { sleep, expandTopic } from './util'
|
||||
@@ -15,7 +16,6 @@ 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
|
||||
@@ -153,7 +153,7 @@ describe('MQTT Explorer Comprehensive UI Tests', function () {
|
||||
})
|
||||
|
||||
describe('Connection Management', () => {
|
||||
it('should connect to MQTT broker successfully', async function () {
|
||||
it('should connect to MQTT broker successfully', async () => {
|
||||
// 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")]')
|
||||
@@ -166,7 +166,7 @@ describe('MQTT Explorer Comprehensive UI Tests', function () {
|
||||
})
|
||||
|
||||
describe('Topic Tree Structure', () => {
|
||||
it('should display the correct number of root topics from mock data', async function () {
|
||||
it('should display the correct number of root topics from mock data', async () => {
|
||||
// Then: We should see expected root topics (livingroom, kitchen, garden, etc.)
|
||||
const rootTopics = ['livingroom', 'kitchen', 'garden']
|
||||
for (const topicName of rootTopics) {
|
||||
@@ -181,7 +181,7 @@ describe('MQTT Explorer Comprehensive UI Tests', function () {
|
||||
})
|
||||
|
||||
describe('Topic Navigation and Search', () => {
|
||||
it('should search and filter topics containing "temp"', async function () {
|
||||
it('should search and filter topics containing "temp"', async () => {
|
||||
// When: User searches for "temp"
|
||||
await searchTree('temp', page)
|
||||
await sleep(1000)
|
||||
@@ -205,7 +205,7 @@ describe('MQTT Explorer Comprehensive UI Tests', function () {
|
||||
})
|
||||
|
||||
describe('Message Visualization', () => {
|
||||
it('Given a JSON message on topic actuality/showcase, should display formatted JSON', async function () {
|
||||
it('Given a JSON message on topic actuality/showcase, should display formatted JSON', async () => {
|
||||
// showJsonPreview internally calls expandTopic, so we don't need to call it here
|
||||
await showJsonPreview(page)
|
||||
await sleep(1000)
|
||||
@@ -213,7 +213,7 @@ describe('MQTT Explorer Comprehensive UI Tests', function () {
|
||||
await page.screenshot({ path: 'test-screenshot-json-display.png' })
|
||||
})
|
||||
|
||||
it('should show numeric plots for topics with numeric values', async function () {
|
||||
it('should show numeric plots for topics with numeric values', async () => {
|
||||
// When: We navigate to a numeric topic and show plot
|
||||
await expandTopic('livingroom/temperature', page)
|
||||
await sleep(500)
|
||||
@@ -226,7 +226,7 @@ describe('MQTT Explorer Comprehensive UI Tests', function () {
|
||||
})
|
||||
|
||||
describe('Clipboard Operations', () => {
|
||||
it('should copy topic path to clipboard', async function () {
|
||||
it('should copy topic path to clipboard', async () => {
|
||||
// When: We copy topic to clipboard
|
||||
await expandTopic('livingroom/lamp/state', page)
|
||||
await sleep(500)
|
||||
@@ -239,7 +239,7 @@ describe('MQTT Explorer Comprehensive UI Tests', function () {
|
||||
})
|
||||
|
||||
describe('SparkplugB Support', () => {
|
||||
it('Given SparkplugB messages, should decode and display the payload', async function () {
|
||||
it('Given SparkplugB messages, should decode and display the payload', async () => {
|
||||
// When: We show SparkplugB decoding
|
||||
await showSparkPlugDecoding(page)
|
||||
await sleep(1000)
|
||||
@@ -249,7 +249,7 @@ describe('MQTT Explorer Comprehensive UI Tests', function () {
|
||||
})
|
||||
|
||||
describe('Settings and Configuration', () => {
|
||||
it('should show settings menu', async function () {
|
||||
it('should show settings menu', async () => {
|
||||
// When: We open settings menu
|
||||
await showMenu(page)
|
||||
await sleep(1000)
|
||||
@@ -259,7 +259,7 @@ describe('MQTT Explorer Comprehensive UI Tests', function () {
|
||||
})
|
||||
|
||||
describe('Retained Messages', () => {
|
||||
it('Given retained messages on multiple topics, should display retained indicator', async function () {
|
||||
it('Given retained messages on multiple topics, should display retained indicator', async () => {
|
||||
// When: We navigate to a topic with retained message
|
||||
await expandTopic('garden/pump/state', page)
|
||||
await sleep(500)
|
||||
@@ -273,7 +273,7 @@ describe('MQTT Explorer Comprehensive UI Tests', function () {
|
||||
})
|
||||
|
||||
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 () {
|
||||
it('Given topic with MAC address format (01-80-C2-00-00-0F/LWT), should display correctly', async () => {
|
||||
// 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 })
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { Browser, BrowserContext, ElectronApplication, Page, _electron as electron, chromium } from 'playwright'
|
||||
import type { MqttClient } from 'mqtt'
|
||||
import { createTestMock, stopTestMock } from './mock-mqtt-test'
|
||||
import { default as MockSparkplug } from './mock-sparkplugb'
|
||||
import { sleep } from './util'
|
||||
import { connectTo } from './scenarios/connect'
|
||||
import { searchTree, clearSearch } from './scenarios/searchTree'
|
||||
import { expandTopic } from './util/expandTopic'
|
||||
import type { MqttClient } from 'mqtt'
|
||||
|
||||
/**
|
||||
* MQTT Explorer UI Tests
|
||||
@@ -61,7 +61,7 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
throw new Error('BROWSER_MODE_URL environment variable must be set when running in browser mode')
|
||||
}
|
||||
console.log(`Browser URL: ${browserUrl}`)
|
||||
|
||||
|
||||
// Check if mobile viewport should be used
|
||||
const useMobileViewport = process.env.USE_MOBILE_VIEWPORT === 'true'
|
||||
console.log(`Mobile viewport: ${useMobileViewport}`)
|
||||
@@ -76,14 +76,15 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
const contextOptions: any = {
|
||||
permissions: ['clipboard-read', 'clipboard-write'],
|
||||
}
|
||||
|
||||
|
||||
if (useMobileViewport) {
|
||||
// Use same viewport as mobile demo (Pixel 6)
|
||||
contextOptions.viewport = {
|
||||
width: 412,
|
||||
height: 914,
|
||||
}
|
||||
contextOptions.userAgent = 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36'
|
||||
contextOptions.userAgent =
|
||||
'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36'
|
||||
console.log('Using mobile viewport: 412x914 (Pixel 6)')
|
||||
} else {
|
||||
// Desktop viewport - ensure width > 768px so mobile UI doesn't activate
|
||||
@@ -179,17 +180,15 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
if (browser) {
|
||||
await browser.close()
|
||||
}
|
||||
} else {
|
||||
if (electronApp) {
|
||||
await electronApp.close()
|
||||
}
|
||||
} else if (electronApp) {
|
||||
await electronApp.close()
|
||||
}
|
||||
|
||||
stopTestMock()
|
||||
})
|
||||
|
||||
describe('Connection Management', () => {
|
||||
it('should connect and expand livingroom/lamp topic', async function () {
|
||||
it('should connect and expand livingroom/lamp topic', async () => {
|
||||
// Given: Connected to broker with topics loaded
|
||||
// When: Expand topic
|
||||
await expandTopic('livingroom/lamp', page)
|
||||
@@ -204,7 +203,7 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
})
|
||||
|
||||
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 () => {
|
||||
// Given: Connected to broker with kitchen/coffee_maker topic
|
||||
// When: Expand topic
|
||||
await expandTopic('kitchen/coffee_maker', page)
|
||||
@@ -217,7 +216,7 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
await page.screenshot({ path: 'test-screenshot-kitchen-json.png' })
|
||||
})
|
||||
|
||||
it('should expand nested topic livingroom/lamp/state', async function () {
|
||||
it('should expand nested topic livingroom/lamp/state', async () => {
|
||||
// Given: Connected to broker with nested topics
|
||||
// When: Expand to nested topic
|
||||
await expandTopic('livingroom/lamp/state', page)
|
||||
@@ -232,7 +231,7 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
})
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('should search for temperature and expand kitchen/temperature', async function () {
|
||||
it('should search for temperature and expand kitchen/temperature', async () => {
|
||||
// Given: Connected to broker with temperature topics
|
||||
// When: Search and expand
|
||||
await searchTree('temp', page)
|
||||
@@ -249,7 +248,7 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
await page.screenshot({ path: 'test-screenshot-search-temp.png' })
|
||||
})
|
||||
|
||||
it('should search for lamp and expand kitchen/lamp', async function () {
|
||||
it('should search for lamp and expand kitchen/lamp', async () => {
|
||||
// Given: Connected to broker with lamp topics
|
||||
// When: Search and expand
|
||||
await searchTree('kitchen/lamp', page)
|
||||
@@ -268,7 +267,7 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
})
|
||||
|
||||
describe('Clipboard Operations', () => {
|
||||
it('should copy topic path to clipboard in both Electron and browser modes', async function () {
|
||||
it('should copy topic path to clipboard in both Electron and browser modes', async () => {
|
||||
// Given: A topic is selected
|
||||
await clearSearch(page)
|
||||
await sleep(1000)
|
||||
@@ -318,7 +317,7 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
await page.screenshot({ path: 'test-screenshot-copy-topic.png' })
|
||||
})
|
||||
|
||||
it('should copy message value to clipboard in both Electron and browser modes', async function () {
|
||||
it('should copy message value to clipboard in both Electron and browser modes', async () => {
|
||||
// Given: A topic with a value is selected (reuse already expanded topic)
|
||||
// When: Copy value button is clicked (the second copy button in the value section)
|
||||
const copyButtons = page.getByTestId('copy-button')
|
||||
@@ -363,7 +362,7 @@ describe('MQTT Explorer UI Tests', function () {
|
||||
})
|
||||
|
||||
describe('File Save/Download Operations', () => {
|
||||
it('should save/download message to file in both Electron and browser modes', async function () {
|
||||
it('should save/download message to file in both Electron and browser modes', async () => {
|
||||
// Given: A topic with a message is already selected from previous test
|
||||
await sleep(500)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clickOn } from './'
|
||||
import { Page, Locator } from 'playwright'
|
||||
import { clickOn } from '.'
|
||||
|
||||
// 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
|
||||
@@ -18,7 +18,7 @@ export async function expandTopic(path: string, browser: Page) {
|
||||
const isMobileViewport = viewport && viewport.width <= 768
|
||||
|
||||
// Expand each level of the topic tree one at a time
|
||||
// Strategy:
|
||||
// Strategy:
|
||||
// - Desktop: Click topic text (selects + expands, original behavior)
|
||||
// - Mobile: Click expand button only (doesn't select, mobile-specific behavior)
|
||||
for (let i = 0; i < topics.length; i += 1) {
|
||||
@@ -69,10 +69,10 @@ export async function expandTopic(path: string, browser: Page) {
|
||||
// Navigate to the parent span (TreeNodeTitle container) and find the expander
|
||||
const parentSpan = topicLocator.locator('..')
|
||||
const expandButton = parentSpan.locator('span.expander, span[class*="expander"]')
|
||||
|
||||
|
||||
const expandButtonCount = await expandButton.count()
|
||||
const isLastTopic = i === topics.length - 1
|
||||
|
||||
|
||||
// Only click expand button if it exists (topics with children)
|
||||
// Topics without children don't have an expand button
|
||||
if (expandButtonCount > 0) {
|
||||
@@ -85,7 +85,7 @@ export async function expandTopic(path: string, browser: Page) {
|
||||
// Check if already expanded (▼ means expanded, ▶ means collapsed)
|
||||
const buttonText = await expandButton.textContent()
|
||||
const isCollapsed = buttonText?.includes('▶')
|
||||
|
||||
|
||||
if (isCollapsed) {
|
||||
console.log(`Expanding topic: ${topicName}`)
|
||||
// Click the expand button to expand this level
|
||||
@@ -104,27 +104,27 @@ export async function expandTopic(path: string, browser: Page) {
|
||||
} else {
|
||||
// DESKTOP: Click the topic text (original behavior - selects + expands)
|
||||
console.log(`Clicking topic text to expand: ${topicName}`)
|
||||
|
||||
|
||||
// Scroll into view
|
||||
await topicLocator.scrollIntoViewIfNeeded()
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
|
||||
|
||||
// Check if topic has children that can be expanded
|
||||
const parentSpan = topicLocator.locator('..')
|
||||
const expandButton = parentSpan.locator('span.expander, span[class*="expander"]')
|
||||
const hasExpandButton = await expandButton.count() > 0
|
||||
const hasExpandButton = (await expandButton.count()) > 0
|
||||
const isLastTopic = i === topics.length - 1
|
||||
|
||||
|
||||
if (hasExpandButton) {
|
||||
// Check if already expanded
|
||||
const buttonText = await expandButton.textContent()
|
||||
const isCollapsed = buttonText?.includes('▶')
|
||||
|
||||
|
||||
if (isCollapsed) {
|
||||
console.log(`Topic ${topicName} is collapsed, clicking to expand`)
|
||||
// Click the topic text - on desktop this selects AND toggles expansion
|
||||
await clickOn(topicLocator, 1, 0, 'left', false)
|
||||
|
||||
|
||||
// Give the UI time to expand and render child topics
|
||||
await new Promise(resolve => setTimeout(resolve, TREE_EXPANSION_DELAY_MS))
|
||||
} else {
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function setTextInInput(name: string, text: string, browser: Page)
|
||||
`//div[contains(@class, 'MuiTextField')]//label[contains(text(), "${name}")]/..//input`,
|
||||
`//input[@name="${name.toLowerCase()}"]`,
|
||||
]
|
||||
|
||||
|
||||
let input: Locator | null = null
|
||||
for (const selector of selectors) {
|
||||
const locator = browser.locator(selector)
|
||||
@@ -56,7 +56,7 @@ export async function setTextInInput(name: string, text: string, browser: Page)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!input) {
|
||||
throw new Error(`Could not find input for label "${name}"`)
|
||||
}
|
||||
@@ -88,8 +88,8 @@ export async function moveToCenterOfElement(element: Locator) {
|
||||
await runJavascript(js, element.page())
|
||||
// IMPORTANT: Wait for animation to complete before returning
|
||||
// The animation duration + a small buffer for frame rendering
|
||||
await sleep(duration, true) // Use required=true to ensure we actually wait
|
||||
await sleep(100, true) // Extra buffer for the last frame
|
||||
await sleep(duration, true) // Use required=true to ensure we actually wait
|
||||
await sleep(100, true) // Extra buffer for the last frame
|
||||
} catch (error) {
|
||||
// window.demo.moveMouse might not be available in all test environments
|
||||
// This is fine - we'll proceed with the click anyway
|
||||
@@ -138,7 +138,12 @@ export async function clickOn(
|
||||
}
|
||||
}
|
||||
// Click happens after simulated cursor has reached its destination
|
||||
await element.click({ delay, button, force, clickCount: clicks })
|
||||
await element.click({
|
||||
delay,
|
||||
button,
|
||||
force,
|
||||
clickCount: clicks,
|
||||
})
|
||||
await sleep(50)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { clickOn } from './'
|
||||
import { Page, Locator } from 'playwright'
|
||||
import { clickOn } from '.'
|
||||
|
||||
/**
|
||||
* Selects a topic by clicking on its text (not the expand button)
|
||||
* On mobile, this will also switch to the Details tab automatically
|
||||
*
|
||||
*
|
||||
* @param path - Topic path like "mqtt/topic/name" or just "topicname"
|
||||
* @param browser - Playwright Page object
|
||||
*/
|
||||
export async function selectTopic(path: string, browser: Page) {
|
||||
const topics = path.split('/')
|
||||
const topicName = topics[topics.length - 1] // Get the last topic in the path
|
||||
|
||||
|
||||
console.log('selectTopic', topicName, 'from path', path)
|
||||
|
||||
// Find the topic by its data-test-topic attribute
|
||||
|
||||
Reference in New Issue
Block a user