Files
workinf_Blender_Wasm/web/tests/e2e/upstream-blendfile-autosave.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

274 lines
40 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, performNativeAction, exposeWebTools, captureNativeWorkflowState, resolveProjectReplacement } from '../fixtures/native-ui-workflow';
import { nativeRequest, queryNativeJSON, readNativeHistory, readNativeBlend, focusNativeViewport, nativeKeyFrame, waitNativeModal } from '../fixtures/native-modeling-workflow';
import { pythonReply, switchNativeWorkspace, openFromUI } from '../fixtures/wasm-console-workflow';
import { stageOriginalAutosaveCase } from '../fixtures/upstream-autosave-case';
test.setTimeout(300000);
test.afterEach(async ({ page }, info) => { fs.writeFileSync(info.outputPath('upstream-autosave-last-state.json'), JSON.stringify(await captureNativeWorkflowState(page).catch(error => ({ error: String(error) })), null, 2)); });
async function loadNativeJSON(page: Page, source: string) {
/* Opening a file rewrites Main, so the strict readback helper (which rejects
* a changed Main) cannot be used for this call. */
await nativeRequest(page, { type: 'jsonCommand', cmd: 'pythonExec', params: { source: `exec(${JSON.stringify(source)}, {'__name__': '__wbv2_readback__'})`, filename: 'B2MainReadback.py', console: false } });
const reply = await pythonReply(page); expect(reply?.state, reply?.output).toBe('DONE'); expect(reply?.truncated).toBe(false);
/* Operators such as save_as_mainfile print "Info: ..." first, so only the
* trailing JSON line is the readback this helper returns. */
const jsonLine = reply!.output.split('\n').findLast(line => line.trimStart().startsWith('{'));
expect(jsonLine, reply!.output).toBeTruthy();
return { revision: reply!.revision, value: JSON.parse(jsonLine!) };
}
test('upstream blendfile_autosave preserves original assertions with real assets in native WASM', async ({ page }, info) => {
await observeNative(page); await page.goto('/?ui=native');
await expect.poll(() => page.evaluate(() => Array.isArray((window as any).__nativeWorkflowMain?.nodes)), { timeout: 60000 }).toBe(true); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-open-status', 'SUCCEEDED', { timeout: 60000 });
await exposeWebTools(page); await switchNativeWorkspace(page, 'Scripting');
const prepared = await stageOriginalAutosaveCase(page, info); fs.writeFileSync(info.outputPath('upstream-autosave-binding.json'), JSON.stringify(prepared, null, 2));
const program = [
'import os,sys,runpy,hashlib,json,bpy', `case_dir = ${JSON.stringify(prepared.caseDir)}`,
`source_hashes = {name:hashlib.sha256(open(os.path.join(case_dir,name),'rb').read()).hexdigest() for name in ${JSON.stringify(Object.keys(prepared.sourceHashes))}}`,
`asset_hashes = {name:hashlib.sha256(open(os.path.join(case_dir,'assets',name),'rb').read()).hexdigest() for name in ${JSON.stringify(Object.keys(prepared.assetHashes))}}`,
`old_argv=sys.argv; sys.argv=${JSON.stringify([`${prepared.caseDir}/${prepared.script}`, '--', ...prepared.argv])}`,
`try:\n runpy.run_path(os.path.join(case_dir,${JSON.stringify(prepared.script)}),run_name='__main__')\nfinally:\n sys.argv=old_argv`,
"print('WBV2_SOURCE_AUTOSAVE_PASS',json.dumps({'platform':sys.platform,'version':list(bpy.app.version),'sourceHashes':source_hashes,'assetHashes':asset_hashes,'objects':sorted(obj.name for obj in bpy.context.scene.objects) if bpy.context.scene else [],'frame':bpy.context.scene.frame_current if bpy.context.scene else None}))",
].join('\n');
const input = page.getByLabel('Python 输入', { exact: true }); await input.fill(`exec(${JSON.stringify(program)})`);
await performNativeAction(page, { type: 'jsonCommand', jsonCommand: 'pythonExec' }, () => input.press('Enter'));
const reply = await pythonReply(page); fs.writeFileSync(info.outputPath('upstream-autosave-result.json'), JSON.stringify({ prepared, reply, pairedDesktopBaseline: 'NOT_CAPTURED', fullAcceptance: 'NOT_CLAIMED' }, null, 2));
expect(reply?.state, reply?.output).toBe('DONE'); expect(reply?.truncated).toBe(false);
const marker = reply!.output.split('\n').findLast(line => line.startsWith('WBV2_SOURCE_AUTOSAVE_PASS ')); expect(marker, reply!.output).toBeTruthy(); const payload = JSON.parse(marker!.slice('WBV2_SOURCE_AUTOSAVE_PASS '.length));
expect(payload.platform).toBe('emscripten'); expect(payload.version).toEqual([5, 2, 0]);
expect(payload.sourceHashes).toEqual(prepared.sourceHashes); expect(payload.assetHashes).toEqual(prepared.assetHashes);
await expect.poll(() => page.evaluate(() => (window as any).__nativeWorkflowMain?.nodes.map((node: any) => node.name).sort())).toEqual(payload.objects);
await expect(page.locator('main.blender-app')).toHaveAttribute('data-editor-workflow-status', 'AVAILABLE');
await expect(page.locator('main.blender-app')).toHaveAttribute('data-current-frame', String(payload.frame));
await page.screenshot({ path: info.outputPath('upstream-autosave-ui.png') });
});
test('browser autosave recovery selection uses original source assets', async ({ page }, info) => {
await observeNative(page); await page.goto('/?ui=native');
await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-open-status', 'SUCCEEDED', { timeout: 60000 });
await exposeWebTools(page); await switchNativeWorkspace(page, 'Scripting');
const prepared = await stageOriginalAutosaveCase(page, info);
const loaded = await loadNativeJSON(page, `import bpy,json,sys\nbpy.ops.wm.open_mainfile(filepath=${JSON.stringify(`${prepared.caseDir}/assets/io_tests/blend_autosave/autosave_image_test.blend`)},load_ui=False)\nprint(json.dumps({'platform':sys.platform,'version':list(bpy.app.version),'resolutionX':bpy.context.scene.render.resolution_x}))`);
const initial = loaded.value as { platform: string; version: number[]; resolutionX: number }; expect(initial.platform).toBe('emscripten'); expect(initial.version).toEqual([5, 2, 0]); expect(Number.isInteger(initial.resolutionX)).toBe(true);
const main = page.locator('main.blender-app'), downloads: string[] = []; page.on('download', download => downloads.push(download.suggestedFilename()));
const stages: Record<string, unknown> = { kind: 'source-derived-browser-workflow', sourceBasisOperators: ['WM_OT_save_auto_save', 'WM_OT_recover_auto_save'], browserCommands: ['saveRecoveryCheckpoint', 'recoverCachedProject'], prepared }; const record = (name: string, value: unknown) => { stages[name] = value; fs.writeFileSync(info.outputPath('browser-autosave-recovery.json'), JSON.stringify(stages, null, 2)); };
const revision = async (attribute: string) => { const value = await main.getAttribute(attribute); expect(value).toMatch(/^\d+$/); return Number(value); };
const readResolution = async () => ((await queryNativeJSON(page, "import bpy,json\nprint(json.dumps({'resolutionX':bpy.context.scene.render.resolution_x}))")).value as { resolutionX: number }).resolutionX;
const setResolution = async (value: number) => { await switchNativeWorkspace(page, 'Scripting'); const input = page.getByLabel('Python 输入', { exact: true }); await input.fill(`exec(${JSON.stringify(`import bpy,json\nbpy.context.scene.render.resolution_x=${value}\nprint(json.dumps({'resolutionX':bpy.context.scene.render.resolution_x}))`)})`); await performNativeAction(page, { type: 'jsonCommand', jsonCommand: 'pythonExec' }, () => input.press('Enter')); const reply = await pythonReply(page); expect(reply?.state, reply?.output).toBe('DONE'); const line = reply!.output.split('\n').findLast(item => item.startsWith('{')); expect(line, reply!.output).toBeTruthy(); expect((JSON.parse(line!) as { resolutionX: number }).resolutionX).toBe(value); };
const choose = async (command: 'saveRecoveryCheckpoint' | 'recoverCachedProject') => {
const label = command === 'saveRecoveryCheckpoint' ? 'Save Browser Checkpoint' : 'Recover Browser Checkpoint';
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); await action.click();
await expect(dialog).toBeHidden();
};
const valueA = initial.resolutionX < 65533 ? initial.resolutionX + 1 : initial.resolutionX - 3; await setResolution(valueA); await expect(main).toHaveAttribute('data-dirty', 'true');
const pointARevision = await revision('data-current-main-revision'), committedBefore = await revision('data-committed-main-revision');
await choose('saveRecoveryCheckpoint'); await expect(main).toContainText('Storage: 已创建自动恢复点');
/* The stored snapshot is stamped with the project revision, which can run
* ahead of the UI revision, so ask the dialog which revision it lists. */
const probe = page.getByRole('dialog', { name: '恢复自动保存', exact: true });
await choose('recoverCachedProject'); await expect(probe).toBeVisible();
const listed = await probe.getByRole('combobox', { name: '自动恢复点' }).locator('option')
.evaluateAll(nodes => nodes.map(node => Number((node as HTMLOptionElement).value)).filter(value => Number.isSafeInteger(value) && value > 0));
expect(listed.length, 'no recovery point was listed').toBeGreaterThan(0);
const checkpointRevision = Math.max(...listed);
await probe.getByRole('button', { name: '取消', exact: true }).click(); await expect(probe).toBeHidden();
await expect(main).toHaveAttribute('data-dirty', 'true'); expect(await revision('data-committed-main-revision')).toBe(committedBefore); expect(downloads).toHaveLength(0); expect(await readResolution()).toBe(valueA);
record('checkpointA', { revision: pointARevision, committedRevision: committedBefore, resolutionX: valueA, downloads: [...downloads], dirty: await main.getAttribute('data-dirty') });
await setResolution(valueA + 1); await expect(main).toHaveAttribute('data-dirty', 'true');
await exposeWebTools(page); const downloading = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click();
const download = await downloading; await download.saveAs(info.outputPath('autosave-newer-explicit-commit.blend')); await expect(main).toHaveAttribute('data-dirty', 'false'); const newerCommit = await revision('data-committed-main-revision');
expect(newerCommit).toBeGreaterThan(checkpointRevision); record('newerExplicitCommit', { revision: newerCommit, resolutionX: await readResolution(), downloads: [...downloads] });
await setResolution(valueA + 2); await expect(main).toHaveAttribute('data-dirty', 'true');
const beforeCancel = await readNativeBlend(page), historyBeforeCancel = await readNativeHistory(page);
await choose('recoverCachedProject'); const recovery = page.getByRole('dialog', { name: '恢复自动保存', exact: true }); await expect(recovery).toBeVisible();
const recoveryOptions = await recovery.getByRole('combobox', { name: '自动恢复点' }).locator('option')
.evaluateAll(nodes => nodes.map(node => ({ value: (node as HTMLOptionElement).value, label: node.textContent })));
record('recoveryOptions', recoveryOptions);
expect(recoveryOptions.map(item => item.value), JSON.stringify(recoveryOptions)).toContain(String(checkpointRevision));
await recovery.getByRole('combobox', { name: '自动恢复点' }).selectOption(String(checkpointRevision));
await recovery.getByRole('button', { name: '取消', exact: true }).click(); await expect(recovery).toBeHidden();
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 readResolution()).toBe(valueA + 2); await expect(main).toHaveAttribute('data-dirty', 'true'); expect(downloads).toHaveLength(1);
record('cancelledSelection', { selectedRevision: checkpointRevision, uiRevision: pointARevision, blendBytesUnchanged: afterCancel.equals(beforeCancel), historyBeforeCancel, historyAfterCancel, resolutionX: valueA + 2, downloads: [...downloads] });
await choose('recoverCachedProject'); await recovery.getByRole('combobox', { name: '自动恢复点' }).selectOption(String(checkpointRevision));
await recovery.getByRole('button', { name: '恢复选中版本', exact: true }).click();
await resolveProjectReplacement(page, 'discard'); await expect(main).toHaveAttribute('data-project-action-locked', 'false');
expect(await readResolution()).toBe(valueA); await expect(main).toHaveAttribute('data-dirty', 'true'); expect(await revision('data-committed-main-revision')).toBe(newerCommit);
const restoredRevision = await revision('data-current-main-revision'); expect(restoredRevision).toBeGreaterThan(newerCommit); expect(downloads).toHaveLength(1); record('restoredSelection', { selectedRevision: pointARevision, restoredRevision, committedRevision: newerCommit, resolutionX: valueA, downloads: [...downloads] });
await choose('saveRecoveryCheckpoint'); await expect(main).toContainText('Storage: 已创建自动恢复点'); await expect(main).toHaveAttribute('data-dirty', 'true'); expect(await revision('data-current-main-revision')).toBe(restoredRevision);
expect(downloads).toHaveLength(1); record('checkpointAfterRestore', { restoredRevision, resolutionX: await readResolution(), downloads: [...downloads], fullAcceptance: 'NOT_CLAIMED', pairedDesktopBaseline: 'NOT_CAPTURED' });
await page.screenshot({ path: info.outputPath('browser-autosave-selected-recovery.png') });
});
test('native autosave file selector invokes original WM recovery', async ({ page }, info) => {
test.setTimeout(600000);
await page.addInitScript(() => { (window as any).__nativeAutosaveInvokes = []; const post = Worker.prototype.postMessage; Worker.prototype.postMessage = function (this: Worker, message: any, ...rest: any[]) { const command = message?.command; if (command?.type === 'gpuUI' && command.op === 'invoke') (window as any).__nativeAutosaveInvokes.push({ operator: command.operator, properties: { ...command.properties }, session: command.session, ownerGeneration: command.ownerGeneration }); return Reflect.apply(post, this, [message, ...rest]); } as typeof post; });
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 inputFile = `${prepared.caseDir}/assets/io_tests/blend_autosave/autosave_image_test.blend`, tempDirectory = `${prepared.caseDir}/native-autosaves`;
{ const source = `import bpy,json,os\nos.makedirs(${JSON.stringify(tempDirectory)},exist_ok=True)\nbpy.context.preferences.filepaths.temporary_directory=${JSON.stringify(tempDirectory)}\nbpy.context.preferences.filepaths.auto_save_time=60\nbpy.ops.wm.open_mainfile(filepath=${JSON.stringify(inputFile)},load_ui=False)\nprint(json.dumps({'opened':bpy.data.filepath}))`; await nativeRequest(page, { type: 'jsonCommand', cmd: 'pythonExec', params: { source: `exec(${JSON.stringify(source)}, {'__name__': '__wbv2_readback__'})`, filename: 'B2MainReadback.py', console: false } }); const opened = await pythonReply(page); expect(opened?.state, opened?.output).toBe('DONE'); expect(opened?.truncated).toBe(false); }
const readState = async () => (await queryNativeJSON(page, "import bpy,json,sys\nprint(json.dumps({'platform':sys.platform,'version':list(bpy.app.version),'filepath':bpy.data.filepath,'resolutionX':bpy.context.scene.render.resolution_x,'images':[{'name':image.name,'size':list(image.size),'pixels':list(image.pixels[:4])} for image in bpy.data.images if image.source=='FILE']}))")).value as { platform: string; version: number[]; filepath: string; resolutionX: number; images: Array<{ name: string; size: number[]; pixels: number[] }> };
await focusNativeViewport(page); const initial = await readState(); expect(initial.platform).toBe('emscripten'); expect(initial.version).toEqual([5, 2, 0]); expect(initial.filepath).toBe(inputFile); expect(initial.images.length).toBeGreaterThan(0); for (const image of initial.images) { expect(image.size[0]).toBeGreaterThan(0); expect(image.size[1]).toBeGreaterThan(0); expect(image.pixels).toHaveLength(4); }
const valueA = initial.resolutionX < 65534 ? initial.resolutionX + 1 : initial.resolutionX - 2; const setResolution = async (value: number) => { await loadNativeJSON(page, `import bpy,json\nbpy.context.scene.render.resolution_x=${value}\nprint(json.dumps({'resolutionX':bpy.context.scene.render.resolution_x}))`); expect((await readState()).resolutionX).toBe(value); };
const downloads: string[] = []; page.on('download', download => downloads.push(download.suggestedFilename())); const evidence: Record<string, unknown> = { kind: 'native-WM-UI-workflow', prepared }; const record = (name: string, value: unknown) => { evidence[name] = value; fs.writeFileSync(info.outputPath('native-autosave-file-ui.json'), JSON.stringify(evidence, null, 2)); };
const fileBrowser = () => page.evaluate(() => (window as any).__nativeWorkflowEditor?.context?.activeEditor === 'FILE_BROWSER');
const chooseNative = async (label: 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); const receipt = await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'invoke' }, () => action.click());
await expect(dialog).toBeHidden(); record(`invoke:${label}:${Object.keys(evidence).length}`, receipt);
};
await setResolution(valueA); await chooseNative('Save Native Autosave File');
await waitNativeModal(page, false); await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true');
const located = await loadNativeJSON(page, `import bpy,json,os,runpy\nsource=runpy.run_path(${JSON.stringify(`${prepared.caseDir}/bl_blendfile_autosave.py`)},run_name='wbv2_native_autosave')\nautosave=source['TestBlendFileAutosave'].find_autosave_path(${JSON.stringify(tempDirectory)},'autosave_image_test')\nprint(json.dumps({'path':autosave,'bytes':os.path.getsize(autosave) if autosave else None,'autoSaveMinutes':bpy.context.preferences.filepaths.auto_save_time}))`);
const autosave = located.value as { path: string; bytes: number; autoSaveMinutes: number }; expect(typeof autosave.path).toBe('string'); expect(autosave.path).toContain(tempDirectory); expect(autosave.path).not.toBe(inputFile); expect(autosave.bytes).toBeGreaterThan(0); expect(autosave.autoSaveMinutes).toBe(60);
const savedMain = await readState(); expect(savedMain.resolutionX).toBe(valueA); expect(savedMain.filepath).toBe(inputFile); expect(downloads).toEqual([]); await expect(page.locator('main.blender-app')).toHaveAttribute('data-dirty', 'true'); record('nativeAutosave', { autosave, savedMain });
await setResolution(valueA + 1); const beforeCancel = await readNativeBlend(page), historyBeforeCancel = await readNativeHistory(page);
await chooseNative('Recover Native Autosave File'); await expect.poll(fileBrowser).toBe(true); await expect(page.getByRole('dialog', { name: '恢复自动保存', exact: true })).toBeHidden();
await page.screenshot({ path: info.outputPath('native-autosave-file-selector.png') });
const canvas = page.locator('canvas[aria-label="Blender desktop"]');
// A DOM focus is not enough: the native WM only routes the key to the file
// browser once the pointer has entered that area.
const bounds = await canvas.boundingBox(); expect(bounds).not.toBeNull();
await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', mouseReleased: true },
() => page.mouse.click(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2));
// Escape is consumed by the focused filename field, so cancel through the
// operator's own button at the bottom right of the native 1440x900 surface.
const cancelFrame = await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', mouseReleased: true },
() => page.mouse.click(bounds!.x + 1225 * bounds!.width / 1440, bounds!.y + 858 * bounds!.height / 900));
record('cancelFrame', cancelFrame); await waitNativeModal(page, false);
await expect.poll(fileBrowser).toBe(false); await waitNativeModal(page, false);
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);
const cancelledMain = await readState(); expect(cancelledMain.resolutionX).toBe(valueA + 1); expect(cancelledMain.filepath).toBe(inputFile); expect(downloads).toEqual([]); record('nativeCancel', { cancelledMain, historyBeforeCancel, historyAfterCancel, blendBytesUnchanged: afterCancel.equals(beforeCancel) });
await chooseNative('Recover Native Autosave File'); await expect.poll(fileBrowser).toBe(true);
record('confirmFrame', await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', mouseReleased: true },
() => page.mouse.click(bounds!.x + 1366 * bounds!.width / 1440, bounds!.y + 858 * bounds!.height / 900)));
await expect.poll(fileBrowser, { timeout: 60000 }).toBe(false); await waitNativeModal(page, false);
const recovered = await readState(); expect(recovered.resolutionX).toBe(valueA); expect(recovered.filepath).toBe(inputFile); expect(recovered.filepath).not.toBe(autosave.path); expect(recovered.images).toEqual(savedMain.images);
const invokes = await page.evaluate(() => (window as any).__nativeAutosaveInvokes.filter((item: any) => ['wm.save_auto_save', 'wm.recover_auto_save'].includes(item.operator))); expect(invokes.map((item: any) => item.operator)).toEqual(['wm.save_auto_save', 'wm.recover_auto_save', 'wm.recover_auto_save']);
expect(invokes.filter((item: any) => item.operator === 'wm.recover_auto_save').every((item: any) => item.properties.use_scripts === false)).toBe(true); expect(downloads).toEqual([]); await expect(page.locator('main.blender-app')).toHaveAttribute('data-dirty', 'true');
record('nativeRecovered', { recovered, autosave, invokes, downloads, pairedDesktopBaseline: 'NOT_CAPTURED', fullAcceptance: 'NOT_CLAIMED' }); await page.screenshot({ path: info.outputPath('native-autosave-recovered.png') });
const autosaveName = autosave.path.split('/').at(-1)!, exportRelative = `recovery-export-${prepared.caseDir.split('/').at(-1)}/${autosaveName}`;
const exported = await queryNativeJSON(page, `import os,shutil,json,hashlib\ntarget=${JSON.stringify(`/browser/${exportRelative}`)}\nos.makedirs(os.path.dirname(target),exist_ok=True)\nshutil.copyfile(${JSON.stringify(autosave.path)},target)\nprint(json.dumps({'sha256':hashlib.sha256(open(target,'rb').read()).hexdigest(),'bytes':os.path.getsize(target)}))`);
/* The transfer panel lives in a region gated by the canvas' own toggle, so
* open it before touching the refresh button; DOM presence alone is hidden. */
await page.getByLabel('Blender 桌面').getByRole('button', { name: '文件传输', exact: true }).click();
const transfer = page.locator('details').filter({ has: page.getByText('桌面文件传输', { exact: true }) }); await expect(transfer).toHaveCount(1); if (await transfer.getAttribute('open') === null) await transfer.locator('summary').click();
await transfer.getByRole('button', { name: '刷新文件', exact: true }).click();
await transfer.getByRole('combobox', { name: '下载桌面文件' }).selectOption(exportRelative);
const exporting = page.waitForEvent('download'); await transfer.getByRole('button', { name: '下载文件', exact: true }).click();
const exportedFile = await exporting, importDirectory = info.outputPath('external-recovery'); fs.mkdirSync(importDirectory, { recursive: true }); const externalFile = `${importDirectory}/${autosaveName}`; await exportedFile.saveAs(externalFile);
const exportedIdentity = exported.value as { sha256: string; bytes: number }, downloadedBytes = fs.readFileSync(externalFile); expect(downloadedBytes.length).toBe(exportedIdentity.bytes); expect(createHash('sha256').update(downloadedBytes).digest('hex')).toBe(exportedIdentity.sha256);
await setResolution(valueA + 1); const beforeIngress = await readState(), ingressHistoryBefore = await readNativeHistory(page);
await transfer.getByLabel('上传文件夹', { exact: true }).setInputFiles(importDirectory);
const importedRelative = `external-recovery/${autosaveName}`, importedPath = `/browser/${importedRelative}`; await expect(transfer.getByRole('combobox', { name: '下载桌面文件' }).locator('option').filter({ hasText: importedRelative })).toHaveCount(1);
const uploadedIdentity = await queryNativeJSON(page, `import json,hashlib\nprint(json.dumps({'sha256':hashlib.sha256(open(${JSON.stringify(importedPath)},'rb').read()).hexdigest()}))`); expect((uploadedIdentity.value as { sha256: string }).sha256).toBe(exportedIdentity.sha256);
expect(await readState()).toEqual(beforeIngress); const ingressHistoryAfter = await readNativeHistory(page); expect(ingressHistoryAfter.cursor).toBe(ingressHistoryBefore.cursor); expect(ingressHistoryAfter.entries).toEqual(ingressHistoryBefore.entries);
record('externalIngress', { exportedIdentity, importedPath, ingressHistoryBefore, ingressHistoryAfter, unchangedMain: beforeIngress, downloads: [...downloads] });
await chooseNative('Save Native Autosave File'); await waitNativeModal(page, false);
const newerInternal = await queryNativeJSON(page, `import json,hashlib\nprint(json.dumps({'sha256':hashlib.sha256(open(${JSON.stringify(autosave.path)},'rb').read()).hexdigest()}))`);
expect((newerInternal.value as { sha256: string }).sha256).not.toBe(exportedIdentity.sha256); record('distinctInternalAutosave', { newerInternal: newerInternal.value, selectedExternal: exportedIdentity, currentMain: await readState() });
await chooseNative('Recover Native Autosave File'); await expect.poll(fileBrowser).toBe(true);
// Ctrl+L focuses the directory bar and the typed full path fills the filename
// field on Enter; these keys keep the browser open, so no modal wait here.
await canvas.focus();
await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 76 },
() => page.keyboard.press('Control+l'));
await page.keyboard.press('Control+a'); await page.keyboard.type(importedPath);
record('externalPathEntered', await performNativeAction(page,
{ type: 'gpuUI', gpuOperation: 'frame', keyPressed: 13 }, () => page.keyboard.press('Enter')));
await expect.poll(fileBrowser).toBe(true);
await page.screenshot({ path: info.outputPath('native-external-autosave-selector.png') });
// Keyboard input only commits the filename field; the file itself is opened by
// activating the listed row, which is what a double click does natively.
record('externalConfirmed', await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', mouseReleased: true },
() => page.mouse.dblclick(bounds!.x + 350 * bounds!.width / 1440, bounds!.y + 105 * bounds!.height / 900)));
await expect.poll(fileBrowser, { timeout: 60000 }).toBe(false); await waitNativeModal(page, false);
// Read only after the modal ends: pythonExec is refused while a gesture owns
// the desktop (NATIVE_GESTURE_ACTIVE).
record('afterExternalConfirm', { state: await readState().catch(error => String(error)),
editor: await page.evaluate(() => (window as any).__nativeWorkflowEditor?.context?.activeEditor) });
const externalRecovered = await readState(); expect(externalRecovered.resolutionX).toBe(valueA); expect(externalRecovered.filepath).toBe(inputFile); expect(externalRecovered.images).toEqual(savedMain.images); expect(downloads).toHaveLength(1);
const allInvokes = await page.evaluate(() => (window as any).__nativeAutosaveInvokes.filter((item: any) => ['wm.save_auto_save', 'wm.recover_auto_save'].includes(item.operator))); expect(allInvokes.map((item: any) => item.operator)).toEqual(['wm.save_auto_save', 'wm.recover_auto_save', 'wm.recover_auto_save', 'wm.save_auto_save', 'wm.recover_auto_save']);
record('externalRecovered', { importedPath, externalRecovered, selectedIdentity: exportedIdentity, newerInternal: newerInternal.value, allInvokes, scope: 'same-Worker external file ingress; restart persistence not claimed', fullAcceptance: 'NOT_CLAIMED' }); await page.screenshot({ path: info.outputPath('native-external-autosave-recovered.png') });
const emptyDirectory = '/browser/external-recovery/empty-preserved'; await queryNativeJSON(page, `import os,json\nos.makedirs(${JSON.stringify(emptyDirectory)},exist_ok=True)\nprint(json.dumps({'created':os.path.isdir(${JSON.stringify(emptyDirectory)})}))`);
const replacementFile = info.outputPath('replacement-working-copy.blend'); fs.copyFileSync(path.resolve(import.meta.dirname, '../../..', 'tests/files/web/basic_scene.blend'), replacementFile); await exposeWebTools(page); await openFromUI(page, async () => { await page.getByTestId('blend-file-input').setInputFiles(replacementFile); await resolveProjectReplacement(page, 'discard'); });
// exposeWebTools leaves the Web tools view active, which parks the native frame
// pump, so switch back to the desktop surface before waiting for input readiness.
await page.getByLabel('工作区显示').getByRole('button', { name: 'Blender 桌面', exact: true }).click();
await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true', { timeout: 60000 });
const preserved = await queryNativeJSON(page, `import os,json,hashlib\nprint(json.dumps({'imported':hashlib.sha256(open(${JSON.stringify(importedPath)},'rb').read()).hexdigest(),'exported':hashlib.sha256(open(${JSON.stringify(`/browser/${exportRelative}`)},'rb').read()).hexdigest(),'emptyDirectory':os.path.isdir(${JSON.stringify(emptyDirectory)})}))`);
expect(preserved.value).toEqual({ imported: exportedIdentity.sha256, exported: exportedIdentity.sha256, emptyDirectory: true }); record('nativeFilesAfterMainReplacement', { preserved: preserved.value, replacementFile, fullAcceptance: 'NOT_CLAIMED' });
await queryNativeJSON(page, "import bpy,json\nbpy.context.preferences.filepaths.temporary_directory=''\nprint(json.dumps({'temporaryDirectory':bpy.context.preferences.filepaths.temporary_directory}))");
const restartValue = (await readState()).resolutionX + 3; await setResolution(restartValue); const beforeRestart = await readState();
await chooseNative('Save Native Autosave File'); await waitNativeModal(page, false);
const durableFiles = await queryNativeJSON(page, "import glob,hashlib,json,os\nprint(json.dumps([{'path':p,'bytes':os.path.getsize(p),'sha256':hashlib.sha256(open(p,'rb').read()).hexdigest()} for p in glob.glob('/browser/.autosave/*_autosave.blend')]))");
const durableAutosaves = durableFiles.value as Array<{ path: string; bytes: number; sha256: string }>; expect(durableAutosaves).toHaveLength(1); const durableAutosave = durableAutosaves[0]; expect(durableAutosave.bytes).toBeGreaterThan(0);
record('beforeWorkerRestart', { beforeRestart, durableAutosave, source: 'wm_files.cc:wm_autosave_location/WM_autosave_write', fullAcceptance: 'NOT_CLAIMED' });
await page.reload(); await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true', { timeout: 60000 }); await exposeWebTools(page);
const afterRestart = await queryNativeJSON(page, `import os,json,hashlib\nprint(json.dumps({'autosave':hashlib.sha256(open(${JSON.stringify(durableAutosave.path)},'rb').read()).hexdigest(),'imported':hashlib.sha256(open(${JSON.stringify(importedPath)},'rb').read()).hexdigest(),'exported':hashlib.sha256(open(${JSON.stringify(`/browser/${exportRelative}`)},'rb').read()).hexdigest(),'emptyDirectory':os.path.isdir(${JSON.stringify(emptyDirectory)})}))`);
expect(afterRestart.value).toEqual({ autosave: durableAutosave.sha256, imported: exportedIdentity.sha256, exported: exportedIdentity.sha256, emptyDirectory: true }); record('filesAfterWorkerRestart', { restored: afterRestart.value, fullAcceptance: 'NOT_CLAIMED' });
await chooseNative('Recover Native Autosave File'); await expect.poll(fileBrowser).toBe(true);
// Same native flow as above: Ctrl+L and the path Enter keep the browser open, so
// they must not wait for a modal to close; the row activation performs the load.
await canvas.focus();
await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 76 }, () => page.keyboard.press('Control+l'));
await page.keyboard.press('Control+a'); await page.keyboard.type(durableAutosave.path);
await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 13 }, () => page.keyboard.press('Enter'));
await expect.poll(fileBrowser).toBe(true);
await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', mouseReleased: true },
() => page.mouse.dblclick(bounds!.x + 350 * bounds!.width / 1440, bounds!.y + 105 * bounds!.height / 900));
await expect.poll(fileBrowser, { timeout: 60000 }).toBe(false); await waitNativeModal(page, false);
let recoveredAfterRestart: Awaited<ReturnType<typeof readState>>;
try { recoveredAfterRestart = await readState(); }
catch (error) {
const start = await page.evaluate(() => (window as any).__nativeWorkflowResponses.length);
try { await page.waitForFunction(start => (window as any).__nativeWorkflowResponses.length >= start + 10, start, { timeout: 5000 }); } catch (observationError) { record('inputObservationIncomplete', String(observationError)); }
record('blockedReadAfterFrames', await captureNativeWorkflowState(page)); throw error;
}
expect(recoveredAfterRestart.resolutionX).toBe(restartValue);
/* readfile.cc:3621 only restores the original path when the autosave stores one;
* this working copy came from a File input, so the loaded autosave path stands. */
expect(recoveredAfterRestart.filepath).toBe(beforeRestart.filepath || durableAutosave.path); record('nativeRecoveryAfterWorkerRestart', { recoveredAfterRestart, selectedAutosave: durableAutosave, fullAcceptance: 'NOT_CLAIMED' });
const beforeCompeting = await readState(), competingHistory = await readNativeHistory(page), competing = await page.context().newPage();
try {
await competing.addInitScript(() => { (window as any).__nativeWriterErrors = []; const BaseWorker = Worker; window.Worker = class extends BaseWorker { constructor(url: string | URL, options?: WorkerOptions) { super(url, options); this.addEventListener('message', event => { if (event.data?.kind === 'result' && event.data.ok === false) (window as any).__nativeWriterErrors.push(event.data.error); }); } }; });
await competing.goto('/?ui=native'); await expect.poll(() => competing.evaluate(() => (window as any).__nativeWriterErrors.some((error: any) => error.message.includes('Native files are open in another Web Blender window')))).toBe(true);
record('competingWriterRejected', { errors: await competing.evaluate(() => (window as any).__nativeWriterErrors), fullAcceptance: 'NOT_CLAIMED' });
} finally { await competing.close(); }
expect(await readState()).toEqual(beforeCompeting); const ownerHistory = await readNativeHistory(page); expect(ownerHistory.cursor).toBe(competingHistory.cursor); expect(ownerHistory.entries).toEqual(competingHistory.entries);
const quotaCopy = info.outputPath('quota-recovery-copy.blend'); fs.copyFileSync(externalFile, quotaCopy);
const engineWorker = page.workers().find(worker => worker.url().includes('web-engine.worker')); if (!engineWorker) throw new Error('Native engine Worker was not available for the storage boundary case');
const beforeQuota = await readState(), quotaHistory = await readNativeHistory(page);
await engineWorker.evaluate(() => { const original = IDBObjectStore.prototype.put; (globalThis as any).__autosaveOriginalIDBPut = original; IDBObjectStore.prototype.put = function (this: IDBObjectStore, ...args: Parameters<typeof original>) { if (this.transaction.db.name === '/browser') throw new DOMException('Native file persistence quota case', 'QuotaExceededError'); return Reflect.apply(original, this, args); }; });
try {
// The desktop surface may already be active (its own layout then has no
// workspace toggle), so only switch when that control is actually present.
const desktopView = page.getByLabel('工作区显示').getByRole('button', { name: 'Blender 桌面', exact: true });
if (await desktopView.isVisible().catch(() => false)) await desktopView.click();
const transferToggle = page.getByLabel('Blender 桌面').getByRole('button', { name: '文件传输', exact: true });
if (await transferToggle.getAttribute('aria-expanded') === 'false') await transferToggle.click();
if (await transfer.getAttribute('open') === null) await transfer.locator('summary').click();
await transfer.getByLabel('上传文件', { exact: true }).setInputFiles(quotaCopy); await expect(transfer).toContainText('NATIVE_FILES_PERSISTENCE_PENDING'); record('quotaFailure', { message: await transfer.textContent(), injectedBoundary: 'IDBFS storeRemoteEntry -> IndexedDB put', fullAcceptance: 'NOT_CLAIMED' });
await transfer.getByRole('button', { name: '刷新文件', exact: true }).click(); await transfer.getByRole('combobox', { name: '下载桌面文件' }).selectOption('quota-recovery-copy.blend');
const backupPending = page.waitForEvent('download'); await transfer.getByRole('button', { name: '下载文件', exact: true }).click(); const backup = await backupPending, backupPath = info.outputPath('quota-emergency-backup.blend'); await backup.saveAs(backupPath);
const backupHash = createHash('sha256').update(fs.readFileSync(backupPath)).digest('hex'); expect(backupHash).toBe(exportedIdentity.sha256); record('backupWhilePersistenceFailed', { backupPath, sha256: backupHash, fullAcceptance: 'NOT_CLAIMED' });
} finally { await engineWorker.evaluate(() => { IDBObjectStore.prototype.put = (globalThis as any).__autosaveOriginalIDBPut; delete (globalThis as any).__autosaveOriginalIDBPut; }); }
expect(await readState()).toEqual(beforeQuota); const retainedHistory = await readNativeHistory(page); expect(retainedHistory.cursor).toBe(quotaHistory.cursor); expect(retainedHistory.entries).toEqual(quotaHistory.entries);
await page.reload(); await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready', 'true', { timeout: 60000 }); await exposeWebTools(page);
const retriedFile = await queryNativeJSON(page, "import json,hashlib\nprint(json.dumps({'sha256':hashlib.sha256(open('/browser/quota-recovery-copy.blend','rb').read()).hexdigest()}))");
expect((retriedFile.value as { sha256: string }).sha256).toBe(exportedIdentity.sha256); record('persistenceRetryAfterRestart', { restoredFile: retriedFile.value, retainedHistory, fullAcceptance: 'NOT_CLAIMED' });
// exposeWebTools leaves the Web tools view active, which unmounts the native
// surface the file-menu invokes need, so restore it if that control is shown.
const desktopAgain = page.getByLabel('工作区显示').getByRole('button', { name: 'Blender 桌面', exact: true });
if (await desktopAgain.isVisible().catch(() => false)) await desktopAgain.click();
const quitFile = '/browser/.autosave/quit.blend'; await loadNativeJSON(page, `import bpy,os,json\nos.makedirs('/browser/.autosave',exist_ok=True)\nbpy.ops.wm.save_as_mainfile(filepath=${JSON.stringify(quitFile)},check_existing=False)\nbpy.ops.wm.open_mainfile(filepath='/browser/quota-recovery-copy.blend',load_ui=False)\nprint(json.dumps({'quitExists':os.path.isfile(${JSON.stringify(quitFile)}),'restored':bpy.data.filepath}))`);
await chooseNative('Recover Native Last Session'); await waitNativeModal(page, false); const lastSession = await readState(); expect(lastSession.filepath).toBe(quitFile); record('nativeQuitFileRecovery', { quitFile, lastSession, source: 'wm_files.cc:WM_file_recover_last_session', fullAcceptance: 'NOT_CLAIMED' });
});