mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
229 lines
16 KiB
JavaScript
229 lines
16 KiB
JavaScript
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
|
|
const assert = require('node:assert/strict')
|
|
const { execFileSync } = require('node:child_process')
|
|
|
|
;(async () => {
|
|
const browser = await chromium.launch({ headless: true, ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) })
|
|
try {
|
|
const page = await browser.newPage()
|
|
const errors = []
|
|
page.on('pageerror', error => errors.push(error.message))
|
|
await page.route('**/settings-state-test', route => route.fulfill({ contentType: 'text/html', body:
|
|
'<link rel="stylesheet" href="css/galaxy.css"><link rel="stylesheet" href="css/settings.css"><div id="test"></div>' }))
|
|
await page.goto((process.env.GALAXY_URL || 'http://127.0.0.1:8765/') + 'settings-state-test')
|
|
const pages = await page.evaluate(async () => {
|
|
const vue = await import('./vendor/vue/vue.esm-browser.js')
|
|
const settings = await import('./js/settings.js')
|
|
window.mount = (component, props = {}) => {
|
|
window.app?.unmount()
|
|
window.app = vue.createApp(component, { mode: 'local', unauthorized() {}, ...props })
|
|
window.vm = window.app.mount('#test')
|
|
}
|
|
window.requests = []
|
|
window.fetch = (url, options = {}) => url === './api/sounds'
|
|
? Promise.resolve({ ok: true, status: 200, json: async () => ({ parked: true, job: null, packs: [{ id: 'one', name: 'Test pack', installed: false }] }) })
|
|
: new Promise(resolve => requests.push({ url, options, resolve }))
|
|
window.reply = async (body, status = 200) => {
|
|
const response = { ok: status === 200, status, json: async () => body, clone() { return this } }
|
|
requests.shift().resolve(response)
|
|
await vue.nextTick()
|
|
}
|
|
window.snapshot = name => ({ page: name, title: name, view: 'view', parked: true, rows: [
|
|
{ key: 'switch', label: 'Test switch', value: 'Off', choices: ['Off', 'On'], available: true, action: true },
|
|
{ key: 'slider', label: 'Test slider', value: '1', defaultValue: '0', resetAvailable: true, minimum: 0, maximum: 10, step: 1, available: true, action: true },
|
|
{ key: 'select', label: 'Test select', value: 'Stock', choices: ['Stock', 'Custom'], available: true, action: true },
|
|
] })
|
|
window.SettingsPage = settings.SettingsPage
|
|
return [...new Set(settings.SETTINGS_SECTIONS.flatMap(section => section.pages))]
|
|
.filter(name => !['ui_layout', 'favorites', 'developer'].includes(name))
|
|
.concat(['sentry', 'vasm', 'standard/acceleration', 'standard/braking', 'standard/following'])
|
|
})
|
|
async function waiting(path) {
|
|
await page.waitForFunction(path => requests[0]?.url.includes(path), path)
|
|
}
|
|
async function verify(selector, value) {
|
|
// Several paint frames must retain the pending value and full opacity.
|
|
const result = await page.evaluate(async ({ selector, value }) => {
|
|
for (let i = 0; i < 6; i++) {
|
|
await new Promise(requestAnimationFrame)
|
|
const input = document.querySelector(selector)
|
|
if (input.disabled || !input.closest('[inert]') || (input.type === 'checkbox' ? input.checked : input.value) !== value) return false
|
|
const visible = input.type === 'checkbox' ? input.nextElementSibling : input
|
|
if (getComputedStyle(visible).opacity !== '1') return false
|
|
for (const button of document.querySelectorAll('.gx-setting-default, [aria-label="Download Test pack"]')) {
|
|
if (button.disabled || getComputedStyle(button).opacity !== '1') return false
|
|
}
|
|
}
|
|
return true
|
|
}, { selector, value })
|
|
assert.equal(result, true, `Pending ${selector} stays stable`)
|
|
}
|
|
for (const name of pages) {
|
|
await page.evaluate(name => { requests = []; mount(SettingsPage, { initialPage: name }) }, name)
|
|
await waiting('/pages/')
|
|
await page.evaluate(name => reply(snapshot(name)), name)
|
|
await page.getByRole('switch', { name: 'Test switch' }).waitFor()
|
|
const saved = await page.evaluate(name => snapshot(name), name)
|
|
for (const [index, selector, value] of [[0, '[role="switch"]', true], [1, 'input[type="range"]', '7'], [2, '.gx-select select', 'Custom']]) {
|
|
if (index === 0) await page.locator(selector).click()
|
|
else if (index === 1) await page.locator(selector).evaluate(input => {
|
|
input.value = '7'
|
|
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
input.dispatchEvent(new Event('change', { bubbles: true }))
|
|
})
|
|
else {
|
|
await page.getByRole('combobox', { name: 'Test select' }).click()
|
|
await page.getByRole('option', { name: 'Custom', exact: true }).click()
|
|
}
|
|
await waiting('/preview')
|
|
if (index < 2) await verify(selector, value)
|
|
else assert.equal(await page.getByRole('combobox', { name: 'Test select' }).textContent(), 'Custom')
|
|
await page.evaluate(() => reply({ intent: 'intent', question: 'Save?' }))
|
|
await waiting('/confirm')
|
|
if (index < 2) await verify(selector, value)
|
|
saved.rows[index].value = index === 0 ? 'On' : value
|
|
await page.evaluate(() => reply({ saved: true }))
|
|
await waiting('/pages/')
|
|
if (index < 2) await verify(selector, value)
|
|
await page.evaluate(saved => reply(saved), saved)
|
|
await page.waitForFunction(selector => !document.querySelector(selector).closest('[inert]'), selector)
|
|
}
|
|
}
|
|
// Error recovery must restore the confirmed value and retain the row node.
|
|
await page.locator('[role="switch"]').click()
|
|
await waiting('/preview')
|
|
await verify('[role="switch"]', false)
|
|
await page.evaluate(() => { window.rowNode = document.querySelector('.gx-row'); return reply({ error: 'Rejected' }, 400) })
|
|
await page.waitForFunction(() => document.querySelector('[role="switch"]').checked)
|
|
assert.equal(await page.evaluate(() => rowNode === document.querySelector('.gx-row')), true)
|
|
// Reset Default is the same guarded preview/confirm/readback sequence.
|
|
await page.evaluate(() => { vm.feed.load() })
|
|
await waiting('/pages/')
|
|
await page.evaluate(name => reply(snapshot(name)), pages.at(-1))
|
|
await page.getByRole('button', { name: 'Reset Test slider to default' }).click()
|
|
await waiting('/reset-default')
|
|
await verify('input[type="range"]', '1')
|
|
await page.evaluate(() => reply({ intent: 'reset', question: 'Reset?' }))
|
|
await waiting('/confirm')
|
|
await verify('input[type="range"]', '1')
|
|
await page.evaluate(() => reply({ saved: true }))
|
|
await waiting('/pages/')
|
|
await verify('input[type="range"]', '1')
|
|
await page.evaluate(name => { const data = snapshot(name); data.rows[1].value = '0'; return reply(data) }, pages.at(-1))
|
|
await page.waitForFunction(() => !document.querySelector('.gx-setting-default'))
|
|
const layout = 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="a"*64, editable=True, valid=True, activeProfile="large")))'], { encoding: 'utf8' }))
|
|
const audited = await page.evaluate(async layout => {
|
|
const { nextTick } = await import('./vendor/vue/vue.esm-browser.js')
|
|
const clone = value => JSON.parse(JSON.stringify(value))
|
|
const prepareLayout = vm => {
|
|
vm.feed.publish({ status: 'ready', data: clone(layout), draft: clone(layout.document) })
|
|
vm.state.draft.clock24Hour = !vm.state.draft.clock24Hour
|
|
}
|
|
const prepareModels = vm => Object.assign(vm, { loading: false, selectionUncertain: false,
|
|
activeSmallModel: 'one', activeBigModel: '', models: ['one', 'two'].map(value => ({ value, label: value, requiresGpu: false, installed: true, selectable: true })),
|
|
capabilities: { select: true, download: true, downloadAll: true, refresh: true, favorites: true },
|
|
status: { isOnroad: false, downloading: false, randomizer: false, models: [], capabilities: { select: true, download: true, downloadAll: true, refresh: true, favorites: true } } })
|
|
const cases = [
|
|
['pip', 'PipPage', vm => {
|
|
const data = { ...snapshot('pip'), editorRow: 0, editor: { width: 1344, height: 760, cropSize: 300, centerLeft: [350, 350], centerRight: [1000, 350], invert: false } }
|
|
vm.feed.publish({ status: 'ready', data }); vm.state.imageName = 'Cabin snapshot'
|
|
}],
|
|
['vasm', 'VasmPage', vm => {
|
|
const data = { ...snapshot('vasm'), editorRow: 0, editor: { width: 1344, height: 760, cameraLeft: [[100, 100], [500, 100], [500, 500]], cameraRight: [[800, 100], [1000, 100], [1000, 500]] } }
|
|
vm.feed.publish({ status: 'ready', data }); vm.state.imageName = 'Cabin snapshot'
|
|
}],
|
|
['longitudinal-curves', 'LongitudinalCurvesPage', vm => vm.feed.publish({ status: 'ready', data: snapshot('standard/acceleration') })],
|
|
['favorites', 'FavoritesPage', vm => vm.feed.publish({ status: 'ready', data: { editable: true, valid: true,
|
|
slots: Array.from({ length: 3 }, () => ({ key: 'test', label: 'Test', enabled: true, show_onroad: true })),
|
|
options: [{ key: 'test', label: 'Test', section: 'Visual' }], states: Array.from({ length: 3 }, () => ({ stateLabel: 'Off', reason: '' })) } })],
|
|
['controllers', 'ControllersPage', vm => vm.feed.publish({ status: { editable: true, available: true, revision: 'a'.repeat(64), enabled: true,
|
|
slots: Array.from({ length: 13 }, (_, index) => ({ index, key: null, label: `Button ${index}` })),
|
|
devices: [], options: [], learning: null, testing: false, bindings: [], lastPress: null }, busy: false })],
|
|
['cloud-provider', 'CloudProviderPage', vm => { vm.status = { selected: 'comma', active: 'comma', canSelect: true,
|
|
providers: [{ id: 'comma', label: 'comma' }, { id: 'konik', label: 'konik' }] } }],
|
|
['software-status', 'SoftwarePage', vm => vm.feed.publish({ status: 'ready', data: { installed: { branch: 'Dom', version: 'Test', commit: 'a'.repeat(40) },
|
|
updater: { state: 'idle', finalizedUpdateReady: false }, operations: { parked: true, selectedTarget: 'Dom', availableBranches: ['Dom'],
|
|
canCheck: true, canFastUpdate: true, canDownload: true, canSelect: true, canConfigure: true, automaticDownloads: true, request: null } } })],
|
|
['starport', 'StarPortPage', vm => Object.assign(vm, { setup: { enabled: true, parked: true, installReady: true, serviceReady: true,
|
|
bluetoothEnabled: true, identity: { installed: true }, import: { state: 'idle' } }, runtime: { running: false, auto_connect: true }, pairing: { active: false, devices: [] } })],
|
|
['offline-road-maps', 'OfflineRoadMapsPanel', vm => { vm.data = { areas: [], settings: { saveDriven: true }, service: {}, position: null,
|
|
constants: { minRadiusKm: 2, maxRadiusKm: 200, defaultRadiusKm: 25, maxAreas: 24 } } }],
|
|
['onroad-layout', 'OnroadLayoutPage', prepareLayout],
|
|
['onroad-layout', 'OnroadLayoutPage', prepareLayout, { projection: true }],
|
|
['models', 'ModelsPage', prepareModels],
|
|
['model-laboratory', 'LaboratoryPage', vm => Object.assign(vm, { loading: false,
|
|
configuration: { enabled: false, lateralModel: 'one', longitudinalModel: 'two' }, status: {
|
|
configuration: { enabled: false, lateralModel: 'one', longitudinalModel: 'two' },
|
|
isOnroad: false, runtimeSupported: true, chestnutReady: true, runtime: {}, download: { downloading: false },
|
|
models: ['one', 'two'].map(value => ({ value, label: value, small: true, modelLabEligible: true, modelLabArtifactAvailable: true, modelLabArtifactInstalled: true })),
|
|
capabilities: { configure: true, download: true, delete: true, cancel: true, refresh: true } } })],
|
|
['sentry-notifications', 'SentryNotifications', vm => { vm.notifications = { subscriptionCount: 0, subscriptions: [],
|
|
channels: Object.fromEntries(vm.channels.map(name => [name, { enabled: true, configured: true, pending: 0, lastState: 'idle', lastError: '' }])) } }],
|
|
['drive-state', 'DriveStatePanel', vm => { vm.state = { mode: 'auto', available: true, effective: 'offroad', overrideAllowed: true } }],
|
|
['bluetooth', 'BluetoothPage', vm => { vm.status = { parked: true, available: true, powered: true, saved: [], nearby: [], pairing: null } }],
|
|
['toggle-backup', 'ToggleBackup', () => {}],
|
|
['flm', 'FlmPage', vm => Object.assign(vm, { inventoryStatus: 'ready', inventory: { segments: [] }, selected: ['test--0'] })],
|
|
['recording-actions', 'RecordingActions', () => {}, { recording: { displayName: 'Test drive', routeId: 'test', preserved: false } }],
|
|
['vehicle-controls', 'VehicleControlsPage', vm => { vm.state.status = 'ready'; vm.state.data = { parked: true, readable: true, selected: null, vehicles: [] } }],
|
|
['galaxy', 'GalaxyPage', vm => Object.assign(vm, { loading: false, paired: true, url: 'https://galaxy.test' })],
|
|
['navigation', 'NavigationPage', vm => { vm.data = { enabled: true, hasKey: false, favorites: [], recents: [] } }],
|
|
['map-operations', 'MapOperationsPanel', vm => { vm.setup = { parked: true, packageReady: true, snapshotReady: true }; vm.catalog = { regions: [] } }],
|
|
]
|
|
for (const [file, name, prepare, props] of cases) {
|
|
const component = (await import(`./js/${file}.js`))[name]
|
|
mount({ ...component, mounted() {}, beforeUnmount() {} }, { go() {}, localAccess: true, ...props })
|
|
prepare(vm)
|
|
await nextTick()
|
|
const controls = [...document.querySelectorAll('#test button, #test input, #test select, #test .gx-switch__track, #test .gx-row__label')]
|
|
.map(node => ({ node, disabled: node.disabled, opacity: getComputedStyle(node).opacity }))
|
|
if (!controls.length) throw new Error(`${name}: no controls rendered`)
|
|
const setBusy = value => {
|
|
if (name === 'SentryNotifications') vm.notificationBusy = value
|
|
else if (['ControllersPage', 'VehicleControlsPage'].includes(name)) vm.state.busy = value
|
|
else if (vm.state?.status) vm.state.status = value ? 'saving' : 'ready'
|
|
else vm.busy = value
|
|
}
|
|
setBusy(true)
|
|
await nextTick()
|
|
const saving = controls.map(({ node }) => ({ node, disabled: node.disabled, opacity: getComputedStyle(node).opacity }))
|
|
for (let frame = 0; frame < 4; frame++) {
|
|
await new Promise(requestAnimationFrame)
|
|
for (const { node, disabled, opacity } of saving) {
|
|
if (!node.isConnected || node.disabled !== disabled || getComputedStyle(node).opacity !== opacity)
|
|
throw new Error(`${name}: control flickered during saving: ${node.getAttribute('aria-label') || node.textContent}`)
|
|
}
|
|
}
|
|
setBusy(false)
|
|
await nextTick()
|
|
for (const { node, disabled } of controls) {
|
|
if (!node.isConnected || node.disabled !== disabled) throw new Error(`${name}: controls did not recover after saving`)
|
|
}
|
|
}
|
|
const component = (await import('./js/models.js')).ModelsPage
|
|
mount({ ...component, mounted() {}, beforeUnmount() {} })
|
|
prepareModels(vm)
|
|
vm.manager.action = (action, model) => new Promise(resolve => {
|
|
window.finishModel = success => {
|
|
if (success) vm.activeSmallModel = model.value
|
|
resolve(success ? { message: 'Selection saved.' } : null)
|
|
}
|
|
})
|
|
await nextTick()
|
|
return cases.map(([, name]) => name)
|
|
}, layout)
|
|
for (const success of [false, true]) {
|
|
await page.getByRole('combobox', { name: /^Active Small/ }).click()
|
|
await page.getByRole('option', { name: 'two', exact: true }).click()
|
|
await page.waitForFunction(() => !!vm.busy)
|
|
assert.equal(await page.getByRole('combobox', { name: /^Active Small/, includeHidden: true }).textContent(), 'two')
|
|
await page.evaluate(success => finishModel(success), success)
|
|
await page.waitForFunction(() => !vm.busy)
|
|
assert.equal(await page.getByRole('combobox', { name: /^Active Small/ }).textContent(), success ? 'two' : 'one')
|
|
}
|
|
assert.deepEqual(errors, [])
|
|
console.log(`Settings state: ${pages.length} settings pages and ${audited.length} specialized pages; delayed preview/save/readback, Default/download buttons, stable control nodes/opacity and failed-save recovery passed.`)
|
|
await page.evaluate(() => app.unmount())
|
|
} finally { await browser.close() }
|
|
})().catch(error => { console.error(error); process.exitCode = 1 })
|