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})