import esbuild from "esbuild"; import { chromium } from "playwright-core"; import { createServer } from "node:http"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { extname, join, resolve } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; const rootDir = resolve(fileURLToPath(new URL("..", import.meta.url))); const outDir = join(rootDir, ".tmp", "preview-verify"); const failureDir = join(rootDir, ".tmp", "preview-failures"); const bundlePath = join(outDir, "preview.js"); const shimPath = join(outDir, "obsidian-shim.js"); const entryPath = join(rootDir, "scripts", "visual-preview-entry.ts"); const modelPath = parseModelPath(); const stylesPath = join(rootDir, "styles.css"); function parseModelPath() { const modelIndex = process.argv.indexOf("--model"); if (modelIndex >= 0) { return resolve(process.argv[modelIndex + 1]); } return join(rootDir, "models", "rubiks-cube-3x3.glb"); } function parseMode() { const modeIndex = process.argv.indexOf("--mode"); if (modeIndex >= 0) { return process.argv[modeIndex + 1] ?? "basic"; } return "basic"; } function parseRollout() { const rolloutIndex = process.argv.indexOf("--rollout"); if (rolloutIndex >= 0) { return process.argv[rolloutIndex + 1] ?? "three-direct-glb"; } return "three-direct-glb"; } function parseAllowWorkbenchThree() { return process.argv.includes("--allow-workbench-three"); } function parseExpectBackend() { const backendIndex = process.argv.indexOf("--expect-backend"); if (backendIndex >= 0) { return process.argv[backendIndex + 1] ?? null; } return null; } function parseRouteOnly() { return process.argv.includes("--route-only"); } function parseExpectWarning() { const warningIndex = process.argv.indexOf("--expect-warning"); if (warningIndex >= 0) { return process.argv[warningIndex + 1] ?? null; } return null; } function parseExpectNoWarnings() { return process.argv.includes("--expect-no-warnings"); } function parseExpectGroupParts() { return process.argv.includes("--expect-group-parts"); } const verifyMode = parseMode(); const verifyRollout = parseRollout(); const verifyAllowWorkbenchThree = parseAllowWorkbenchThree(); const verifyExpectedBackend = parseExpectBackend(); const verifyRouteOnly = parseRouteOnly(); const verifyExpectedWarning = parseExpectWarning(); const verifyExpectNoWarnings = parseExpectNoWarnings(); const verifyExpectGroupParts = parseExpectGroupParts(); const mimeTypes = new Map([ [".html", "text/html; charset=utf-8"], [".js", "text/javascript; charset=utf-8"], [".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"], [".gltf", "model/gltf+json"], [".stl", "application/sla"], [".ply", "application/octet-stream"], [".obj", "text/plain"], [".wasm", "application/wasm"], ]); function assert(condition, message) { if (!condition) { throw new Error(message); } } function candidateBrowsers() { const candidates = []; if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE) { candidates.push(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE); } candidates.push( "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable", "/snap/bin/chromium", ); if (process.env.HOME) { candidates.push( join(process.env.HOME, "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"), join(process.env.HOME, "Applications", "Microsoft Edge.app", "Contents", "MacOS", "Microsoft Edge"), join(process.env.HOME, "Applications", "Chromium.app", "Contents", "MacOS", "Chromium"), join(process.env.HOME, "Applications", "Brave Browser.app", "Contents", "MacOS", "Brave Browser"), ); } const programFiles = [ process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA, ].filter(Boolean); for (const base of programFiles) { candidates.push( join(base, "Microsoft", "Edge", "Application", "msedge.exe"), join(base, "Google", "Chrome", "Application", "chrome.exe"), join(base, "Chromium", "Application", "chrome.exe"), ); } return [...new Set(candidates)].filter((file) => existsSync(file)); } async function buildHarness() { await rm(outDir, { recursive: true, force: true }); await mkdir(outDir, { recursive: true }); await writeFile( shimPath, [ "export const Platform = { isMobile: false };", "export class TFile {}", "export class TFolder { constructor() { this.children = []; } }", "export class Notice {}", "export class Plugin {}", "export class Component {", " constructor() { this.children = []; }", " load() {}", " unload() { this.children.length = 0; }", " addChild(child) { this.children.push(child); return child; }", " removeChild(child) { this.children = this.children.filter((entry) => entry !== child); }", "}", "export const MarkdownRenderer = {", " async render(_app, content, el) { el.textContent = content; }", "};", "if (typeof HTMLElement !== 'undefined' && !HTMLElement.prototype.setCssProps) {", " HTMLElement.prototype.setCssProps = function(props) {", " for (const [key, value] of Object.entries(props)) this.style.setProperty(key, value);", " };", "}", "if (typeof SVGElement !== 'undefined' && !SVGElement.prototype.setCssProps) {", " SVGElement.prototype.setCssProps = function(props) {", " for (const [key, value] of Object.entries(props)) this.style.setProperty(key, value);", " };", "}", "", ].join("\n"), "utf8", ); await esbuild.build({ entryPoints: [entryPath], bundle: true, outfile: bundlePath, format: "iife", target: "es2020", sourcemap: "inline", logLevel: "silent", banner: { js: "var activeWindow = window;", }, plugins: [ { name: "obsidian-browser-shim", setup(build) { build.onResolve({ filter: /^obsidian$/ }, () => ({ path: shimPath })); }, }, ], }); } function createStaticServer() { const server = createServer(async (request, response) => { try { const url = new URL(request.url ?? "/", "http://127.0.0.1"); const pathname = decodeURIComponent(url.pathname); let filePath; if (pathname === "/" || pathname === "/index.html") { response.writeHead(200, { "content-type": "text/html; charset=utf-8" }); response.end(`
`); return; } if (pathname.startsWith("/.tmp/preview-verify/")) { filePath = join(rootDir, pathname.slice(1)); } else if (pathname === "/styles.css") { filePath = stylesPath; } else if (pathname.startsWith("/models/")) { filePath = join(rootDir, pathname.slice(1)); } else { response.writeHead(404); response.end("Not found"); return; } const data = await readFile(filePath); response.writeHead(200, { "content-type": mimeTypes.get(extname(filePath)) ?? "application/octet-stream", }); response.end(data); } catch (error) { response.writeHead(500, { "content-type": "text/plain; charset=utf-8" }); response.end(error instanceof Error ? error.stack ?? error.message : String(error)); } }); return new Promise((resolveServer) => { server.listen(0, "127.0.0.1", () => { const address = server.address(); assert(address && typeof address === "object", "Failed to bind verification server"); resolveServer({ server, url: `http://127.0.0.1:${address.port}/`, }); }); }); } async function canvasPixelStats(page) { return page.locator("#preview-canvas").evaluate((canvas) => { const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true }) ?? canvas.getContext("webgl", { preserveDrawingBuffer: true }); if (!gl) { throw new Error("Canvas WebGL context is unavailable for pixel readback"); } const width = gl.drawingBufferWidth; const height = gl.drawingBufferHeight; const pixels = new Uint8Array(width * height * 4); gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); const stepX = Math.max(1, Math.floor(width / 64)); const stepY = Math.max(1, Math.floor(height / 64)); let nonBackground = 0; let samples = 0; let min = 255; let max = 0; for (let y = 0; y < height; y += stepY) { for (let x = 0; x < width; x += stepX) { const offset = (y * width + x) * 4; const r = pixels[offset]; const g = pixels[offset + 1]; const b = pixels[offset + 2]; const a = pixels[offset + 3]; const brightness = (r + g + b) / 3; min = Math.min(min, brightness); max = Math.max(max, brightness); if (a > 0 && Math.abs(r - 32) + Math.abs(g - 36) + Math.abs(b - 46) > 18) { nonBackground += 1; } samples += 1; } } return { samples, nonBackground, nonBackgroundRatio: nonBackground / samples, contrast: max - min, }; }); } async function readPreviewState(page) { try { return await page.evaluate(() => window.__ai3dPreviewVerify ?? null); } catch (error) { return { status: "unavailable", error: error instanceof Error ? error.message : String(error), }; } } const toolbarLabels = { wireframe: "Toggle wireframe", axes: "Toggle orientation axes", boundingBox: "Toggle bounding box", resolution: "Change resolution", }; async function getToolbarButton(page, label) { const button = page.locator(`.ai3d-helper-toolbar button[aria-label="${label}"]`).first(); try { await button.waitFor({ state: "visible", timeout: 1000 }); } catch { // Secondary actions are collapsed by default; expand the "more" menu. const more = page.locator(".ai3d-helper-toolbar .ai3d-mobile-more-toggle").first(); await more.click(); await button.waitFor({ state: "visible", timeout: 5000 }); } return button; } async function dispatchCanvasClick(page, clientX, clientY) { await page.evaluate(({ clientX, clientY }) => { const canvas = document.querySelector("#preview-canvas"); if (!(canvas instanceof HTMLCanvasElement)) { throw new Error("Preview canvas is unavailable for synthetic click"); } canvas.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, buttons: 1, clientX, clientY, isPrimary: true, pointerId: 1, pointerType: "mouse", })); canvas.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, button: 0, buttons: 0, clientX, clientY, isPrimary: true, pointerId: 1, pointerType: "mouse", })); }, { clientX, clientY }); } async function pickSelectedPartInfo(page, box) { const offsets = [ [0, 0], [0.12, -0.12], [-0.12, -0.12], [0.12, 0.12], [-0.12, 0.12], ]; for (const [offsetX, offsetY] of offsets) { const clientX = box.x + box.width * (0.5 + offsetX); const clientY = box.y + box.height * (0.5 + offsetY); await dispatchCanvasClick(page, clientX, clientY); await page.waitForTimeout(100); const markdown = await page.evaluate(() => window.__ai3dPreview?.exportSelectedPartInfo?.() ?? ""); if (markdown.includes("Part Info")) { return { markdown, clientX, clientY }; } } return { markdown: "", clientX: box.x + box.width / 2, clientY: box.y + box.height / 2 }; } async function verifyHelperToolbar(page) { await page.waitForSelector(".ai3d-helper-toolbar", { timeout: 5000 }); const verifyToggleButton = async (button, label) => { const before = await button.evaluate((entry) => entry.classList.contains("ai3d-btn-active")); await button.click(); await page.waitForTimeout(100); const after = await button.evaluate((entry) => entry.classList.contains("ai3d-btn-active")); assert(after !== before, `${label} toolbar button did not toggle`); }; const wireBtn = await getToolbarButton(page, toolbarLabels.wireframe); await verifyToggleButton(wireBtn, "Wireframe"); const axesBtn = await getToolbarButton(page, toolbarLabels.axes); await verifyToggleButton(axesBtn, "Orientation axes"); const bboxBtn = await getToolbarButton(page, toolbarLabels.boundingBox); await verifyToggleButton(bboxBtn, "Bounding box"); const resBtn = await getToolbarButton(page, toolbarLabels.resolution); const beforeText = (await resBtn.textContent())?.trim(); await resBtn.click(); await page.waitForTimeout(100); const afterText = (await resBtn.textContent())?.trim(); assert( !!beforeText && !!afterText && beforeText !== afterText, `Resolution toolbar button did not cycle value: before=${beforeText ?? "null"}, after=${afterText ?? "null"}`, ); } async function verifyReadonlyPinMode(page, state) { assert(state?.mode === "readonly-pin", `Expected readonly-pin mode, received ${state?.mode ?? "unknown"}`); await page.waitForFunction(() => { const verify = window.__ai3dPreviewVerify; return verify?.pinCount === 2 && verify.pinLabels?.includes("Center Pin") && verify.pinLabels?.includes("Occluded Pin"); }, null, { timeout: 5000 }); const pin = page.locator(".ai3d-annotation-pin", { hasText: "Center Pin" }).first(); await pin.waitFor({ state: "visible", timeout: 5000 }); const pinLabel = (await pin.locator(".ai3d-pin-label").textContent()) ?? ""; assert(pinLabel.includes("Center Pin"), `Readonly pin label was unexpected: ${pinLabel}`); assert(await page.locator(".ai3d-pin-delete").count() === 0, "Readonly pin unexpectedly exposed delete controls"); await pin.click(); await page.waitForTimeout(200); assert(await page.locator(".ai3d-annotation-editor").count() === 0, "Readonly pin unexpectedly opened editor"); await page.waitForFunction(() => { const pins = Array.from(document.querySelectorAll(".ai3d-annotation-pin")); const pin = pins.find((entry) => entry.textContent?.includes("Occluded Pin")); if (!pin?.classList.contains("ai3d-pin-occluded")) return false; const styles = getComputedStyle(pin); return styles.visibility === "hidden" && styles.pointerEvents === "none" && Number(styles.opacity) === 0; }, null, { timeout: 5000 }); await verifyOcclusionUpdatesWhileRotating(page); } async function verifyFocusSelectionAfterExistingPick(page, selectedPartMarkdown) { const focusOn = await page.evaluate(() => window.__ai3dPreview?.toggleFocusSelection()); assert(focusOn === true, "Focus selection did not turn on"); const focusedPartMarkdown = await page.evaluate(() => window.__ai3dPreview?.exportSelectedPartInfo?.() ?? ""); assert(focusedPartMarkdown.includes("Part Info"), "Focus mode did not preserve the existing selected part"); assert( focusedPartMarkdown === selectedPartMarkdown, "Focus mode did not align to the previously selected part", ); } async function verifyFocusSelectionBlankClickPreservesFocus(page, box, selectedPartMarkdown) { const beforeFocus = await page.evaluate(() => window.__ai3dPreview?.exportSelectedPartInfo?.() ?? ""); assert(beforeFocus === selectedPartMarkdown, "Focus mode changed selected part before blank-click verification"); await dispatchCanvasClick(page, box.x + 12, box.y + 12); await page.waitForTimeout(150); const afterFocus = await page.evaluate(() => window.__ai3dPreview?.exportSelectedPartInfo?.() ?? ""); assert(afterFocus === selectedPartMarkdown, "Blank click cleared or changed the focused part"); } async function verifyOcclusionUpdatesWhileRotating(page) { const occludedPin = page.locator(".ai3d-annotation-pin", { hasText: "Occluded Pin" }).first(); await occludedPin.waitFor({ state: "attached", timeout: 5000 }); const before = await occludedPin.evaluate((pin) => ({ left: pin.style.getPropertyValue("--pin-left"), top: pin.style.getPropertyValue("--pin-top"), occluded: pin.classList.contains("ai3d-pin-occluded"), })); const moved = await page.evaluate(async () => { const preview = window.__ai3dPreview; const provider = preview?.getAnnotationProvider?.(); if (!preview || !provider || typeof preview.applyConfig !== "function") { return { skipped: true }; } preview.applyConfig({ models: [{ path: "models/rubiks-cube-3x3.glb" }], camera: { position: [-4.8, 3.1, -4.8], lookAt: [0.45, 0.2, 0] }, }); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const projection = { screenX: 0, screenY: 0, depth: 0 }; const projected = provider.projectWorldPoint({ x: 0, y: 0, z: 0 }, projection); return { skipped: false, projected, occluded: provider.isWorldPointOccluded({ x: 0, y: 0, z: 0 }), left: `${Math.round(projection.screenX)}px`, top: `${Math.round(projection.screenY)}px`, }; }); assert(!moved.skipped, "Occlusion rotation verification could not access annotation provider"); assert(moved.projected, "Occlusion rotation verification could not project the occluded pin"); await page.waitForFunction((expected) => { const pins = Array.from(document.querySelectorAll(".ai3d-annotation-pin")); const pin = pins.find((entry) => entry.textContent?.includes("Occluded Pin")); if (!(pin instanceof HTMLElement)) return false; return pin.style.getPropertyValue("--pin-left") === expected.left && pin.style.getPropertyValue("--pin-top") === expected.top && pin.classList.contains("ai3d-pin-occluded") === expected.occluded; }, moved, { timeout: 5000 }); const after = await occludedPin.evaluate((pin) => ({ left: pin.style.getPropertyValue("--pin-left"), top: pin.style.getPropertyValue("--pin-top"), occluded: pin.classList.contains("ai3d-pin-occluded"), })); assert( before.left !== after.left || before.top !== after.top || before.occluded !== after.occluded, "Occluded pin projection/occlusion state did not update after camera rotation", ); const priority = await page.evaluate(() => { const pins = Array.from(document.querySelectorAll(".ai3d-annotation-pin")); return pins.map((pin) => ({ text: pin.textContent ?? "", zIndex: Number.parseInt(getComputedStyle(pin).zIndex || "0", 10), })); }); assert( priority.some((pin) => Number.isFinite(pin.zIndex) && pin.zIndex >= 100), `Pin visual priority was not applied: ${JSON.stringify(priority)}`, ); } async function verifyThreePerformanceBudgetSnapshot(page, route, performanceSnapshot) { if (route?.backend !== "three") return; assert( typeof performanceSnapshot?.frameBudgetPixelRatioScale === "number", `Three performance snapshot is missing frame budget scale: ${JSON.stringify(performanceSnapshot)}`, ); assert( typeof performanceSnapshot?.frameBudgetObserverStride === "number", `Three performance snapshot is missing observer stride: ${JSON.stringify(performanceSnapshot)}`, ); assert( performanceSnapshot?.viewportVisible === true, `Three preview should be visible during verification: ${JSON.stringify(performanceSnapshot)}`, ); assert( performanceSnapshot?.disposalAudit?.reason, `Three disposal audit is missing: ${JSON.stringify(performanceSnapshot)}`, ); } function verifyGroupedPartsEvidence(state) { if (!verifyExpectGroupParts) return; const parts = Array.isArray(state?.evidence?.parts) ? state.evidence.parts : []; const groupParts = parts.filter((part) => part?.source === "group"); const meshParts = parts.filter((part) => part?.source === "mesh"); const componentParts = parts.filter((part) => part?.source === "component"); const groupNames = new Set(groupParts.map((part) => part.name)); assert(groupParts.length >= 2, `Expected at least 2 grouped parts, got ${JSON.stringify(parts)}`); assert(groupNames.has("Left Assembly"), `Missing Left Assembly group part: ${JSON.stringify(parts)}`); assert(groupNames.has("Right Assembly"), `Missing Right Assembly group part: ${JSON.stringify(parts)}`); assert(!groupNames.has("Grouped Parts Fixture"), `Whole-model wrapper was registered as a group part: ${JSON.stringify(parts)}`); assert( groupParts.every((part) => part.childCount >= 2 && Array.isArray(part.meshNames) && part.meshNames.length >= 2), `Grouped parts did not keep child mesh evidence: ${JSON.stringify(groupParts)}`, ); assert( meshParts.some((part) => part.name === "Loose Detail"), `Ungrouped mesh part was not preserved alongside grouped parts: ${JSON.stringify(parts)}`, ); const taggedComponent = componentParts.find((part) => part.componentId === "FASTENER-M3"); assert( taggedComponent?.occurrenceId === "ASM-ROOT/FASTENER-001" && taggedComponent.partNumber === "DIN912-M3x8" && taggedComponent.componentPath === "ASM-ROOT/FASTENER-001", `Tagged GLB component metadata was not preserved: ${JSON.stringify(parts)}`, ); } async function verifyWorkbenchMode(page, state, stats, performanceSnapshot, selectedPartMarkdown) { assert(state?.mode === "workbench", `Expected workbench mode, received ${state?.mode ?? "unknown"}`); assert(state?.route?.requireWorkbenchFeatures === true, "Workbench route did not require workbench features"); assert(state?.pinCount === 1, "Workbench annotation provider did not mount initial pin"); const result = await page.evaluate(async () => { const preview = window.__ai3dPreview; const canvas = document.querySelector("#preview-canvas"); if (!preview || !(canvas instanceof HTMLCanvasElement)) { return { missing: true }; } const methods = [ "getAnnotationProvider", "focusWorldPoint", "hasAnimations", "setRenderQuality", "captureSnapshot", "getModelEvidence", "exportModelInfo", "exportSelectedPartInfo", ]; const missingMethods = methods.filter((method) => typeof preview[method] !== "function"); const before = canvas.toDataURL("image/png"); preview.focusWorldPoint?.({ x: 0.8, y: 0.8, z: 0.8 }); await new Promise((resolve) => activeWindow.setTimeout(resolve, 450)); const focused = canvas.toDataURL("image/png"); const snapshot = preview.captureSnapshot?.() ?? ""; const evidence = preview.getModelEvidence?.() ?? null; return { missing: false, missingMethods, focusChanged: before !== focused, snapshot, evidence, modelInfo: preview.exportModelInfo?.("rubiks-cube-3x3.glb") ?? "", }; }); assert(!result.missing, "Workbench preview was unavailable"); assert(result.missingMethods.length === 0, `Workbench preview is missing methods: ${result.missingMethods.join(", ")}`); assert(result.focusChanged, "Workbench focusWorldPoint did not change the rendered canvas"); assert(result.snapshot.startsWith("data:image/png;base64,"), "Workbench snapshot did not return a PNG data URL"); assert(result.evidence?.parts?.length > 0, `Workbench evidence is missing parts: ${JSON.stringify(result.evidence)}`); assert(result.evidence?.summary?.meshCount === state.summary.meshCount, "Workbench evidence summary did not match preview summary"); assert(result.modelInfo.includes("Model Info"), "Workbench model info export failed"); const rows = Array.isArray(state.registeredMatchRows) ? state.registeredMatchRows : []; const noteRow = rows.find((row) => row.title === "Left Assembly"); const modelRow = rows.find((row) => row.title === "Right Assembly"); assert(noteRow?.button === "Note", `Registered match part-note row did not render Note action: ${JSON.stringify(rows)}`); assert( noteRow.targetPath === "Parts/3D Components/legacy/01 Left Assembly.md", `Registered match part-note target path was wrong: ${JSON.stringify(noteRow)}`, ); assert(noteRow.target === "Opens matched part note", `Registered match part-note target copy was wrong: ${JSON.stringify(noteRow)}`); assert(noteRow.model === "From legacy grouped parts.gltf", `Registered match source model label was wrong: ${JSON.stringify(noteRow)}`); assert(noteRow.disabled === false, `Registered match part-note action was disabled: ${JSON.stringify(noteRow)}`); assert(modelRow?.button === "Model", `Registered match source-model row did not render Model action: ${JSON.stringify(rows)}`); assert( modelRow.targetPath === "models/auto registered parts.gltf", `Registered match source-model target path was wrong: ${JSON.stringify(modelRow)}`, ); assert(modelRow.target === "Opens source model", `Registered match source-model target copy was wrong: ${JSON.stringify(modelRow)}`); assert(modelRow.disabled === false, `Registered match source-model action was disabled: ${JSON.stringify(modelRow)}`); console.log("Workbench preview verification passed"); console.log(JSON.stringify({ mode: verifyMode, rendererRollout: verifyRollout, allowWorkbenchThree: verifyAllowWorkbenchThree, route: state.route, summary: state.summary, pixelStats: stats, performance: performanceSnapshot, selectedPart: selectedPartMarkdown, }, null, 2)); } async function verifyThreeDisassemblyDragResponsive(page, route, pickPoint) { if (route?.backend !== "three") return; const setup = await page.evaluate(() => { const preview = window.__ai3dPreview; const canvas = document.querySelector("#preview-canvas"); if (!preview || !(canvas instanceof HTMLCanvasElement) || typeof preview.toggleDisassembly !== "function") { return { skipped: true }; } if (typeof preview.isFocusSelectionEnabled === "function" && preview.isFocusSelectionEnabled()) { preview.toggleFocusSelection(); } const enabled = preview.toggleDisassembly(); return { skipped: false, enabled, before: canvas.toDataURL("image/png"), }; }); assert(!setup.skipped, "Three disassembly verification could not access the preview"); assert(setup.enabled === true, "Disassembly mode did not turn on"); await page.mouse.move(pickPoint.clientX, pickPoint.clientY); await page.mouse.down(); await page.mouse.move(pickPoint.clientX + 96, pickPoint.clientY + 16, { steps: 6 }); await page.mouse.up(); await page.waitForTimeout(120); const result = await page.evaluate(() => { const preview = window.__ai3dPreview; const canvas = document.querySelector("#preview-canvas"); if (!preview || !(canvas instanceof HTMLCanvasElement)) { return { skipped: true }; } const after = canvas.toDataURL("image/png"); preview.resetDisassembly?.(); if (typeof preview.isDisassemblyEnabled === "function" && preview.isDisassemblyEnabled()) { preview.toggleDisassembly(); } return { skipped: false, after, }; }); assert(!result.skipped, "Three disassembly verification could not read the canvas after drag"); assert(setup.before !== result.after, "Disassembly drag did not refresh the Three canvas immediately"); } async function saveFailureArtifacts(page, browserMessages, error) { await mkdir(failureDir, { recursive: true }); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); const basePath = join(failureDir, `preview-failure-${stamp}`); const screenshotPath = `${basePath}.png`; const logPath = `${basePath}.txt`; let screenshotLine = "Screenshot: not captured"; let screenshotCaptured = false; if (page && !page.isClosed()) { try { await page.screenshot({ path: screenshotPath, fullPage: true }); screenshotLine = `Screenshot: ${screenshotPath}`; screenshotCaptured = true; } catch (screenshotError) { screenshotLine = `Screenshot failed: ${ screenshotError instanceof Error ? screenshotError.stack ?? screenshotError.message : String(screenshotError) }`; } } const state = page && !page.isClosed() ? await readPreviewState(page) : null; const lines = [ `Error: ${error instanceof Error ? error.stack ?? error.message : String(error)}`, screenshotLine, "", "Preview state:", JSON.stringify(state, null, 2), "", "Browser messages:", browserMessages.length > 0 ? browserMessages.join("\n") : "(none)", "", ]; await writeFile(logPath, lines.join("\n"), "utf8"); return { screenshotPath: screenshotCaptured ? screenshotPath : null, logPath }; } async function verify() { assert(existsSync(modelPath), `Missing sample model: ${modelPath}`); await buildHarness(); const { server, url } = await createStaticServer(); const browsers = candidateBrowsers(); assert( browsers.length > 0, "No Chromium browser found. Install Microsoft Edge/Chrome or set PLAYWRIGHT_CHROMIUM_EXECUTABLE.", ); const browser = await chromium.launch({ executablePath: browsers[0], headless: true, }); let page = null; const browserMessages = []; try { page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); page.on("console", (message) => browserMessages.push(`${message.type()}: ${message.text()}`)); page.on("pageerror", (error) => browserMessages.push(`pageerror: ${error.stack ?? error.message}`)); const params = new URLSearchParams(); if (verifyMode !== "basic") { params.set("mode", verifyMode); } params.set("rollout", verifyRollout); if (verifyAllowWorkbenchThree) { params.set("allowWorkbenchThree", "1"); } const modelsRoot = `${join(rootDir, "models").replace(/\\/g, "/")}/`; const normalizedModelPath = modelPath.replace(/\\/g, "/"); const modelRef = normalizedModelPath.startsWith(modelsRoot) ? normalizedModelPath.slice(modelsRoot.length) : modelPath.split(/[/\\]/).pop(); if (modelRef && modelRef !== "rubiks-cube-3x3.glb") { params.set("model", modelRef); } const targetUrl = params.size > 0 ? `${url}?${params.toString()}` : url; await page.goto(targetUrl, { waitUntil: "commit" }); await page.waitForFunction(() => !!window.__ai3dPreviewVerify && window.__ai3dPreviewVerify.status !== "loading", null, { timeout: 15000, }); const state = await page.evaluate(() => window.__ai3dPreviewVerify); assert( state?.status === "ready", `Preview failed: ${state?.error ?? "unknown error"}\n${browserMessages.join("\n")}`, ); assert(state.summary.meshCount > 0, "Model summary reports zero meshes"); assert(state.summary.triangleCount > 0, "Model summary reports zero triangles"); assert(state.summary.vertexCount > 0, "Model summary reports zero vertices"); assert(state.route?.backend === expectedBackend(verifyMode, verifyRollout), `Unexpected route: ${JSON.stringify(state.route)}`); const warnings = Array.isArray(state.summary.resourceWarnings) ? state.summary.resourceWarnings : []; if (verifyExpectedWarning) { assert( warnings.some((warning) => String(warning).includes(verifyExpectedWarning)), `Expected resource warning '${verifyExpectedWarning}', got ${JSON.stringify(warnings)}`, ); } if (verifyExpectNoWarnings) { assert(warnings.length === 0, `Expected no resource warnings, got ${JSON.stringify(warnings)}`); } verifyGroupedPartsEvidence(state); await page.locator("#preview-canvas").scrollIntoViewIfNeeded(); await page.waitForTimeout(500); const stats = await canvasPixelStats(page); assert(stats.nonBackgroundRatio > 0.01, `Canvas looks blank: ${JSON.stringify(stats)}`); assert(stats.contrast > 12, `Canvas has too little contrast: ${JSON.stringify(stats)}`); const performanceSnapshot = await page.evaluate(() => window.__ai3dPreview?.getPerformanceSnapshot?.() ?? null); assert( performanceSnapshot?.backend === state.route?.backend, `Performance snapshot backend did not match route: ${JSON.stringify(performanceSnapshot)}`, ); await verifyThreePerformanceBudgetSnapshot(page, state.route, performanceSnapshot); if (verifyRouteOnly) { console.log("Preview route-only verification passed"); console.log(JSON.stringify({ mode: verifyMode, rendererRollout: verifyRollout, route: state.route, summary: state.summary, pixelStats: stats, performance: performanceSnapshot, }, null, 2)); return; } const beforeScroll = await page.evaluate(() => window.scrollY); const box = await page.locator("#preview-canvas").boundingBox(); assert(box, "Canvas bounding box is unavailable"); await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); await page.mouse.wheel(0, 600); await page.waitForTimeout(100); const afterScroll = await page.evaluate(() => window.scrollY); assert( Math.abs(afterScroll - beforeScroll) < 2, `Wheel over preview scrolled the page: before=${beforeScroll}, after=${afterScroll}`, ); const selectedPartPick = await pickSelectedPartInfo(page, box); const selectedPartMarkdown = selectedPartPick.markdown; assert(selectedPartMarkdown.includes("Part Info"), "Selected part info was not exported"); assert(selectedPartMarkdown.includes("| Triangles |"), "Selected part info is missing triangle count"); await verifyFocusSelectionAfterExistingPick(page, selectedPartMarkdown); await verifyFocusSelectionBlankClickPreservesFocus(page, box, selectedPartMarkdown); if (verifyMode === "basic") { await verifyThreeDisassemblyDragResponsive(page, state.route, { clientX: selectedPartPick.clientX, clientY: selectedPartPick.clientY, }); } await verifyHelperToolbar(page); if (verifyMode === "workbench") { await verifyWorkbenchMode(page, state, stats, performanceSnapshot, selectedPartMarkdown); return; } if (verifyMode === "direct-edit") { assert(state?.mode === "direct-edit", `Expected direct-edit mode, received ${state?.mode ?? "unknown"}`); const snapshot = await page.evaluate(() => window.__ai3dPreview?.captureSnapshot?.() ?? ""); assert(snapshot.startsWith("data:image/png;base64,"), "Snapshot capture did not return a PNG data URL"); await page.waitForSelector(".ai3d-annotation-editor", { timeout: 5000 }); const editorBox = await page.locator(".ai3d-annotation-editor").boundingBox(); assert(editorBox, "Annotation editor did not open"); assert( Math.abs(editorBox.x - selectedPartPick.clientX) < 220 && Math.abs(editorBox.y - selectedPartPick.clientY) < 220, `Annotation editor anchored too far from pick point: ${JSON.stringify(editorBox)}`, ); await page.locator(".ai3d-annotation-editor-input").fill("Phase 2 Pin"); await page.locator(".ai3d-annotation-editor-confirm").click(); await page.waitForFunction(() => window.__ai3dPreviewVerify?.pinCount === 1, null, { timeout: 5000 }); const pin = page.locator(".ai3d-annotation-pin").first(); await pin.waitFor({ state: "visible", timeout: 5000 }); const pinLabel = (await pin.locator(".ai3d-pin-label").textContent()) ?? ""; assert(pinLabel.includes("Phase 2 Pin"), `Created pin label was unexpected: ${pinLabel}`); await pin.click(); await page.waitForFunction(() => { const input = document.querySelector(".ai3d-annotation-editor-input"); return input instanceof HTMLInputElement && input.value === "Phase 2 Pin"; }, null, { timeout: 5000 }); await page.locator(".ai3d-annotation-editor-input").fill("Updated Pin"); await page.locator(".ai3d-annotation-editor-confirm").click(); await page.waitForFunction(() => window.__ai3dPreviewVerify?.pinLabels?.[0] === "Updated Pin", null, { timeout: 5000 }); await pin.click(); await page.waitForSelector(".ai3d-annotation-editor-delete", { timeout: 5000 }); await page.locator(".ai3d-annotation-editor-delete").click(); await page.waitForFunction(() => window.__ai3dPreviewVerify?.pinCount === 0, null, { timeout: 5000 }); console.log("Direct edit preview verification passed"); console.log(JSON.stringify({ mode: verifyMode, rendererRollout: verifyRollout, route: state.route, summary: state.summary, pixelStats: stats, performance: performanceSnapshot, selectedPart: selectedPartMarkdown, }, null, 2)); return; } if (verifyMode === "readonly-pin") { await verifyReadonlyPinMode(page, state); console.log("Readonly pin preview verification passed"); console.log(JSON.stringify({ mode: verifyMode, rendererRollout: verifyRollout, route: state.route, summary: state.summary, pixelStats: stats, performance: performanceSnapshot, selectedPart: selectedPartMarkdown, }, null, 2)); return; } console.log("Preview verification passed"); console.log(JSON.stringify({ mode: verifyMode, rendererRollout: verifyRollout, route: state.route, summary: state.summary, pixelStats: stats, performance: performanceSnapshot, selectedPart: selectedPartMarkdown, }, null, 2)); } catch (error) { const artifacts = await saveFailureArtifacts(page, browserMessages, error); console.error(`Preview failure artifacts saved: ${artifacts.logPath}`); if (artifacts.screenshotPath) { console.error(`Preview failure screenshot saved: ${artifacts.screenshotPath}`); } throw error; } finally { await browser.close(); server.close(); } } function expectedBackend(mode, rollout) { if (verifyExpectedBackend === "three" || verifyExpectedBackend === "babylon") { return verifyExpectedBackend; } if (mode === "workbench" && !verifyAllowWorkbenchThree) { return "babylon"; } if (mode === "workbench") { return rollout === "babylon-safe" ? "babylon" : "three"; } if (mode === "direct-edit") { return rollout === "three-direct-glb" ? "three" : "babylon"; } return rollout === "babylon-safe" ? "babylon" : "three"; } verify().catch((error) => { console.error(error instanceof Error ? error.stack ?? error.message : String(error)); process.exitCode = 1; });