const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright') const { execFileSync } = require('node:child_process') const { readFileSync } = require('node:fs') const path = require('node:path') const { tmpdir } = require('node:os') const assert = require('node:assert/strict') const web = path.resolve(__dirname, '../../web') const snapshot = JSON.parse(execFileSync(process.env.PYTHON || 'python3', ['-c', 'import json; from openpilot.starpilot.ui.onroad_customization import default_document, customization_metadata; print(json.dumps(dict(document=default_document(), defaults=default_document(), metadata=customization_metadata(), revision="layers", editable=True, valid=True, activeProfile="large")))'], {encoding:'utf8'})) const projection = JSON.parse(execFileSync(process.env.PYTHON || 'python3', ['-c', 'import json; from openpilot.starpilot.system.starport.projection_layout import default_layout_for_viewport, layout_metadata_for_viewport; from openpilot.starpilot.ui.onroad_customization import default_document; d=default_layout_for_viewport((2880,1080)); c=default_document(); print(json.dumps(dict(version=1,document=d,defaults=d,metadata=layout_metadata_for_viewport((2880,1080)),screen=dict(width=1280,height=720,margin_width=0,margin_height=240),revision="aa-layers",editable=True,valid=True,colors=dict(palette=c["palette"],widgetColors=c["widgetColors"]["large"],roadColors={}))))'], {encoding:'utf8'})) ;(async () => { const browser = await chromium.launch({headless:true, ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? {executablePath:process.env.PLAYWRIGHT_EXECUTABLE_PATH} : {})}) try { const page = await browser.newPage({viewport:{width:1280,height:1000}}) await page.emulateMedia({reducedMotion:'reduce'}) const errors = [] page.on('pageerror', error => errors.push(error.message)) await page.route('http://layers.test/**', async route => { const pathname = new URL(route.request().url()).pathname if (pathname === '/data/runtime.json') return route.fulfill({json:{schemaVersion:1, monitor:'local'}}) if (pathname === '/api/auth/session') return route.fulfill({json:{state:'configured', authenticated:true, localAccess:true}}) if (pathname === '/api/device/state') return route.fulfill({json:{state:'parked', maxAgeMs:3000}}) if (pathname === '/' && new URL(route.request().url()).searchParams.has('shell')) return route.fulfill({contentType:'text/html', body: readFileSync(path.join(web, 'index.html'))}) if (pathname.startsWith('/api/')) return route.fulfill({json: pathname === '/api/starport/layout' ? projection : snapshot}) if (pathname === '/') return route.fulfill({contentType:'text/html', body:`
`}) const filename = path.join(web, pathname) return route.fulfill({body:readFileSync(filename), contentType:pathname.endsWith('.js')?'text/javascript':pathname.endsWith('.css')?'text/css':'application/octet-stream'}) }) await page.goto('http://layers.test/') const rows = page.locator('.gx-layout__layer') await rows.first().waitFor() const initial = await rows.locator('.gx-layout__layer-name').allTextContents() const source = await rows.last().boundingBox(), destination = await rows.first().boundingBox() const beforeMouse = await page.evaluate(() => JSON.stringify(window.editor.state.draft)) await page.mouse.move(source.x + 30, source.y + source.height / 2) await page.mouse.down() await page.mouse.move(destination.x + 30, destination.y + destination.height / 2, {steps: 5}) assert.equal(await page.locator('.gx-layout__drag-ghost').isVisible(), true) assert.equal(await page.locator('.gx-layout__outline').first().isVisible(), true) assert.equal(await page.locator('.is-drop-target').count(), 1) assert.equal(await page.evaluate(() => window.editor.renderWidgets.at(-1).id), 'pip_left') assert.equal(await page.evaluate(() => window.editor.state.history.undo.length), 0) await page.screenshot({path:path.join(tmpdir(), 'theme-live-layer-drag.png')}) await page.mouse.up() assert.equal(await page.locator('.gx-layout__drag-ghost').count(), 0) assert.equal(await page.evaluate(() => window.editor.state.history.undo.length), 1) await page.getByRole('button', {name:'Undo', exact:true}).click() assert.equal(await page.evaluate(() => JSON.stringify(window.editor.state.draft)), beforeMouse) await page.getByRole('button', {name:'Redo', exact:true}).click() assert.equal((await rows.locator('.gx-layout__layer-name').allTextContents())[0], initial.at(-1)) const order = await page.evaluate(() => window.editor.state.draft.widgetOrder.large .filter(id => window.editor.state.draft.layouts.large[id].enabled)) assert.equal(order.at(-1), 'pip_left') assert.equal(await page.getByRole('button', {name:/Move .* (forward|backward)/}).count(), 0) await page.setViewportSize({width:390,height:844}) await rows.first().evaluate(row => row.scrollIntoView({block:'center'})) const touchRows = await rows.evaluateAll(elements => { const top = document.querySelector('.gx-layout__sections').getBoundingClientRect().bottom return elements.filter(row => row.getBoundingClientRect().top > top && row.getBoundingClientRect().bottom < innerHeight) .slice(0, 2).map(row => ({ id: row.dataset.layerId, box: row.getBoundingClientRect().toJSON(), grip: row.querySelector('.gx-layout__row-grip').getBoundingClientRect().toJSON() })) }) assert.equal(touchRows.length, 2, 'two rows are visible below the pinned preview and tabs') const handle = touchRows[0].grip, target = touchRows[1].box const beforeTouch = await page.evaluate(() => window.editor.state.draft.widgetOrder.large .filter(id => window.editor.state.draft.layouts.large[id].enabled)) const client = await page.context().newCDPSession(page) await client.send('Emulation.setTouchEmulationEnabled', {enabled:true}) const touch = (x, y) => [{x, y, id:1}] await client.send('Input.dispatchTouchEvent', {type:'touchStart', touchPoints:touch(handle.x + handle.width / 2, handle.y + handle.height / 2)}) await client.send('Input.dispatchTouchEvent', {type:'touchMove', touchPoints:touch(target.x + target.width / 2, target.y + target.height / 2)}) await page.waitForFunction(() => window.editor.state.layerDrag?.moved) await client.send('Input.dispatchTouchEvent', {type:'touchEnd', touchPoints:[]}) const afterTouch = await page.evaluate(() => window.editor.state.draft.widgetOrder.large .filter(id => window.editor.state.draft.layouts.large[id].enabled)) assert.equal(afterTouch.indexOf(touchRows[0].id), beforeTouch.indexOf(touchRows[1].id)) // A canceled touch must leave the saved draft order untouched. const beforeCancel = await page.evaluate(() => JSON.stringify(window.editor.state.draft)) await client.send('Input.dispatchTouchEvent', {type:'touchStart', touchPoints:touch(handle.x + handle.width / 2, handle.y + handle.height / 2)}) await client.send('Input.dispatchTouchEvent', {type:'touchMove', touchPoints:touch(target.x + target.width / 2, target.y + target.height / 2)}) await client.send('Input.dispatchTouchEvent', {type:'touchCancel', touchPoints:[]}) assert.equal(await page.evaluate(() => JSON.stringify(window.editor.state.draft)), beforeCancel) assert.equal(await page.evaluate(() => window.editor.state.layerDrag), null) await page.screenshot({path:path.join(tmpdir(), 'theme-widget-layers.png')}) // A tap (including a little finger movement) selects without changing the order. const firstRow = await page.locator(`[data-layer-id="${touchRows[0].id}"]`).boundingBox() const tapX = firstRow.x + firstRow.width / 2, tapY = firstRow.y + firstRow.height / 2 const beforeTap = await page.evaluate(() => JSON.stringify(window.editor.state.draft)) await client.send('Input.dispatchTouchEvent', {type:'touchStart', touchPoints:touch(tapX, tapY)}) await client.send('Input.dispatchTouchEvent', {type:'touchMove', touchPoints:touch(tapX + 2, tapY + 2)}) await client.send('Input.dispatchTouchEvent', {type:'touchEnd', touchPoints:[]}) await page.getByRole('combobox', {name:/^Selected widget/}).waitFor() assert.equal(await page.evaluate(() => JSON.stringify(window.editor.state.draft)), beforeTap) await page.getByRole('button', {name:'Widgets', exact:true}).click() await rows.first().locator('.gx-layout__layer-name').click() await page.getByRole('combobox', {name:/^Selected widget/}).waitFor() // Both outputs retain the same workspace and all editor features at phone and desktop sizes. for (const aa of [false, true]) { await page.goto('http://layers.test/' + (aa ? '?projection=1' : '')) await rows.first().waitFor() // Swiping names scrolls the page without rearranging widgets or starting an edit. await page.setViewportSize({width:390, height:844}) await client.send('Emulation.setTouchEmulationEnabled', {enabled:true}) await rows.first().scrollIntoViewIfNeeded() const visibleId = await rows.evaluateAll(elements => { const top = document.querySelector('.gx-layout__sections').getBoundingClientRect().bottom return elements.find(row => row.getBoundingClientRect().top > top && row.getBoundingClientRect().bottom < innerHeight)?.dataset.layerId }) assert.ok(visibleId, 'a widget name is visible below the pinned preview') const name = await page.locator(`[data-layer-id="${visibleId}"] .gx-layout__layer-name`).boundingBox() const beforeScroll = await page.evaluate(() => ({draft:JSON.stringify(window.editor.state.draft), y:scrollY})) const scrollX = name.x + name.width / 2, scrollY = name.y + name.height / 2 await client.send('Input.dispatchTouchEvent', {type:'touchStart', touchPoints:touch(scrollX, scrollY)}) for (const distance of [30, 60, 100, 150]) await client.send('Input.dispatchTouchEvent', {type:'touchMove', touchPoints:touch(scrollX, scrollY - distance)}) await client.send('Input.dispatchTouchEvent', {type:'touchEnd', touchPoints:[]}) await page.waitForFunction(y => scrollY > y, beforeScroll.y) assert.equal(await page.evaluate(() => JSON.stringify(window.editor.state.draft)), beforeScroll.draft) assert.equal(await page.evaluate(() => window.editor.state.layerDrag), null) assert.equal(await page.evaluate(() => window.editor.state.inspectorPanel), 'widgets') // The outline follows the tentative order; native artwork refreshes after release. await page.setViewportSize({width:1280, height:1000}) await client.send('Emulation.setTouchEmulationEnabled', {enabled:false}) await page.evaluate(() => window.scrollTo(0, 0)) await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) const deviceRows = await rows.evaluateAll(elements => elements.map(row => row.querySelector('.gx-layout__row-grip')) .filter(grip => !grip.disabled && (() => { const box = grip.getBoundingClientRect() return document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2)?.closest('.gx-layout__row-grip') === grip })()).slice(0, 2).map(grip => grip.closest('[data-layer-id]').dataset.layerId)) assert.equal(deviceRows.length, 2) await page.locator(`[data-layer-id="${deviceRows[1]}"] .gx-layout__row-grip`).hover() await page.mouse.down() const deviceTarget = await page.locator(`[data-layer-id="${deviceRows[0]}"] .gx-layout__row-grip`).boundingBox() await page.mouse.move(deviceTarget.x + deviceTarget.width / 2, deviceTarget.y + deviceTarget.height / 2, {steps:5}) await page.locator('.gx-layout__outline').waitFor({state:'visible'}) assert.equal(await page.evaluate(() => window.editor.state.layerDrag.moved), true) const expectedOrder = await page.evaluate(() => window.editor.state.draft.widgetOrder.large) const previewRequest = page.waitForRequest(request => { if (!request.url().endsWith('/api/ui/layout/preview')) return false const doc = request.postDataJSON()?.document, order = doc?.widgetOrder return JSON.stringify(aa ? order : order?.large) === JSON.stringify(expectedOrder) }) await page.mouse.up() await previewRequest for (const width of [360, 390, 768, 1280, 1920]) { await page.setViewportSize({width, height:900}) await page.evaluate(() => window.scrollTo(0, 0)) const undoLabel = page.getByRole('button', {name:'Undo', exact:true}).locator('.gx-layout__action-label') assert.equal(await undoLabel.isVisible(), await page.locator('.gx-layout').evaluate(el => el.clientWidth > 850)) for (const name of ['Undo', 'Redo', 'More actions', 'Save changes']) { const action = page.getByRole('button', {name, exact:true}) assert.equal(await action.isVisible(), true) if (width <= 768) assert.equal(await action.locator('svg').isVisible(), name !== 'Save changes') } const more = page.getByRole('button', {name:'More actions', exact:true}) await more.click() for (const name of ['Reload saved', 'Reset to stock StarPilot']) assert.equal(await page.getByRole('menuitem', {name, exact:true}).isVisible(), true) await more.click() const saveButton = page.getByRole('button', {name:'Save changes', exact:true}) assert.equal(await saveButton.isVisible(), true) assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true) await page.getByRole('button', {name:/^Edit(?: widget)?$/}).click() assert.equal(await page.getByRole('combobox', {name:/^Selected widget/}).isVisible(), true) assert.equal(await page.getByLabel('X', {exact:true}).isVisible(), true) assert.equal(await page.locator('svg.gx-layout__preview .gx-layout__remove').count(), 0) await page.getByRole('button', {name:'Widgets', exact:true}).click() await rows.filter({has:page.locator('.gx-layout__remove')}).first().locator('.gx-layout__remove').click() await page.getByRole('button', {name:'Remove widget', exact:true}).click() await page.getByRole('button', {name:/^Edit(?: widget)?$/}).click() assert.equal(await page.getByRole('button', {name:'Add to layout', exact:true}).isVisible(), true) await page.getByRole('button', {name:'Add to layout', exact:true}).click() if (!aa) { await page.getByRole('button', {name:/^Road(?: colors)?$/}).click() assert.equal(await page.getByRole('combobox', {name:'Path style', exact:true}).isVisible(), true) } assert.equal(await page.locator('svg.gx-layout__preview').isVisible(), true) await page.getByRole('button', {name:'Widgets', exact:true}).click() await page.evaluate(() => window.scrollTo(0, 0)) await page.screenshot({path:path.join(tmpdir(), 'theme-workspace-' + (aa ? 'starport-' : 'comma-') + width + '.png'), fullPage:true}) } if (aa) { // Home and Work independently switch between compact icons and word cards. await page.setViewportSize({width:390, height:844}) await page.getByRole('button', {name:/^Edit(?: widget)?$/}).click() await page.evaluate(() => { window.editor.add('nav_home'); window.editor.add('nav_work'); window.editor.state.selected = 'nav_home' }) const homeDisplay = page.getByRole('group', {name:'Home display', exact:true}) await homeDisplay.getByRole('button', {name:'Icons', exact:true}).click() assert.equal(await page.evaluate(() => window.editor.state.draft.layouts.large.nav_home.display), 'icons') assert.equal(await page.evaluate(() => window.editor.state.draft.layouts.large.nav_work.display), 'words') const homePreview = page.locator('svg.gx-layout__preview .gx-layout__widget').filter({has:page.locator('.gx-layout__hit[width="110"]')}) assert.equal(await homePreview.count(), 1) assert.equal(await homePreview.locator('.gx-layout-widget-art path').count(), 1) assert.equal(await homePreview.locator('.gx-layout-widget-art text').count(), 0) await page.getByRole('button', {name:'Undo', exact:true}).click() assert.equal(await page.evaluate(() => window.editor.state.draft.layouts.large.nav_home.display), 'words') await page.getByRole('button', {name:'Redo', exact:true}).click() assert.equal(await homeDisplay.getByRole('button', {name:'Icons', exact:true}).getAttribute('aria-pressed'), 'true') await page.evaluate(() => { window.editor.state.selected = 'nav_work' }) await page.getByRole('group', {name:'Work display', exact:true}).getByRole('button', {name:'Icons', exact:true}).click() assert.equal(await homePreview.count(), 2) assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true) await page.screenshot({path:path.join(tmpdir(), 'starport-favorite-icons.png'),fullPage:true}) await page.getByRole('group', {name:'Work display', exact:true}).getByRole('button', {name:'Words', exact:true}).click() assert.equal(await homePreview.count(), 1) assert.equal(await page.evaluate(() => window.editor.layout.bookmark.enabled), false) await page.getByRole('combobox', {name:/^Selected widget/}).click() await page.getByRole('option', {name:/^Bookmark/}).click() await page.getByRole('button', {name:'Add to layout', exact:true}).click() const bookmarkDisplay = page.getByRole('group', {name:'Bookmark display', exact:true}) await bookmarkDisplay.getByRole('button', {name:'Icons', exact:true}).click() await page.getByLabel('Show background', {exact:true}).uncheck() const opacity = page.locator('#layout-widget-opacity') await opacity.fill('45') await opacity.dispatchEvent('change') const bookmarkPreview = page.locator('.gx-layout__widget[aria-label^="Bookmark"] .gx-layout-widget-art') assert.equal(await bookmarkPreview.locator('rect').count(), 0) assert.equal(await bookmarkPreview.locator('path').count(), 1) assert.equal(await bookmarkPreview.locator('g[opacity="0.45"]').count(), 1) assert.equal(await page.evaluate(() => window.editor.layout.nav_work.opacity), 100) await page.getByRole('button', {name:'Undo', exact:true}).click() assert.equal(await opacity.inputValue(), '100') await page.getByRole('button', {name:'Redo', exact:true}).click() assert.equal(await opacity.inputValue(), '45') assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true) await page.screenshot({path:path.join(tmpdir(), 'starport-bookmark-widget.png'), fullPage:true}) await page.evaluate(() => { window.editor.state.draft.layouts.large.current_speed.x += 1 window.editor.requestLeave(() => { window.leaveCompleted = true }) }) assert.equal(await page.getByText('Leave without saving your changes?', {exact:true}).isVisible(), true) assert.equal(await page.getByRole('button', {name:'Undo', exact:true}).count(), 0) for (const name of ['Keep editing', 'Discard and leave', 'Save changes']) assert.equal(await page.getByRole('button', {name, exact:true}).isVisible(), true) await page.getByRole('button', {name:'Keep editing', exact:true}).click() await page.evaluate(() => window.editor.requestLeave(() => { window.leaveCompleted = true }, 'modal')) const leaveDialog = page.getByRole('alertdialog') assert.equal(await leaveDialog.getByText('Leave without saving your changes?', {exact:true}).isVisible(), true) for (const name of ['Keep editing', 'Discard and leave', 'Save changes']) assert.equal(await leaveDialog.getByRole('button', {name, exact:true}).isVisible(), true) await leaveDialog.getByRole('button', {name:'Keep editing', exact:true}).click() assert.equal(await page.evaluate(() => window.leaveCompleted), undefined) } } // Footer navigation uses the pinned confirmation; drawer navigation uses a modal. async function toolsFromNavigation(width) { if (width < 768) return page.locator('.blur-nav').getByRole('button', {name:'Tools', exact:true}).click() const navigation = page.getByRole('complementary', {name:'Galaxy navigation'}) if (!await navigation.evaluate(node => node.classList.contains('open'))) await page.getByRole('button', {name:'Menu', exact:true}).click() await navigation.getByRole('button').filter({hasText:/^Tools$/}).click() } for (const route of ['/theme_maker', '/theme_maker/starport']) { for (const width of [360, 390, 1280]) { await page.setViewportSize({width, height:844}) await page.goto('http://layers.test/?shell=1#' + route) await rows.first().waitFor() await page.getByRole('button', {name:/^Edit(?: widget)?$/}).click() const x = page.getByLabel('X', {exact:true}) await x.fill(String(Number(await x.inputValue()) + 1)) await x.press('Tab') await page.getByRole('button', {name:'Widgets', exact:true}).click() await rows.last().scrollIntoViewIfNeeded() await toolsFromNavigation(width) const dialog = width < 768 ? page.locator('.gx-layout__savebar--leave') : page.getByRole('alertdialog', {name:'Leave without saving your changes?'}) await dialog.waitFor() assert.equal(new URL(page.url()).hash, '#' + route, 'navigation waits for confirmation') const bounds = await dialog.boundingBox() assert(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844) assert.equal(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth + 1), true) for (const name of ['Keep editing', 'Discard and leave', 'Save changes']) { const button = dialog.getByRole('button', {name, exact:true}) assert.equal(await button.isVisible(), true) assert((await button.boundingBox()).height >= (width < 768 ? 44 : 48)) } await page.screenshot({path:path.join(tmpdir(), `theme-leave-${route.endsWith('starport') ? 'starport' : 'comma'}-${width}.png`)}) await dialog.getByRole('button', {name:'Keep editing', exact:true}).click() await dialog.waitFor({state:'hidden'}) assert.equal(new URL(page.url()).hash, '#' + route) assert.equal(await page.getByRole('button', {name:'Save changes', exact:true}).isEnabled(), true) await toolsFromNavigation(width) await dialog.getByRole('button', {name:'Discard and leave', exact:true}).click() await page.waitForURL('**#/tools') await dialog.waitFor({state:'hidden'}) } } assert.deepEqual(errors, []) console.log('Theme Maker: mouse/touch layering, both outputs, all editor panels, preview switching and 360–1920px layouts passed') } finally { await browser.close() } })().catch(error => {console.error(error);process.exitCode=1})