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>
170 lines
11 KiB
TypeScript
170 lines
11 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 { 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<WebEngineResponse, { ok: boolean }>;
|
|
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<NativeReply>((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<WebEngineResponse>) => {
|
|
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');
|
|
});
|