diff --git a/content/software/app-lab/assets/playwright/editor/stop-button-bottom.png b/content/software/app-lab/assets/playwright/editor/stop-button-bottom.png index dc44fd0eff..7b94c1b10b 100644 Binary files a/content/software/app-lab/assets/playwright/editor/stop-button-bottom.png and b/content/software/app-lab/assets/playwright/editor/stop-button-bottom.png differ diff --git a/content/software/app-lab/assets/playwright/editor/stop-button.png b/content/software/app-lab/assets/playwright/editor/stop-button.png index 78bc6b1a66..a816a919ff 100644 Binary files a/content/software/app-lab/assets/playwright/editor/stop-button.png and b/content/software/app-lab/assets/playwright/editor/stop-button.png differ diff --git a/content/software/app-lab/assets/playwright/navigation/sidebar-icons-hl.png b/content/software/app-lab/assets/playwright/navigation/sidebar-icons-hl.png index a42d35a21f..fa73524478 100644 Binary files a/content/software/app-lab/assets/playwright/navigation/sidebar-icons-hl.png and b/content/software/app-lab/assets/playwright/navigation/sidebar-icons-hl.png differ diff --git a/content/software/app-lab/assets/playwright/statusbar-terminal-hl.png b/content/software/app-lab/assets/playwright/statusbar-terminal-hl.png new file mode 100644 index 0000000000..b0204abc83 Binary files /dev/null and b/content/software/app-lab/assets/playwright/statusbar-terminal-hl.png differ diff --git a/scripts/playwright/core/capture.js b/scripts/playwright/core/capture.js index e2da8b1b3d..f5dfcded11 100644 --- a/scripts/playwright/core/capture.js +++ b/scripts/playwright/core/capture.js @@ -285,6 +285,12 @@ async function capture(page, pathname, outDir, options = {}) { insetHighlight = false } = options; + // Move mouse away to prevent hover tooltips or active states + await page.mouse.move(0, 0).catch(() => {}); + await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {}); + await suppressToasts(page); + await dismissToasts(page); + let labelRects = []; if (highlight) { @@ -377,6 +383,13 @@ async function capture(page, pathname, outDir, options = {}) { async function suppressToasts(page) { await page.addStyleTag({ content: ` + [role="tooltip"], + [data-role="tooltip"], + [data-radix-popper-content-wrapper], + [data-radix-tooltip-content], + div[class*="react-aria-Tooltip"], + body > div[class*="_tooltip_"], + body > div[class*="Tooltip_"], div[class*="Snackbar_"], li[class*="Toastify"], div[class*="toast"], @@ -385,6 +398,8 @@ async function suppressToasts(page) { dialog.message-dialog, div[role="status"] { display: none !important; + opacity: 0 !important; + visibility: hidden !important; pointer-events: none !important; } ` @@ -392,9 +407,8 @@ async function suppressToasts(page) { } async function dismissToasts(page) { - await page.evaluate(() => { - document.querySelectorAll('div[class*="Snackbar_"], li[class*="Toastify"], div[class*="toast"], div[class*="sonner"], #message-dialog-container, dialog.message-dialog').forEach(el => el.remove()); - }).catch(() => {}); + // CSS suppression in suppressToasts hides toasts and tooltips safely without breaking React DOM trees. + await suppressToasts(page); } module.exports = { diff --git a/scripts/playwright/flows/app-lab.js b/scripts/playwright/flows/app-lab.js index 3984d2995e..8e9988ebb4 100644 --- a/scripts/playwright/flows/app-lab.js +++ b/scripts/playwright/flows/app-lab.js @@ -69,16 +69,14 @@ async function dismissAnyOpenModals(page) { if (await okGotIt.isVisible({ timeout: 500 }).catch(() => false)) { await okGotIt.click({ force: true }).catch(() => {}); } - const closeBtn = page.locator('#close-button').first(); + const cancelBtn = page.locator('div[role="dialog"] button').filter({ hasText: /Cancel/i }).first(); + if (await cancelBtn.isVisible({ timeout: 500 }).catch(() => false)) { + await cancelBtn.click({ force: true }).catch(() => {}); + } + const closeBtn = page.locator('#close-button, button[class*="close"]').first(); if (await closeBtn.isVisible({ timeout: 500 }).catch(() => false)) { await closeBtn.click({ force: true }).catch(() => {}); } - await page.evaluate(() => { - document.querySelectorAll('div[class*="Modal--overlay"], div[role="dialog"]').forEach(el => el.remove()); - document.body.style.pointerEvents = 'auto'; - document.body.removeAttribute('data-scroll-locked'); - document.documentElement.style.pointerEvents = 'auto'; - }).catch(() => {}); } async function stopRunningAppIfAny(page) { @@ -92,25 +90,32 @@ async function stopRunningAppIfAny(page) { } async function cleanupExistingAwesomeApp(page) { + await dismissAnyOpenModals(page); const myAppsButton = page.locator('#my-apps a'); - if (await myAppsButton.isVisible({ timeout: 1000 }).catch(() => false)) { + if (await myAppsButton.isVisible({ timeout: 2000 }).catch(() => false)) { await myAppsButton.click({ force: true }).catch(() => {}); - await page.waitForTimeout(500); + await page.locator('div[class*="app-link"]').first().waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}); + await page.waitForTimeout(1000); + const existingApp = page.locator('div[class*="app-link"]').filter({ hasText: /AwesomeApp/i }).first(); - if (await existingApp.isVisible({ timeout: 1000 }).catch(() => false)) { + if (await existingApp.isVisible().catch(() => false)) { console.log('Removing previous AwesomeApp instance...'); await existingApp.click({ force: true }).catch(() => {}); - const settingsTrigger = page.locator('button[aria-label="App settings"], [id*="react-aria"]').first(); - if (await settingsTrigger.isVisible({ timeout: 2000 }).catch(() => false)) { - await settingsTrigger.click({ force: true }).catch(() => {}); - const deleteBtn = page.getByRole('button', { name: 'Delete' }); - if (await deleteBtn.isVisible({ timeout: 2000 }).catch(() => false)) { - await deleteBtn.click({ force: true }).catch(() => {}); - const confirmDelete = page.getByRole('button', { name: 'Delete Delete' }).or(page.getByRole('button', { name: 'Delete' }).last()); - await confirmDelete.click({ force: true }).catch(() => {}); - await dismissToasts(page); - } - } + const settingsTrigger = page.locator('div[class*="app-actions"] button').first(); + await settingsTrigger.waitFor({ state: 'visible', timeout: 15000 }); + await settingsTrigger.click({ force: true }); + await page.waitForTimeout(500); + const deleteMenuItem = page.locator('[role="menuitem"]').filter({ hasText: /Delete/i }).first(); + await deleteMenuItem.waitFor({ state: 'visible', timeout: 5000 }); + await deleteMenuItem.click({ force: true }); + await page.waitForTimeout(500); + const confirmDelete = page.locator('div[role="dialog"] button[type="submit"]').filter({ hasText: /Delete/i }).first(); + await confirmDelete.waitFor({ state: 'visible', timeout: 5000 }); + await confirmDelete.click({ force: true }); + await dismissToasts(page); + await page.waitForTimeout(2000); + await myAppsButton.click({ force: true }).catch(() => {}); + await page.waitForTimeout(1000); } } } @@ -169,8 +174,21 @@ module.exports = async function runAppLabFlow(page, outDir, options = {}) { await capture(page, 'navigation/sidebar-icons-hl.png', outDir, { highlight: [ { locators: [myAppsButton, inspirationsButton, examplesButton, learnButton, bricksButton] }, - { locators: accountButton, label: 'ACCOUNT', labelSide: 'right', connectorSide: 'right' }, - { locators: settingsButton, label: 'SETTINGS', labelSide: 'right', connectorSide: 'right' } + { + locators: accountButton, + label: 'ACCOUNT', + labelSide: 'right', + connectorSide: 'right', + fixedY: 575, + connectorMidX: 100 + }, + { + locators: settingsButton, + label: 'SETTINGS', + labelSide: 'right', + connectorSide: 'right', + alignVertical: true + } ] }); @@ -191,14 +209,32 @@ module.exports = async function runAppLabFlow(page, outDir, options = {}) { await capture(page, 'navigation/sidebar-icons-hl-crop.png', outDir, { highlight: [ { locators: [myAppsButton, inspirationsButton, examplesButton, learnButton, bricksButton] }, - { locators: accountButton, label: 'ACCOUNT', labelSide: 'right', connectorSide: 'right' }, - { locators: settingsButton, label: 'SETTINGS', labelSide: 'right', connectorSide: 'right' } + { + locators: accountButton, + label: 'ACCOUNT', + labelSide: 'right', + connectorSide: 'right', + fixedY: 575, + connectorMidX: 100 + }, + { + locators: settingsButton, + label: 'SETTINGS', + labelSide: 'right', + connectorSide: 'right', + alignVertical: true + } ], percentage: 'left 40%' }); await capture(page, 'statusbar-hl.png', outDir, { highlight: statusbar, insetHighlight: true }); await capture(page, 'statusbar-board-hl.png', outDir, { highlight: boardSwitchControl, insetHighlight: false, percentage: 'bottom 115px' }); + await capture(page, 'statusbar-terminal-hl.png', outDir, { highlight: openTerminalButton, insetHighlight: false, percentage: 'bottom 115px' }); + await capture(page, 'statusbar-status-hl.png', outDir, { highlight: systemStatus, insetHighlight: false, percentage: 'bottom 115px' }); + await capture(page, 'statusbar-notifications-hl.png', outDir, { highlight: notificationsButton, insetHighlight: false, percentage: 'bottom 115px' }); + await capture(page, 'statusbar-network-hl.png', outDir, { highlight: networkButton, insetHighlight: false, percentage: 'bottom 115px' }); + await capture(page, 'statusbar-agent-mode-hl.png', outDir, { highlight: agentModeButton, insetHighlight: false, percentage: 'bottom 115px' }); await capture(page, 'statusbar-controls-hl.png', outDir, { highlight: [ @@ -250,6 +286,10 @@ module.exports = async function runAppLabFlow(page, outDir, options = {}) { console.log('\n--- Step: Inspirations ---'); await inspirationsButton.click({ force: true }); await page.waitForTimeout(1000); + if (!page.url().includes('/inspirations')) { + await inspirationsButton.click({ force: true }); + await page.waitForTimeout(1000); + } // Find the Blink card in Inspirations const blinkCard = page.locator('div[class*="app-link"]').filter({ hasText: /Blink/i }).first(); @@ -287,16 +327,18 @@ module.exports = async function runAppLabFlow(page, outDir, options = {}) { // Open create app dialog const createAppDropdown = page.locator('button').filter({ hasText: /Create new app|Create App/i }).first(); + await createAppDropdown.waitFor({ state: 'visible', timeout: 10000 }); await createAppDropdown.click({ force: true }); await page.waitForTimeout(300); const createAppMenuItem = page.locator('[role="menuitem"]').filter({ hasText: /Create/i }).first(); - if (await createAppMenuItem.isVisible({ timeout: 1000 }).catch(() => false)) { + if (await createAppMenuItem.isVisible({ timeout: 2000 }).catch(() => false)) { await createAppMenuItem.click({ force: true }); await page.waitForTimeout(500); } // Fill dialog - const nameInput = page.getByRole('textbox').first(); + const nameInput = page.locator('div[role="dialog"] input[type="text"]').or(page.getByRole('textbox')).first(); + await nameInput.waitFor({ state: 'visible', timeout: 10000 }); await nameInput.fill('AwesomeApp'); // Emoji picker - select rocket emoji @@ -305,33 +347,61 @@ module.exports = async function runAppLabFlow(page, outDir, options = {}) { console.log('Selecting rocket emoji for AwesomeApp...'); await emojiButton.click({ force: true }).catch(() => {}); await page.waitForTimeout(500); - const travelPlacesTab = page.getByRole('tab', { name: /Travel & Places/i }).first(); - if (await travelPlacesTab.isVisible({ timeout: 1000 }).catch(() => false)) { - await travelPlacesTab.click({ force: true }).catch(() => {}); - await page.waitForTimeout(300); - } - const rocketEmoji = page.locator('button[aria-label*="rocket"]').or(page.getByRole('button', { name: /rocket/i })).first(); - let scrollAttempts = 0; - while (!(await rocketEmoji.isVisible().catch(() => false)) && scrollAttempts < 15) { - await page.keyboard.press('ArrowDown'); - scrollAttempts++; - await page.waitForTimeout(100); - } - if (await rocketEmoji.isVisible().catch(() => false)) { - await rocketEmoji.click({ force: true }).catch(() => {}); - await page.waitForTimeout(500); + + // Attempt to use search input + const searchInput = page.locator('input.epr-search, input[placeholder*="Search"]').first(); + if (await searchInput.isVisible({ timeout: 1000 }).catch(() => false)) { + await searchInput.fill('rocket'); + await page.waitForTimeout(1000); + const rocketEmoji = page.locator('button[aria-label*="rocket"]').or(page.getByRole('button', { name: /rocket/i })).or(page.locator('button[data-unified*="1f680"]')).first(); + if (await rocketEmoji.isVisible({ timeout: 1000 }).catch(() => false)) { + await rocketEmoji.click({ force: true }).catch(() => {}); + await page.waitForTimeout(500); + } + } else { + const travelPlacesTab = page.getByRole('tab', { name: /Travel & Places/i }).first(); + if (await travelPlacesTab.isVisible({ timeout: 1000 }).catch(() => false)) { + await travelPlacesTab.click({ force: true }).catch(() => {}); + await page.waitForTimeout(300); + } + const scrollable = page.locator('.epr-body, [class*="emoji-scroll"]').first(); + const rocketEmoji = page.locator('button[aria-label*="rocket"]').or(page.getByRole('button', { name: /rocket/i })).or(page.locator('button[data-unified*="1f680"]')).first(); + + if (await scrollable.isVisible().catch(() => false)) { + let found = false; + for(let i=0; i<30; i++) { + const c = await rocketEmoji.count(); + if (c > 0 && await rocketEmoji.isVisible()) { + await rocketEmoji.click({ force: true }).catch(() => {}); + await page.waitForTimeout(500); + found = true; + break; + } + await scrollable.evaluate(el => el.scrollBy(0, 150)).catch(() => {}); + await page.waitForTimeout(100); + } + } else if (await rocketEmoji.count() > 0) { + await rocketEmoji.click({ force: true }).catch(() => {}); + await page.waitForTimeout(500); + } } + await page.evaluate(() => { + window.scrollTo(0, 0); + document.documentElement.scrollTop = 0; + document.body.scrollTop = 0; + }).catch(() => {}); } - const confirmCreateBtn = page.locator('button[type="submit"]').filter({ hasText: /Create new|Confirm/i }).first(); + const confirmCreateBtn = page.locator('div[role="dialog"] button').filter({ hasText: /Create new app|Create new|Create|Confirm/i }).last(); await confirmCreateBtn.click({ force: true }); await dismissToasts(page); - await page.waitForTimeout(1500); + await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {}); + await page.locator('div[class*="app-actions"]').first().waitFor({ state: 'visible', timeout: 15000 }); // Editor Elements const appActionsButton = page.locator('div[class*="app-actions"]').first(); const emojiPickerButton = page.locator('button[class*="emoji-picker-button"]').first(); - const editorSidePanel = page.locator('#side'); + const editorSidePanel = page.locator('#side').first(); const breadcrumbs = page.locator('nav[aria-label="breadcrumbs"]').first(); const runButton = page.locator('button').filter({ hasText: 'Run' }).first(); @@ -411,16 +481,21 @@ module.exports = async function runAppLabFlow(page, outDir, options = {}) { const stopButtonBottom = statusbar.locator('button').filter({ hasText: 'Stop' }).first(); const runningAppBottom = statusbar.locator('div[class*="app-name-container"]').first(); + // Wait for 'Running...' state to appear so we can screenshot it + await expect(startingAppText).toBeVisible({ timeout: 120000 }).catch(() => {}); + await page.waitForTimeout(500); + + await capture(page, 'editor/stop-button.png', outDir, { highlight: stopButtonTop, percentage: 'top 25%' }); + // Wait for compile/upload to complete (up to 2 minutes) await expect(startingAppText).toBeHidden({ timeout: 120000 }).catch(() => {}); await page.waitForTimeout(3000); - await capture(page, 'editor/stop-button.png', outDir, { highlight: stopButtonTop, percentage: 'top 25%' }); await capture(page, 'editor/stop-button-bottom.png', outDir, { highlight: stopButtonBottom, percentage: 'bottom 25%' }); await capture(page, 'editor/running-app-bottom.png', outDir, { highlight: runningAppBottom, percentage: 'bottom 50%' }); // Console Panel - const consolePanel = page.locator('#console'); + const consolePanel = page.locator('#console').first(); const consoleHeader = consolePanel.locator('div[class*="console-panel-header_"]').or(consolePanel.locator('header')).first(); const consoleTabSerialMonitor = consolePanel.getByRole('button', { name: /Serial Monitor/i }).first(); diff --git a/scripts/playwright/runner.js b/scripts/playwright/runner.js index 9e899d988f..e554f72d95 100644 --- a/scripts/playwright/runner.js +++ b/scripts/playwright/runner.js @@ -49,6 +49,41 @@ async function run() { const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: CONFIG.viewport, deviceScaleFactor: 1 }); + await context.addInitScript(() => { + const injectStyles = () => { + if (document.getElementById('playwright-suppress-overlays')) return; + const style = document.createElement('style'); + style.id = 'playwright-suppress-overlays'; + style.textContent = ` + [role="tooltip"], + [data-role="tooltip"], + [data-radix-popper-content-wrapper], + [data-radix-tooltip-content], + div[class*="react-aria-Tooltip"], + body > div[class*="_tooltip_"], + body > div[class*="Tooltip_"], + div[class*="Snackbar_"], + li[class*="Toastify"], + div[class*="toast"], + div[class*="sonner"], + #message-dialog-container, + dialog.message-dialog, + div[role="status"] { + display: none !important; + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; + } + `; + if (document.head) { + document.head.appendChild(style); + } else { + document.addEventListener('DOMContentLoaded', () => document.head && document.head.appendChild(style)); + } + }; + injectStyles(); + window.addEventListener('DOMContentLoaded', injectStyles); + }); const page = await context.newPage(); page.on('console', msg => {