Files
workinf_Blender_Wasm/web/tests/e2e/desktop-transfer-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

67 lines
14 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, 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'), companions = path.join(root, 'tests/files/web/b3_obj_bundle_v1');
async function installTransferGate(page: Page) {
await page.addInitScript(() => {
const original = File.prototype.stream, releases: (() => void)[] = [], plan = { mode: 'off', name: '', reads: [] as any[] }; (window as any).__transferGate = plan; (window as any).__releaseTransferReads = () => releases.splice(0).forEach(release => release());
File.prototype.stream = function () {
if (plan.mode === 'off' || (plan.name !== '*' && this.name !== plan.name)) return original.call(this);
const inner = original.call(this).getReader(), state = { name: this.name, relative: this.webkitRelativePath, cancelled: false, innerReleased: false, outerReleased: false, failed: false }; plan.reads.push(state);
let release!: () => void; const gate = new Promise<void>(resolve => release = resolve); releases.push(release);
const stream = new ReadableStream<Uint8Array>({ async pull(controller) { if (plan.mode === 'error') { state.failed = true; await inner.cancel(); inner.releaseLock(); state.innerReleased = true; throw new Error('B3_TRANSFER_READ_FAILED'); } await gate; if (state.cancelled) return; const result = await inner.read(); if (result.done) { inner.releaseLock(); state.innerReleased = true; controller.close(); } else controller.enqueue(result.value); }, async cancel(reason) { state.cancelled = true; await inner.cancel(reason); inner.releaseLock(); state.innerReleased = true; } });
const getReader = stream.getReader.bind(stream); (stream as any).getReader = () => { const reader = getReader(), releaseLock = reader.releaseLock.bind(reader); reader.releaseLock = () => { state.outerReleased = true; releaseLock(); }; return reader; }; return stream;
};
const post = Worker.prototype.postMessage, requests: any[] = []; (window as any).__transferRequests = requests;
Worker.prototype.postMessage = function (message: any, ...rest: any[]) { if (message.command?.type === 'gpuUI' && message.command.op === 'files') requests.push({ requestId: message.requestId, action: message.command.action, path: message.command.path }); return Reflect.apply(post, this, [message, ...rest]); } as typeof post;
});
}
async function openTransferredBlend(page: Page, filePath: string) {
await page.getByRole('button', { name: '关闭文件传输', exact: true }).click(); await exposeWebTools(page); await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(filePath));
}
for (const mode of ['file', 'partial', 'folder', 'restore', 'hide', 'error']) test(`Desktop transfer panel preserves native data: ${mode}`, async ({ page }, info) => {
const evidence: Record<string, unknown> = { mode }, record = (key: string, value: unknown) => { evidence[key] = value; fs.writeFileSync(info.outputPath('b3-transfer.json'), JSON.stringify(evidence, null, 2)); };
await installTransferGate(page); await observeNative(page); await page.goto('/'); await expect.poll(() => page.evaluate(() => Array.isArray((window as any).__nativeWorkflowMain?.nodes)), { timeout: 60000 }).toBe(true); const desktopButton = page.getByRole('button', { name: '桌面', exact: true }); const desktopCanvas = page.locator('canvas[aria-label="Blender desktop"]'); if (await desktopButton.isVisible() && !await desktopCanvas.isVisible()) await desktopButton.click();
const blendInput = page.locator('input[data-testid="blend-file-input"]'); await blendInput.waitFor({ state: 'attached', timeout: 60000 }); await blendInput.setInputFiles(fixture); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false', { timeout: 60000 });
const canvas = page.locator('canvas[aria-label="Blender desktop"]'), bounds = await canvas.boundingBox(); const transferToggle = page.getByLabel('Blender 桌面').getByRole('button', { name: '文件传输', exact: true }); await expect(transferToggle).toBeVisible({ timeout: 90000 }); await transferToggle.click();
const panel = page.locator('details').filter({ has: page.getByText('桌面文件传输', { exact: true }) }); await expect(panel.getByLabel('上传文件', { exact: true })).toBeEnabled(); expect(await canvas.boundingBox()).toEqual(bounds);
const before = await readNativeBlend(page), history = await readNativeHistory(page); record('historyBefore', history); fs.writeFileSync(info.outputPath('before-transfer.blend'), before);
const beforeObjects = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps(sorted(o.name for o in bpy.data.objects)))"); record('beforeObjects', beforeObjects);
const folder = mode === 'folder' || mode === 'restore', partial = mode === 'partial' || mode === 'error'; const input = panel.getByLabel(mode === 'folder' ? '上传文件夹' : mode === 'restore' ? '恢复桌面文件夹' : '上传文件', { exact: true });
const files = [{ name: 'b3-held.blend', mimeType: 'application/octet-stream', buffer: fs.readFileSync(fixture) }]; if (partial) { files.unshift({ name: 'b3-retained.png', mimeType: 'image/png', buffer: fs.readFileSync(path.join(companions, 'textures/albedo.png')) }); files.push({ name: 'b3-following.mtl', mimeType: 'text/plain', buffer: fs.readFileSync(path.join(companions, 'materials/paint.mtl')) }); }
await page.evaluate(({ mode, name }) => Object.assign((window as any).__transferGate, { mode, name }), { mode: mode === 'error' ? 'error' : 'hold', name: folder ? '*' : 'b3-held.blend' }); await input.setInputFiles(folder ? companions : files);
await expect.poll(() => page.evaluate(() => (window as any).__transferGate.reads.length)).toBe(1);
if (mode === 'error') await expect(panel.getByRole('alert')).toContainText('B3_TRANSFER_READ_FAILED');
else if (mode === 'hide') { await page.getByTestId('native-web-tools-toggle').click(); await expect(canvas).toBeHidden(); }
else await panel.getByRole('button', { name: '取消传输', exact: true }).click();
await expect.poll(() => page.evaluate(() => { const r = (window as any).__transferGate.reads[0]; return Boolean(r?.outerReleased && r.innerReleased && (r.cancelled || r.failed)); })).toBe(true);
if (mode === 'hide') { await focusNativeViewport(page); if (await page.getByRole('button', { name: '文件传输', exact: true }).isVisible()) await page.getByRole('button', { name: '文件传输', exact: true }).click(); }
await expect(input).toBeEnabled(); if (mode !== 'error' && mode !== 'hide') await expect(panel.getByRole('status')).toContainText('取消');
const uploadCount = await page.evaluate(() => (window as any).__transferRequests.filter((request: any) => request.action === 'upload').length); expect(uploadCount).toBe(partial ? 1 : 0);
await page.evaluate(() => { (window as any).__transferGate.mode = 'off'; (window as any).__releaseTransferReads(); }); await waitNativeModal(page, false, await page.evaluate(() => (window as any).__nativeWorkflowGPU?.requestId));
expect(await page.evaluate(() => (window as any).__transferRequests.filter((request: any) => request.action === 'upload').length)).toBe(uploadCount); expect(await readNativeHistory(page)).toEqual(history); expect((await readNativeBlend(page)).equals(before)).toBe(true); record('stoppedRead', await page.evaluate(() => ({ reads: (window as any).__transferGate.reads, requests: (window as any).__transferRequests })));
await panel.getByRole('button', { name: '刷新文件', exact: true }).click(); await expect(input).toBeEnabled(); const options = await panel.getByRole('combobox', { name: '下载桌面文件', exact: true }).locator('option').evaluateAll(items => items.map(item => (item as HTMLOptionElement).value)); record('retainedPaths', options);
expect(options.some(value => value.endsWith('b3-held.blend') || value.endsWith('b3-following.mtl'))).toBe(false); expect(options.some(value => value.endsWith('b3-retained.png'))).toBe(partial);
if (partial) { const retained = options.find(value => value.endsWith('b3-retained.png'))!; await panel.getByRole('combobox', { name: '下载桌面文件', exact: true }).selectOption(retained); const pending = page.waitForEvent('download'); await panel.getByRole('button', { name: '下载文件', exact: true }).click(); const file = info.outputPath('retained.png'); await (await pending).saveAs(file); expect(fs.readFileSync(file)).toEqual(fs.readFileSync(path.join(companions, 'textures/albedo.png'))); }
if (folder) { await input.setInputFiles(companions); await expect(input).toBeEnabled(); await expect(panel.getByRole('status')).toContainText(mode === 'restore' ? '已恢复 3' : '已上传 3');
const paths = await panel.getByRole('combobox', { name: '下载桌面文件', exact: true }).locator('option').evaluateAll(items => items.map(item => (item as HTMLOptionElement).value)); record('folderPaths', paths); const prefix = mode === 'restore' ? '' : 'b3_obj_bundle_v1/'; for (const name of ['materials/paint.mtl', 'textures/albedo.png', 'triangle model.obj']) expect(paths.some(value => value === prefix + name || value.endsWith('/' + prefix + name))).toBe(true);
}
await panel.getByLabel('上传文件', { exact: true }).setInputFiles({ name: 'panel-main.blend', mimeType: 'application/octet-stream', buffer: fs.readFileSync(fixture) }); await expect(panel.getByLabel('上传文件', { exact: true })).toBeEnabled(); await expect(panel.getByRole('status')).toContainText('已上传 1');
const selection = panel.getByRole('combobox', { name: '下载桌面文件', exact: true }), uploadedPath = await selection.locator('option').evaluateAll(items => items.map(item => (item as HTMLOptionElement).value).find(value => value.endsWith('panel-main.blend'))); expect(typeof uploadedPath).toBe('string'); await selection.selectOption(uploadedPath!);
await page.getByRole('button', { name: '关闭文件传输', exact: true }).click(); await expect(canvas).toBeFocused(); await page.getByLabel('Blender 桌面').getByRole('button', { name: '文件传输', exact: true }).click(); await expect(selection).toHaveValue(uploadedPath!); expect(await canvas.boundingBox()).toEqual(bounds);
const pending = page.waitForEvent('download'); await panel.getByRole('button', { name: '下载文件', exact: true }).click(); const transferFile = info.outputPath('transferred.blend'); await (await pending).saveAs(transferFile); expect(fs.readFileSync(transferFile)).toEqual(fs.readFileSync(fixture)); record('transferred', { path: transferFile, nativePath: uploadedPath });
await waitNativeModal(page, false, await page.evaluate(() => (window as any).__nativeWorkflowGPU?.requestId));
expect(await readNativeHistory(page)).toEqual(history); expect((await readNativeBlend(page)).equals(before)).toBe(true); await page.screenshot({ path: info.outputPath('panel-transferred.png') });
await openTransferredBlend(page, transferFile); const openedTransfer = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'objects': sorted(o.name for o in bpy.data.objects), 'filepath': bpy.data.filepath}))"); expect(openedTransfer.value.objects).toEqual(beforeObjects.value); record('openedTransfer', openedTransfer);
await exposeWebTools(page); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false', { timeout: 60000 }); 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))"); const viewport = await focusNativeViewport(page); await page.mouse.move(viewport.point.x - 17, viewport.point.y - 13); await page.mouse.move(viewport.point.x, viewport.point.y); await canvas.focus(); await waitNativeModal(page, false, await page.evaluate(() => (window as any).__nativeWorkflowGPU?.requestId)); const unmoved = await readModelingMain(page, name); await transformNative(page, 'g', 'x', '1'); const edited = await readModelingMain(page, name); record('edited', edited);
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, edited.value);
await exposeWebTools(page); const saving = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const saved = info.outputPath('transfer-roundtrip.blend'); await (await saving).saveAs(saved); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED');
await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(saved)); const reopened = await readModelingMain(page, name); expectModelingData(reopened.value, edited.value); record('reopened', reopened); await showNativeObjectProperties(page); record('reopenedUI', await expectUILocation(page, name, [1, 0, 0])); await page.screenshot({ path: info.outputPath('transfer-reopened.png') });
record('result', { status: 'PASS', kind: process.env.WBV2_CASE_KIND, scope: 'Panel ' + mode + ' cancellation/error cleanup, exact Main/history preservation, actual files and native Open/edit/UndoRedo/SaveReopen' });
});
test.afterEach(async ({ page }, info) => { fs.writeFileSync(info.outputPath('b3-transfer-last-state.json'), JSON.stringify(await captureNativeWorkflowState(page).catch(error => ({ captureError: String(error) })), null, 2)); });