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

72 lines
12 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, resolveProjectReplacement } from '../fixtures/native-ui-workflow';
import { expectModelingData, focusNativeViewport, nativeKeyFrame, nativeRequest, queryNativeJSON, readModelingMain, readNativeBlend, readNativeHistory, transformNative, waitNativeModal } 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 readDeliveryOwner(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'].map(key => [key, element.getAttribute(key)]))); }
async function nativeSaveAs(page: Page) {
const { canvas } = await focusNativeViewport(page), bounds = await canvas.boundingBox(); expect(bounds).not.toBeNull();
await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', mouseReleased: true }, () => page.mouse.click(bounds!.x + 44, bounds!.y + 13));
await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', mouseReleased: true }, () => page.mouse.click(bounds!.x + 90, bounds!.y + 163));
await expect.poll(() => page.evaluate(() => (window as any).__nativeWorkflowEditor?.context?.activeEditor)).toBe('FILE_BROWSER');
await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', mouseReleased: true }, () => page.mouse.click(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2));
await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 13 }, () => page.keyboard.press('Enter'));
await expect.poll(() => page.evaluate(() => (window as any).__nativeWorkflowEditor?.context?.activeEditor)).not.toBe('FILE_BROWSER'); await waitNativeModal(page, false);
}
async function installDeliveryFaults(page: Page) {
await page.addInitScript(() => { const Base = window.Worker, anchorClick = HTMLAnchorElement.prototype.click; (window as any).__deliveryFault = { download: false, storage: false, commits: [] };
HTMLAnchorElement.prototype.click = function () { if ((window as any).__deliveryFault.download && this.download.endsWith('.blend')) throw new Error('B3_NATIVE_DOWNLOAD_BLOCKED'); return anchorClick.call(this); };
class ObservedWorker extends Base { constructor(url: string | URL, options?: WorkerOptions) { super(url, options); const post = this.postMessage;
this.postMessage = ((message: any, ...rest: any[]) => { if (message.command?.type === 'saveProject') { const fault = (window as any).__deliveryFault; fault.commits.push({ projectId: message.command.projectId, revision: message.command.revision, injected: fault.storage }); if (fault.storage) message = { ...message, command: { ...message.command, faultAt: 'before-metadata-commit' } }; } return Reflect.apply(post, this, [message, ...rest]); }) as typeof this.postMessage;
} } window.Worker = ObservedWorker;
});
}
for (const mode of ['download', 'storage', 'stale-revision', 'stale-project']) test(`Native file delivery retries safely: ${mode}`, async ({ page }, info) => {
const evidence: Record<string, unknown> = { mode }, downloads: string[] = [], record = (key: string, value: unknown) => { evidence[key] = value; fs.writeFileSync(info.outputPath('b3-delivery.json'), JSON.stringify(evidence, null, 2)); };
page.on('download', download => downloads.push(download.suggestedFilename())); await installDeliveryFaults(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))"); await focusNativeViewport(page); const unmoved = await readModelingMain(page, name); await transformNative(page, 'g', 'x', '1');
const before = await readModelingMain(page, name), owner = await readDeliveryOwner(page); record('before', before); record('owner', owner);
const historyBefore = await readNativeHistory(page); record('historyBefore', historyBefore);
await page.evaluate(storage => { Object.assign((window as any).__deliveryFault, { storage, download: !storage }); }, mode === 'storage');
const firstDownload = mode === 'storage' ? page.waitForEvent('download', { timeout: 60000 }) : null; await nativeSaveAs(page);
const panel = page.getByRole('complementary', { name: '待完成文件', exact: true }); await expect(panel).toBeVisible(); await expect(panel.getByRole('button', { name: '重试', exact: true })).toBeEnabled();
const recovered = info.outputPath('recovered.blend'); if (firstDownload) await (await firstDownload).saveAs(recovered); expect(downloads).toHaveLength(mode === 'storage' ? 1 : 0);
record('failure', { message: await panel.getByRole('status').innerText(), owner: await readDeliveryOwner(page), downloads: [...downloads] }); expect(await readDeliveryOwner(page)).toEqual(owner);
expectModelingData((await readModelingMain(page, name)).value, before.value); const history = await readNativeHistory(page); record('historyAfterSave', history);
if (history.cursor !== historyBefore.cursor) {
fs.writeFileSync(info.outputPath('history-current.blend'), await readNativeBlend(page));
for (const index of [historyBefore.cursor]) { const live = await readNativeHistory(page); const response = await nativeRequest(page, { type: 'jsonCommand', cmd: 'history', params: { baseRevision: live.revision, snapshotIndex: index } }); const bytes = response.result?.json?.snapshotBlend; expect(Array.isArray(bytes)).toBe(true); fs.writeFileSync(info.outputPath(`history-${index}.blend`), Buffer.from(bytes)); }
}
expect(history.cursor).toBe(historyBefore.cursor); expect(history.entries).toEqual(historyBefore.entries);
const failedBytes = await readNativeBlend(page); fs.writeFileSync(info.outputPath('failed-main.blend'), failedBytes); await page.screenshot({ path: info.outputPath('retained-failure.png') });
await page.evaluate(() => Object.assign((window as any).__deliveryFault, { storage: false, download: false }));
let undoExpected = unmoved, redoExpected = before;
if (mode === 'stale-revision') {
await focusNativeViewport(page); await transformNative(page, 'g', 'x', '2'); const changed = await readModelingMain(page, name), changedOwner = await readDeliveryOwner(page), changedHistory = await readNativeHistory(page); record('changed', changed);
undoExpected = before; redoExpected = changed; const pending = page.waitForEvent('download'); await panel.getByRole('button', { name: '重试', exact: true }).click(); await (await pending).saveAs(recovered);
await expect(panel.getByRole('status')).toContainText('Main 已有新修改'); expect(await readDeliveryOwner(page)).toEqual(changedOwner); expect(await readNativeHistory(page)).toEqual(changedHistory); expectModelingData((await readModelingMain(page, name)).value, changed.value);
record('staleRevisionRejected', { owner: await readDeliveryOwner(page), message: await panel.getByRole('status').innerText() });
} else if (mode === 'stale-project') {
await focusNativeViewport(page); await nativeKeyFrame(page, 'Control+z', 90); expectModelingData((await readModelingMain(page, name)).value, unmoved.value); await nativeKeyFrame(page, 'Control+Shift+z', 90); expectModelingData((await readModelingMain(page, name)).value, before.value);
await exposeWebTools(page); await openFromUI(page, async () => { await page.getByTestId('blend-file-input').setInputFiles({ name: 'different-project.blend', mimeType: 'application/octet-stream', buffer: fs.readFileSync(fixture) }); await resolveProjectReplacement(page, 'discard'); });
const changedOwner = await readDeliveryOwner(page), changedHistory = await readNativeHistory(page), changedBytes = await readNativeBlend(page); expect(changedOwner['data-project-id']).not.toBe(owner['data-project-id']);
await expect(panel.getByRole('button', { name: '重试', exact: true })).toBeDisabled(); const pending = page.waitForEvent('download'); await panel.getByRole('button', { name: '下载已保留文件', exact: true }).click(); await (await pending).saveAs(recovered);
expect(await readDeliveryOwner(page)).toEqual(changedOwner); expect(await readNativeHistory(page)).toEqual(changedHistory); expect((await readNativeBlend(page)).equals(changedBytes)).toBe(true); record('staleProjectRejected', { owner: changedOwner, retryDisabled: true });
} else {
const pending = mode === 'download' ? page.waitForEvent('download') : null; await panel.getByRole('button', { name: '重试', exact: true }).dblclick(); if (pending) await (await pending).saveAs(recovered);
await expect(panel).toBeHidden(); expect(downloads).toHaveLength(1); expectModelingData((await readModelingMain(page, name)).value, before.value); expect(await readNativeHistory(page)).toEqual(history); expect((await readNativeBlend(page)).equals(failedBytes)).toBe(true);
const committed = await readDeliveryOwner(page); expect(committed['data-project-id']).not.toBe(owner['data-project-id']); expect(committed['data-dirty']).toBe('false'); record('retried', { owner: committed, downloads: [...downloads], storageAttempts: await page.evaluate(() => (window as any).__deliveryFault.commits) });
}
if (mode.startsWith('stale-')) { await panel.getByRole('button', { name: '忽略', exact: true }).click(); await expect(panel).toBeHidden(); expect(downloads).toHaveLength(1); }
if (mode !== 'stale-project') { await focusNativeViewport(page); await nativeKeyFrame(page, 'Control+z', 90); expectModelingData((await readModelingMain(page, name)).value, undoExpected.value); await nativeKeyFrame(page, 'Control+Shift+z', 90); expectModelingData((await readModelingMain(page, name)).value, redoExpected.value); }
if (mode === 'storage' || mode === 'download') { const commits = await page.evaluate(() => (window as any).__deliveryFault.commits); expect(commits).toHaveLength(mode === 'storage' ? 2 : 1); expect(new Set(commits.map((entry: any) => entry.projectId)).size).toBe(1); }
await exposeWebTools(page); const dirty = (await readDeliveryOwner(page))['data-dirty'] === 'true'; await openFromUI(page, async () => { await page.getByTestId('blend-file-input').setInputFiles(recovered); if (dirty) await resolveProjectReplacement(page, 'discard'); });
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: info.outputPath('recovered-file.png') });
record('result', { status: 'PASS', kind: process.env.WBV2_CASE_KIND, scope: `Native Save As ${mode} recovery, retained bytes, owner protection, native UndoRedo and actual file Reopen` });
});
test.afterEach(async ({ page }, info) => { fs.writeFileSync(info.outputPath('b3-delivery-last-state.json'), JSON.stringify(await captureNativeWorkflowState(page).catch(error => ({ captureError: String(error) })), null, 2)); });