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>
100 lines
14 KiB
TypeScript
100 lines
14 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { NativeUIWorkflow, observeNative } from '../fixtures/native-ui-workflow';
|
|
import type { WebEngineRequest, WebEngineResponse, WebEngineEditCommand } from '../../protocol/web-engine';
|
|
import type { SceneSnapshotIR } from '../../protocol/scene-ir';
|
|
const root = path.resolve(import.meta.dirname, '../../..');
|
|
const evidence = path.join(root, 'tests/golden/WBV2-P1424-001/object-hierarchy-20261021');
|
|
const candidate = process.env.WBV2_PRODUCTION_CANDIDATE ?? path.join(root, 'web/dist');
|
|
const sha = (file: string) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
type Reply = Extract<WebEngineResponse, { ok: boolean }>;
|
|
async function request(page: Page, command: WebEngineRequest['command']) {
|
|
return page.evaluate(command => new Promise<Reply>((resolve, reject) => {
|
|
const worker = (window as unknown as { __nativeWorkflowEngine: Worker }).__nativeWorkflowEngine;
|
|
const requestId = 'hierarchy-' + crypto.randomUUID();
|
|
const timer = setTimeout(() => { worker.removeEventListener('message', receive); reject(new Error('Hierarchy request timeout')); }, 30_000);
|
|
const receive = ({ data }: MessageEvent<WebEngineResponse>) => { if (data.requestId === requestId && 'ok' in data) { clearTimeout(timer); worker.removeEventListener('message', receive); resolve(data); } };
|
|
worker.addEventListener('message', receive); worker.postMessage({ requestId, command });
|
|
}), command);
|
|
}
|
|
async function snapshot(page: Page): Promise<SceneSnapshotIR> { const reply = await request(page, { type: 'snapshot' }); expect(reply.ok).toBe(true); if (!reply.ok || !reply.result.snapshot) throw new Error('Missing hierarchy Main snapshot'); return reply.result.snapshot; }
|
|
const canonical = (s: SceneSnapshotIR) => ({ nodes: s.nodes.map(({ id,name,type,parentId,dataId,localMatrix,worldMatrix,visible,selectable }) => ({ id,name,type,parentId,dataId,localMatrix,worldMatrix,visible,selectable })).sort((a,b)=>a.id.localeCompare(b.id)), collections: [...s.collections].sort((a,b)=>a.id.localeCompare(b.id)) });
|
|
const digest = (value: unknown) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
async function mutate(page: Page, action: () => Promise<unknown>) { const before = await snapshot(page); await action(); await expect.poll(async () => (await snapshot(page)).revision, { timeout: 30_000 }).toBeGreaterThan(before.revision); const after = await snapshot(page); await expect(page.getByTestId('editor-workflow-workbench')).toHaveAttribute('data-revision', String(after.revision)); return after; }
|
|
async function expandHierarchy(page: Page) {
|
|
for (let pass = 0; pass < 3; pass++) for (const name of ['Scene Collection','Assembly','Parts','Archive','RootRig','Mount','NewParent']) {
|
|
const button = page.getByRole('button', { name: `展开 ${name}`, exact: true }).first(); if (await button.count()) await mutate(page, () => button.click());
|
|
}
|
|
}
|
|
async function selectObject(page: Page, name: string, extend = false) { await expandHierarchy(page); return mutate(page, () => page.getByRole('treeitem', { name, exact: true }).first().locator('.tree-node-name').click({ modifiers: extend ? ['Control'] : [] })); }
|
|
const matrixError = (a: number[], b: number[]) => Math.max(...a.map((value,index) => Math.abs(value-b[index])));
|
|
test('hierarchy parent and collection lifecycle in production', async ({ page, browser }) => {
|
|
page.setDefaultTimeout(10_000);
|
|
const started = Date.now(), directory = path.join(evidence, String(started)); const ui = new NativeUIWorkflow(page, directory);
|
|
const oracle = JSON.parse(fs.readFileSync(path.join(evidence, 'desktop-hierarchy.json'), 'utf8')); const fixture = path.join(root, oracle.fixture); expect(sha(fixture)).toBe(oracle.fixtureSha256);
|
|
const record = { started, chrome: browser.version(), viewport: page.viewportSize(), fixture: oracle.fixture, fixtureSha256: sha(fixture), isolated: false, failures: [] as string[], consoleErrors: [] as string[], modules: [] as { url: string; sha256: string }[], states: {} as { [key: string]: SceneSnapshotIR }, negatives: [] as unknown[] }; let passed = false;
|
|
page.on('requestfailed', request => record.failures.push(`${request.url()}: ${request.failure()?.errorText}`)); page.on('request', request => { if (new URL(request.url()).pathname.startsWith('/src/')) record.failures.push('NON_PRODUCTION_SOURCE '+request.url()); });
|
|
page.on('console', message => { if (message.type() === 'error') record.consoleErrors.push(message.text()); }); page.on('pageerror', error => record.consoleErrors.push(error.message));
|
|
page.on('response', response => { const url = new URL(response.url()).pathname; if (response.status() >= 400) record.failures.push(`${response.status()} ${url}`); if (/\.wasm$|wasm-module-manifest\.json$/.test(url)) record.modules.push({ url, sha256: sha(path.join(candidate, url)) }); });
|
|
try {
|
|
await observeNative(page); await page.goto('/?ui=web'); record.isolated = await page.evaluate(() => crossOriginIsolated); expect(record.isolated).toBe(true);
|
|
await page.getByTestId('blend-file-input').setInputFiles(fixture); await expect(page.getByTestId('engine-status')).toContainText(/SceneIR r\d+/, { timeout: 60_000 });
|
|
await expandHierarchy(page); record.states.initial = await snapshot(page);
|
|
expect(record.states.initial.nodes.map(node => node.type)).toEqual(expect.arrayContaining(['MESH','EMPTY','CAMERA','LIGHT']));
|
|
const ids = Object.fromEntries(record.states.initial.nodes.map(node => [node.name,node.id])); const original = record.states.initial.nodes.find(node => node.id === ids.Payload)!;
|
|
expect(original.parentId).toBe(ids.Mount); expect(record.states.initial.nodes.find(node => node.id === ids.Mount)?.parentId).toBe(ids.RootRig);
|
|
await mutate(page, () => page.getByRole('button', { name: '折叠 Assembly', exact: true }).click()); await expect(page.getByRole('treeitem', { name: 'Mount', exact: true })).toHaveCount(0);
|
|
await expandHierarchy(page); await expect(page.getByRole('treeitem', { name: 'Mount', exact: true }).first()).toBeVisible();
|
|
await ui.save('before');
|
|
for (const command of [{ type: 'setParent', objectId: ids.RootRig, parentId: ids.Payload, keepTransform: true }, { type: 'setParents', objectIds: [ids.NewParent, ids.RootRig], parentId: ids.Payload, keepTransform: true }, { type: 'setParent', objectId: ids.Payload, parentId: ids.NewParent, keepTransform: 'invalid' }, { type: 'setParent', objectId: ids.Payload, parentId: ids.NewParent, baseRevision: record.states.initial.revision-1 }, { type: 'moveObjectToCollection', objectId: ids.Payload, collectionId: 'missing' }, { type: 'moveObjectToCollection', objectId: 'missing', collectionId: record.states.initial.collections[0].id }, { type: 'moveObjectToCollection', objectId: ids.Payload, collectionId: record.states.initial.collections[0].id, keepExistingLinks: 'invalid' }] as WebEngineEditCommand[]) {
|
|
const before = await snapshot(page), reply = await request(page, { type: 'applyCommand', payload: command }), after = await snapshot(page);
|
|
record.negatives.push({ command, reply, beforeSha256: digest(before), afterSha256: digest(after) }); expect(reply.ok).toBe(false); expect(after).toEqual(before);
|
|
}
|
|
await ui.save('negative-state');
|
|
await selectObject(page, 'Payload'); await selectObject(page, 'NewParent', true);
|
|
const selected = await snapshot(page); expect(selected.selectedObjectIds).toEqual(expect.arrayContaining([ids.Payload,ids.NewParent])); expect(selected.activeObjectId).toBe(ids.NewParent);
|
|
record.states.parented = await mutate(page, async () => { await page.getByRole('button', { name: '物体', exact: true }).click(); await page.getByRole('menuitem', { name: '父级', exact: true }).click(); await page.getByRole('menuitem', { name: '设定父级', exact: true }).click(); });
|
|
expect(record.states.parented.nodes.find(node => node.id === ids.Payload)?.parentId).toBe(ids.NewParent);
|
|
expect(matrixError(record.states.parented.nodes.find(node => node.id === ids.Payload)!.worldMatrix, original.worldMatrix)).toBeLessThan(1e-5); await ui.save('parented');
|
|
await mutate(page, () => page.getByRole('button', { name: '撤销', exact: true }).click()); expect((await snapshot(page)).nodes.find(node => node.id === ids.Payload)?.parentId).toBe(ids.Mount);
|
|
await mutate(page, () => page.getByRole('button', { name: '重做', exact: true }).click()); expect((await snapshot(page)).nodes.find(node => node.id === ids.Payload)?.parentId).toBe(ids.NewParent);
|
|
await selectObject(page, 'Payload');
|
|
record.states.unparented = await mutate(page, async () => { await page.getByRole('button', { name: '物体', exact: true }).click(); await page.getByRole('menuitem', { name: '父级', exact: true }).click(); await page.getByRole('menuitem', { name: '清除父级', exact: true }).click(); });
|
|
expect(record.states.unparented.nodes.find(node => node.id === ids.Payload)?.parentId).toBeNull(); expect(matrixError(record.states.unparented.nodes.find(node => node.id === ids.Payload)!.worldMatrix, original.worldMatrix)).toBeLessThan(1e-5);
|
|
await ui.save('unparented');
|
|
await mutate(page, () => page.getByRole('button', { name: '撤销', exact: true }).click()); expect((await snapshot(page)).nodes.find(node => node.id === ids.Payload)?.parentId).toBe(ids.NewParent);
|
|
await mutate(page, () => page.getByRole('button', { name: '重做', exact: true }).click()); expect((await snapshot(page)).nodes.find(node => node.id === ids.Payload)?.parentId).toBeNull();
|
|
const archiveId = record.states.unparented.collections.find(collection => collection.name === 'Archive')!.id;
|
|
const memberships = (state: SceneSnapshotIR) => state.collections.filter(collection => collection.objectIds.includes(ids.Payload)).map(collection => collection.name).sort();
|
|
record.states.linked = await mutate(page, () => page.getByRole('combobox', { name: '链接到 Collection' }).selectOption(archiveId)); expect(memberships(record.states.linked)).toEqual(['Archive','Parts']); await ui.save('linked');
|
|
await mutate(page, () => page.getByRole('button', { name: '撤销', exact: true }).click()); expect(memberships(await snapshot(page))).toEqual(['Parts']);
|
|
await mutate(page, () => page.getByRole('button', { name: '重做', exact: true }).click()); expect(memberships(await snapshot(page))).toEqual(['Archive','Parts']);
|
|
record.states.moved = await mutate(page, () => page.getByRole('combobox', { name: '移动到 Collection' }).selectOption(archiveId)); expect(memberships(record.states.moved)).toEqual(['Archive']); await ui.save('moved');
|
|
await mutate(page, () => page.getByRole('button', { name: '撤销', exact: true }).click()); expect(memberships(await snapshot(page))).toEqual(['Archive','Parts']);
|
|
await mutate(page, () => page.getByRole('button', { name: '重做', exact: true }).click()); expect(memberships(await snapshot(page))).toEqual(['Archive']);
|
|
const collection = page.getByTestId('collection-visibility-workbench').getByRole('group', { name: 'Assembly', exact: true });
|
|
record.states.hidden = await mutate(page, () => collection.getByRole('button', { name: 'Hide Viewport', exact: true }).click());
|
|
expect(record.states.hidden.nodes.filter(node => ['RootRig','Mount','KeyLight','InspectionCamera'].includes(node.name)).every(node => !node.visible)).toBe(true); expect(record.states.hidden.nodes.find(node => node.id === ids.Payload)?.visible).toBe(true); await ui.save('hidden');
|
|
await mutate(page, () => page.getByRole('button', { name: '撤销', exact: true }).click()); expect((await snapshot(page)).nodes.every(node => node.visible)).toBe(true);
|
|
await mutate(page, () => page.getByRole('button', { name: '重做', exact: true }).click()); expect((await snapshot(page)).nodes.filter(node => ['RootRig','Mount','KeyLight','InspectionCamera'].includes(node.name)).every(node => !node.visible)).toBe(true);
|
|
record.states['render-hidden'] = await mutate(page, () => collection.getByRole('button', { name: 'Disable Render', exact: true }).click()); expect(record.states['render-hidden'].collections.find(item => item.name === 'Assembly')?.renderVisible).toBe(false); await ui.save('render-hidden');
|
|
await mutate(page, () => page.getByRole('button', { name: '撤销', exact: true }).click()); expect((await snapshot(page)).collections.find(item => item.name === 'Assembly')?.renderVisible).toBe(true);
|
|
await mutate(page, () => page.getByRole('button', { name: '重做', exact: true }).click());
|
|
await mutate(page, () => collection.getByRole('button', { name: 'Show Viewport', exact: true }).click());
|
|
record.states.visible = await mutate(page, () => collection.getByRole('button', { name: 'Enable Render', exact: true }).click()); await ui.save('visible');
|
|
await selectObject(page, 'Payload'); await page.getByRole('textbox', { name: '对象名称', exact: true }).fill('PayloadRenamed');
|
|
record.states.renamed = await mutate(page, () => page.getByRole('textbox', { name: '对象名称', exact: true }).press('Enter')); expect(record.states.renamed.nodes.some(node => node.name === 'PayloadRenamed')).toBe(true); await ui.save('renamed');
|
|
await mutate(page, () => page.getByRole('button', { name: '撤销', exact: true }).click()); expect((await snapshot(page)).nodes.some(node => node.name === 'Payload')).toBe(true);
|
|
await mutate(page, () => page.getByRole('button', { name: '重做', exact: true }).click());
|
|
const beforeReopen = canonical(await snapshot(page)); await ui.save('final');
|
|
await page.getByTestId('blend-file-input').setInputFiles(path.join(directory, 'final.blend')); await expect(page.getByTestId('engine-status')).toContainText(/SceneIR r\d+/);
|
|
record.states.reopened = await snapshot(page); expect(canonical(record.states.reopened)).toEqual(beforeReopen);
|
|
expect(record.failures).toEqual([]); expect(record.consoleErrors).toEqual([]);
|
|
passed = true;
|
|
} finally {
|
|
fs.writeFileSync(path.join(directory, 'receipt.json'), JSON.stringify({ ...record, passed, exitCode: passed ? 0 : 1, durationMs: Date.now()-started, testSourceSha256: sha(import.meta.filename) }, null, 2));
|
|
}
|
|
});
|