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:
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"]')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('/')}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
68
src/spec/util/selectTopic.ts
Normal file
68
src/spec/util/selectTopic.ts
Normal 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}"`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user