Files
workinf_Blender_Wasm/web/tests/e2e/file-copy-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

56 lines
9.6 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { captureNativeWorkflowState, exposeWebTools, observeNative, performNativeAction } from '../fixtures/native-ui-workflow';
import { expectModelingData, focusNativeViewport, nativeKeyFrame, queryNativeJSON, readModelingMain, readNativeBlend, readNativeHistory, transformNative } from '../fixtures/native-modeling-workflow';
import { expectUILocation, openFromUI, showNativeObjectProperties } from '../fixtures/wasm-console-workflow';
const root = path.resolve(import.meta.dirname, '../../..'), fixture = path.resolve(root, process.env.WBV2_CASE_FIXTURE ?? 'tests/files/web/basic_scene.blend');
async function readOwner(page: Page) { return page.locator('main.blender-app').evaluate(element => Object.fromEntries(['data-project-id', 'data-save-committed-revision', 'data-save-committed-hash', 'data-dirty', 'data-current-main-revision', 'data-committed-main-revision'].map(key => [key, element.getAttribute(key)]))); }
async function openCopyDialog(page: Page) {
await exposeWebTools(page); await page.getByRole('button', { name: '文件', exact: true }).click(); await page.getByRole('menuitem', { name: '保存副本', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '保存副本', exact: true }); await expect(dialog.getByRole('textbox', { name: '副本文件名', exact: true })).toBeVisible(); return dialog;
}
async function observeCopyRecovery(page: Page) {
await page.addInitScript(() => { const Base = window.Worker, snapshots: unknown[] = []; (window as any).__copySnapshots = snapshots;
class ObservedWorker extends Base { constructor(url: string | URL, options?: WorkerOptions) { super(url, options); const pending = new Map<string, any>(), post = this.postMessage;
this.postMessage = ((message: any, ...rest: any[]) => { if (message.command?.type === 'saveSnapshot') { const entry = { requestId: message.requestId, projectId: message.command.projectId, revision: message.command.revision, startedAt: performance.now() }; pending.set(message.requestId, entry); snapshots.push(entry); } return Reflect.apply(post, this, [message, ...rest]); }) as typeof this.postMessage;
this.addEventListener('message', ({ data }) => { const entry = pending.get(data?.requestId); if (entry) Object.assign(entry, { response: data, finishedAt: performance.now() }); });
} } window.Worker = ObservedWorker;
});
}
test('Save Copy preserves the original project and delivers a reopenable blend', async ({ page }, testInfo) => {
const evidence: Record<string, unknown> = {}, downloads: string[] = [], record = (key: string, value: unknown) => { evidence[key] = value; fs.writeFileSync(testInfo.outputPath('b3-copy-main.json'), JSON.stringify(evidence, null, 2)); };
page.on('download', download => downloads.push(download.suggestedFilename())); await observeCopyRecovery(page); await observeNative(page); await page.goto('/');
await expect.poll(() => page.evaluate(() => Array.isArray((window as any).__nativeWorkflowMain?.nodes)), { timeout: 60000 }).toBe(true); await exposeWebTools(page); await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(fixture));
await performNativeAction(page, { command: 'createPrimitive' }, () => page.getByRole('button', { name: '添加立方体', exact: true }).click());
const { value: name } = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps(bpy.context.active_object.name))"); expect(typeof name).toBe('string');
const initialDownload = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const initial = await initialDownload; expect(initial.suggestedFilename()).toBe(path.basename(fixture)); await initial.saveAs(testInfo.outputPath('original-before.blend'));
await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED'); record('originalOwner', await readOwner(page));
await focusNativeViewport(page); const unmoved = await readModelingMain(page, name); await transformNative(page, 'g', 'x', '1'); await exposeWebTools(page); await showNativeObjectProperties(page); await expectUILocation(page, name, [1, 0, 0]);
const before = await readModelingMain(page, name), owner = await readOwner(page), history = await readNativeHistory(page), bytes = await readNativeBlend(page); record('before', before); record('owner', owner); record('history', history); expect(owner['data-dirty']).toBe('true'); fs.writeFileSync(testInfo.outputPath('before-copy.blend'), bytes);
let dialog = await openCopyDialog(page); await dialog.getByRole('textbox', { name: '副本文件名', exact: true }).fill('cancelled-copy.blend'); await dialog.getByRole('button', { name: '取消', exact: true }).click();
expect(await readOwner(page)).toEqual(owner); expect(await readNativeHistory(page)).toEqual(history); expect((await readNativeBlend(page)).equals(bytes)).toBe(true); expect(downloads).toHaveLength(1); record('cancelled', { owner: await readOwner(page), downloads: [...downloads] });
await page.evaluate(() => { const original = HTMLAnchorElement.prototype.click; (window as any).__copyOriginalClick = original; HTMLAnchorElement.prototype.click = function () { if (this.download.endsWith('.blend')) throw new Error('B3_COPY_DOWNLOAD_BLOCKED'); return original.call(this); }; });
try {
dialog = await openCopyDialog(page); await dialog.getByRole('button', { name: '确认', exact: true }).click(); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-as-status', 'FAILED');
await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-as-error', 'SAVE_AS_DOWNLOAD_FAILED');
expect(await readOwner(page)).toEqual(owner); expect(await readNativeHistory(page)).toEqual(history); expect((await readNativeBlend(page)).equals(bytes)).toBe(true); expect(downloads).toHaveLength(1); record('downloadFailed', { owner: await readOwner(page), error: 'SAVE_AS_DOWNLOAD_FAILED' });
} finally { await page.evaluate(() => { HTMLAnchorElement.prototype.click = (window as any).__copyOriginalClick; delete (window as any).__copyOriginalClick; }); }
dialog = await openCopyDialog(page); await dialog.getByRole('textbox', { name: '副本文件名', exact: true }).fill('b3-copied.blend');
const copyStarted = await page.evaluate(() => performance.now()), copying = page.waitForEvent('download', { timeout: 60000 }); await dialog.getByRole('button', { name: '确认', exact: true }).click();
const copyDownload = await copying, copied = testInfo.outputPath('copied.blend'); expect(copyDownload.suggestedFilename()).toBe('b3-copied.blend'); await copyDownload.saveAs(copied);
await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-as-status', 'SUCCEEDED'); expect(await readOwner(page)).toEqual(owner); expect(await readNativeHistory(page)).toEqual(history); expect((await readNativeBlend(page)).equals(bytes)).toBe(true);
expect(fs.readFileSync(copied).equals(bytes)).toBe(true); record('copied', { path: copied, owner: await readOwner(page), bytes: fs.statSync(copied).size });
await expect.poll(() => page.evaluate(({ started, project }) => ((window as any).__copySnapshots ?? []).some((entry: any) => entry.startedAt >= started && entry.projectId === project && entry.response?.ok === true), { started: copyStarted, project: owner['data-project-id'] }), { timeout: 30000 }).toBe(true);
record('copyRecovery', await page.evaluate(() => (window as any).__copySnapshots)); expect(await readOwner(page)).toEqual(owner);
await focusNativeViewport(page); await nativeKeyFrame(page, 'Control+z', 90); const undone = await readModelingMain(page, name); record('undone', undone); expectModelingData(undone.value, unmoved.value);
await nativeKeyFrame(page, 'Control+Shift+z', 90); const redone = await readModelingMain(page, name); record('redone', redone); expectModelingData(redone.value, before.value);
await exposeWebTools(page); const savingOriginal = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const original = await savingOriginal; expect(original.suggestedFilename()).toBe(path.basename(fixture)); await original.saveAs(testInfo.outputPath('original-after.blend'));
await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED'); const savedOwner = await readOwner(page); expect(savedOwner['data-project-id']).toBe(owner['data-project-id']); expect(savedOwner['data-dirty']).toBe('false'); expect(Number(savedOwner['data-save-committed-revision'])).toBeGreaterThan(Number(owner['data-save-committed-revision']));
record('originalSaved', savedOwner); expectModelingData((await readModelingMain(page, name)).value, before.value);
await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(copied)); const reopened = await readModelingMain(page, name); record('reopened', reopened); expectModelingData(reopened.value, before.value);
await showNativeObjectProperties(page); record('reopenedUI', await expectUILocation(page, name, [1, 0, 0])); await page.screenshot({ path: testInfo.outputPath('copy-reopened.png') });
record('result', { status: 'PASS', kind: process.env.WBV2_CASE_KIND, route: 'BROWSER_EQUIVALENT', scope: 'Save Copy cancellation/download failure and exact owner/Main/history preservation; real copy download, recovery checkpoint, native UndoRedo, original Save and copy Reopen' });
});
test.afterEach(async ({ page }, info) => { fs.writeFileSync(info.outputPath('b3-copy-last-state.json'), JSON.stringify(await captureNativeWorkflowState(page).catch(error => ({ captureError: String(error) })), null, 2)); });