Files
Web_FreeCAD_Bitbybit/scripts/run-chrome-app-e2e.mjs

532 lines
50 KiB
JavaScript

import { createHash } from 'node:crypto'
import { createReadStream, existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import { extname, normalize, resolve } from 'node:path'
import { spawn } from 'node:child_process'
import { createChromeProfile, removeChromeProfile } from './chrome-profile.mjs'
const root = resolve(new URL('..', import.meta.url).pathname)
const dist = resolve(root, 'dist')
const evidenceDir = resolve(root, 'fixtures/chrome-app-e2e')
const reportPath = resolve(root, 'config/chrome-app-e2e-verification.json')
const chromeExecutable = process.env.CHROME_BIN || '/home/mes123456/.local/bin/google-chrome'
const contentTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.wasm': 'application/wasm',
}
if (!existsSync(resolve(dist, 'index.html'))) throw new Error('Chrome app E2E requires a production build in dist/. Run npm run build first.')
if (!existsSync(chromeExecutable)) throw new Error(`Chrome executable is unavailable: ${chromeExecutable}`)
await mkdir(evidenceDir, { recursive: true })
const server = createServer(async (request, response) => {
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
response.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
response.setHeader('Cross-Origin-Resource-Policy', 'same-origin')
const requestPath = decodeURIComponent((request.url || '/').split('?')[0])
const requestedFile = requestPath === '/' ? resolve(dist, 'index.html') : normalize(resolve(dist, `.${requestPath}`))
const file = requestedFile.startsWith(dist) && existsSync(requestedFile) ? requestedFile : resolve(dist, 'index.html')
response.setHeader('Content-Type', contentTypes[extname(file)] || 'application/octet-stream')
createReadStream(file).on('error', (error) => {
response.writeHead(500)
response.end(String(error))
}).pipe(response)
})
await new Promise((resolveServer, rejectServer) => {
server.once('error', rejectServer)
server.listen(0, '127.0.0.1', resolveServer)
})
const port = server.address().port
const baseUrl = `http://127.0.0.1:${port}`
const chromeProfile = await createChromeProfile('app-e2e')
const chrome = spawn(chromeExecutable, [
'--headless=new',
'--no-sandbox',
'--disable-dev-shm-usage',
'--no-first-run',
'--no-default-browser-check',
'--enable-webgl',
'--enable-unsafe-swiftshader',
'--use-angle=swiftshader',
'--remote-debugging-port=0',
`--user-data-dir=${chromeProfile}`,
'about:blank',
], { cwd: root, stdio: ['ignore', 'ignore', 'pipe'] })
let chromeStderr = ''
const debuggerUrl = await new Promise((resolveDebugger, rejectDebugger) => {
const timer = setTimeout(() => rejectDebugger(new Error(`Chrome DevTools endpoint timed out. ${chromeStderr.slice(-2000)}`)), 30_000)
chrome.stderr.on('data', (chunk) => {
chromeStderr += String(chunk)
const match = chromeStderr.match(/DevTools listening on (ws:\/\/[^\s]+)/)
if (!match) return
clearTimeout(timer)
resolveDebugger(match[1])
})
chrome.once('exit', (code, signal) => {
clearTimeout(timer)
rejectDebugger(new Error(`Chrome exited before DevTools was ready: code=${code}, signal=${signal}. ${chromeStderr.slice(-2000)}`))
})
})
class CdpClient {
constructor(url) {
this.socket = new WebSocket(url)
this.sequence = 0
this.pending = new Map()
this.listeners = new Set()
}
async open() {
await new Promise((resolveOpen, rejectOpen) => {
this.socket.addEventListener('open', resolveOpen, { once: true })
this.socket.addEventListener('error', () => rejectOpen(new Error('Chrome DevTools WebSocket failed to open.')), { once: true })
})
this.socket.addEventListener('message', (event) => {
const message = JSON.parse(String(event.data))
if (message.id) {
const pending = this.pending.get(message.id)
if (!pending) return
this.pending.delete(message.id)
clearTimeout(pending.timer)
if (message.error) pending.reject(new Error(`${pending.method}: ${message.error.message}`))
else pending.resolve(message.result)
return
}
for (const listener of this.listeners) listener(message)
})
}
send(method, params = {}, sessionId) {
const id = ++this.sequence
return new Promise((resolveMessage, rejectMessage) => {
const timer = setTimeout(() => {
this.pending.delete(id)
const detail = typeof params.expression === 'string' ? ` expression=${params.expression.slice(0, 180)}` : ''
rejectMessage(new Error(`${method}: Chrome DevTools request timed out.${detail}`))
}, 20_000)
this.pending.set(id, { resolve: resolveMessage, reject: rejectMessage, method, timer })
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }))
})
}
close() {
this.socket.close()
}
}
const client = new CdpClient(debuggerUrl)
let status = 'failed'
try {
await client.open()
const version = await client.send('Browser.getVersion')
if (!/Chrome/i.test(version.product || '')) throw new Error(`Unexpected browser product: ${version.product || 'unknown'}`)
const { targetInfos } = await client.send('Target.getTargets')
const target = targetInfos.find((candidate) => candidate.type === 'page')
if (!target) throw new Error('Chrome did not expose a page target.')
const { sessionId } = await client.send('Target.attachToTarget', { targetId: target.targetId, flatten: true })
await Promise.all([
client.send('Page.enable', {}, sessionId),
client.send('Runtime.enable', {}, sessionId),
client.send('Log.enable', {}, sessionId),
])
const pageErrors = []
client.listeners.add((message) => {
if (message.sessionId !== sessionId) return
if (message.method === 'Runtime.exceptionThrown') pageErrors.push(message.params?.exceptionDetails?.text || 'Uncaught page exception')
if (message.method === 'Runtime.consoleAPICalled' && message.params?.type === 'error') pageErrors.push(message.params.args?.map((entry) => entry.value || entry.description || '').join(' ') || 'console.error')
if (message.method === 'Log.entryAdded' && message.params?.entry?.level === 'error') pageErrors.push(message.params.entry.text || 'Chrome log error')
})
const evaluate = async (expression) => {
const result = await client.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }, sessionId)
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'Chrome evaluation failed.')
return result.result?.value
}
const waitFor = async (expression, label, timeoutMs = 30_000) => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await evaluate(expression)) return
await new Promise((resolveWait) => setTimeout(resolveWait, 100))
}
const snapshot = await evaluate('document.body.innerText.slice(-1200)')
throw new Error(`Chrome E2E timed out waiting for ${label}. Body tail: ${snapshot}`)
}
const setViewport = (width, height, deviceScaleFactor = 1) => client.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor, mobile: width <= 480 }, sessionId)
const navigate = async (path) => {
await client.send('Page.navigate', { url: `${baseUrl}${path}` }, sessionId)
await waitFor('document.readyState === "complete" && Boolean(document.querySelector(".app-shell"))', `${path} application shell`)
}
const clickButton = async (label) => {
const clicked = await evaluate(`(() => { const label = ${JSON.stringify(label)}; const button = [...document.querySelectorAll('button')].find((candidate) => !candidate.disabled && [candidate.getAttribute('aria-label'), candidate.getAttribute('title'), candidate.textContent?.trim()].some((value) => value?.includes(label))); if (!button) return false; button.click(); return true })()`)
if (!clicked) throw new Error(`Chrome E2E could not find enabled button containing ${JSON.stringify(label)}.`)
}
const screenshot = async (name) => {
const capture = await client.send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false }, sessionId)
const bytes = Buffer.from(capture.data, 'base64')
if (bytes.byteLength < 20_000) throw new Error(`${name} screenshot is unexpectedly small: ${bytes.byteLength} bytes.`)
const relativePath = `fixtures/chrome-app-e2e/${name}.png`
await writeFile(resolve(root, relativePath), bytes)
return { path: relativePath, byteLength: bytes.byteLength, sha256: createHash('sha256').update(bytes).digest('hex') }
}
await setViewport(1440, 1000)
await navigate('/start')
await waitFor('document.body.innerText.includes("Start a new design.")', 'start page heading')
const offline = await evaluate(`({ path: location.pathname, shell: document.querySelector('.app-shell') !== null, heading: document.body.innerText.includes('Start a new design.'), mode: 'online-shell' })`)
if (offline.path !== '/start' || offline.shell !== true || offline.heading !== true) throw new Error(`Chrome shell route gate failed: ${JSON.stringify(offline)}`)
await clickButton('New document')
await waitFor('Boolean(document.querySelector(".workspace-page"))', 'workspace page')
await waitFor('Boolean(document.querySelector(".three-viewport-host canvas"))', 'Three.js viewport canvas')
await waitFor('document.querySelector(".three-viewport-host canvas")?.dataset.geometrySource === "bitbybit-occt"', 'Bitbybit geometry preview', 90_000)
const kernelPreviewSource = await evaluate(`document.querySelector('.three-viewport-host canvas')?.dataset.geometrySource || ''`)
const selectedPartForBoxSelection = await evaluate(`(() => { const select = document.querySelector('select[aria-label="Workbench"]'); if (!select) return false; select.value = 'Part'; select.dispatchEvent(new Event('change', { bubbles: true })); return true })()`)
if (!selectedPartForBoxSelection) throw new Error('Workbench selector could not switch to Part for viewport box selection.')
await waitFor('document.querySelector("select[aria-label=Workbench]")?.value === "Part"', 'Part workbench change')
await clickButton('Create primitives')
await waitFor('Boolean(document.querySelector(".task-body select.field-input"))', 'Box primitive task')
await clickButton('OK')
await waitFor('document.querySelector(".report-line")?.textContent?.includes("Recompute completed") === true && Number(document.querySelector(".three-viewport-host canvas")?.dataset.objectCount || 0) >= 1', 'Box primitive recompute', 90_000)
await clickButton('Create primitives')
await waitFor('Boolean(document.querySelector(".task-body select.field-input"))', 'Cylinder primitive task')
const selectedCylinder = await evaluate(`(() => { const select = document.querySelector('.task-body select.field-input'); if (!select) return false; select.value = 'Cylinder'; select.dispatchEvent(new Event('change', { bubbles: true })); return true })()`)
if (!selectedCylinder) throw new Error('Cylinder primitive type could not be selected.')
await clickButton('OK')
await waitFor('document.querySelector(".report-line")?.textContent?.includes("Recompute completed") === true && Number(document.querySelector(".three-viewport-host canvas")?.dataset.objectCount || 0) >= 2', 'multi-object viewport scene', 90_000)
const boxGesture = await evaluate(`(() => { const bounds = document.querySelector('.three-viewport-host canvas')?.getBoundingClientRect(); return bounds ? { startX: bounds.right - 20, startY: bounds.top + 20, endX: bounds.left + 20, endY: bounds.bottom - 70 } : null })()`)
if (!boxGesture) throw new Error('Viewport canvas bounds are unavailable for box selection.')
await client.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: boxGesture.startX, y: boxGesture.startY, button: 'left', buttons: 1, modifiers: 8, clickCount: 1 }, sessionId)
await client.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: boxGesture.endX, y: boxGesture.endY, button: 'left', buttons: 1, modifiers: 8 }, sessionId)
await client.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: boxGesture.endX, y: boxGesture.endY, button: 'left', buttons: 0, modifiers: 8, clickCount: 1 }, sessionId)
const viewportBoxGesture = await evaluate(`(() => { const canvas = document.querySelector('.three-viewport-host canvas'); return { mode: canvas?.dataset.boxSelectionMode || '', objectIds: (canvas?.dataset.boxSelectionObjectIds || '').split(',').filter(Boolean) } })()`)
if (viewportBoxGesture.mode !== 'crossing' || viewportBoxGesture.objectIds.length < 2) throw new Error(`Viewport box gesture did not resolve multiple objects: ${JSON.stringify({ boxGesture, viewportBoxGesture })}`)
const reopenedModelForBox = await evaluate(`(() => { const tab = [...document.querySelectorAll('.panel-tabs button')].find((candidate) => candidate.textContent?.trim() === 'Model'); if (!tab) return false; tab.click(); return true })()`)
if (!reopenedModelForBox) throw new Error('Model tab could not be opened after viewport box selection.')
await waitFor('document.querySelectorAll(".tree-row.is-selected").length >= 2', 'viewport box multi-selection')
const viewportBoxSelection = await evaluate(`(() => { const canvas = document.querySelector('.three-viewport-host canvas'); return { objectCount: Number(canvas?.dataset.objectCount || 0), topologySource: canvas?.dataset.topologySource || '', topologySources: { face: canvas?.dataset.faceTopologySource || '', edge: canvas?.dataset.edgeTopologySource || '', vertex: canvas?.dataset.vertexTopologySource || '' }, edgeCount: Number(canvas?.dataset.subshapeEdgeCount || 0), vertexCount: Number(canvas?.dataset.subshapeVertexCount || 0), selectedCount: document.querySelectorAll('.tree-row.is-selected').length, labels: [...document.querySelectorAll('.tree-row.is-selected')].map((row) => row.textContent?.trim() || ''), gesture: ${JSON.stringify(viewportBoxGesture)} } })()`)
if (viewportBoxSelection.objectCount < 2 || viewportBoxSelection.topologySource !== 'occt-analytic' || viewportBoxSelection.edgeCount < 4 || viewportBoxSelection.vertexCount < 4 || viewportBoxSelection.selectedCount < 2 || !viewportBoxSelection.labels.includes('Box') || !viewportBoxSelection.labels.includes('Cylinder')) throw new Error(`Viewport box selection gate failed: ${JSON.stringify(viewportBoxSelection)}`)
await clickButton('File')
await waitFor('Boolean(document.querySelector(".menu-popover[role=menu]"))', 'File menu')
const fileMenu = await evaluate(`(() => { const menu = document.querySelector('.menu-popover[role=menu]'); return { items: menu?.querySelectorAll('[role=menuitem]').length || 0, labels: [...(menu?.querySelectorAll('[role=menuitem]') || [])].map((item) => item.textContent?.trim() || ''), expanded: document.querySelector('.top-menu button[aria-expanded=true]')?.textContent || '' } })()`)
if (fileMenu.items < 7 || fileMenu.expanded !== 'File' || !fileMenu.labels.some((label) => label.includes('Save'))) throw new Error(`FreeCAD File menu gate failed: ${JSON.stringify(fileMenu)}`)
await evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
await waitFor('!document.querySelector(".menu-popover")', 'File menu Escape close')
await clickButton('Search commands')
await waitFor('Boolean(document.querySelector(".freecad-dialog .command-palette"))', 'command search dialog')
await waitFor('document.activeElement?.getAttribute("aria-label") === "Command search"', 'command search focus')
const commandDialog = await evaluate(`(() => ({ modal: document.querySelector('.freecad-dialog')?.getAttribute('aria-modal') === 'true', results: document.querySelectorAll('.command-palette-list [role=option]').length, title: document.querySelector('#freecad-dialog-title')?.textContent || '', focused: document.activeElement?.getAttribute('aria-label') || '' }))()`)
if (!commandDialog.modal || commandDialog.results < 10 || commandDialog.title !== 'Command search' || commandDialog.focused !== 'Command search') throw new Error(`Command search dialog gate failed: ${JSON.stringify(commandDialog)}`)
await clickButton('Close dialog')
await clickButton('Edit')
await clickButton('Preferences')
await waitFor('document.querySelector("#freecad-dialog-title")?.textContent === "Preferences"', 'Preferences dialog')
const preferencesDialog = await evaluate(`(() => ({ selects: document.querySelectorAll('.dialog-form select').length, checks: document.querySelectorAll('.dialog-form input[type=checkbox]').length, modal: document.querySelector('.freecad-dialog')?.getAttribute('aria-modal') === 'true' }))()`)
if (!preferencesDialog.modal || preferencesDialog.selects !== 3 || preferencesDialog.checks !== 2) throw new Error(`Preferences dialog gate failed: ${JSON.stringify(preferencesDialog)}`)
await clickButton('Cancel')
await clickButton('Help')
await clickButton('About BitBybit CAD')
await waitFor('document.querySelector("#freecad-dialog-title")?.textContent.includes("About BitBybit Web FreeCAD")', 'About dialog')
const aboutDialog = await evaluate(`(() => ({ modal: document.querySelector('.freecad-dialog')?.getAttribute('aria-modal') === 'true', baseline: document.body.innerText.includes('FreeCAD 1.1.1 interaction baseline') }))()`)
if (!aboutDialog.modal || !aboutDialog.baseline) throw new Error(`About dialog gate failed: ${JSON.stringify(aboutDialog)}`)
await clickButton('Close')
const selectedSketcher = await evaluate(`(() => { const select = document.querySelector('select[aria-label="Workbench"]'); if (!select) return false; select.value = 'Sketcher'; select.dispatchEvent(new Event('change', { bubbles: true })); return true })()`)
if (!selectedSketcher) throw new Error('Workbench selector is missing from the workspace.')
await waitFor('document.querySelector("select[aria-label=Workbench]")?.value === "Sketcher" && document.body.innerText.includes("Sketcher workbench loaded")', 'Sketcher workbench change')
await clickButton('Create sketch')
await waitFor('Boolean(document.querySelector(".toast"))', 'Create sketch notice')
const cancelledSketch = await evaluate(`(() => { const button = document.querySelector('.task-actions-top .button-quiet'); if (!button || button.disabled) return false; button.click(); return true })()`)
if (!cancelledSketch) throw new Error('Chrome E2E could not cancel the Create Sketch preview.')
await waitFor('document.querySelector(".toast")?.textContent.includes("Task cancelled")', 'Create sketch task cancellation')
const openedModelTab = await evaluate(`(() => { const tab = [...document.querySelectorAll('.panel-tabs button')].find((candidate) => candidate.textContent?.trim() === 'Model'); if (!tab) return false; tab.click(); return true })()`)
if (!openedModelTab) throw new Error('Chrome E2E could not reopen the model tree tab.')
const selectedProfile = await evaluate(`(() => { const row = [...document.querySelectorAll('.tree-row')].find((candidate) => candidate.textContent?.trim() === 'Sketch'); if (!row) return { ok: false, rows: [...document.querySelectorAll('.tree-row')].map((candidate) => candidate.textContent?.trim() || '') }; row.click(); return { ok: true, rows: [] } })()`)
if (!selectedProfile?.ok) throw new Error(`Chrome E2E could not select the existing Sketch profile. Rows: ${JSON.stringify(selectedProfile?.rows || [])}`)
const openedContextMenu = await evaluate(`(() => { const row = [...document.querySelectorAll('.tree-row')].find((candidate) => candidate.textContent?.trim() === 'Sketch'); if (!row) return false; row.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 180, clientY: 330 })); return true })()`)
if (!openedContextMenu) throw new Error('Chrome E2E could not open the model tree context menu.')
await waitFor('Boolean(document.querySelector(".tree-context-menu[role=menu]"))', 'model tree context menu')
const contextMenu = await evaluate(`(() => ({ items: [...document.querySelectorAll('.tree-context-menu [role=menuitem]')].map((item) => item.textContent?.trim() || ''), left: document.querySelector('.tree-context-menu')?.getBoundingClientRect().left || -1, top: document.querySelector('.tree-context-menu')?.getBoundingClientRect().top || -1 }))()`)
if (contextMenu.items.length !== 4 || !contextMenu.items.some((label) => label.includes('Toggle visibility')) || !contextMenu.items.some((label) => label.includes('Delete')) || contextMenu.left < 0 || contextMenu.top < 0) throw new Error(`Model tree context menu gate failed: ${JSON.stringify(contextMenu)}`)
await evaluate(`document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))`)
await waitFor('!document.querySelector(".tree-context-menu")', 'model tree context menu close')
const stagedMultiSelection = await evaluate(`(() => { const rows = [...document.querySelectorAll('.tree-row')]; const sketch = rows.find((candidate) => candidate.textContent?.trim() === 'Sketch'); const pad = rows.find((candidate) => candidate.textContent?.trim() === 'Pad'); if (!sketch || !pad) return { ok: false, rows: rows.map((candidate) => candidate.textContent?.trim() || '') }; sketch.click(); pad.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true })); return { ok: true, rows: [] } })()`)
if (!stagedMultiSelection?.ok) throw new Error(`Chrome E2E could not stage model tree multi-selection. Rows: ${JSON.stringify(stagedMultiSelection?.rows || [])}`)
await waitFor('document.querySelectorAll(".tree-row.is-selected").length >= 2', 'model tree multi-selection')
const multiSelection = await evaluate(`(() => ({ count: document.querySelectorAll('.tree-row.is-selected').length, labels: [...document.querySelectorAll('.tree-row.is-selected')].map((row) => row.textContent?.trim() || '') }))()`)
if (!multiSelection.labels.includes('Sketch') || !multiSelection.labels.includes('Pad')) throw new Error(`Model tree multi-selection gate failed: ${JSON.stringify(multiSelection)}`)
await evaluate(`(() => { const sketch = [...document.querySelectorAll('.tree-row')].find((candidate) => candidate.textContent?.trim() === 'Sketch'); sketch?.click(); return Boolean(sketch) })()`)
const selectedPartDesign = await evaluate(`(() => { const select = document.querySelector('select[aria-label="Workbench"]'); if (!select) return false; select.value = 'Part Design'; select.dispatchEvent(new Event('change', { bubbles: true })); return true })()`)
if (!selectedPartDesign) throw new Error('Workbench selector could not switch to Part Design.')
await waitFor('document.querySelector("select[aria-label=Workbench]")?.value === "Part Design"', 'Part Design workbench change')
await clickButton('Additive loft')
await waitFor('document.querySelectorAll(".task-link-list input[type=checkbox]").length >= 1', 'Loft section task controls')
const loftTask = await evaluate(`(() => {
const list = document.querySelector('.task-link-list')
const panel = document.querySelector('.right-panel')
const checks = [...document.querySelectorAll('.task-link-list input[type=checkbox]')]
return {
sectionCount: checks.length,
checkedSectionCount: checks.filter((input) => input.checked).length,
labels: [...document.querySelectorAll('.task-link-list .check-row span')].map((entry) => entry.textContent?.trim() || ''),
listVerticalOverflow: list ? list.scrollHeight - list.clientHeight : -1,
panelHorizontalOverflow: panel ? panel.scrollWidth - panel.clientWidth : -1,
}
})()`)
if (loftTask.sectionCount < 1 || loftTask.checkedSectionCount !== 1 || !loftTask.labels.includes('Sketch (sketch)') || loftTask.panelHorizontalOverflow > 1) throw new Error(`Loft task UI gate failed: ${JSON.stringify(loftTask)}`)
await clickButton('Collapse bottom panel')
const desktop = await evaluate(`(async () => {
await new Promise((resolveFrame) => requestAnimationFrame(resolveFrame))
const canvas = document.querySelector('.three-viewport-host canvas')
const left = document.querySelector('.left-panel')?.getBoundingClientRect()
const viewport = document.querySelector('.viewport-region')?.getBoundingClientRect()
const right = document.querySelector('.right-panel')?.getBoundingClientRect()
const gl = canvas?.getContext('webgl2') || canvas?.getContext('webgl')
let uniqueCanvasColors = 0
let canvasGlError = -1
if (canvas && gl && canvas.width > 0 && canvas.height > 0) {
const pixels = new Uint8Array(canvas.width * canvas.height * 4)
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels)
canvasGlError = gl.getError()
const colors = new Set()
const stride = Math.max(4, Math.floor(pixels.length / 4096 / 4) * 4)
for (let index = 0; index < pixels.length && colors.size < 64; index += stride) colors.add([pixels[index], pixels[index + 1], pixels[index + 2], pixels[index + 3]].join(','))
uniqueCanvasColors = colors.size
}
const overflowingControls = [...document.querySelectorAll('button, select, input')].filter((element) => {
const style = getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && element.clientWidth > 0 && element.scrollWidth > element.clientWidth + 2
}).length
return {
path: location.pathname,
workbench: document.querySelector('select[aria-label="Workbench"]')?.value || '',
persistenceMode: document.querySelector('.app-shell')?.dataset.persistenceMode || '',
bodyHorizontalOverflow: document.documentElement.scrollWidth - innerWidth,
overflowingControls,
canvas: { width: canvas?.width || 0, height: canvas?.height || 0, source: canvas?.dataset.geometrySource || '', uniqueColors: uniqueCanvasColors, glError: canvasGlError },
panelsSeparated: Boolean(left && viewport && right && left.right <= viewport.left + 1 && viewport.right <= right.left + 1),
commandGroups: document.querySelectorAll('.workbench-commandbar .command-tool-group').length,
comboTabs: [...document.querySelectorAll('.combo-panel > .panel-tabs button')].map((entry) => entry.textContent?.trim() || ''),
selectionView: Boolean(document.querySelector('.selection-panel[aria-label="Selection View"]')),
statusBar: Boolean(document.querySelector('.freecad-statusbar[role=status]')),
}
})()`)
desktop.kernelPreviewSource = kernelPreviewSource
if (desktop.path !== '/workspace/pump-housing' || desktop.workbench !== 'Part Design') throw new Error('Desktop workflow did not retain its workspace route and workbench state.')
if (desktop.bodyHorizontalOverflow > 1 || !desktop.panelsSeparated || desktop.overflowingControls > 0 || desktop.commandGroups < 4 || !desktop.comboTabs.some((label) => label.startsWith('Model')) || !desktop.comboTabs.some((label) => label.startsWith('Tasks')) || !desktop.selectionView || !desktop.statusBar) throw new Error(`Desktop layout gate failed: ${JSON.stringify(desktop)}`)
if (desktop.kernelPreviewSource !== 'bitbybit-occt' || desktop.canvas.width < 100 || desktop.canvas.height < 100 || desktop.canvas.uniqueColors < 4 || desktop.canvas.glError !== 0) throw new Error(`Desktop canvas pixel gate failed: ${JSON.stringify({ kernelPreviewSource: desktop.kernelPreviewSource, canvas: desktop.canvas })}`)
const desktopScreenshot = await screenshot('desktop-workspace')
await clickButton('Cancel')
await waitFor('document.querySelector(".toast")?.textContent.includes("Task cancelled")', 'task cancellation')
const selectedCam = await evaluate(`(() => { const select = document.querySelector('select[aria-label="Workbench"]'); if (!select) return false; select.value = 'CAM'; select.dispatchEvent(new Event('change', { bubbles: true })); return true })()`)
if (!selectedCam) throw new Error('Workbench selector could not switch to CAM.')
await waitFor('document.querySelector("select[aria-label=Workbench]")?.value === "CAM" && Boolean([...document.querySelectorAll(".top-menu > .menu-wrapper > button")].find((button) => button.textContent?.trim() === "CAM"))', 'CAM workbench chrome')
await clickButton('CAM')
await waitFor('Boolean(document.querySelector(".menu-popover-cam[role=menu]"))', 'CAM workbench menu')
const camMenu = await evaluate(`(() => ({ items: document.querySelectorAll('.menu-popover-cam [role=menuitem]').length, groups: [...document.querySelectorAll('.menu-popover-cam .menu-section-label')].map((entry) => entry.textContent?.trim() || ''), labels: [...document.querySelectorAll('.menu-popover-cam [role=menuitem]')].map((entry) => entry.textContent?.trim() || '') }))()`)
if (camMenu.items !== 60 || JSON.stringify(camMenu.groups) !== JSON.stringify(['Project Setup', 'Simulation and Tools', '2D Operations', 'Machining Operations', 'Path Modification']) || !camMenu.labels.some((label) => label.includes('Post Process')) || !camMenu.labels.some((label) => label.includes('Drag Knife Dress-up'))) throw new Error(`CAM menu gate failed: ${JSON.stringify(camMenu)}`)
await evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
await waitFor('!document.querySelector(".menu-popover-cam")', 'CAM menu close')
await clickButton('Create Job')
await waitFor('document.querySelector("[data-testid=cam-task-panel] .task-header h2")?.textContent === "Create Job"', 'CAM Job task')
const camJobTask = await evaluate(`(() => ({ stockModes: document.querySelector('[data-testid=cam-task-panel] select')?.options.length || 0, fields: document.querySelectorAll('[data-testid=cam-task-panel] input').length, actions: [...document.querySelectorAll('[data-testid=cam-task-panel] .task-actions-top button')].map((button) => button.textContent?.trim() || '') }))()`)
if (camJobTask.stockModes !== 4 || camJobTask.fields < 5 || JSON.stringify(camJobTask.actions) !== JSON.stringify(['OK', 'Apply', 'Cancel'])) throw new Error(`CAM Job task gate failed: ${JSON.stringify(camJobTask)}`)
await clickButton('OK')
await waitFor('document.querySelector(".toast")?.textContent.includes("CAM Job and SetupSheet updated")', 'CAM Job task commit')
await clickButton('Fixture')
await waitFor('document.querySelector("[data-testid=cam-task-panel] .task-header h2")?.textContent === "Fixture"', 'CAM Fixture task')
const camFixtureTask = await evaluate(`(() => ({ inputs: document.querySelectorAll('[data-testid=cam-task-panel] input').length, section: [...document.querySelectorAll('[data-testid=cam-task-panel] .cam-task-section')].some((entry) => entry.textContent?.trim() === 'Fixture Bounds'), labels: [...document.querySelectorAll('[data-testid=cam-task-panel] .field-label')].map((label) => label.textContent?.trim() || '') }))()`)
if (camFixtureTask.inputs !== 7 || !camFixtureTask.section || !camFixtureTask.labels.some((label) => label.includes('Fixture ID')) || !camFixtureTask.labels.some((label) => label.includes('Max Z'))) throw new Error(`CAM Fixture task gate failed: ${JSON.stringify(camFixtureTask)}`)
await clickButton('Cancel')
await waitFor('!document.querySelector("[data-testid=cam-task-panel] .task-actions-top")', 'CAM Fixture cancellation')
await clickButton('Add Tool Controller')
await waitFor('document.querySelector("[data-testid=cam-task-panel] .task-header h2")?.textContent === "Tool Controller"', 'CAM Tool Controller task')
const camToolTask = await evaluate(`(() => ({ selects: document.querySelectorAll('[data-testid=cam-task-panel] select').length, inputs: document.querySelectorAll('[data-testid=cam-task-panel] input').length }))()`)
if (camToolTask.selects < 2 || camToolTask.inputs !== 13) throw new Error(`CAM Tool Controller task gate failed: ${JSON.stringify(camToolTask)}`)
await clickButton('OK')
await waitFor('document.querySelector(".toast")?.textContent.includes("ToolBit and Tool Controller")', 'CAM Tool Controller commit')
await clickButton('Profile')
await waitFor('document.querySelector("[data-testid=cam-task-panel] .task-header h2")?.textContent === "Profile"', 'CAM Profile task')
const camOperationTask = await evaluate(`(() => ({ inputs: document.querySelectorAll('[data-testid=cam-task-panel] input').length, selects: document.querySelectorAll('[data-testid=cam-task-panel] select').length }))()`)
if (camOperationTask.inputs < 5 || camOperationTask.selects < 2) throw new Error(`CAM Profile task gate failed: ${JSON.stringify(camOperationTask)}`)
await clickButton('OK')
await waitFor('document.querySelector(".toast")?.textContent.includes("path points")', 'CAM Profile generation')
await clickButton('Undo')
await waitFor('Number(document.querySelector(".three-viewport-host canvas")?.dataset.toolpathPoints || 0) === 0', 'CAM toolpath undo')
const camUndoUi = await evaluate(`({ pathPoints: Number(document.querySelector('.three-viewport-host canvas')?.dataset.toolpathPoints || 0), redoEnabled: document.querySelector('button[aria-label="Redo"]')?.disabled === false })`)
if (camUndoUi.pathPoints !== 0 || !camUndoUi.redoEnabled) throw new Error(`CAM Undo UI gate failed: ${JSON.stringify(camUndoUi)}`)
await clickButton('Redo')
await waitFor('Number(document.querySelector(".three-viewport-host canvas")?.dataset.toolpathPoints || 0) >= 5', 'CAM toolpath redo')
const camRedoUi = await evaluate(`({ pathPoints: Number(document.querySelector('.three-viewport-host canvas')?.dataset.toolpathPoints || 0), undoEnabled: document.querySelector('button[aria-label="Undo"]')?.disabled === false })`)
if (camRedoUi.pathPoints < 5 || !camRedoUi.undoEnabled) throw new Error(`CAM Redo UI gate failed: ${JSON.stringify(camRedoUi)}`)
await clickButton('Load ToolBit')
await waitFor('document.querySelector("[data-testid=cam-task-panel] .task-header h2")?.textContent === "Load ToolBit"', 'CAM ToolBit Load task')
const camToolBitLoadTask = await evaluate(`(() => ({ inputs: document.querySelectorAll('[data-testid=cam-task-panel] input').length, textareas: document.querySelectorAll('[data-testid=cam-task-panel] textarea').length, schemaVersion: document.querySelector('[data-testid=cam-task-panel] textarea')?.value.includes('"schemaVersion": 1') === true }))()`)
if (camToolBitLoadTask.inputs !== 1 || camToolBitLoadTask.textareas !== 1 || !camToolBitLoadTask.schemaVersion) throw new Error(`CAM ToolBit Load task gate failed: ${JSON.stringify(camToolBitLoadTask)}`)
await clickButton('Cancel')
await waitFor('!document.querySelector("[data-testid=cam-task-panel] .task-actions-top")', 'CAM ToolBit Load cancellation')
await clickButton('Drilling')
await waitFor('document.querySelector("[data-testid=cam-task-panel] .task-header h2")?.textContent === "Drilling"', 'CAM Drilling task')
const camDrillingTask = await evaluate(`(() => ({ inputs: document.querySelectorAll('[data-testid=cam-task-panel] input').length, selects: document.querySelectorAll('[data-testid=cam-task-panel] select').length, labels: [...document.querySelectorAll('[data-testid=cam-task-panel] .field-label')].map((label) => label.textContent?.trim() || '') }))()`)
if (camDrillingTask.inputs !== 5 || camDrillingTask.selects !== 2 || !camDrillingTask.labels.some((label) => label.includes('Final Depth'))) throw new Error(`CAM Drilling task gate failed: ${JSON.stringify(camDrillingTask)}`)
await clickButton('Cancel')
await waitFor('!document.querySelector("[data-testid=cam-task-panel] .task-actions-top")', 'CAM Drilling cancellation')
await clickButton('Drag Knife Dress-up')
await waitFor('document.querySelector("[data-testid=cam-task-panel] .task-header h2")?.textContent === "Drag Knife Dress-up"', 'CAM Drag Knife task')
const camDragKnifeTask = await evaluate(`(() => ({ inputs: document.querySelectorAll('[data-testid=cam-task-panel] input').length, selects: document.querySelectorAll('[data-testid=cam-task-panel] select').length, knifeOffset: [...document.querySelectorAll('[data-testid=cam-task-panel] .field-label')].some((label) => label.textContent?.includes('Knife Offset')) }))()`)
if (camDragKnifeTask.inputs !== 1 || camDragKnifeTask.selects !== 1 || !camDragKnifeTask.knifeOffset) throw new Error(`CAM Drag Knife task gate failed: ${JSON.stringify(camDragKnifeTask)}`)
await clickButton('Cancel')
await waitFor('!document.querySelector("[data-testid=cam-task-panel] .task-actions-top")', 'CAM Drag Knife cancellation')
await clickButton('Post Process')
await waitFor('document.querySelector("[data-testid=cam-task-panel] .task-header h2")?.textContent === "Post Process"', 'CAM Post Process task')
const selectedFiveAxis = await evaluate(`(() => { const label = [...document.querySelectorAll('[data-testid=cam-task-panel] .field-label')].find((entry) => entry.textContent?.includes('Axis Configuration')); const select = label?.querySelector('select'); if (!select) return false; select.value = '5-axis'; select.dispatchEvent(new Event('change', { bubbles: true })); return true })()`)
if (!selectedFiveAxis) throw new Error('CAM Post Process task could not select 5-axis mode.')
await waitFor('document.querySelectorAll("[data-testid=cam-task-panel] select").length === 4 && document.querySelectorAll("[data-testid=cam-task-panel] input").length === 4', 'CAM 5-axis post controls')
const camPostTask = await evaluate(`(() => ({ inputs: document.querySelectorAll('[data-testid=cam-task-panel] input').length, selects: document.querySelectorAll('[data-testid=cam-task-panel] select').length, labels: [...document.querySelectorAll('[data-testid=cam-task-panel] .field-label')].map((label) => label.textContent?.trim() || ''), note: document.querySelector('[data-testid=cam-task-panel] .task-note')?.textContent?.trim() || '' }))()`)
if (camPostTask.inputs !== 4 || camPostTask.selects !== 4 || !camPostTask.labels.some((label) => label.includes('Rotary End')) || !camPostTask.labels.some((label) => label.includes('Tilt End')) || !camPostTask.note.includes('rotary limit checks')) throw new Error(`CAM 5-axis Post task gate failed: ${JSON.stringify(camPostTask)}`)
await clickButton('Cancel')
await waitFor('!document.querySelector("[data-testid=cam-task-panel] .task-actions-top")', 'CAM Post Process cancellation')
const camModelTab = await evaluate(`(() => { const tab = [...document.querySelectorAll('.combo-panel > .panel-tabs button')].find((button) => button.textContent?.trim() === 'Model'); if (!tab) return false; tab.click(); return true })()`)
if (!camModelTab) throw new Error('CAM workflow could not open the Model tab.')
await waitFor('Boolean(document.querySelector("[data-testid=cam-job-tree]")) && document.querySelectorAll("[data-testid=cam-job-tree] .cam-tree-row").length >= 7', 'CAM Job tree')
await clickButton('Simulators')
await waitFor('document.querySelector(".toast")?.textContent.includes("CAM simulation passed")', 'CAM simulation')
const reopenedCamModelTab = await evaluate(`(() => { const tab = [...document.querySelectorAll('.combo-panel > .panel-tabs button')].find((button) => button.textContent?.trim() === 'Model'); if (!tab) return false; tab.click(); return true })()`)
if (!reopenedCamModelTab) throw new Error('CAM workflow could not reopen the Model tab after simulation.')
await waitFor('Boolean(document.querySelector("[data-testid=cam-job-tree]"))', 'CAM Job tree after simulation')
const camUi = await evaluate(`(() => { const canvas = document.querySelector('.three-viewport-host canvas'); return { workbench: document.querySelector('select[aria-label=Workbench]')?.value || '', commandGroups: document.querySelectorAll('.workbench-commandbar .command-tool-group').length, toolbarCommands: document.querySelectorAll('.workbench-commandbar .command-tool-buttons button').length, jobTree: Boolean(document.querySelector('[data-testid=cam-job-tree]')), treeRows: document.querySelectorAll('[data-testid=cam-job-tree] .cam-tree-row').length, operations: [...document.querySelectorAll('[data-testid=cam-job-tree] .cam-tree-row')].filter((row) => row.textContent?.includes('profile')).length, generated: [...document.querySelectorAll('[data-testid=cam-job-tree] .cam-state')].some((state) => state.textContent?.trim() === 'generated'), toolpathPoints: Number(canvas?.dataset.toolpathPoints || 0), toolpathOperations: Number(canvas?.dataset.toolpathOperations || 0), toolpathLegend: document.body.innerText.includes('Toolpath'), horizontalOverflow: document.documentElement.scrollWidth - innerWidth } })()`)
if (camUi.workbench !== 'CAM' || camUi.commandGroups !== 4 || camUi.toolbarCommands !== 60 || !camUi.jobTree || camUi.treeRows < 7 || camUi.operations !== 1 || !camUi.generated || camUi.toolpathPoints < 5 || camUi.toolpathOperations !== 1 || !camUi.toolpathLegend || camUi.horizontalOverflow > 1) throw new Error(`CAM workbench UI gate failed: ${JSON.stringify(camUi)}`)
const camScreenshot = await screenshot('cam-workbench')
await clickButton('Save project')
await waitFor('document.querySelector(".toast")?.textContent.includes("Saved to local workspace")', 'OPFS save completion')
await clickButton('Open project')
await waitFor('location.pathname === "/projects" && document.body.innerText.includes("Your projects.")', 'Project Manager')
await waitFor('[...document.querySelectorAll(".project-list-row .list-main strong")].some((entry) => entry.textContent?.trim() === "Pump Housing")', 'saved OPFS project row')
const openedSavedProject = await evaluate(`(() => { const label = [...document.querySelectorAll('.project-list-row .list-main strong')].find((entry) => entry.textContent?.trim() === 'Pump Housing'); const row = label?.closest('button'); if (!row) return false; row.click(); return true })()`)
if (!openedSavedProject) throw new Error('Saved project row could not be reopened.')
await waitFor('location.pathname === "/workspace/pump-housing" && document.querySelector(".toast")?.textContent.includes("Loaded Pump Housing")', 'saved OPFS project reopen')
const persistenceRoundTrip = await evaluate(`({ path: location.pathname, documentLabel: document.querySelector('.context-document')?.textContent?.trim() || '', persistenceMode: document.querySelector('.app-shell')?.dataset.persistenceMode || '' })`)
if (!persistenceRoundTrip.documentLabel.includes('Pump Housing') || persistenceRoundTrip.persistenceMode !== 'sqlite-opfs') throw new Error(`OPFS project reopen gate failed: ${JSON.stringify(persistenceRoundTrip)}`)
await clickButton('Open project')
await waitFor('location.pathname === "/projects" && document.body.innerText.includes("Your projects.")', 'project manager for import')
await clickButton('Import')
await waitFor('location.pathname === "/import" && document.body.innerText.includes("Select a CAD file")', 'import page')
const importFlow = await evaluate(`(() => ({ path: location.pathname, accept: document.querySelector('input[type="file"]')?.getAttribute('accept') || '', exchangeHint: document.body.innerText.includes('STEP · IGES · BREP · FCStd') }))()`)
if (importFlow.accept !== '.FCStd,.fcstd,.step,.stp,.iges,.igs,.brep,.brp' || importFlow.exchangeHint !== true) throw new Error(`Import format state gate failed: ${JSON.stringify(importFlow)}`)
await navigate('/workspace/pump-housing')
await clickButton('File')
await clickButton('Export...')
await waitFor('location.pathname === "/export" && document.body.innerText.includes("Export a clean deliverable.")', 'export page')
const exportFlow = await evaluate(`(() => {
const formats = Object.fromEntries([...document.querySelectorAll('.format-card')].map((button) => [button.querySelector('strong')?.textContent || '', { disabled: button.disabled, selected: button.classList.contains('is-selected') }]))
return { path: location.pathname, formats }
})()`)
if (exportFlow.formats?.STEP?.selected !== true || exportFlow.formats?.STEP?.disabled !== false || exportFlow.formats?.STL?.disabled !== false || exportFlow.formats?.IGES?.disabled !== false) throw new Error(`Export format state gate failed: ${JSON.stringify(exportFlow)}`)
await clickButton('STL')
await waitFor('document.querySelector(".toast")?.textContent.includes("STL selected")', 'STL format selection')
exportFlow.selectedAfterInteraction = await evaluate(`document.querySelector('.format-card.is-selected strong')?.textContent || ''`)
if (exportFlow.selectedAfterInteraction !== 'STL') throw new Error('STL export selection did not update the UI state.')
await setViewport(390, 844, 2)
await navigate('/start')
await waitFor('document.body.innerText.includes("Start a new design.")', 'mobile start page')
const mobile = await evaluate(`(() => {
const topbar = document.querySelector('.topbar')?.getBoundingClientRect()
const heading = document.querySelector('.page-heading h1')?.getBoundingClientRect()
const actions = document.querySelector('.page-actions')?.getBoundingClientRect()
return {
path: location.pathname,
viewport: { width: innerWidth, height: innerHeight, devicePixelRatio },
bodyHorizontalOverflow: document.documentElement.scrollWidth - innerWidth,
visibleHeading: Boolean(heading && heading.width > 0 && heading.top >= (topbar?.bottom || 0) && heading.bottom < innerHeight),
visibleActions: Boolean(actions && actions.width > 0 && actions.top >= (heading?.bottom || 0) && actions.bottom < innerHeight),
}
})()`)
if (mobile.path !== '/start' || mobile.viewport.width !== 390 || mobile.bodyHorizontalOverflow > 1 || !mobile.visibleHeading || !mobile.visibleActions) throw new Error(`Mobile layout gate failed: ${JSON.stringify(mobile)}`)
const pwa = await evaluate(`(async () => { const link = document.querySelector('link[rel="manifest"]'); const response = await fetch(link?.getAttribute('href') || ''); const manifest = response.ok ? await response.json() : null; return { manifestLinked: Boolean(link), manifestStatus: response.status, name: manifest?.name || '', startUrl: manifest?.start_url || '', scope: manifest?.scope || '', display: manifest?.display || '' } })()`)
if (pwa.manifestLinked !== true || pwa.manifestStatus !== 200 || pwa.name !== 'BitBybit CAD Studio' || pwa.startUrl !== '/start' || pwa.scope !== '/' || pwa.display !== 'standalone') throw new Error(`PWA manifest gate failed: ${JSON.stringify(pwa)}`)
const accessibility = await evaluate(`(() => {
const visible = (element) => { const style = getComputedStyle(element); return style.display !== 'none' && style.visibility !== 'hidden' && element.getClientRects().length > 0 }
const name = (element) => element.getAttribute('aria-label') || element.getAttribute('title') || element.textContent?.trim() || element.getAttribute('placeholder') || ''
const controls = [...document.querySelectorAll('button, input, select, textarea')].filter(visible)
const missing = controls.filter((element) => !name(element)).map((element) => element.tagName.toLowerCase() + '.' + (element.className || ''))
return { checked: controls.length, missing }
})()`)
if (accessibility.missing.length > 0) throw new Error(`Chrome accessibility naming gate failed: ${JSON.stringify(accessibility)}`)
const accessibilityTree = await client.send('Accessibility.getFullAXTree', {}, sessionId)
const accessibilityRoles = new Set(['button', 'checkbox', 'combobox', 'link', 'menuitem', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox'])
const accessibilityNodes = (accessibilityTree.nodes || []).filter((node) => node.ignored !== true)
const accessibilityControls = accessibilityNodes.filter((node) => accessibilityRoles.has(node.role?.value || ''))
const screenReader = {
nodes: accessibilityNodes.length,
controls: accessibilityControls.length,
namedControls: accessibilityControls.filter((node) => Boolean(node.name?.value?.trim())).length,
unnamedControls: accessibilityControls.filter((node) => !node.name?.value?.trim()).length,
landmarks: accessibilityNodes.filter((node) => ['main', 'navigation', 'region'].includes(node.role?.value || '')).length,
}
if (screenReader.nodes < 10 || screenReader.controls < 8 || screenReader.namedControls !== screenReader.controls || screenReader.unnamedControls !== 0) throw new Error(`Chrome accessibility tree gate failed: ${JSON.stringify(screenReader)}`)
const focusSequence = []
for (let index = 0; index < 8; index += 1) {
await client.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9, nativeVirtualKeyCode: 9 }, sessionId)
await client.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9, nativeVirtualKeyCode: 9 }, sessionId)
focusSequence.push(await evaluate(`(() => { const element = document.activeElement; return { tag: element?.tagName || '', name: element?.getAttribute('aria-label') || element?.getAttribute('title') || element?.textContent?.trim() || element?.getAttribute('placeholder') || '', visible: Boolean(element && element.getClientRects().length) } })()`))
}
const keyboard = { steps: focusSequence.length, named: focusSequence.filter((entry) => entry.visible && entry.name).length, unnamedVisible: focusSequence.filter((entry) => entry.visible && !entry.name).length, sequence: focusSequence }
if (keyboard.steps !== 8 || keyboard.named !== keyboard.steps || keyboard.unnamedVisible !== 0) throw new Error(`Chrome keyboard focus gate failed: ${JSON.stringify(keyboard)}`)
const mobileScreenshot = await screenshot('mobile-start')
if (pageErrors.length > 0) throw new Error(`Chrome application emitted page errors: ${pageErrors.join(' | ')}`)
status = 'pass'
const report = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
browserId: 'chrome',
status,
browser: { product: version.product, protocolVersion: version.protocolVersion, userAgent: version.userAgent },
headers: { coop: 'same-origin', coep: 'require-corp' },
workflows: ['start-to-workspace', 'viewport-multi-object-box-selection', 'freecad-menus', 'command-search-dialog', 'preferences-about-dialogs', 'model-tree-context-menu', 'workbench-change', 'create-sketch-command', 'loft-section-task', 'drawer-collapse', 'cam-workbench-job-flow', 'opfs-save-reopen', 'import-format-selection', 'export-format-selection'],
uiParity: { fileMenu, commandDialog, preferencesDialog, aboutDialog, contextMenu, multiSelection, viewportBoxSelection, camMenu },
desktop,
loftTask,
cam: { jobTask: camJobTask, toolTask: camToolTask, operationTask: camOperationTask, undoRedo: { undo: camUndoUi, redo: camRedoUi }, extendedTasks: { fixture: camFixtureTask, toolBitLoad: camToolBitLoadTask, drilling: camDrillingTask, dragKnife: camDragKnifeTask, post: camPostTask }, ui: camUi },
persistenceRoundTrip,
importFlow,
exportFlow,
mobile,
accessibility,
screenReader,
keyboard,
pwa,
offline,
screenshots: { desktop: desktopScreenshot, cam: camScreenshot, mobile: mobileScreenshot },
pageErrors,
}
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify(report, null, 2))
} finally {
client.close()
chrome.kill('SIGTERM')
await new Promise((resolveServer) => server.close(resolveServer))
await removeChromeProfile(chromeProfile)
if (status !== 'pass') console.error(chromeStderr.slice(-3000))
}