532 lines
50 KiB
JavaScript
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))
|
|
}
|