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 } from '../../protocol/web-engine'; import type { SceneSnapshotIR } from '../../protocol/scene-ir'; const root = path.resolve(import.meta.dirname, '../../..'), evidence = path.join(root, 'tests/golden/WBV2-P1424-001/hierarchy-simplify-combined-20261021'); const sha = (file: string) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); const digest = (value: unknown) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex'); type Reply = Extract; async function request(page: Page, command: WebEngineRequest['command']): Promise { return page.evaluate(command => new Promise((resolve, reject) => { const worker = (window as unknown as { __nativeWorkflowEngine: Worker }).__nativeWorkflowEngine, requestId = 'combined-' + crypto.randomUUID(); const timer = setTimeout(() => { worker.removeEventListener('message', receive); reject(new Error('Combined 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 Main snapshot'); return reply.result.snapshot; } async function mutate(page: Page, action: () => Promise) { const before = await snapshot(page); await action(); await expect.poll(async () => (await snapshot(page)).revision).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 expand(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 select(page: Page, name: string, extend = false) { await expand(page); return mutate(page, () => page.getByRole('treeitem', { name, exact: true }).first().locator('.tree-node-name').click({ modifiers: extend ? ['Control'] : [] })); } const canonical = (s: SceneSnapshotIR) => ({ nodes: s.nodes.map(({ id,name,type,parentId,dataId,localMatrix,worldMatrix,visible }) => ({ id,name,type,parentId,dataId,localMatrix,worldMatrix,visible })).sort((a,b)=>a.id.localeCompare(b.id)), meshes: [...s.meshes].sort((a,b)=>a.id.localeCompare(b.id)), materials: [...s.materials].sort((a,b)=>a.id.localeCompare(b.id)), collections: [...s.collections].sort((a,b)=>a.id.localeCompare(b.id)) }); test('hierarchy simplify survives both Worker terminations and GLB export', async ({ page, browser }) => { page.setDefaultTimeout(15_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-combined.json'), 'utf8')), fixture = path.join(root, oracle.fixture); expect(sha(fixture)).toBe(oracle.fixtureSha256); const record = { started, chrome: browser.version(), viewport: page.viewportSize(), isolated: false, fixture: oracle.fixture, fixtureSha256: sha(fixture), failures: [] as string[], consoleErrors: [] as string[], modules: [] as { url: string; sha256: string }[], states: {} as { [key: string]: SceneSnapshotIR }, negatives: [] as unknown[], terminatedWorkers: [] as string[], restartedWorkers: [] as string[], projectId: '', exportStatus: '' }; 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(process.env.WBV2_PRODUCTION_CANDIDATE!, url)) }); }); try { await observeNative(page); await page.addInitScript(() => { const registry: { url: string; worker: Worker }[] = []; (window as unknown as { __combinedWorkers: typeof registry }).__combinedWorkers = registry; window.Worker = new Proxy(window.Worker, { construct(target,args) { const worker = Reflect.construct(target,args) as Worker; registry.push({ url: String(args[0]), worker }); return worker; } }); }); 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 }); record.states.initial = await snapshot(page); const ids = Object.fromEntries(record.states.initial.nodes.map(node => [node.name,node.id])); const payload = record.states.initial.nodes.find(node => node.id === ids.Payload)!; await select(page, 'Payload'); await select(page, 'NewParent', true); 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); await ui.save('parented'); await select(page, 'Payload'); const source = await snapshot(page), mesh = source.meshes.find(mesh => mesh.id === payload.dataId)!; const profile = { schemaVersion: 1 as const, mode: 'COLLAPSE' as const, ratio: 0.5, sourceMeshRevision: source.revision, attributePolicy: 'PRESERVE' as const }; await ui.save('negative-before'); for (const command of [{ type: 'decimateMesh', meshId: mesh.id, profile: { ...profile, ratio: 0 } }, { type: 'decimateMesh', meshId: mesh.id, profile: { ...profile, sourceMeshRevision: source.revision-1 } }, { type: 'setParent', objectId: ids.NewParent, parentId: ids.Payload, keepTransform: true }, { type: 'decimateMesh', meshId: mesh.id, profile: { ...profile, triangleBudget: 1 } }] as Extract['payload'][]) { 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-after'); expect(sha(path.join(directory,'negative-before.blend'))).toBe(sha(path.join(directory,'negative-after.blend'))); await page.getByRole('button', { name: '预览 Decimate', exact: true }).click(); await expect(page.getByTestId('simplify-triangle-counts')).toHaveText('三角形 578 → 289', { timeout: 60_000 }); await page.getByRole('button', { name: '取消 Decimate 预览', exact: true }).click(); expect(await snapshot(page)).toEqual(source); record.states.applied = await mutate(page, () => page.getByRole('button', { name: '应用 Decimate', exact: true }).click()); expect(record.states.applied.meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(oracle.trianglesAfter); expect(Math.max(...record.states.applied.nodes.find(node => node.id === ids.Payload)!.worldMatrix.map((value,index)=>Math.abs(value-payload.worldMatrix[index])))).toBeLessThan(1e-5); await mutate(page, () => page.getByRole('button', { name: '撤销', exact: true }).click()); expect((await snapshot(page)).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(oracle.trianglesBefore); await mutate(page, () => page.getByRole('button', { name: '重做', exact: true }).click()); expect((await snapshot(page)).meshes.find(value => value.id === mesh.id)?.triangleCount).toBe(oracle.trianglesAfter); await ui.save('applied'); record.projectId = (await page.locator('main.blender-app').getAttribute('data-project-id'))!; expect(record.projectId).toBe('hierarchy_simplify_combined'); record.states.saved = await snapshot(page); record.terminatedWorkers = await page.evaluate(() => { const workers = (window as unknown as { __combinedWorkers: { url: string; worker: Worker }[] }).__combinedWorkers.filter(item => /web-engine\.worker|storage\.worker/.test(item.url)); workers.forEach(item=>item.worker.terminate()); return workers.map(item=>item.url); }); expect(record.terminatedWorkers.some(url=>url.includes('web-engine.worker'))).toBe(true); expect(record.terminatedWorkers.some(url=>url.includes('storage.worker'))).toBe(true); await page.reload(); await expect(page.getByTestId('engine-status')).toContainText('ready, open a .blend file', { timeout: 60_000 }); await expect(page.locator('main.blender-app')).toHaveAttribute('data-recent-project-ids', record.projectId); await page.getByTestId('recent-projects').selectOption(record.projectId); await expect(page.getByTestId('engine-status')).toContainText('Recovery:', { timeout: 60_000 }); record.states.recovered = await snapshot(page); expect(canonical(record.states.recovered)).toEqual(canonical(record.states.saved)); record.restartedWorkers = await page.evaluate(() => (window as unknown as { __combinedWorkers: { url: string; worker: Worker }[] }).__combinedWorkers.map(item=>item.url)); expect(record.restartedWorkers.some(url=>url.includes('web-engine.worker'))).toBe(true); expect(record.restartedWorkers.some(url=>url.includes('storage.worker'))).toBe(true); const oldCommit = Number(await page.locator('main.blender-app').getAttribute('data-save-committed-revision')); const recoveredDownload = page.waitForEvent('download', { timeout: 60_000 }); await page.getByRole('button', { name: '保存项目', exact: true }).click(); await (await recoveredDownload).saveAs(path.join(directory, 'recovered.blend')); await expect(page.locator('main.blender-app')).toHaveAttribute('data-save-committed-revision', String(oldCommit+1)); await expect(page.locator('main.blender-app')).toHaveAttribute('data-dirty', 'false'); await ui.save('recovered-again'); expect(sha(path.join(directory,'recovered-again.blend'))).toBe(sha(path.join(directory,'recovered.blend'))); await expect(page.locator('main.blender-app')).toHaveAttribute('data-save-committed-revision', String(oldCommit+1)); const download = page.waitForEvent('download', { timeout: 60_000 }); await page.getByRole('button', { name: '导出 GLB', exact: true }).click(); await (await download).saveAs(path.join(directory, 'recovered.glb')); await expect(page.getByTestId('engine-status')).toContainText('fixture_weight'); record.exportStatus = (await page.getByTestId('engine-status').getAttribute('title')) ?? ''; expect(canonical(await snapshot(page))).toEqual(canonical(record.states.recovered)); expect(record.exportStatus).toContain('InspectionCamera: CAMERA data is exported as an empty hierarchy node'); expect(record.exportStatus).toContain('KeyLight: LIGHT data is exported as an empty hierarchy node'); expect(record.failures).toEqual([]); expect(record.consoleErrors).toEqual([]); passed = true; } finally { if (await page.getByTestId('export-diagnostics').isEnabled()) { const diagnostic = page.waitForEvent('download'); await page.getByTestId('export-diagnostics').click(); await (await diagnostic).saveAs(path.join(directory,'diagnostics.json')); } fs.writeFileSync(path.join(directory,'terminal-ui.json'), JSON.stringify(await page.evaluate(() => ({ attributes: Object.fromEntries(Array.from(document.querySelector('main.blender-app')?.attributes ?? []).map(attribute=>[attribute.name,attribute.value])), status: document.querySelector('[data-testid="engine-status"]')?.getAttribute('title'), requests: (window as unknown as { __nativeWorkflowRequests: unknown[] }).__nativeWorkflowRequests, responses: (window as unknown as { __nativeWorkflowResponses: unknown[] }).__nativeWorkflowResponses })), null, 2)); fs.writeFileSync(path.join(directory, 'receipt.json'), JSON.stringify({ ...record, passed, exitCode: passed ? 0 : 1, durationMs: Date.now()-started, canonicalHashes: Object.fromEntries(Object.entries(record.states).map(([name,state])=>[name,digest(canonical(state))])), testSourceSha256: sha(import.meta.filename) }, null, 2)); } });