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