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 = { 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 = { 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>; 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) { 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' }); });