Implement mobile-first navigation with tabs, server-side auto-connect, improve mobile UX (#1008)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thomasnordquist <7721625+thomasnordquist@users.noreply.github.com>
Co-authored-by: Thomas Nordquist <thomasnordquist@users.noreply.github.com>
This commit is contained in:
Copilot
2025-12-27 17:02:49 +01:00
committed by GitHub
parent 8f86d272c7
commit 4de52aba7c
45 changed files with 1381 additions and 224 deletions

View File

@@ -12,7 +12,7 @@ import ConfigStorage from '../backend/src/ConfigStorage'
import { SocketIOServerEventBus } from '../events/EventSystem/SocketIOServerEventBus'
import { Rpc } from '../events/EventSystem/Rpc'
import { makeOpenDialogRpc, makeSaveDialogRpc } from '../events/OpenDialogRequest'
import { getAppVersion, writeToFile, readFromFile } from '../events'
import { getAppVersion, writeToFile, readFromFile, addMqttConnectionEvent } from '../events'
import { RpcEvents } from '../events/EventsV2'
const PORT = process.env.PORT || 3000
@@ -215,17 +215,6 @@ async function startServer() {
next()
})
// Send auth status to clients on connection
io.on('connection', (socket) => {
// Inform client about auth status
const authDisabled = (socket as any).authDisabled === true
socket.emit('auth-status', { authDisabled })
if (!isProduction) {
console.log(`Client connected, auth disabled: ${authDisabled}`)
}
})
// Initialize backend event bus with Socket.io
const backendEvents = new SocketIOServerEventBus(io)
const backendRpc = new Rpc(backendEvents)
@@ -238,6 +227,59 @@ async function startServer() {
const configStorage = new ConfigStorage(path.join(process.cwd(), 'data', 'settings.json'), backendRpc)
configStorage.init()
// Send auth status to clients on connection
io.on('connection', (socket) => {
// Inform client about auth status
const authDisabled = (socket as any).authDisabled === true
socket.emit('auth-status', { authDisabled })
if (!isProduction) {
console.log(`Client connected, auth disabled: ${authDisabled}`)
}
// Auto-connect to MQTT broker if configured via environment variables
const autoConnectHost = process.env.MQTT_AUTO_CONNECT_HOST
if (autoConnectHost) {
const connectionId = 'auto-connect-' + Date.now()
// Notify client immediately that auto-connect will happen
socket.emit('auto-connect-initiated', { connectionId })
// Delay auto-connect to give client time to subscribe to events
setTimeout(() => {
const protocol = process.env.MQTT_AUTO_CONNECT_PROTOCOL || 'mqtt'
const port = parseInt(process.env.MQTT_AUTO_CONNECT_PORT || '1883')
const tls = protocol.endsWith('s') // mqtts or wss
const url = `${protocol}://${autoConnectHost}:${port}`
const autoConnectConfig = {
id: connectionId,
options: {
url,
username: process.env.MQTT_AUTO_CONNECT_USERNAME,
password: process.env.MQTT_AUTO_CONNECT_PASSWORD,
tls,
certValidation: false,
clientId: process.env.MQTT_AUTO_CONNECT_CLIENT_ID || 'mqtt-explorer-' + Math.random().toString(16).substr(2, 8),
subscriptions: [{ topic: '#', qos: 0 as 0 | 1 | 2 }], // Subscribe to all topics
}
}
if (!isProduction) {
console.log('Auto-connecting to MQTT broker:', {
connectionId,
url: autoConnectConfig.options.url,
clientId: autoConnectConfig.options.clientId,
username: autoConnectConfig.options.username || '(none)',
})
}
// Trigger connection via backend events
backendEvents.emit(addMqttConnectionEvent, autoConnectConfig)
}, 1000) // 1 second delay to allow client to set up event subscriptions
}
})
// Setup RPC handlers for file operations
backendRpc.on(makeOpenDialogRpc(), async request => {
// In browser mode, file selection is handled client-side via upload

View File

@@ -25,12 +25,10 @@ export type SceneNames =
| 'mobile_intro'
| 'mobile_connect'
| 'mobile_browse_topics'
| 'mobile_search'
| 'mobile_view_message'
| 'mobile_search'
| 'mobile_json_view'
| 'mobile_clipboard'
| 'mobile_plots'
| 'mobile_menu'
| 'mobile_settings'
| 'mobile_end'
export const SCENE_TITLES: Record<SceneNames, string> = {
@@ -52,12 +50,10 @@ export const SCENE_TITLES: Record<SceneNames, string> = {
mobile_intro: 'MQTT Explorer on Mobile',
mobile_connect: 'Connect to MQTT Broker',
mobile_browse_topics: 'Browse Topic Tree',
mobile_search: 'Search Topics',
mobile_view_message: 'View Message Details',
mobile_search: 'Search Topics',
mobile_json_view: 'JSON Message Formatting',
mobile_clipboard: 'Copy to Clipboard',
mobile_plots: 'View Numeric Plots',
mobile_menu: 'Settings & Menu',
mobile_settings: 'Settings with Disconnect/Logout',
mobile_end: 'Mobile-Friendly MQTT Explorer',
}

View File

@@ -7,7 +7,7 @@ import { Browser, BrowserContext, Page, chromium } from 'playwright'
import mockMqtt, { stop as stopMqtt } from './mock-mqtt'
import { default as MockSparkplug } from './mock-sparkplugb'
import { clearSearch, searchTree } from './scenarios/searchTree'
import { clickOnHistory, createFakeMousePointer, hideText, showText, sleep } from './util'
import { clickOn, clickOnHistory, createFakeMousePointer, hideText, showText, sleep } from './util'
import { connectTo } from './scenarios/connect'
import { copyTopicToClipboard } from './scenarios/copyTopicToClipboard'
import { copyValueToClipboard } from './scenarios/copyValueToClipboard'
@@ -19,6 +19,8 @@ import { showJsonPreview } from './scenarios/showJsonPreview'
import { showMenu } from './scenarios/showMenu'
import { showNumericPlot } from './scenarios/showNumericPlot'
import { showOffDiffCapability } from './scenarios/showOffDiffCapability'
import { expandTopic } from './util/expandTopic'
import { selectTopic } from './util/selectTopic'
/**
* Mobile Demo Video - Pixel 6 viewport
@@ -58,16 +60,29 @@ async function doStuff() {
console.log('Starting playwright/chromium in mobile mode (Pixel 6)')
// Launch Chromium browser with mobile emulation
// headless: false is required so the browser renders to the X display for video recording
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
headless: false,
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--app=http://localhost:3000', // App mode - no browser UI
'--window-size=412,914', // Match the mobile viewport size
'--window-position=0,0',
'--disable-features=TranslateUI',
'--no-first-run',
'--no-default-browser-check',
'--disable-infobars',
'--disable-translate',
],
})
// Create browser context with Pixel 6 viewport
// Note: Height must be even for video encoding (h264 requirement)
const context = await browser.newContext({
viewport: {
width: 412,
height: 915,
height: 914, // Changed from 915 to 914 (must be even for h264)
},
deviceScaleFactor: 2.625,
isMobile: true,
@@ -85,11 +100,18 @@ async function doStuff() {
// Print the title
console.log(await page.title())
// Capture a screenshot
await page.screenshot({ path: 'intro-mobile.png' })
// Try to capture a screenshot (may fail in headed mode, but that's ok)
try {
await page.screenshot({ path: 'intro-mobile.png' })
} catch (error) {
console.log('Screenshot skipped (headed mode)')
}
// Direct console to Node terminal
page.on('console', console.log)
// Enable the fake mouse pointer for visual cursor tracking
await createFakeMousePointer(page)
// Handle authentication if required
const username = process.env.MQTT_EXPLORER_USERNAME || 'admin'
@@ -136,25 +158,55 @@ async function doStuff() {
await showText('Connect to MQTT Broker', 1500, page, 'top')
await connectTo(brokerHost, page)
await MockSparkplug.run() // Start sparkplug client after connect
await sleep(2000)
await sleep(3000) // Give more time for topics to load
await hideText(page)
})
await scenes.record('mobile_browse_topics', async () => {
await showText('Browse Topic Tree', 1500, page, 'top')
await sleep(1500)
// Try to expand a topic in the tree
const firstTopic = page.locator('[data-testid="tree-node"]').first()
if (await firstTopic.isVisible()) {
await firstTopic.click()
await sleep(1000)
await showText('Browse Topics - Topics Tab', 1500, page, 'top')
await sleep(2000)
// Wait for tree nodes to be visible
await page.waitForSelector('[data-test-topic]', { timeout: 10000 }).catch(() => {
console.log('Tree nodes not found, continuing...')
})
await sleep(1000)
try {
// Expand topics using the expandTopic utility
// On mobile, this clicks expand buttons (▶/▼) to navigate the tree
await showText('Expand Topic Tree', 1000, page, 'top')
await sleep(500)
await expandTopic('livingroom/lamp', page)
await sleep(1500)
} catch (error) {
console.log('Topic expansion failed, continuing...', error)
}
await sleep(1500)
await hideText(page)
})
await scenes.record('mobile_view_message', async () => {
await showText('Tap Topic to View Details', 1500, page, 'top')
await sleep(1000)
try {
// Select a topic by clicking its text
// On mobile, this will switch to the Details tab automatically
await selectTopic('livingroom/lamp/state', page)
await sleep(2000)
// The mobile UI should now show the Details tab with the selected topic
await showText('Details Tab Activated', 1000, page, 'top')
await sleep(1500)
} catch (error) {
console.log('Topic selection failed, continuing...', error)
}
await hideText(page)
})
await scenes.record('mobile_search', async () => {
await showText('Search Topics', 1500, page, 'top')
await sleep(500)
await searchTree('temp', page)
await sleep(1500)
await showText('Filter Results', 1000, page, 'top')
@@ -164,46 +216,53 @@ async function doStuff() {
await hideText(page)
})
await scenes.record('mobile_view_message', async () => {
await showText('View Message Details', 1500, page, 'top')
await sleep(1000)
// Click on a topic to view details in sidebar
const topicNode = page.locator('[data-testid="tree-node"]').first()
if (await topicNode.isVisible()) {
await topicNode.click()
await sleep(2000)
}
await hideText(page)
})
await scenes.record('mobile_json_view', async () => {
await showText('JSON Message Formatting', 1500, page, 'top')
await showJsonPreview(page)
await sleep(2000)
await hideText(page)
})
await scenes.record('mobile_clipboard', async () => {
await showText('Copy to Clipboard', 1500, page, 'top')
await copyTopicToClipboard(page)
await sleep(1000)
await copyValueToClipboard(page)
await sleep(1500)
try {
// Navigate back to Topics tab to show tree navigation
const topicsTab = page.locator('button:has-text("TOPICS"), button:has-text("Topics")')
const topicsTabVisible = await topicsTab.isVisible().catch(() => false)
if (topicsTabVisible) {
await topicsTab.click()
await sleep(1000)
}
// Expand and select kitchen/coffee_maker to show JSON
await expandTopic('kitchen/coffee_maker', page)
await sleep(1000)
await selectTopic('kitchen/coffee_maker', page)
await sleep(2000)
await showText('JSON Payload View', 1000, page, 'top')
await sleep(1500)
} catch (error) {
console.log('JSON view navigation failed, continuing...', error)
}
await hideText(page)
})
await scenes.record('mobile_plots', async () => {
await showText('View Numeric Plots', 1500, page, 'top')
await showNumericPlot(page)
await sleep(2500)
await hideText(page)
})
await scenes.record('mobile_menu', async () => {
await showText('Settings & Menu', 1500, page, 'top')
await showMenu(page)
await sleep(2000)
await hideText(page)
await scenes.record('mobile_settings', async () => {
try {
await showText('Settings with Disconnect/Logout', 1500, page, 'top')
await sleep(2000)
// Just show that settings are available, don't click
await hideText(page)
} catch (error) {
console.log('Settings scene failed, continuing...', error)
// Try to dismiss any error dialogs
try {
const closeButton = page.locator('button:has-text("Close"), button[aria-label="close"]')
if (await closeButton.isVisible().catch(() => false)) {
await closeButton.click()
await sleep(500)
}
} catch (e) {
// Ignore if we can't close dialog
}
}
})
await scenes.record('mobile_end', async () => {

View File

@@ -4,7 +4,12 @@ import { Page } from 'playwright'
export async function connectTo(host: string, browser: Page) {
await setTextInInput('Host', host, browser)
await browser.screenshot({ path: 'screen1.png' })
// Try to capture screenshot (may fail in headed mode)
try {
await browser.screenshot({ path: 'screen1.png' })
} catch (error) {
// Screenshot may fail in headed mode, that's ok
}
// Use data-testid for reliable button location
const connectButton = browser.locator('[data-testid="connect-button"]')

View File

@@ -3,6 +3,10 @@ import { expandTopic, sleep } from '../util'
export async function showJsonPreview(browser: Page) {
await expandTopic('actuality/showcase', browser)
await browser.screenshot({ path: 'screen3.png' })
try {
await browser.screenshot({ path: 'screen3.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
await sleep(1000)
}

View File

@@ -9,7 +9,11 @@ export async function showMenu(browser: Page) {
// moveToCenterOfElement(brokerStatistics, browser)
await sleep(2000)
await browser.screenshot({ path: 'screen4.png' })
try {
await browser.screenshot({ path: 'screen4.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
const topicOrder = await browser.locator('//input[@name="node-order"]/../div')
await clickOn(topicOrder)
@@ -24,7 +28,11 @@ export async function showMenu(browser: Page) {
const themeSwitch = await browser.locator('[data-testid="dark-mode-toggle"]')
await clickOn(themeSwitch)
await sleep(3000)
await browser.screenshot({ path: 'screen_dark_mode.png' })
try {
await browser.screenshot({ path: 'screen_dark_mode.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
await clickOn(themeSwitch)
await clickOn(menuButton)

View File

@@ -2,6 +2,8 @@ import { Page } from 'playwright'
import { moveToCenterOfElement, clickOn, clickOnHistory, expandTopic, sleep } from '../util'
export async function showNumericPlot(browser: Page) {
// On desktop, expandTopic will also select the topic (original behavior restored)
// This shows the JSON properties in the details panel where chart icons are located
await expandTopic('kitchen/coffee_maker', browser)
let heater = await valuePreviewGuttersShowChartIcon('heater', browser)
await moveToCenterOfElement(heater)
@@ -30,7 +32,11 @@ export async function showNumericPlot(browser: Page) {
await clickAway('temperature', browser)
await sleep(2500)
await browser.screenshot({ path: 'screen_chart_panel.png' })
try {
await browser.screenshot({ path: 'screen_chart_panel.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
await removeChart('heater', browser)
await sleep(750)

View File

@@ -4,6 +4,10 @@ import { expandTopic, sleep } from '../util'
export async function showSparkPlugDecoding(browser: Page) {
// spell-checker: disable-next-line
await expandTopic('spBv1.0/Sparkplug Devices/DDATA/JavaScript Edge Node/Emulated Device', browser)
await browser.screenshot({ path: 'screen_sparkplugb_decoding.png' })
try {
await browser.screenshot({ path: 'screen_sparkplugb_decoding.png' })
} catch (error) {
// Screenshot may fail in headed mode
}
await sleep(1000)
}

View File

@@ -61,6 +61,10 @@ 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}`)
// Launch Chromium browser
browser = await chromium.launch({
@@ -68,9 +72,29 @@ describe('MQTT Explorer UI Tests', function () {
args: ['--no-sandbox', '--disable-dev-shm-usage'],
})
browserContext = await browser.newContext({
// Create browser context with optional mobile viewport
const contextOptions: any = {
permissions: ['clipboard-read', 'clipboard-write'],
})
}
if (useMobileViewport) {
// Use same viewport as mobile demo (Pixel 6)
contextOptions.viewport = {
width: 412,
height: 914,
}
contextOptions.userAgent = 'Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36'
console.log('Using mobile viewport: 412x914 (Pixel 6)')
} else {
// Desktop viewport - ensure width > 768px so mobile UI doesn't activate
contextOptions.viewport = {
width: 1280,
height: 720,
}
console.log('Using desktop viewport: 1280x720')
}
browserContext = await browser.newContext(contextOptions)
page = await browserContext.newPage()
// Listen for console messages

View File

@@ -12,9 +12,15 @@ export async function expandTopic(path: string, browser: Page) {
const topics = path.split('/')
console.log('expandTopic', path)
// Determine if we're in mobile viewport
// Desktop tests use 1280x720, mobile tests use 412x914
const viewport = browser.viewportSize()
const isMobileViewport = viewport && viewport.width <= 768
// Expand each level of the topic tree one at a time
// Strategy: Click on each topic level individually, relying on the fact that
// after clicking a parent, its children become visible and we can find the next level
// Strategy:
// - Desktop: Click topic text (selects + expands, original behavior)
// - Mobile: Click expand button only (doesn't select, mobile-specific behavior)
for (let i = 0; i < topics.length; i += 1) {
const topicName = topics[i]
const currentPath = topics.slice(0, i + 1)
@@ -24,25 +30,25 @@ export async function expandTopic(path: string, browser: Page) {
// Find the topic by its data-test-topic attribute
// After expanding previous levels, the current level should be visible
const selector = `span[data-test-topic='${topicName}']`
const topicSelector = `span[data-test-topic='${topicName}']`
console.log(`Using selector: ${selector}`)
console.log(`Using selector: ${topicSelector}`)
// Get all matching elements (there may be multiple topics with the same name)
const allMatches = browser.locator(selector)
const allMatches = browser.locator(topicSelector)
// Count how many matches we have
const count = await allMatches.count()
console.log(`Found ${count} elements matching '${topicName}'`)
// Find the first visible match
let locator: Locator | null = null
let topicLocator: Locator | null = null
for (let j = 0; j < count; j += 1) {
const candidate = allMatches.nth(j)
try {
// Increased timeout to 3000ms to handle slower UI after many test runs
await candidate.waitFor({ state: 'visible', timeout: 3000 })
locator = candidate
topicLocator = candidate
console.log(`Using match #${j} for '${topicName}'`)
break
} catch {
@@ -51,24 +57,89 @@ export async function expandTopic(path: string, browser: Page) {
}
}
if (!locator) {
if (!topicLocator) {
console.error(`Failed to find visible topic "${topicName}" in path "${currentPath.join('/')}"`)
throw new Error(`Could not find topic "${topicName}" in path "${currentPath.join('/')}"`)
}
try {
console.log(`Found and clicking topic: ${topicName}`)
if (isMobileViewport) {
// MOBILE: Click the expand button (▶/▼) only - doesn't select the topic
// The expand button is a sibling of the topic text within the same TreeNodeTitle
// Navigate to the parent span (TreeNodeTitle container) and find the expander
const parentSpan = topicLocator.locator('..')
const expandButton = parentSpan.locator('span.expander, span[class*="expander"]')
const expandButtonCount = await expandButton.count()
const isLastTopic = i === topics.length - 1
// Only click expand button if it exists (topics with children)
// Topics without children don't have an expand button
if (expandButtonCount > 0) {
console.log(`Found expand button for topic: ${topicName}`)
// Scroll the element into view to ensure it's clickable
await locator.scrollIntoViewIfNeeded()
await new Promise(resolve => setTimeout(resolve, 200))
// Scroll the expand button into view to ensure it's clickable
await expandButton.scrollIntoViewIfNeeded()
await new Promise(resolve => setTimeout(resolve, 200))
// Click to expand/select this level
await clickOn(locator)
// Check if already expanded (▼ means expanded, ▶ means collapsed)
const buttonText = await expandButton.textContent()
const isCollapsed = buttonText?.includes('▶')
if (isCollapsed) {
console.log(`Expanding topic: ${topicName}`)
// Click the expand button to expand this level
// Use force:true to bypass any overlays (e.g., accordions) that might intercept
await clickOn(expandButton, 1, 0, 'left', true)
// Give the UI time to expand and render child topics
// This is important for MQTT async operations and tree rendering
await new Promise(resolve => setTimeout(resolve, TREE_EXPANSION_DELAY_MS))
// Give the UI time to expand and render child topics
// This is important for MQTT async operations and tree rendering
await new Promise(resolve => setTimeout(resolve, TREE_EXPANSION_DELAY_MS))
} else {
console.log(`Topic ${topicName} is already expanded`)
}
} else {
console.log(`Topic ${topicName} has no expand button (leaf topic or empty)`)
}
} else {
// DESKTOP: Click the topic text (original behavior - selects + expands)
console.log(`Clicking topic text to expand: ${topicName}`)
// Scroll into view
await topicLocator.scrollIntoViewIfNeeded()
await new Promise(resolve => setTimeout(resolve, 200))
// Check if topic has children that can be expanded
const parentSpan = topicLocator.locator('..')
const expandButton = parentSpan.locator('span.expander, span[class*="expander"]')
const hasExpandButton = await expandButton.count() > 0
const isLastTopic = i === topics.length - 1
if (hasExpandButton) {
// Check if already expanded
const buttonText = await expandButton.textContent()
const isCollapsed = buttonText?.includes('▶')
if (isCollapsed) {
console.log(`Topic ${topicName} is collapsed, clicking to expand`)
// Click the topic text - on desktop this selects AND toggles expansion
await clickOn(topicLocator, 1, 0, 'left', false)
// Give the UI time to expand and render child topics
await new Promise(resolve => setTimeout(resolve, TREE_EXPANSION_DELAY_MS))
} else {
console.log(`Topic ${topicName} is already expanded, clicking to select`)
// Topic is already expanded, just click to select it
await clickOn(topicLocator, 1, 0, 'left', false)
await new Promise(resolve => setTimeout(resolve, 500))
}
} else {
// Leaf topic - click to select it (important for final topic in path)
console.log(`Topic ${topicName} has no children, clicking to select`)
await clickOn(topicLocator, 1, 0, 'left', false)
await new Promise(resolve => setTimeout(resolve, 500))
}
}
// If this is not the last topic in the path, verify that children rendered
if (nextTopicName) {
@@ -88,8 +159,8 @@ export async function expandTopic(path: string, browser: Page) {
}
}
} catch (error) {
console.error(`Failed to click topic "${topicName}" in path "${currentPath.join('/')}"`, error)
throw new Error(`Could not click topic "${topicName}" in path "${currentPath.join('/')}"`)
console.error(`Failed to expand topic "${topicName}" in path "${currentPath.join('/')}"`, error)
throw new Error(`Could not expand topic "${topicName}" in path "${currentPath.join('/')}"`)
}
}
}

View File

@@ -3,6 +3,7 @@ import * as fs from 'fs'
import { Page, Locator } from 'playwright'
export { expandTopic } from './expandTopic'
export { selectTopic } from './selectTopic'
let fast = false
export function setFast() {
@@ -85,8 +86,10 @@ export async function moveToCenterOfElement(element: Locator) {
try {
const js = `window.demo.moveMouse(${targetX}, ${targetY}, ${duration});`
await runJavascript(js, element.page())
await sleep(duration)
await sleep(250, true)
// IMPORTANT: Wait for animation to complete before returning
// The animation duration + a small buffer for frame rendering
await sleep(duration, true) // Use required=true to ensure we actually wait
await sleep(100, true) // Extra buffer for the last frame
} catch (error) {
// window.demo.moveMouse might not be available in all test environments
// This is fine - we'll proceed with the click anyway
@@ -115,17 +118,26 @@ export async function clickOn(
// Ensure element is visible before trying to interact
await element.waitFor({ state: 'visible', timeout: 30000 })
// Scroll element into view first (important for mobile viewports)
await element.scrollIntoViewIfNeeded()
await sleep(100)
// Skip hover when force is true (used when modal backdrop might intercept)
if (!force) {
try {
// Move the simulated mouse cursor and wait for animation to complete
await moveToCenterOfElement(element)
// Now hover with the real cursor (this is instant but comes after animation)
await element.hover()
// Small delay after hover for visual smoothness
await sleep(50, true)
} catch (error) {
// If custom mouse movement fails, we can still proceed with the click
// Playwright's click will handle scrolling into view automatically
console.log('Custom mouse movement failed, proceeding with direct click')
}
}
// Click happens after simulated cursor has reached its destination
await element.click({ delay, button, force, clickCount: clicks })
await sleep(50)
}

View File

@@ -0,0 +1,68 @@
import { clickOn } from './'
import { Page, Locator } from 'playwright'
/**
* Selects a topic by clicking on its text (not the expand button)
* On mobile, this will also switch to the Details tab automatically
*
* @param path - Topic path like "mqtt/topic/name" or just "topicname"
* @param browser - Playwright Page object
*/
export async function selectTopic(path: string, browser: Page) {
const topics = path.split('/')
const topicName = topics[topics.length - 1] // Get the last topic in the path
console.log('selectTopic', topicName, 'from path', path)
// Find the topic by its data-test-topic attribute
const topicSelector = `span[data-test-topic='${topicName}']`
console.log(`Using selector: ${topicSelector}`)
// Get all matching elements (there may be multiple topics with the same name)
const allMatches = browser.locator(topicSelector)
// Count how many matches we have
const count = await allMatches.count()
console.log(`Found ${count} elements matching '${topicName}'`)
// Find the first visible match
let topicLocator: Locator | null = null
for (let j = 0; j < count; j += 1) {
const candidate = allMatches.nth(j)
try {
await candidate.waitFor({ state: 'visible', timeout: 3000 })
topicLocator = candidate
console.log(`Using match #${j} for '${topicName}'`)
break
} catch {
// This candidate is not visible, try the next one
continue
}
}
if (!topicLocator) {
console.error(`Failed to find visible topic "${topicName}"`)
throw new Error(`Could not find topic "${topicName}"`)
}
try {
console.log(`Selecting topic by clicking text: ${topicName}`)
// Scroll the element into view to ensure it's clickable
await topicLocator.scrollIntoViewIfNeeded()
await new Promise(resolve => setTimeout(resolve, 200))
// Click on the topic text to select it
// On mobile, this will also switch to the Details tab
await clickOn(topicLocator, 1, 0, 'left', false)
// Give the UI time to process the selection and tab switch
await new Promise(resolve => setTimeout(resolve, 500))
console.log(`Successfully selected topic: ${topicName}`)
} catch (error) {
console.error(`Failed to select topic "${topicName}"`, error)
throw new Error(`Could not select topic "${topicName}"`)
}
}