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>
299 lines
55 KiB
TypeScript
299 lines
55 KiB
TypeScript
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); }
|
||
});
|