import { test, expect, type Page } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { NativeUIWorkflow, exposeWebTools, observeNative } from '../fixtures/native-ui-workflow'; import type { WebEngineRequest, WebEngineResponse } from '../../protocol/web-engine'; type NativeReply = Extract; const root = path.resolve('..'); const fixture = path.join(root, 'tests/files/web/sequencer_scene_source.blend'); const kinds = ['positive', 'negative', 'undo_redo', 'save_reopen', 'determinism'] as const; const digest = (value: unknown) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex'); const sha = (file: string) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); const request = (page: Page, command: WebEngineRequest['command']) => page.evaluate(command => new Promise((resolve, reject) => { const engine = (window as unknown as { __nativeWorkflowEngine: Worker }).__nativeWorkflowEngine; const requestId = `scene-strip-check-${crypto.randomUUID()}`; const timeout = setTimeout(() => { engine.removeEventListener('message', receive); reject(new Error('Scene strip native response timed out')); }, 5000); const receive = (event: MessageEvent) => { if (event.data.requestId !== requestId || !('ok' in event.data)) return; clearTimeout(timeout); engine.removeEventListener('message', receive); resolve(event.data); }; engine.addEventListener('message', receive); engine.postMessage({ requestId, command }); }), command); test('scene strips use explicit source scenes and preserve native references', async ({ page, browser, baseURL }, testInfo) => { test.setTimeout(100_000); page.setDefaultTimeout(10_000); const receipts = process.env.WBV2_RECEIPT_DIR ?? testInfo.outputPath('cases'); const directory = path.join(receipts, 'scene-strip-artifacts'); fs.mkdirSync(directory, { recursive: true }); const load = async (target: Page, file: string, flow: NativeUIWorkflow) => { await flow.command(() => target.getByTestId('blend-file-input').setInputFiles(file)); /* The panel lives in the Web tools layout; loading alone stays on the native desktop view. * The toggle only exists once the loaded snapshot is published, so wait before switching. */ await expect(target.getByTestId('native-web-tools-toggle')).toHaveCount(1, { timeout: 60000 }); await exposeWebTools(target); await expect(target.getByRole('region', { name: 'Sequencer Strip Editing' })).toBeVisible(); }; const edit = async (target: Page, flow: NativeUIWorkflow, checkpoints: boolean) => { const panel = target.getByRole('region', { name: 'Sequencer Strip Editing' }); await panel.getByLabel('New sequencer strip type').selectOption('SCENE'); await expect(panel.getByRole('button', { name: 'Add Strip', exact: true })).toBeDisabled(); await expect(panel.getByLabel('Source scene').locator('option')).toHaveText(['Select scene', 'SourceScene']); await panel.getByLabel('Source scene').selectOption({ label: 'SourceScene' }); await flow.measured('add-scene-strip', () => flow.command(() => panel.getByRole('button', { name: 'Add Strip', exact: true }).click())); await expect(panel.getByLabel('Sequencer strip', { exact: true })).toHaveValue('strip:Web SCENE'); if (checkpoints) await flow.save('created'); await flow.measured('add-second-scene-strip', () => flow.command(() => panel.getByRole('button', { name: 'Add Strip', exact: true }).click())); await expect(panel.getByLabel('Sequencer strip', { exact: true })).toHaveValue('strip:Web SCENE.001'); if (checkpoints) await flow.save('two_strips'); await flow.command(() => panel.getByRole('button', { name: 'Remove', exact: true }).click()); await panel.getByLabel('Sequencer strip', { exact: true }).selectOption('strip:Web SCENE'); await flow.measured('move-scene-strip', () => flow.command(() => panel.getByRole('button', { name: 'Move +1', exact: true }).click())); await expect(panel.getByLabel('Start', { exact: true })).toHaveValue('2'); await flow.measured('mute-scene-strip', () => flow.command(() => panel.getByLabel('Muted', { exact: true }).click())); await expect(panel.getByLabel('Muted', { exact: true })).toBeChecked(); }; await observeNative(page); await page.goto('/'); const flow = new NativeUIWorkflow(page, directory); await load(page, path.join(root, 'tests/files/web/sequencer_scene.blend'), flow); await page.getByLabel('New sequencer strip type').selectOption('SCENE'); await expect(page.getByLabel('Source scene').locator('option')).toHaveCount(1); await expect(page.getByRole('button', { name: 'Add Strip', exact: true })).toBeDisabled(); await flow.measured('load-multiple-scenes', () => load(page, fixture, flow)); await flow.save('before'); await flow.measured('edit', () => edit(page, flow, true)); await flow.save('after'); await flow.measured('negative', async () => { const read = await request(page, { type: 'snapshot' }); if (!read.ok || !read.result.snapshot) throw new Error('Missing native scene snapshot'); const scene = read.result.snapshot.scenes.find(item => item.name === 'SequencerScene')!; for (const sourceId of [scene.id, 'scene:MissingSourceScene']) { const timeline = structuredClone(scene.sequencerTimeline!); timeline.strips.find(strip => strip.type === 'SCENE')!.sourceId = sourceId; const response = await request(page, { type: 'applyCommand', payload: { type: 'setSequencerStrips', sceneId: scene.id, timeline, } }); expect(response.ok).toBe(false); if (!response.ok) expect(response.error.message).toMatch(/own scene|missing scene/); } await flow.save('negative'); }); await flow.measured('delete-history', async () => { const panel = page.getByRole('region', { name: 'Sequencer Strip Editing' }); await flow.command(() => panel.getByRole('button', { name: 'Remove', exact: true }).click()); await flow.save('removed'); await flow.command(() => page.getByRole('button', { name: '撤销', exact: true }).click()); await flow.save('undo'); await flow.command(() => page.getByRole('button', { name: '重做', exact: true }).click()); await flow.save('redo'); }); await flow.measured('reopen', async () => { await load(page, path.join(directory, 'after.blend'), flow); await flow.save('reopened'); }); await page.screenshot({ path: path.join(directory, 'scene-strip-after.png'), fullPage: true }); await page.close(); const detached: { stage: string; seconds: number }[] = []; const repeatContext = await browser.newContext({ baseURL, acceptDownloads: true }); repeatContext.setDefaultTimeout(10_000); try { const start = performance.now(); const repeat = await repeatContext.newPage(); await observeNative(repeat); await repeat.goto('/'); const repeatDirectory = path.join(directory, 'repeat'); const repeatFlow = new NativeUIWorkflow(repeat, repeatDirectory); await load(repeat, fixture, repeatFlow); await edit(repeat, repeatFlow, false); await repeatFlow.save('second'); fs.copyFileSync(path.join(repeatDirectory, 'second.blend'), path.join(directory, 'second.blend')); detached.push({ stage: 'independent-repeat', seconds: (performance.now() - start) / 1000 }); } finally { await repeatContext.close(); } const native = process.env.BLENDER_BIN ?? path.join(root, 'build_blender_native/bin/blender'); const readStart = performance.now(); const log = fs.openSync(path.join(directory, 'desktop.log'), 'w'); try { execFileSync(native, ['--background', '--factory-startup', '--python-exit-code', '1', '--python', path.join(root, 'web/tests/fixtures/scene-strip-main-snapshots.py'), '--', directory], { timeout: 30_000, stdio: ['ignore', log, 'pipe'] }); } finally { fs.closeSync(log); } detached.push({ stage: 'desktop-readback', seconds: (performance.now() - readStart) / 1000 }); fs.writeFileSync(path.join(directory, 'detached-timings.json'), JSON.stringify(detached, null, 2)); const states = JSON.parse(fs.readFileSync(path.join(directory, 'native-scene-strip-snapshots.json'), 'utf8')); const strips = (state: string) => states[state].SequencerScene.strips; expect(strips('created').find((strip: { type: string }) => strip.type === 'SCENE')).toMatchObject({ sourceScene: 'SourceScene', start: 1, end: 25, mute: false, }); expect(strips('two_strips').filter((strip: { type: string }) => strip.type === 'SCENE') .map((strip: { name: string; sourceScene: string }) => ({ name: strip.name, sourceScene: strip.sourceScene }))) .toEqual([{ name: 'Web SCENE', sourceScene: 'SourceScene' }, { name: 'Web SCENE.001', sourceScene: 'SourceScene' }]); expect(strips('after').find((strip: { type: string }) => strip.type === 'SCENE')).toMatchObject({ sourceScene: 'SourceScene', start: 2, end: 26, mute: true, }); expect(strips('after').filter((strip: { type: string }) => strip.type !== 'SCENE')).toEqual(strips('before')); for (const state of Object.values(states) as { SourceScene: unknown }[]) expect(state.SourceScene).toEqual(states.before.SourceScene); expect(states.negative).toEqual(states.after); expect(states.removed).toEqual(states.before); expect(states.undo).toEqual(states.after); expect(states.redo).toEqual(states.removed); expect(states.reopened).toEqual(states.after); expect(states.second).toEqual(states.after); const observations = { positive: { before: digest(states.before), after: digest(states.after) }, negative: { before: digest(states.after), after: digest(states.negative) }, undo_redo: { before: digest(states.after), after: digest(states.removed), undo: digest(states.undo), redo: digest(states.redo) }, save_reopen: { saved: digest(states.after), reopened: digest(states.reopened) }, determinism: { first: digest(states.after), second: digest(states.second) }, }; const artifacts = [native, fixture, path.join(root, 'build_web_blender6/bin/web_engine.wasm'), path.join(directory, 'repeat/timings.json'), ...fs.readdirSync(directory).filter((name: string) => /\.(blend|json|png)$/.test(name)).map((name: string) => path.join(directory, name))] .map(file => ({ path: path.relative(root, file), sha256: sha(file) })); for (const kind of kinds) fs.writeFileSync(path.join(receipts, `scene-strip-${kind}.json`), JSON.stringify({ status: 'PASS', runtime: 'CHROMIUM_WEBENGINE', caseId: `scene-strip-${kind}`, kind, unitIds: ['web-command:setSequencerStrips'], observations: observations[kind], artifacts, }, null, 2) + '\n'); });