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; async function request(page: Page, command: WebEngineRequest['command']) { return page.evaluate(command => new Promise((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) => { 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 { 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) { 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)); } });