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

299 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { observeNative, exposeWebTools, performNativeAction, captureNativeWorkflowState } from '../fixtures/native-ui-workflow';
import { queryNativeJSON, readNativeHistory, readNativeBlend, nativeKeyFrame, waitNativeModal, focusNativeViewport } from '../fixtures/native-modeling-workflow';
import { pythonReply, switchNativeWorkspace } from '../fixtures/wasm-console-workflow';
import { stageOriginalAutosaveCase } from '../fixtures/upstream-autosave-case';
test.setTimeout(600000);
test.afterEach(async ({ page }, info) => { fs.writeFileSync(info.outputPath('native-quit-last-state.json'), JSON.stringify(await captureNativeWorkflowState(page).catch(error => ({ error: String(error) })), null, 2)); });
test('B3 native quit cancel durable recovery Undo Save-Reopen', async ({ page }, info) => {
const artifactSha = (file: string) => { const hash = createHash('sha256'), descriptor = fs.openSync(file, 'r'), buffer = Buffer.alloc(1048576); try { let count: number; while ((count = fs.readSync(descriptor, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, count)); } finally { fs.closeSync(descriptor); } return hash.digest('hex'); };
const desktopReceiptPath = process.env.WBV2_QUIT_DESKTOP_RECEIPT; if (!desktopReceiptPath) throw new Error('Independent native GUI quit/readback receipt is required before browser case actions');
const desktop = JSON.parse(fs.readFileSync(path.resolve(desktopReceiptPath), 'utf8')); expect(desktop).toMatchObject({ schemaVersion: 1, kind: 'desktop-native-quit-baseline', phase: 'DESKTOP_BASELINE_CAPTURED', version: [5, 2, 0], platform: 'linux', acceptance: 'NOT_CLAIMED' }); expect(desktop.cleanupPendingPid).toBeUndefined();
for (const label of ['gui-producer', 'background-reader']) { const step = desktop.steps[label]; expect(step.exitCode).toBe(0); expect(step.timedOut).not.toBe(true); expect(artifactSha(step.log)).toBe(step.logSha256); expect(step.command).toContain(desktop.nativeBinary.path); }
expect(desktop.steps['gui-producer'].command).not.toContain('--background'); expect(desktop.steps['background-reader'].command).toContain('--background'); expect(artifactSha(desktop.nativeBinary.path)).toBe(desktop.nativeBinary.sha256);
expect(artifactSha(desktop.readback.path)).toBe(desktop.readback.sha256); const desktopReadback = JSON.parse(fs.readFileSync(desktop.readback.path, 'utf8'));
expect(desktopReadback).toMatchObject({ schemaVersion: 1, kind: 'desktop-native-quit-readback', phase: 'NATIVE_QUIT_READBACK_CAPTURED', version: [5, 2, 0], platform: 'linux', nativeBinary: desktop.nativeBinary.path }); expect(desktopReadback.quitFile).toEqual(desktop.quitFile); expect(artifactSha(desktop.quitFile.path)).toBe(desktop.quitFile.sha256);
const desktopSources = { producer: path.resolve(import.meta.dirname, '../fixtures/native-last-session-prepare.py'), reader: path.resolve(import.meta.dirname, '../fixtures/native-last-session-readback.py'), capture: path.resolve(import.meta.dirname, '../../../tools/web/capture-native-last-session.py') };
for (const [name, file] of Object.entries(desktopSources)) { expect(fs.realpathSync(desktop.sources[name].path)).toBe(fs.realpathSync(file)); expect(artifactSha(file)).toBe(desktop.sources[name].sha256); }
for (const [step, source] of [['gui-producer', 'producer'], ['background-reader', 'reader']]) { const command = desktop.steps[step].command as string[], index = command.indexOf('--python'); expect(index).toBeGreaterThanOrEqual(0); expect(command[index + 1]).toBe(desktop.sources[source].path); }
expect(desktopReadback.producerSha256).toBe(desktop.sources.producer.sha256); expect(desktopReadback.readerSha256).toBe(desktop.sources.reader.sha256); expect(desktopReadback.inputSha256).toBe(desktop.input.sha256);
expect(desktop.quitFile.path).toBe(path.join(desktop.workDir, 'native-temp', 'quit.blend')); expect(artifactSha(path.join(desktop.workDir, 'native-last-session-expected.json'))).toBe(desktopReadback.expectedReceiptSha256);
for (const asset of [desktop.input, desktop.input.companion]) { expect(artifactSha(asset.source)).toBe(asset.sha256); expect(artifactSha(asset.path)).toBe(asset.sha256); }
await observeNative(page); await page.goto('/?ui=native');
await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true', { timeout: 60000 });
await exposeWebTools(page); await switchNativeWorkspace(page, 'Scripting');
const prepared = await stageOriginalAutosaveCase(page, info);
const evidence: Record<string, unknown> = { kind: 'B3-last-session-original-asset', prepared, acceptance: 'NOT_CLAIMED' }, record = (name: string, value: unknown) => { evidence[name] = value; fs.writeFileSync(info.outputPath('native-quit-evidence.json'), JSON.stringify(evidence, null, 2)); };
const readOPFSRecovery = () => page.evaluate(async () => {
const root = await navigator.storage.getDirectory(), projects = await root.getDirectoryHandle('projects');
const directory = await projects.getDirectoryHandle('_native_session_recovery_v1');
const head = JSON.parse(await (await (await directory.getFileHandle('native-recovery-head.json')).getFile()).text());
const assets = await (await directory.getDirectoryHandle('assets')).getDirectoryHandle('sha256');
const payload = await (await (await (await assets.getDirectoryHandle(head.current.sha256.slice(0, 2))).getFileHandle(head.current.sha256)).getFile()).arrayBuffer();
return { head, actualBytes: payload.byteLength, actualSha256: Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', payload)), byte => byte.toString(16).padStart(2, '0')).join('') };
});
const runPython = async (source: string) => {
await exposeWebTools(page); const input = page.getByLabel('Python 输入', { exact: true }); await input.fill(`exec(${JSON.stringify(source)})`);
await performNativeAction(page, { type: 'jsonCommand', jsonCommand: 'pythonExec' }, () => input.press('Enter'));
const reply = await pythonReply(page); expect(reply?.state, reply?.output).toBe('DONE'); expect(reply?.truncated).toBe(false);
const frame = await page.evaluate(() => (window as any).__nativeWorkflowGPU); if (frame?.attached && reply?.changed) await waitNativeModal(page, false, frame.requestId);
return reply!;
};
await page.evaluate(() => { (window as any).__nativeQuitSignals = []; (window as any).__nativeWorkflowEngine.addEventListener('message', ({ data }: MessageEvent) => { if (data?.type === 'blender-native-exit-request') (window as any).__nativeQuitSignals.push(data); }); });
const producer = fs.readFileSync(path.resolve(import.meta.dirname, '../fixtures/native-last-session-prepare.py'), 'utf8');
const finalQuit = "assert bpy.ops.wm.quit_blender('EXEC_DEFAULT') == {'FINISHED'}"; expect(producer.trimEnd().endsWith(finalQuit)).toBe(true);
const preparationSource = producer.slice(0, producer.lastIndexOf(finalQuit)), producerSha256 = createHash('sha256').update(producer).digest('hex');
const inputFile = `${prepared.caseDir}/assets/io_tests/blend_autosave/autosave_image_test.blend`, nativeSource = `${prepared.caseDir}/native-last-session-prepare.py`, workDirectory = `/browser/b3-last-session-${path.posix.basename(prepared.caseDir)}`;
record('sourceBinding', { producerSha256, preparationSha256: createHash('sha256').update(preparationSource).digest('hex'), finalQuitDispatchedBy: 'real File/F3 UI, not producer EXEC_DEFAULT', desktopBaseline: 'INDEPENDENT_GUI_EXIT_CAPTURED', sourcePreferences: { use_save_prompt: true, temporary_directory: '' } });
const preparationProgram = [
'import pathlib,sys,json,bpy',
`pathlib.Path(${JSON.stringify(nativeSource)}).write_text(${JSON.stringify(producer)})`,
`old_argv=sys.argv; sys.argv=${JSON.stringify([nativeSource, '--', '--input', inputFile, '--work-dir', workDirectory])}`,
`try:\n exec(compile(${JSON.stringify(preparationSource)},${JSON.stringify(nativeSource)},'exec'),{'__name__':'__main__','__file__':${JSON.stringify(nativeSource)}})\nfinally:\n sys.argv=old_argv`,
"assert bpy.ops.ed.undo_push(message='B3 native unsaved marker') == {'FINISHED'}; bpy.context.preferences.filepaths.temporary_directory=''; bpy.context.preferences.view.use_save_prompt=True",
`print('WBV2_QUIT_PREP',json.dumps(json.loads(pathlib.Path(${JSON.stringify(`${workDirectory}/native-last-session-expected.json`)}).read_text())))`,
].join('\n');
const preparedReply = await runPython(preparationProgram), marker = preparedReply.output.split('\n').findLast(line => line.startsWith('WBV2_QUIT_PREP ')); expect(marker, preparedReply.output).toBeTruthy(); const pendingDelivery = page.getByRole('complementary', { name: '待完成文件', exact: true }); if (await pendingDelivery.isVisible()) await pendingDelivery.getByRole('button', { name: '忽略', exact: true }).click();
const expected = JSON.parse(marker!.slice('WBV2_QUIT_PREP '.length)); expect(expected.version).toEqual([5, 2, 0]); expect(expected.platform).toBe('emscripten'); expect(expected.producerSha256).toBe(producerSha256);
expect(expected.inputSha256).toBe(prepared.assetHashes['io_tests/blend_autosave/autosave_image_test.blend']); record('preparedNativeMain', expected);
expect(desktop.input.sha256).toBe(expected.inputSha256); expect(desktop.input.companion.sha256).toBe(prepared.assetHashes['io_tests/blend_autosave/ExternalImage.png']);
expect(desktopReadback.actualMain.filepath).toBe(path.join(desktop.workDir, 'source-session.blend')); expect(expected.expectedMain.filepath).toBe(`${workDirectory}/source-session.blend`);
const desktopMainForWeb = { ...desktopReadback.actualMain, filepath: expected.expectedMain.filepath }; expect(expected.expectedMain).toEqual(desktopMainForWeb);
record('independentDesktopBaseline', { receipt: path.resolve(desktopReceiptPath), receiptSha256: artifactSha(path.resolve(desktopReceiptPath)), quitFile: desktop.quitFile, readback: desktop.readback, pathMapping: { desktop: desktopReadback.actualMain.filepath, web: expected.expectedMain.filepath }, expectedMain: desktopMainForWeb, claim: 'NOT_CLAIMED' });
const readState = async () => (await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'filepath':bpy.data.filepath,'scene':bpy.context.scene.name,'marker':bpy.context.scene.get('wbv2_last_session_unsaved'),'objects':sorted(obj.name for obj in bpy.data.objects),'images':{image.name:{'pixels':list(image.pixels[:4]),'size':list(image.size),'packed':bool(image.packed_file)} for image in bpy.data.images if image.source=='FILE'},'dirty':bpy.data.is_dirty}))")).value;
const state = await readState(); const { dirty, ...mainState } = state as { dirty: boolean; filepath: string; scene: string; marker: string; objects: string[]; images: Record<string, unknown> }; expect(mainState).toEqual(expected.expectedMain); expect(dirty).toBe(true);
/* Choosing through the palette switches to the Web tools view, which hides the
* native canvas; restore it and wait for delivered input before any key press. */
const restoreNativeView = async () => { const button = page.getByLabel('工作区显示').getByRole('button', { name: 'Blender 桌面', exact: true }); if (await button.isVisible().catch(() => false)) await button.click(); await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true', { timeout: 60000 }); };
const chooseNative = async (label: string, expectedErrorOperator?: string) => {
await exposeWebTools(page); await page.getByRole('button', { name: '操作搜索', exact: true }).click();
const dialog = page.getByRole('dialog', { name: '操作搜索', exact: true }); await dialog.getByRole('textbox', { name: '搜索 Blender 操作' }).fill(label);
const action = dialog.getByRole('button').filter({ hasText: label }); await expect(action).toHaveCount(1);
if (expectedErrorOperator) {
const start = await page.evaluate(() => (window as any).__nativeWorkflowRequests.length); await action.click();
const reply = () => page.evaluate(({ begin, target }) => { const w = window as any; const requests = w.__nativeWorkflowRequests.slice(begin).filter((request: any) => request.type === 'gpuUI' && request.gpuOperation === 'invoke' && String(request.nativeOperator ?? '').replace('_OT_', '.').toLowerCase() === target); const response = w.__nativeWorkflowResponses.findLast((item: any) => requests.some((request: any) => request.requestId === item.requestId)); return response ? { ...response, nativeOperator: requests.find((request: any) => request.requestId === response.requestId).nativeOperator } : null; }, { begin: start, target: expectedErrorOperator });
await expect.poll(reply, { timeout: 60000 }).not.toBeNull(); await expect(dialog).toBeHidden(); await restoreNativeView(); return await reply();
}
const receipt = await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'invoke' }, () => action.click());
await expect(dialog).toBeHidden(); await restoreNativeView(); record(`native:${label}:${Object.keys(evidence).length}`, receipt);
};
const { canvas } = await focusNativeViewport(page); await canvas.click({ position: { x: 80, y: 80 } }); await page.keyboard.press('Escape'); await page.keyboard.press('Escape'); await waitNativeModal(page, false); record('quitGuard', (await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'savePrompt':bool(bpy.context.preferences.view.use_save_prompt),'dirty':bool(bpy.data.is_dirty),'saved':bool(bpy.data.is_saved)}))")).value); const beforeCancel = await readNativeBlend(page), historyBeforeCancel = await readNativeHistory(page), stateBeforeCancel = await readState();
await chooseNative('Quit Native WASM Session');
await canvas.focus(); record('quitCancelled', await nativeKeyFrame(page, 'Escape', 27)); await waitNativeModal(page, false);
await expect(page.locator('main.blender-app')).toHaveAttribute('data-native-session-exit', 'idle'); expect(await page.evaluate(() => (window as any).__nativeQuitSignals.length)).toBe(0);
const afterCancel = await readNativeBlend(page), historyAfterCancel = await readNativeHistory(page); expect(afterCancel.equals(beforeCancel)).toBe(true); expect(historyAfterCancel.cursor).toBe(historyBeforeCancel.cursor); expect(historyAfterCancel.entries).toEqual(historyBeforeCancel.entries);
expect(await readState()).toEqual(stateBeforeCancel); record('cancelInvariant', { bytesUnchanged: true, historyBeforeCancel, historyAfterCancel, stateBeforeCancel });
expect(await page.evaluate(async () => { try { const root = await navigator.storage.getDirectory(), projects = await root.getDirectoryHandle('projects'), directory = await projects.getDirectoryHandle('_native_session_recovery_v1'); await directory.getFileHandle('native-recovery-head.json'); return true; } catch (error) { if (error instanceof DOMException && error.name === 'NotFoundError') return false; throw error; } })).toBe(false);
const firstQuitWorker = page.workers().find(worker => worker.url().includes('web-engine.worker')); if (!firstQuitWorker) throw new Error('Native Worker missing before first OPFS publication');
const firstFailureMain = await readState(), firstFailureHistory = await readNativeHistory(page), firstFailureRequestStart = await page.evaluate(() => (window as any).__nativeWorkflowRequests.length);
const ordinarySaveBeforeQuit = await queryNativeJSON(page, "import bpy,pathlib,json,hashlib\np=pathlib.Path(bpy.data.filepath)\nprint(json.dumps({'path':str(p),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}))"); record('ordinarySaveBeforeQuit', ordinarySaveBeforeQuit.value);
await firstQuitWorker.evaluate(() => { const original = FileSystemFileHandle.prototype.createWritable; (globalThis as any).__quitFirstHeadWritable = original; FileSystemFileHandle.prototype.createWritable = function (this: FileSystemFileHandle, ...args: Parameters<typeof original>) { if (this.name === 'native-recovery-head.json') return Promise.reject(new DOMException('B3 first OPFS publication failure', 'QuotaExceededError')); return Reflect.apply(original, this, args); }; });
try {
await chooseNative('Quit Native WASM Session'); await canvas.focus(); await page.keyboard.press('d');
const firstFailureReceipt = () => page.evaluate(start => { const w = window as any; const ids = new Set(w.__nativeWorkflowRequests.slice(start).filter((r: any) => r.type === 'shutdown').map((r: any) => r.requestId)); return w.__nativeWorkflowResponses.findLast((r: any) => ids.has(r.requestId)) ?? null; }, firstFailureRequestStart);
await expect.poll(firstFailureReceipt, { timeout: 60000 }).toMatchObject({ ok: false, errorMessage: expect.stringContaining('NATIVE_SESSION_OPFS_PERSISTENCE_PENDING') });
await expect(page.locator('main.blender-app')).toHaveAttribute('data-native-session-exit', 'idle'); expect(page.workers()).toContain(firstQuitWorker); expect(await page.evaluate(() => (window as any).__nativeQuitSignals.length)).toBe(1);
expect(await readState()).toEqual(firstFailureMain); const retained = await readNativeHistory(page); expect(retained.cursor).toBe(firstFailureHistory.cursor); expect(retained.entries).toEqual(firstFailureHistory.entries);
record('firstOPFSCommitFailure', { receipt: await firstFailureReceipt(), main: firstFailureMain, history: retained, sameWorker: true });
} finally { await firstQuitWorker.evaluate(() => { FileSystemFileHandle.prototype.createWritable = (globalThis as any).__quitFirstHeadWritable; delete (globalThis as any).__quitFirstHeadWritable; }); }
expect(await page.evaluate(async () => { try { const root = await navigator.storage.getDirectory(), projects = await root.getDirectoryHandle('projects'), directory = await projects.getDirectoryHandle('_native_session_recovery_v1'); await directory.getFileHandle('native-recovery-head.json'); return true; } catch (error) { if (error instanceof DOMException && error.name === 'NotFoundError') return false; throw error; } })).toBe(false);
const ordinarySaveAfterFailedQuit = await queryNativeJSON(page, "import bpy,pathlib,json,hashlib\np=pathlib.Path(bpy.data.filepath)\nprint(json.dumps({'path':str(p),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}))"); expect(ordinarySaveAfterFailedQuit.value).toEqual(ordinarySaveBeforeQuit.value);
const retryDirty = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'dirty':bpy.data.is_dirty}))"); expect((retryDirty.value as { dirty: boolean }).dirty).toBe(true);
await chooseNative('Quit Native WASM Session'); await canvas.focus(); await page.keyboard.press('d');
await expect(page.locator('main.blender-app')).toHaveAttribute('data-native-session-exit', 'closed', { timeout: 120000 }); await expect(page.getByRole('status', { name: '会话已保存并退出' })).toBeVisible();
expect(await page.evaluate(() => (window as any).__nativeQuitSignals.length)).toBe(2); record('durableClosed', { signals: await page.evaluate(() => (window as any).__nativeQuitSignals), expected: expected.expectedMain }); await page.screenshot({ path: info.outputPath('native-quit-closed.png') });
const firstOPFSCommit = await readOPFSRecovery(); expect(firstOPFSCommit.head.schemaVersion).toBe(1);
expect(firstOPFSCommit.head.current.path.startsWith('/browser/')).toBe(true); expect(firstOPFSCommit.head.current.path.endsWith('/quit.blend')).toBe(true);
expect(firstOPFSCommit.actualBytes).toBe(firstOPFSCommit.head.current.bytes); expect(firstOPFSCommit.actualBytes).toBeGreaterThan(0); expect(firstOPFSCommit.actualSha256).toBe(firstOPFSCommit.head.current.sha256);
record('opfsFirstCommit', firstOPFSCommit);
await page.evaluate((filePath: string) => new Promise<void>((resolve, reject) => {
const request = indexedDB.open('/browser'); request.onerror = () => reject(request.error); request.onupgradeneeded = () => { request.transaction?.abort(); reject(new Error('Expected existing legacy native-file database')); };
request.onsuccess = () => {
const db = request.result; let transaction: IDBTransaction; try { transaction = db.transaction('FILE_DATA', 'readwrite'); } catch (error) { db.close(); reject(error); return; } const store = transaction.objectStore('FILE_DATA'), existing = store.get(filePath);
existing.onsuccess = () => { if (existing.result === undefined) { transaction.abort(); return; } store.delete(filePath); };
transaction.oncomplete = () => { db.close(); resolve(); }; transaction.onerror = transaction.onabort = () => { db.close(); reject(transaction.error ?? new Error('Owned legacy quit deletion did not commit')); };
};
}), firstOPFSCommit.head.current.path); record('opfsRestorePrerequisite', { deletedOnlyLegacyQuit: firstOPFSCommit.head.current.path, retainedOPFS: firstOPFSCommit.head.current.sha256 });
await page.getByRole('button', { name: '重新打开 Blender', exact: true }).click();
await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true', { timeout: 60000 }); await exposeWebTools(page); await switchNativeWorkspace(page, 'Scripting');
const persisted = await queryNativeJSON(page, "import bpy,json,pathlib,hashlib\np=pathlib.Path(bpy.app.tempdir).parent/'quit.blend'\nprint(json.dumps({'path':str(p),'bytes':p.stat().st_size,'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}))");
const quitFile = persisted.value as { path: string; bytes: number; sha256: string }; expect(quitFile.path.startsWith('/browser/')).toBe(true); expect(quitFile.bytes).toBeGreaterThan(0); expect(quitFile.sha256).toMatch(/^[a-f0-9]{64}$/); record('quitFileAfterWorkerRestart', quitFile);
expect(quitFile.sha256).toBe(firstOPFSCommit.actualSha256);
const ordinarySaveAfterRestart = await queryNativeJSON(page, `import pathlib,json,hashlib\np=pathlib.Path(${JSON.stringify((ordinarySaveBeforeQuit.value as { path: string }).path)})\nprint(json.dumps({'path':str(p),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}))`);
expect(ordinarySaveAfterRestart.value).toEqual(ordinarySaveBeforeQuit.value); record('unsavedQuitRecoveryProof', { ordinarySave: ordinarySaveAfterRestart.value, nativeQuit: quitFile, discardSource: 'wm_files.cc:5033-5046; interface.cc:1327-1353,2142-2145; interface_handlers.cc:12995-13003' });
await runPython(`import bpy,json\nbpy.context.scene['wbv2_last_session_unsaved']='receiver'\nassert bpy.ops.wm.save_as_mainfile(filepath=${JSON.stringify(`${workDirectory}/receiver.blend`)},relative_remap=True,check_existing=False)=={'FINISHED'}\nprint(json.dumps({'receiver':bpy.data.filepath}))`);
await expect(page.getByTestId('engine-status')).toHaveText('原生另存为已同步:receiver.blend', { timeout: 60000 }); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false');
expect((await readState() as { marker: string }).marker).toBe('receiver');
const recoveryControlPath = `${workDirectory}/quit-recovery-control.blend`;
const recoveryWorker = page.workers().find(worker => worker.url().includes('web-engine.worker')); if (!recoveryWorker) throw new Error('Native Worker missing before recovery error cases');
await runPython(`import pathlib,json,hashlib\nsource=pathlib.Path(${JSON.stringify(quitFile.path)})\ncontrol=pathlib.Path(${JSON.stringify(recoveryControlPath)})\nassert not control.exists(), 'Refuse to overwrite an existing recovery control'\ncontrol.write_bytes(source.read_bytes())\nassert hashlib.sha256(control.read_bytes()).hexdigest()==${JSON.stringify(quitFile.sha256)}\nprint(json.dumps({'control':str(control)}))`);
try {
for (const mode of ['missing', 'corrupt'] as const) {
await runPython(`import pathlib,json\np=pathlib.Path(${JSON.stringify(quitFile.path)})\nmode=${JSON.stringify(mode)}\nif mode=='missing': p.unlink()\nelse: p.write_bytes(b'WBV2_INVALID_QUIT')\nassert (not p.exists()) if mode=='missing' else p.read_bytes()==b'WBV2_INVALID_QUIT'\nprint(json.dumps({'nativeQuitFault':mode,'path':str(p)}))`);
const beforeState = await readState(), beforeBytes = await readNativeBlend(page), beforeHistory = await readNativeHistory(page);
const cleanReceiver = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'dirty':bpy.data.is_dirty}))"); expect((cleanReceiver.value as { dirty: boolean }).dirty).toBe(false);
const attempt = await chooseNative('Recover Native Last Session', 'wm.recover_last_session'); expect(attempt.ok).toBe(true); expect(attempt.nativeError).toMatchObject({ code: 'INVALID_ARGUMENT' });
const nativeMessage = String(attempt.nativeError.message); expect(nativeMessage).toMatch(/NATIVE_INVOKE_(FAILED|CANCELLED)/); if (nativeMessage.includes('NATIVE_INVOKE_FAILED')) expect(nativeMessage).toContain('quit.blend');
await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true', { timeout: 60000 }); await canvas.focus(); record(`nativeRecoveryErrorDismiss:${mode}`, await nativeKeyFrame(page, 'Escape', 27)); await waitNativeModal(page, false); expect(page.workers()).toContain(recoveryWorker);
const afterState = await readState(), afterBytes = await readNativeBlend(page), afterHistory = await readNativeHistory(page); expect(afterState).toEqual(beforeState); expect(afterBytes.equals(beforeBytes)).toBe(true);
expect(afterHistory.cursor).toBe(beforeHistory.cursor); expect(afterHistory.entries).toEqual(beforeHistory.entries);
record(`nativeRecoveryFailure:${mode}`, { attempt, beforeState, afterState, beforeSha256: createHash('sha256').update(beforeBytes).digest('hex'), afterSha256: createHash('sha256').update(afterBytes).digest('hex'), beforeHistory, afterHistory, sameWorker: true });
const inputAfterFailure = await queryNativeJSON(page, `import pathlib,json,hashlib\np=pathlib.Path(${JSON.stringify(quitFile.path)})\ndata=p.read_bytes() if p.exists() else None\nprint(json.dumps({'exists':data is not None,'bytes':len(data) if data is not None else None,'sha256':hashlib.sha256(data).hexdigest() if data is not None else None}))`);
const expectedInput = mode === 'missing' ? { exists: false, bytes: null, sha256: null } : { exists: true, bytes: Buffer.byteLength('WBV2_INVALID_QUIT'), sha256: createHash('sha256').update('WBV2_INVALID_QUIT').digest('hex') };
expect(inputAfterFailure.value).toEqual(expectedInput); record(`nativeRecoveryInputRetained:${mode}`, inputAfterFailure.value);
}
} finally { await runPython(`import pathlib,json,hashlib\nsource=pathlib.Path(${JSON.stringify(recoveryControlPath)})\noriginal=source.read_bytes()\nassert hashlib.sha256(original).hexdigest()==${JSON.stringify(quitFile.sha256)}\npathlib.Path(${JSON.stringify(quitFile.path)}).write_bytes(original)\nassert hashlib.sha256(pathlib.Path(${JSON.stringify(quitFile.path)}).read_bytes()).hexdigest()==${JSON.stringify(quitFile.sha256)}\nprint(json.dumps({'restoredQuit':${JSON.stringify(quitFile.path)}}))`); }
await runPython("import bpy,json\nbpy.context.preferences.view.use_save_prompt=True\nbpy.context.scene['wbv2_last_session_unsaved']='cancel-receiver'\nassert bpy.ops.ed.undo_push(message='B3 cancel receiver') == {'FINISHED'}\nprint(json.dumps({'cancelReceiver':bpy.context.scene['wbv2_last_session_unsaved']}))");
const recoveryCancelDirty = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'dirty':bpy.data.is_dirty}))"); expect((recoveryCancelDirty.value as { dirty: boolean }).dirty).toBe(true);
await expect(page.getByRole('checkbox', { name: '允许恢复文件脚本', exact: true })).not.toBeChecked();
const recoveryCancelState = await readState(), recoveryCancelBytes = await readNativeBlend(page), recoveryCancelHistory = await readNativeHistory(page), recoveryCancelStart = await page.evaluate(() => (window as any).__nativeWorkflowRequests.length);
const recoveryPublicationBefore = await page.locator('main.blender-app').evaluate(node => { const data = (node as HTMLElement).dataset; return { projectId: data.projectId, mainRevision: data.currentMainRevision, snapshotRevision: data.projectSnapshotRevision }; }); expect(recoveryPublicationBefore.mainRevision).toMatch(/^\d+$/);
await chooseNative('Recover Native Last Session'); await waitNativeModal(page, true);
const recoveryCancelRequest = await page.evaluate(begin => (window as any).__nativeWorkflowRequests.slice(begin).findLast((request: any) => request.type === 'gpuUI' && request.gpuOperation === 'invoke' && String(request.nativeOperator ?? '').replace('_OT_', '.').toLowerCase() === 'wm.recover_last_session'), recoveryCancelStart); expect(recoveryCancelRequest).toBeTruthy();
record('nativeRecoveryCancelPrompt', { request: recoveryCancelRequest, beforeState: recoveryCancelState, beforeSha256: createHash('sha256').update(recoveryCancelBytes).digest('hex'), beforeHistory: recoveryCancelHistory });
await canvas.focus(); record('nativeRecoveryCancelKey', await nativeKeyFrame(page, 'Escape', 27)); await waitNativeModal(page, false); expect(page.workers()).toContain(recoveryWorker);
const recoveryCancelledState = await readState(), recoveryCancelledBytes = await readNativeBlend(page), recoveryCancelledHistory = await readNativeHistory(page);
expect(recoveryCancelledState).toEqual(recoveryCancelState); expect(recoveryCancelledBytes.equals(recoveryCancelBytes)).toBe(true); expect(recoveryCancelledHistory.cursor).toBe(recoveryCancelHistory.cursor); expect(recoveryCancelledHistory.entries).toEqual(recoveryCancelHistory.entries);
const quitAfterCancel = await queryNativeJSON(page, `import bpy,pathlib,json,hashlib\np=pathlib.Path(${JSON.stringify(quitFile.path)})\nprint(json.dumps({'sha256':hashlib.sha256(p.read_bytes()).hexdigest(),'dirty':bpy.data.is_dirty}))`); expect(quitAfterCancel.value).toEqual({ sha256: quitFile.sha256, dirty: true });
record('nativeRecoveryCancelled', { state: recoveryCancelledState, beforeSha256: createHash('sha256').update(recoveryCancelBytes).digest('hex'), afterSha256: createHash('sha256').update(recoveryCancelledBytes).digest('hex'), history: recoveryCancelledHistory, quit: quitAfterCancel.value, sameWorker: true });
const recoveryPublicationAfter = await page.locator('main.blender-app').evaluate(node => { const data = (node as HTMLElement).dataset; return { projectId: data.projectId, mainRevision: data.currentMainRevision, snapshotRevision: data.projectSnapshotRevision }; }); expect(recoveryPublicationAfter).toEqual(recoveryPublicationBefore); record('nativeRecoveryCancelPublication', { before: recoveryPublicationBefore, after: recoveryPublicationAfter });
await expect(canvas).toHaveAttribute('data-native-input-ready', 'true'); await page.screenshot({ path: info.outputPath('native-last-session-cancelled.png') });
await chooseNative('Recover Native Last Session'); await waitNativeModal(page, true); await canvas.focus(); await page.keyboard.press('d'); await waitNativeModal(page, false);
await expect.poll(async () => { const { dirty, ...mainState } = (await readState()) as Record<string, unknown>; record('dirtyAfterQuitRecovery', dirty); return mainState; }, { timeout: 60000 }).toEqual(desktopMainForWeb); record('recoveredNativeQuit', { state: await readState(), selectedNativeQuit: quitFile });
const stagedDesktopQuitPath = `${prepared.caseDir}/desktop-native-quit.blend`;
await prepared.writeCaseBytes('desktop-native-quit.blend', fs.readFileSync(desktop.quitFile.path));
const stagedDesktopQuit = await queryNativeJSON(page, `import pathlib,json,hashlib\np=pathlib.Path(${JSON.stringify(stagedDesktopQuitPath)})\nprint(json.dumps({'path':str(p),'bytes':p.stat().st_size,'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}))`); expect(stagedDesktopQuit.value).toEqual({ path: stagedDesktopQuitPath, bytes: desktop.quitFile.bytes, sha256: desktop.quitFile.sha256 }); record('rawDesktopQuitStaged', stagedDesktopQuit.value);
try {
const desktopQuitPlaced = await queryNativeJSON(page, `import pathlib,json,hashlib\ndata=pathlib.Path(${JSON.stringify(stagedDesktopQuitPath)}).read_bytes()\nassert hashlib.sha256(data).hexdigest()==${JSON.stringify(desktop.quitFile.sha256)}\np=pathlib.Path(${JSON.stringify(quitFile.path)})\np.write_bytes(data)\nassert hashlib.sha256(p.read_bytes()).hexdigest()==${JSON.stringify(desktop.quitFile.sha256)}\nprint(json.dumps({'nativeQuitPath':str(p),'bytes':p.stat().st_size,'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}))`);
await runPython(`import bpy,json\nbpy.context.scene['wbv2_last_session_unsaved']='desktop-transfer-receiver'\nassert bpy.ops.wm.save_as_mainfile(filepath=${JSON.stringify(`${workDirectory}/desktop-transfer-receiver.blend`)},relative_remap=True,check_existing=False)=={'FINISHED'}\nprint(json.dumps({'receiver':bpy.data.filepath}))`);
expect((await readState() as { marker: string }).marker).toBe('desktop-transfer-receiver');
await chooseNative('Recover Native Last Session'); await waitNativeModal(page, false);
await exposeWebTools(page); await switchNativeWorkspace(page, 'Scripting');
const { dirty: desktopQuitRecoveredDirty, ...desktopQuitRecoveredMain } = (await readState()) as Record<string, unknown>; record('dirtyAfterDesktopQuitRecovery', desktopQuitRecoveredDirty); expect(desktopQuitRecoveredMain).toEqual(desktopReadback.actualMain);
record('rawDesktopQuitRecovered', { source: desktop.quitFile, staged: stagedDesktopQuit.value, placed: desktopQuitPlaced.value, actualMain: desktopQuitRecoveredMain, referenceMain: desktopReadback.actualMain, pathMapping: 'NONE: actual desktop file path is preserved by native recovery', claim: 'NOT_CLAIMED' });
await page.screenshot({ path: info.outputPath('raw-desktop-quit-recovered.png') });
} finally { const restored = await queryNativeJSON(page, `import pathlib,json,hashlib\ndata=pathlib.Path(${JSON.stringify(recoveryControlPath)}).read_bytes()\nassert hashlib.sha256(data).hexdigest()==${JSON.stringify(quitFile.sha256)}\np=pathlib.Path(${JSON.stringify(quitFile.path)})\np.write_bytes(data)\nassert hashlib.sha256(p.read_bytes()).hexdigest()==${JSON.stringify(quitFile.sha256)}\nprint(json.dumps({'restoredWebQuit':str(p),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}))`); record('rawDesktopQuitControlRestored', restored.value); }
const undoName = `WBV2_Quit_Undo_${path.posix.basename(prepared.caseDir).slice(0, 12)}`;
await runPython(`import bpy,json\nassert ${JSON.stringify(undoName)} not in bpy.data.objects\nif bpy.context.object and bpy.context.object.mode != 'OBJECT': bpy.ops.object.mode_set(mode='OBJECT')\nbpy.ops.ed.undo_push(message='B3 before recovered edit')\nassert bpy.ops.object.empty_add(type='PLAIN_AXES')=={'FINISHED'}\nbpy.context.object.name=${JSON.stringify(undoName)}\nbpy.ops.ed.undo_push(message='B3 after recovered edit')\nprint(json.dumps({'added':bpy.context.object.name}))`);
const editedState = await readState() as { objects: string[] }; expect(editedState.objects).toContain(undoName);
await focusNativeViewport(page); record('undo', await nativeKeyFrame(page, 'Control+z', 90));
await expect.poll(async () => (await readState() as { objects: string[] }).objects).toEqual(expected.expectedMain.objects);
record('redo', await nativeKeyFrame(page, 'Control+Shift+z', 90)); await expect.poll(async () => (await readState() as { objects: string[] }).objects).toEqual(editedState.objects);
await exposeWebTools(page); await switchNativeWorkspace(page, 'Scripting');
const savedPath = `${workDirectory}/after-recovery.blend`; await runPython(`import bpy,json\nassert bpy.ops.wm.save_as_mainfile(filepath=${JSON.stringify(savedPath)},relative_remap=True,check_existing=False)=={'FINISHED'}\nprint(json.dumps({'saved':bpy.data.filepath}))`);
const savedState = await readState();
await runPython(`import bpy,json\nbpy.context.scene['wbv2_last_session_unsaved']='changed-after-save'\nassert bpy.ops.wm.open_mainfile(filepath=${JSON.stringify(savedPath)},load_ui=False,use_scripts=False)=={'FINISHED'}\nprint(json.dumps({'reopened':bpy.data.filepath}))`);
expect(await readState()).toEqual(savedState); record('saveReopen', { savedPath, savedState, scope: 'native WM save/open through visible Python UI' });
const expectQuitFailure = async (message: string) => {
const retainedWorker = page.workers().find(worker => worker.url().includes('web-engine.worker')); if (!retainedWorker) throw new Error('Native Worker missing before failed quit'); const start = await page.evaluate(() => (window as any).__nativeWorkflowRequests.length);
await chooseNative('Quit Native WASM Session');
const receipt = () => page.evaluate(begin => { const w = window as any; const ids = new Set(w.__nativeWorkflowRequests.slice(begin).filter((r: any) => r.type === 'shutdown').map((r: any) => r.requestId)); return w.__nativeWorkflowResponses.findLast((r: any) => ids.has(r.requestId)) ?? null; }, start);
await expect.poll(receipt, { timeout: 60000 }).toMatchObject({ ok: false }); const failure = await receipt(); expect(`${failure.errorMessage ?? ''} ${failure.errorDetail ?? ''}`).toContain(message);
await expect(page.locator('[data-native-session-exit]')).toHaveAttribute('data-native-session-exit', 'idle'); expect(page.workers()).toContain(retainedWorker); record(`retainedQuit:${message}`, { failure, sameWorker: true });
};
const quitBeforeWriteFailure = await queryNativeJSON(page, "import bpy,json,pathlib,hashlib\np=pathlib.Path(bpy.app.tempdir).parent/'quit.blend'\nprint(json.dumps({'path':str(p),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}))");
const writeFailureQuit = quitBeforeWriteFailure.value as { path: string; sha256: string }; const temporaryCollision = `${writeFailureQuit.path}@`;
await runPython(`import pathlib,json\np=pathlib.Path(${JSON.stringify(temporaryCollision)})\nassert not p.exists(), 'Refuse to replace an existing quit temporary artifact'\np.mkdir()\nprint(json.dumps({'createdCollision':str(p)}))`);
const beforeNativeWriteFailure = await readState(), historyBeforeNativeWriteFailure = await readNativeHistory(page);
try {
await expectQuitFailure('Native session recovery write failed');
const priorQuitAfterFailure = await queryNativeJSON(page, `import pathlib,json,hashlib\np=pathlib.Path(${JSON.stringify(writeFailureQuit.path)})\nprint(json.dumps({'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}))`);
expect((priorQuitAfterFailure.value as { sha256: string }).sha256).toBe(writeFailureQuit.sha256); expect(await readState()).toEqual(beforeNativeWriteFailure);
const retainedHistory = await readNativeHistory(page); expect(retainedHistory.cursor).toBe(historyBeforeNativeWriteFailure.cursor); expect(retainedHistory.entries).toEqual(historyBeforeNativeWriteFailure.entries);
record('nativeWriteFailureRetained', { previousQuit: writeFailureQuit, observedQuit: priorQuitAfterFailure.value, main: beforeNativeWriteFailure, history: retainedHistory, faultModel: 'real temporary-path directory collision, not claimed disk-full event' });
} finally { await runPython(`import pathlib,json\np=pathlib.Path(${JSON.stringify(temporaryCollision)})\nif p.exists(): p.rmdir()\nprint(json.dumps({'removedCollision':str(p)}))`); }
const tempPreference = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'directory':bpy.context.preferences.filepaths.temporary_directory}))");
const nonPersistentDirectory = `/wbv2-nonpersistent-${path.posix.basename(prepared.caseDir)}`;
await runPython(`import bpy,pathlib,json\np=pathlib.Path(${JSON.stringify(nonPersistentDirectory)})\np.mkdir()\nbpy.context.preferences.filepaths.temporary_directory=str(p)+'/'\nassert pathlib.Path(bpy.app.tempdir).parent==p\nprint(json.dumps({'nonPersistentTempBase':str(p)}))`);
const beforeNonPersistentQuit = await readState(), historyBeforeNonPersistentQuit = await readNativeHistory(page);
try {
await expectQuitFailure('SESSION_RECOVERY_NOT_PERSISTENT');
const outsideQuit = await queryNativeJSON(page, `import pathlib,json,hashlib\np=pathlib.Path(${JSON.stringify(nonPersistentDirectory)})/'quit.blend'\nq=pathlib.Path(${JSON.stringify(writeFailureQuit.path)})\nprint(json.dumps({'temporaryPath':str(p),'temporaryBytes':p.stat().st_size,'temporarySha256':hashlib.sha256(p.read_bytes()).hexdigest(),'previousQuitSha256':hashlib.sha256(q.read_bytes()).hexdigest()}))`);
const outsideArtifact = outsideQuit.value as { temporaryBytes: number; temporarySha256: string; previousQuitSha256: string }; expect(outsideArtifact.temporaryBytes).toBeGreaterThan(0); expect(outsideArtifact.temporarySha256).toMatch(/^[a-f0-9]{64}$/); expect(outsideArtifact.previousQuitSha256).toBe(writeFailureQuit.sha256);
expect(await readState()).toEqual(beforeNonPersistentQuit); const retainedHistory = await readNativeHistory(page); expect(retainedHistory.cursor).toBe(historyBeforeNonPersistentQuit.cursor); expect(retainedHistory.entries).toEqual(historyBeforeNonPersistentQuit.entries);
record('nonPersistentQuitRetained', { artifact: outsideQuit.value, main: beforeNonPersistentQuit, history: retainedHistory, durableClaim: 'NOT_CLAIMED' });
} finally { await runPython(`import bpy,json\nbpy.context.preferences.filepaths.temporary_directory=${JSON.stringify((tempPreference.value as { directory: string }).directory)}\nprint(json.dumps({'restoredTempDirectory':bpy.context.preferences.filepaths.temporary_directory}))`); }
const engineWorker = page.workers().find(worker => worker.url().includes('web-engine.worker')); if (!engineWorker) throw new Error('Native Worker missing before quit failure case');
const beforeFailedQuit = await readState(), failedQuitHistory = await readNativeHistory(page), requestStart = await page.evaluate(() => (window as any).__nativeWorkflowRequests.length);
const clean = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'dirty':bpy.data.is_dirty}))"); expect((clean.value as { dirty: boolean }).dirty).toBe(false);
await engineWorker.evaluate(() => { const original = IDBObjectStore.prototype.put; (globalThis as any).__quitOriginalIDBPut = original; IDBObjectStore.prototype.put = function (this: IDBObjectStore, ...args: Parameters<typeof original>) { if (this.transaction.db.name === '/browser' && typeof args[1] === 'string' && args[1].endsWith('/quit.blend')) throw new DOMException('B3 quit persistence failure', 'QuotaExceededError'); return Reflect.apply(original, this, args); }; });
try {
await chooseNative('Quit Native WASM Session');
const readQuitFailure = () => page.evaluate(start => { const w = window as any; const ids = new Set(w.__nativeWorkflowRequests.slice(start).filter((request: any) => request.type === 'shutdown').map((request: any) => request.requestId)); return w.__nativeWorkflowResponses.findLast((response: any) => ids.has(response.requestId)) ?? null; }, requestStart);
await expect.poll(readQuitFailure, { timeout: 60000 }).toMatchObject({ ok: false, errorMessage: expect.stringContaining('NATIVE_FILES_PERSISTENCE_PENDING') });
await expect(page.locator('[data-native-session-exit]')).toHaveAttribute('data-native-session-exit', 'idle'); expect(page.workers()).toContain(engineWorker);
record('failedQuitPersistence', { response: await readQuitFailure(), currentWorkerRetained: true, expectedMain: beforeFailedQuit, expectedHistory: failedQuitHistory });
} finally { await engineWorker.evaluate(() => { IDBObjectStore.prototype.put = (globalThis as any).__quitOriginalIDBPut; delete (globalThis as any).__quitOriginalIDBPut; }); }
const stateAfterFailedQuit = await readState(), historyAfterFailedQuit = await readNativeHistory(page); expect(stateAfterFailedQuit).toEqual(beforeFailedQuit);
expect(historyAfterFailedQuit.cursor).toBe(failedQuitHistory.cursor); expect(historyAfterFailedQuit.entries).toEqual(failedQuitHistory.entries);
record('failedQuitRetainedState', { stateAfterFailedQuit, historyBefore: failedQuitHistory, historyAfter: historyAfterFailedQuit });
const opfsBeforeFailure = await readOPFSRecovery();
await engineWorker.evaluate(() => { const original = FileSystemFileHandle.prototype.createWritable; (globalThis as any).__quitOriginalOPFSWritable = original; FileSystemFileHandle.prototype.createWritable = function (this: FileSystemFileHandle, ...args: Parameters<typeof original>) { if (this.name === 'native-recovery-head.json') return Promise.reject(new DOMException('B3 OPFS head publication failure', 'QuotaExceededError')); return Reflect.apply(original, this, args); }; });
try {
await expectQuitFailure('NATIVE_SESSION_OPFS_PERSISTENCE_PENDING');
expect(await readOPFSRecovery()).toEqual(opfsBeforeFailure); expect(await readState()).toEqual(beforeFailedQuit);
const retainedHistory = await readNativeHistory(page); expect(retainedHistory.cursor).toBe(failedQuitHistory.cursor); expect(retainedHistory.entries).toEqual(failedQuitHistory.entries);
record('opfsCommitFailureRetained', { oldCommittedOPFS: opfsBeforeFailure, main: beforeFailedQuit, history: retainedHistory, currentWorkerRetained: page.workers().includes(engineWorker) });
} finally { await engineWorker.evaluate(() => { FileSystemFileHandle.prototype.createWritable = (globalThis as any).__quitOriginalOPFSWritable; delete (globalThis as any).__quitOriginalOPFSWritable; }); }
await chooseNative('Quit Native WASM Session'); await expect(page.locator('[data-native-session-exit]')).toHaveAttribute('data-native-session-exit', 'closed', { timeout: 120000 });
await expect.poll(() => page.workers().includes(engineWorker), { timeout: 10000 }).toBe(false);
record('retryQuitClosed', { expectedMain: beforeFailedQuit, previousWorkerTerminated: true });
const retryOPFSCommit = await readOPFSRecovery(); expect(retryOPFSCommit.actualSha256).toBe(retryOPFSCommit.head.current.sha256); expect(retryOPFSCommit.actualBytes).toBe(retryOPFSCommit.head.current.bytes);
expect(retryOPFSCommit.head.previous?.sha256).toBe(firstOPFSCommit.actualSha256); record('opfsRetryCommit', retryOPFSCommit);
await page.getByRole('button', { name: '重新打开 Blender', exact: true }).click();
await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true', { timeout: 60000 }); await exposeWebTools(page); await switchNativeWorkspace(page, 'Scripting');
const retryPersisted = await queryNativeJSON(page, "import bpy,json,pathlib,hashlib\np=pathlib.Path(bpy.app.tempdir).parent/'quit.blend'\nprint(json.dumps({'path':str(p),'bytes':p.stat().st_size,'sha256':hashlib.sha256(p.read_bytes()).hexdigest()}))");
const retryQuitFile = retryPersisted.value as { path: string; bytes: number; sha256: string }; expect(retryQuitFile.path.startsWith('/browser/')).toBe(true); expect(retryQuitFile.bytes).toBeGreaterThan(0); expect(retryQuitFile.sha256).toMatch(/^[a-f0-9]{64}$/);
await runPython(`import bpy,json\nbpy.context.scene['wbv2_last_session_unsaved']='retry-receiver'\nassert bpy.ops.wm.save_as_mainfile(filepath=${JSON.stringify(`${workDirectory}/retry-receiver.blend`)},relative_remap=True,check_existing=False)=={'FINISHED'}\nprint(json.dumps({'receiver':bpy.data.filepath}))`);
await chooseNative('Recover Native Last Session'); await waitNativeModal(page, false);
expect(retryQuitFile.sha256).toBe(retryOPFSCommit.actualSha256);
await expect.poll(readState, { timeout: 60000 }).toEqual(beforeFailedQuit); record('retryRecoveredAfterRestart', { quitFile: retryQuitFile, recovered: await readState(), claim: 'NOT_CLAIMED' }); await page.screenshot({ path: info.outputPath('native-quit-retry-recovered.png') });
await runPython("import bpy,json\nbpy.context.preferences.view.use_save_prompt=True\nprint(json.dumps({'savePrompt':bpy.context.preferences.view.use_save_prompt}))");
const freezeDirty = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'dirty':bpy.data.is_dirty}))");
await chooseNative('Quit Native WASM Session'); if ((freezeDirty.value as { dirty: boolean }).dirty) { await canvas.focus(); await page.keyboard.press('d'); }
await expect(page.locator('main.blender-app')).toHaveAttribute('data-native-session-exit', 'closed', { timeout: 120000 });
const fallbackBaseline = await readOPFSRecovery(); expect(fallbackBaseline.actualSha256).toBe(fallbackBaseline.head.current.sha256); expect(fallbackBaseline.head.previous).toBeTruthy(); expect(fallbackBaseline.head.previous.sha256).not.toBe(fallbackBaseline.head.current.sha256);
const expectedPreviousMain = new Map<string, unknown>([[firstOPFSCommit.actualSha256, expected.expectedMain], [retryOPFSCommit.actualSha256, beforeFailedQuit]]).get(fallbackBaseline.head.previous.sha256); expect(expectedPreviousMain).toBeDefined();
record('opfsFallbackBaseline', { baseline: fallbackBaseline, expectedPreviousMain, mainKnownBeforeClose: beforeFailedQuit });
const corruptBackup = await page.evaluate(async (current: { sha256: string; bytes: number }) => {
const root = await navigator.storage.getDirectory(), directory = await (await root.getDirectoryHandle('projects')).getDirectoryHandle('_native_session_recovery_v1');
const shard = await (await (await directory.getDirectoryHandle('assets')).getDirectoryHandle('sha256')).getDirectoryHandle(current.sha256.slice(0, 2)); const file = await (await shard.getFileHandle(current.sha256)).getFile(), bytes = await file.arrayBuffer();
const sha256 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), byte => byte.toString(16).padStart(2, '0')).join('');
if (bytes.byteLength !== current.bytes || sha256 !== current.sha256) throw new Error('Current OPFS content drifted before owned corruption setup');
const backupName = `case-corrupt-current-${crypto.randomUUID()}.backup`, backup = await (await directory.getDirectoryHandle('tmp')).getFileHandle(backupName, { create: true }), writable = await backup.createWritable();
try { await writable.write(bytes); await writable.close(); } catch (error) { try { await writable.abort(); } catch {} throw error; } return { backupName, sha256, bytes: bytes.byteLength };
}, fallbackBaseline.head.current);
try {
const corruption = await page.evaluate(async (backup: { backupName: string; sha256: string; bytes: number }) => {
const root = await navigator.storage.getDirectory(), directory = await (await root.getDirectoryHandle('projects')).getDirectoryHandle('_native_session_recovery_v1');
const hash = async (data: ArrayBuffer) => Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', data)), byte => byte.toString(16).padStart(2, '0')).join(''); const original = await (await (await (await directory.getDirectoryHandle('tmp')).getFileHandle(backup.backupName)).getFile()).arrayBuffer(); if (original.byteLength !== backup.bytes || await hash(original) !== backup.sha256) throw new Error('OPFS corruption backup is invalid');
const shard = await (await (await directory.getDirectoryHandle('assets')).getDirectoryHandle('sha256')).getDirectoryHandle(backup.sha256.slice(0, 2)), target = await shard.getFileHandle(backup.sha256), current = await (await target.getFile()).arrayBuffer(); if (current.byteLength !== backup.bytes || await hash(current) !== backup.sha256) throw new Error('OPFS content changed before controlled fault');
const bytes = new Uint8Array(original); bytes[0] ^= 255; const writable = await target.createWritable(); try { await writable.write(bytes); await writable.close(); } catch (error) { try { await writable.abort(); } catch {} throw error; }
const changed = await (await target.getFile()).arrayBuffer(), corruptSha256 = await hash(changed); if (changed.byteLength !== backup.bytes || corruptSha256 === backup.sha256) throw new Error('Controlled OPFS corruption was not written'); return { originalSha256: backup.sha256, corruptSha256, bytes: changed.byteLength };
}, corruptBackup);
record('opfsCurrentContentCorrupted', corruption);
const corruptedOPFS = await readOPFSRecovery(); expect(corruptedOPFS.head).toEqual(fallbackBaseline.head); expect(corruptedOPFS.actualSha256).toBe(corruption.corruptSha256);
await page.getByRole('button', { name: '重新打开 Blender', exact: true }).click();
await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true', { timeout: 60000 });
await expect(page.getByRole('alert', { name: '恢复存储提示', exact: true })).toContainText('上一版');
await exposeWebTools(page); await switchNativeWorkspace(page, 'Scripting');
const fallbackNative = await queryNativeJSON(page, "import bpy,pathlib,json,hashlib\np=pathlib.Path(bpy.app.tempdir).parent/'quit.blend'\nprint(json.dumps({'path':str(p),'sha256':hashlib.sha256(p.read_bytes()).hexdigest(),'bytes':p.stat().st_size,'legacyCopies':[{'path':str(q),'sha256':hashlib.sha256(q.read_bytes()).hexdigest()} for q in p.parent.glob(p.name+'.before-opfs-*.blend')]}))");
const fallbackFile = fallbackNative.value as { path: string; sha256: string; bytes: number; legacyCopies: { path: string; sha256: string }[] }; expect(fallbackFile.path).toBe(fallbackBaseline.head.current.path); expect(fallbackFile.sha256).toBe(fallbackBaseline.head.previous.sha256); expect(fallbackFile.bytes).toBe(fallbackBaseline.head.previous.bytes);
record('opfsPreviousFileRestored', { native: fallbackFile, headUnchanged: corruptedOPFS.head, warning: await page.getByRole('alert', { name: '恢复存储提示', exact: true }).textContent() });
const fallbackWarning = await page.getByRole('alert', { name: '恢复存储提示', exact: true }).textContent(), retainedLatest = fallbackFile.legacyCopies.find(copy => copy.sha256 === fallbackBaseline.head.current.sha256 && fallbackWarning?.includes(copy.path)); expect(retainedLatest).toBeDefined();
record('opfsFallbackRetainedLatest', { copy: retainedLatest, latestValidSha256: fallbackBaseline.actualSha256 });
await runPython(`import bpy,json\nbpy.context.scene['wbv2_last_session_unsaved']='fallback-receiver'\nassert bpy.ops.wm.save_as_mainfile(filepath=${JSON.stringify(`${workDirectory}/fallback-receiver.blend`)},relative_remap=True,check_existing=False)=={'FINISHED'}\nprint(json.dumps({'receiver':bpy.data.filepath}))`);
expect((await readState() as { marker: string }).marker).toBe('fallback-receiver');
await chooseNative('Recover Native Last Session'); await waitNativeModal(page, false);
await expect.poll(async () => { const { dirty: ignoredDirty, ...mainState } = (await readState()) as Record<string, unknown>; expect(typeof ignoredDirty).toBe('boolean'); return mainState; }, { timeout: 60000 }).toEqual(Object.fromEntries(Object.entries(expectedPreviousMain as Record<string, unknown>).filter(([key]) => key !== 'dirty'))); record('opfsPreviousMainRecovered', { main: await readState(), selectedPrevious: fallbackBaseline.head.previous, retainedLatest, claim: 'NOT_CLAIMED' }); await page.screenshot({ path: info.outputPath('native-opfs-previous-recovered.png') });
} finally { await page.evaluate(async (backup: { backupName: string; sha256: string; bytes: number }) => {
const root = await navigator.storage.getDirectory(), directory = await (await root.getDirectoryHandle('projects')).getDirectoryHandle('_native_session_recovery_v1'); const bytes = await (await (await (await directory.getDirectoryHandle('tmp')).getFileHandle(backup.backupName)).getFile()).arrayBuffer();
const sha256 = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), byte => byte.toString(16).padStart(2, '0')).join(''); if (bytes.byteLength !== backup.bytes || sha256 !== backup.sha256) throw new Error('Refuse to restore an invalid OPFS fault backup');
const shard = await (await (await directory.getDirectoryHandle('assets')).getDirectoryHandle('sha256')).getDirectoryHandle(backup.sha256.slice(0, 2)), target = await shard.getFileHandle(backup.sha256), writable = await target.createWritable(); try { await writable.write(bytes); await writable.close(); } catch (error) { try { await writable.abort(); } catch {} throw error; }
const restored = await (await target.getFile()).arrayBuffer(); if (restored.byteLength !== backup.bytes || Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', restored)), byte => byte.toString(16).padStart(2, '0')).join('') !== backup.sha256) throw new Error('OPFS fault cleanup did not restore the original content');
}, corruptBackup); record('opfsCorruptionFixtureRestored', corruptBackup); }
});