Files
workinf_Blender_Wasm/web/tests/e2e/scene-strip-ui-production.spec.ts
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
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>
2026-11-10 11:18:01 +08:00

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');
});