Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
140 lines
13 KiB
TypeScript
140 lines
13 KiB
TypeScript
import { expect, type Locator, type Page } from "@playwright/test";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
const browserDiagnostics = new WeakMap<Page, { type: string; text: string }[]>();
|
|
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<string, string | number | boolean>, action: () => Promise<unknown>, 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<T>(stage: string, action: () => Promise<T>): Promise<T> {
|
|
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<unknown>, 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);
|
|
}
|
|
}
|