Files
workinf_Blender_Wasm/web/tests/e2e/native-drop-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

77 lines
12 KiB
TypeScript

import { expect, test, type Page } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { captureNativeWorkflowState, exposeWebTools, observeNative, performNativeAction } from '../fixtures/native-ui-workflow';
import { expectModelingData, focusNativeViewport, nativeKeyFrame, queryNativeJSON, readModelingMain, readNativeHistory, transformNative, waitNativeModal } from '../fixtures/native-modeling-workflow';
import { expectUILocation, openFromUI, showNativeObjectProperties } from '../fixtures/wasm-console-workflow';
const root = path.resolve(import.meta.dirname, '../../..');
const fixture = path.resolve(root, process.env.WBV2_CASE_FIXTURE ?? 'tests/files/web/basic_scene.blend');
async function assertNativeIntegrity(page: Page) {
const state = await captureNativeWorkflowState(page);
expect(state.browserDiagnostics.filter(({ text }) => /BKE_idtype_index_to_idcode|Code marked as unreachable|memory access out of bounds|PYTHON_REENTRANT_EXECUTION/.test(text))).toEqual([]);
}
test.afterEach(async ({ page }, info) => { await fs.promises.writeFile(info.outputPath('native-drop-rpc-state.json'), JSON.stringify(await captureNativeWorkflowState(page).catch(error => ({ captureError: String(error) })), null, 2)); });
test.afterEach(async ({ page }, info) => { await fs.promises.writeFile(info.outputPath('native-drop-final-state.json'), JSON.stringify(await page.evaluate(() => { const g = (window as any).__dropGate; return { dropEvents: g?.dropEvents, uploads: g?.uploads, frames: g?.frames.slice(-48), replies: g?.replies, editor: (window as any).__nativeWorkflowEditor, failure: (window as any).__nativeWorkflowGPUFailure, alert: document.querySelector('[role="alert"],[role="status"]')?.textContent }; }).catch(error => ({ captureError: String(error) })), null, 2)); });
async function installGate(page: Page) {
await page.addInitScript(() => {
const gate: any = { mode: 'off', reads: [], releases: [], dropEvents: 0, uploads: 0, frames: [] };
(window as any).__dropGate = gate;
const original = File.prototype.stream;
File.prototype.stream = function () {
if (gate.mode !== 'hold') return original.call(this);
const inner = original.call(this).getReader(); let release!: () => void;
const wait = new Promise<void>(resolve => { release = resolve; }); gate.releases.push(release);
const state: any = { cancelled: false, innerReleased: false, outerReleased: false }; gate.reads.push(state);
const stream = new ReadableStream<Uint8Array>({ async pull(controller) { await wait; 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 reader = stream.getReader.bind(stream); (stream as any).getReader = () => { const value = reader(); const releaseLock = value.releaseLock.bind(value); value.releaseLock = () => { state.outerReleased = true; releaseLock(); }; return value; }; return stream;
};
const post = Worker.prototype.postMessage;
const observed = new WeakSet<Worker>(); gate.replies = [];
Worker.prototype.postMessage = function (message: any, ...rest: any[]) { if (!observed.has(this)) { observed.add(this); this.addEventListener('message', ({ data }) => { const gpu = data.result?.gpuUI; if (gpu) { const { cursor, ...reply } = gpu; gate.replies.push({ requestId: data.requestId, ...reply }); if (gate.replies.length > 48) gate.replies.shift(); } }); } const command = message.command; if (command?.type === 'gpuUI' && command.op === 'frame') { gate.dropEvents += (command.events ?? []).filter((event: any) => event.type === 9).length; gate.frames.push({ requestId: message.requestId, ownerGeneration: command.ownerGeneration, width: command.width, height: command.height, events: (command.events ?? []).map((event: any) => event.type) }); } if (command?.type === 'gpuUI' && command.op === 'files' && command.action === 'upload') gate.uploads++; return Reflect.apply(post, this, [message, ...rest]); } as typeof post;
});
}
async function openFixture(page: Page) {
await observeNative(page); await page.goto('/'); await expect.poll(() => page.evaluate(() => Array.isArray((window as any).__nativeWorkflowMain?.nodes)), { timeout: 60000 }).toBe(true);
await page.getByTestId('blend-file-input').setInputFiles(fixture); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false', { timeout: 60000 }); await waitNativeModal(page, false);
await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true', { timeout: 60000 });
}
async function drop(page: Page) {
await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true', { timeout: 60000 });
const bytes = Array.from(fs.readFileSync(fixture));
await page.evaluate(bytes => { const canvas = document.querySelector<HTMLCanvasElement>('canvas[aria-label="Blender desktop"]')!; const rect = canvas.getBoundingClientRect(); const transfer = new DataTransfer(); transfer.items.add(new File([new Uint8Array(bytes)], 'drop.blend', { type: 'application/octet-stream' })); canvas.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: transfer, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 })); }, bytes);
}
test('native drop cancels staging before EVT_DROP without mutation', async ({ page }, info) => {
await installGate(page); await openFixture(page); const before = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'objects': sorted(o.name for o in bpy.data.objects)}))"); const history = await readNativeHistory(page);
await page.evaluate(() => { (window as any).__dropGate.mode = 'hold'; }); await drop(page); await expect(page.getByRole('button', { name: '取消文件传输', exact: true })).toBeVisible(); await page.getByRole('button', { name: '取消文件传输', exact: true }).click();
await expect.poll(() => page.evaluate(() => { const g = (window as any).__dropGate; const r = g.reads[0]; return { cancelled: r?.cancelled, released: r?.innerReleased && r?.outerReleased, drops: g.dropEvents, uploads: g.uploads }; })).toEqual({ cancelled: true, released: true, drops: 0, uploads: 0 });
expect(await readNativeHistory(page)).toEqual(history); const after = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'objects': sorted(o.name for o in bpy.data.objects)}))"); expect(after.value).toEqual(before.value); await assertNativeIntegrity(page); await fs.promises.writeFile(info.outputPath('native-drop-cancel.json'), JSON.stringify({ status: 'PASS', route: 'NATIVE', noMutation: true }, null, 2));
});
test('native drop opens a file, edits, undoes/redoes and saves/reopens', async ({ page }, info) => {
test.setTimeout(180000);
await installGate(page); await openFixture(page); await page.evaluate(() => { (window as any).__dropGate.mode = 'off'; }); await drop(page);
try { await expect.poll(() => page.evaluate(() => (window as any).__dropGate.dropEvents), { timeout: 60000 }).toBe(1); } catch (error) { await fs.promises.writeFile(info.outputPath('native-drop-debug.json'), JSON.stringify(await page.evaluate(() => ({ gate: (window as any).__dropGate, native: (window as any).__nativeWorkflowGPU, error: document.querySelector('[role="alert"]')?.textContent })), null, 2)); throw error; }
await expect.poll(() => page.evaluate(() => (window as any).__dropGate.uploads), { timeout: 60000 }).toBe(1); await expect.poll(() => page.evaluate(() => (window as any).__nativeWorkflowGPU?.modalActive), { timeout: 15000 }).toBe(true);
await page.screenshot({ path: info.outputPath('native-drop-before-confirm.png') });
await fs.promises.writeFile(info.outputPath('native-drop-before-confirm.json'), JSON.stringify(await page.evaluate(() => ({ native: (window as any).__nativeWorkflowGPU, failure: (window as any).__nativeWorkflowGPUFailure, editor: (window as any).__nativeWorkflowEditor, requests: (window as any).__nativeWorkflowRequests.slice(-12), frames: (window as any).__dropGate.frames.slice(-12), alert: document.querySelector('[role="alert"]')?.textContent })), null, 2));
await waitNativeModal(page, true);
await page.keyboard.press('1'); await expect.poll(() => page.evaluate(() => (window as any).__nativeWorkflowGPU?.modalActive), { timeout: 15000 }).toBe(false);
const beforeTools = await page.evaluate(() => (window as any).__nativeWorkflowGPU?.requestId); await exposeWebTools(page); await waitNativeModal(page, false, beforeTools);
const opened = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'filepath': bpy.data.filepath, 'objects': sorted(o.name for o in bpy.data.objects)}))");
expect(opened.value.filepath).toMatch(/\/browser\/drop-[^/]+\/drop\.blend$/);
await assertNativeIntegrity(page); await fs.promises.writeFile(info.outputPath('native-drop-positive.json'), JSON.stringify({ status: 'PASS', route: 'NATIVE', dropEvents: 1, upload: 1, prompt: 'OPENED_STAGED_BLEND', opened }, null, 2));
await performNativeAction(page, { command: 'createPrimitive' }, () => page.getByRole('button', { name: '添加立方体', exact: true }).click());
await focusNativeViewport(page); const { value: name } = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps(bpy.context.active_object.name))"); expect(typeof name).toBe('string');
const beforeEdit = await readModelingMain(page, name); await transformNative(page, 'g', 'x', '1.25');
const edited = await readModelingMain(page, name); expect(edited.value.location[0]).toBeCloseTo(beforeEdit.value.location[0] + 1.25, 5);
await nativeKeyFrame(page, 'Control+z', 90); const undone = await readModelingMain(page, name); expectModelingData(undone.value, beforeEdit.value);
await nativeKeyFrame(page, 'Control+Shift+z', 90); const redone = await readModelingMain(page, name); expectModelingData(redone.value, edited.value);
await exposeWebTools(page); await showNativeObjectProperties(page); const beforeSaveUI = await expectUILocation(page, name, edited.value.location);
const saving = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const saved = info.outputPath('native-drop-roundtrip.blend'); await (await saving).saveAs(saved); expect(fs.statSync(saved).size).toBeGreaterThan(12);
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); await showNativeObjectProperties(page); const reopenedUI = await expectUILocation(page, name, edited.value.location);
await page.screenshot({ path: info.outputPath('native-drop-roundtrip.png') });
await assertNativeIntegrity(page); await fs.promises.writeFile(info.outputPath('native-drop-roundtrip.json'), JSON.stringify({ status: 'PASS', openRoute: 'NATIVE', historyRoute: 'NATIVE_KEYBOARD', saveReopenRoute: 'BROWSER_EQUIVALENT', fixture, fixtureSha256: crypto.createHash('sha256').update(fs.readFileSync(fixture)).digest('hex'), candidatePublic: process.env.WEB_DESKTOP_CANDIDATE_PUBLIC ?? null, specSha256: crypto.createHash('sha256').update(fs.readFileSync(import.meta.filename)).digest('hex'), opened, beforeEdit, edited, undone, redone, reopened, beforeSaveUI, reopenedUI, saved: { path: saved, bytes: fs.statSync(saved).size, sha256: crypto.createHash('sha256').update(fs.readFileSync(saved)).digest('hex') } }, null, 2));
});