import { expect, type Locator, type Page } from "@playwright/test"; import fs from "node:fs"; import path from "node:path"; const browserDiagnostics = new WeakMap(); export async function exposeWebTools(page: Page) { const button = page.getByTestId('native-web-tools-toggle'); /* Browser mode has no native-canvas overlay, so the toggle is absent and the * web tools are already visible. */ if (!(await button.count())) return; const events = await button.locator('..').evaluate(element => getComputedStyle(element).pointerEvents); expect(typeof events).toBe('string'); expect(['auto', 'none']).toContain(events); if (events === 'auto') await button.click(); } export async function resolveProjectReplacement(page: Page, choice: 'cancel' | 'discard' | 'save') { const dialog = page.getByRole('dialog', { name: '保存当前工程的修改?', exact: true }); await expect(dialog).toBeVisible(); await dialog.getByRole('button', { name: { cancel: '取消', discard: '不保存,继续', save: '保存后继续' }[choice], exact: true }).click(); await expect(dialog).toBeHidden({ timeout: 30000 }); } export async function observeNative(page: Page) { const diagnostics: { type: string; text: string }[] = []; browserDiagnostics.set(page, diagnostics); const record = (type: string, text: string) => { const bounded = text.slice(0, 4000); if (!diagnostics.some(item => item.text === bounded)) { diagnostics.push({ type, text: bounded }); if (diagnostics.length > 16) diagnostics.shift(); } }; page.on('console', message => { const text = message.text(); if (message.type() === 'error' || /ERROR|Traceback|Error:/.test(text)) record(message.type(), text); }); page.on('pageerror', error => record('pageerror', error.message)); await page.addInitScript(() => { const responses: { requestId: string; ok: boolean; errorCode?: string }[] = []; const requests: { requestId: string; method?: string; command?: string; [key: string]: unknown }[] = []; (window as any).__nativeWorkflowResponses = responses; (window as any).__nativeWorkflowRequests = requests; const uiKeys = ['data-user-action-open-status', 'data-user-action-save-status', 'data-save-transaction-status', 'data-project-action-conflict', 'data-project-action-conflict-owner-id', 'data-project-action-conflict-request-id', 'data-dirty']; const uiTransitions: unknown[] = []; (window as any).__nativeWorkflowUITransitions = uiTransitions; let previousUI = ''; const captureUI = () => { const root = document.querySelector('main.blender-app'); if (!root) return; const values = Object.fromEntries(uiKeys.map(key => [key, root.getAttribute(key)])), signature = JSON.stringify(values); if (signature !== previousUI) { previousUI = signature; uiTransitions.push({ at: performance.now(), values }); if (uiTransitions.length > 16) uiTransitions.shift(); } }; new MutationObserver(captureUI).observe(document, { subtree: true, childList: true, attributes: true, attributeFilter: uiKeys }); window.Worker = new Proxy(window.Worker, { construct(target, args) { const worker = Reflect.construct(target, args) as Worker; const post = worker.postMessage; worker.postMessage = (...values: any[]) => { const message = values[0]; if (typeof message?.requestId === "string" && message.requestId.startsWith("web-engine-")) { (window as any).__nativeWorkflowEngine = worker; const command = message.command ?? message.params?.command ?? message.args?.command ?? message.payload?.command; const operation = command?.command ?? command?.editCommand ?? command?.payload ?? command; requests.push({ requestId: message.requestId, method: message.method, command: operation?.type, commandId: operation?.id ?? operation?.commandId, markers: operation?.markers }); Object.assign(requests[requests.length - 1], { type: message.command?.type ?? message.type, jsonCommand: message.command?.cmd ?? message.cmd }); Object.assign(requests[requests.length - 1], { gpuOperation: message.command?.type === 'gpuUI' ? message.command.op : undefined, nativeOperator: message.command?.type === 'gpuUI' && message.command.op === 'invoke' ? message.command.operator : undefined }); const inputs = message.command?.type === 'gpuUI' && Array.isArray(message.command.events) ? message.command.events : undefined; Object.assign(requests[requests.length - 1], { mouseReleased: inputs?.some((event: any) => event.type === 3), keyPressed: inputs?.findLast((event: any) => event.type === 5)?.key, inputEvents: inputs?.slice(-32).map((event: any) => ({ type: event.type, key: event.key, modifiers: event.modifiers, x: event.x, y: event.y, value: event.value })) }); Object.assign(requests[requests.length - 1], { editAction: operation?.edit?.action, editOperation: operation?.edit?.operation, context: operation?.edit?.context, workspaceId: operation?.edit?.workspaceId, areaId: operation?.edit?.areaId }); } Reflect.apply(post, worker, values); }; worker.addEventListener("message", ({ data }) => { if (typeof data?.requestId === "string" && data.requestId.startsWith("web-engine-") && typeof data.ok === "boolean") { const snapshot = data.result?.snapshot; if (data.result?.gpuUI) (window as any).__nativeWorkflowGPU = structuredClone({ requestId: data.requestId, ...data.result.gpuUI }); const gpuError = data.result?.gpuUI?.error; if (gpuError) (window as any).__nativeWorkflowGPUFailure = structuredClone({ requestId: data.requestId, ...gpuError, detail: typeof gpuError.detail === 'string' ? gpuError.detail.slice(0, 8192) : undefined }); const activeWorkspace = snapshot?.editorWorkflow?.workspaces?.find((item: any) => item.id === snapshot.editorWorkflow.context.workspaceId); if (activeWorkspace) (window as any).__nativeWorkflowAreas = structuredClone(activeWorkspace.areas.map((area: any) => ({ id: area.id, editor: area.editor, rect: area.rect, regions: area.regions }))); if (snapshot?.editorWorkflow) (window as any).__nativeWorkflowEditor = structuredClone({ requestId: data.requestId, context: snapshot.editorWorkflow.context, workspaces: snapshot.editorWorkflow.workspaces?.map((item: any) => ({ id: item.id, name: item.name })) }); if (data.result?.json?.engine === 'CPYTHON_WEBENGINE') (window as any).__nativeWorkflowPython = structuredClone({ requestId: data.requestId, ...data.result.json }); if (snapshot && Array.isArray(snapshot.nodes)) (window as any).__nativeWorkflowMain = structuredClone({ requestId: data.requestId, revision: snapshot.revision, nodes: snapshot.nodes.map((node: any) => ({ id: node.id, name: node.name, type: node.type, transform: node.transform })) }); responses.push({ requestId: data.requestId, ok: data.ok, errorCode: data.error?.code, ...({ errorMessage: data.error?.message, errorDetail: typeof data.error?.detail === 'string' ? data.error.detail.slice(0, 8192) : undefined, status: structuredClone(data.result?.status), markers: structuredClone(snapshot?.scenes?.[0]?.timelineMarkers) } as object) }); Object.assign(responses[responses.length - 1], { nativeError: structuredClone(data.result?.gpuUI?.error), nativeModalActive: data.result?.gpuUI?.modalActive }); } }); return worker; }, }); }); } export async function captureNativeWorkflowState(page: Page) { const state = await page.evaluate(() => ({ engineStatus: document.querySelector('[data-testid="engine-status"]')?.textContent ?? [...document.querySelectorAll('[data-testid="status-bar"],output')].map((node) => node.textContent?.slice(0, 400)).filter(Boolean).slice(0, 8), main: (window as any).__nativeWorkflowMain, editor: (window as any).__nativeWorkflowEditor, python: (window as any).__nativeWorkflowPython, gpu: (window as any).__nativeWorkflowGPU, areas: (window as any).__nativeWorkflowAreas, diagnosticDetail: document.querySelector('main.blender-app')?.getAttribute('data-last-diagnostic-detail'), canvasTelemetry: (window as any).__wbv2Canvas, requests: (window as any).__nativeWorkflowRequests?.slice(-16), responses: (window as any).__nativeWorkflowResponses?.slice(-8), uiTransitions: (window as any).__nativeWorkflowUITransitions, console: [...document.querySelectorAll('section[aria-label="Python Console"]')].map(region => ({ status: region.querySelector('[role="status"]')?.textContent, output: region.querySelector('pre')?.textContent?.slice(-4096), input: region.querySelector('textarea')?.value, disabled: region.querySelector('textarea')?.disabled })) })); return { ...state, gpuFailure: await page.evaluate(() => (window as any).__nativeWorkflowGPUFailure), browserDiagnostics: browserDiagnostics.get(page) ?? [] }; } export async function performNativeAction(page: Page, match: Record, action: () => Promise, ok = true, dispatchTimeout = 10000) { const start = await page.evaluate(() => (window as any).__nativeWorkflowRequests?.length); expect(Number.isSafeInteger(start)).toBe(true); await action(); const pending = await page.waitForFunction(({ start, match }) => (window as any).__nativeWorkflowRequests.slice(start).find((item: any) => Object.entries(match).every(([key, value]) => item[key] === value)) ?? false, { start, match }, { timeout: dispatchTimeout }); const request = await pending.jsonValue(); expect(typeof request.requestId).toBe('string'); const reply = await page.waitForFunction(id => (window as any).__nativeWorkflowResponses.find((item: any) => item.requestId === id) ?? false, request.requestId, { timeout: 60000 }); const response = await reply.jsonValue(); expect(typeof response.ok).toBe('boolean'); expect(response.ok, response.errorMessage).toBe(ok); return response; } export class NativeUIWorkflow { readonly timings: { stage: string; seconds: number }[] = []; constructor(readonly page: Page, readonly directory: string) { fs.mkdirSync(directory, { recursive: true }); } async measured(stage: string, action: () => Promise): Promise { const start = performance.now(); try { return await action(); } finally { this.timings.push({ stage, seconds: (performance.now() - start) / 1000 }); fs.writeFileSync(path.join(this.directory, "timings.json"), JSON.stringify(this.timings, null, 2)); if (!this.page.isClosed()) fs.writeFileSync(path.join(this.directory, "ui-state.json"), JSON.stringify(await this.page.evaluate(() => ({ requests: (window as any).__nativeWorkflowRequests.slice(-12), responses: (window as any).__nativeWorkflowResponses.slice(-12), markers: [...document.querySelectorAll("input")].filter((element) => /^Marker \d/.test(element.getAttribute("aria-label") ?? "")).map((element) => ({ label: element.getAttribute("aria-label"), value: element.value })) })), null, 2)); } } async command(action: () => Promise, ok = true) { const count = await this.page.evaluate(() => (window as any).__nativeWorkflowRequests.length); await action(); await expect.poll(() => this.page.evaluate(() => (window as any).__nativeWorkflowRequests.length), { timeout: 10000 }).toBeGreaterThan(count); const request = await this.page.evaluate((index) => { const added = (window as any).__nativeWorkflowRequests.slice(index); return added.find((entry: any) => entry.command !== "setFrame" && entry.method !== "previewCommand") ?? added.at(-1); }, count); await expect.poll(() => this.page.evaluate((id) => (window as any).__nativeWorkflowResponses.some((entry: any) => entry.requestId === id), request.requestId), { timeout: 10000 }).toBe(true); const result = await this.page.evaluate((id) => (window as any).__nativeWorkflowResponses.find((entry: any) => entry.requestId === id), request.requestId); expect(result.ok).toBe(ok); return result; } async load(file: string, object: string) { await this.command(() => this.page.getByTestId("blend-file-input").setInputFiles(file)); await expect(this.page.getByTestId("open-progress")).not.toBeVisible(); const nativeToggle = this.page.getByTestId("native-web-tools-toggle"); if (await nativeToggle.count()) await nativeToggle.click({ force: true }); const item = this.page.getByRole("treeitem").filter({ hasText: object }); await item.click(); await expect(item).toHaveAttribute("aria-selected", "true"); } async save(name: string) { const promise = this.page.waitForEvent("download", { timeout: 10000 }); await this.page.getByRole("button", { name: "保存项目", exact: true }).click(); const download = await promise; await download.saveAs(path.join(this.directory, `${name}.blend`)); } async click(button: Locator, ok = true) { return this.command(() => button.click(), ok); } }