import { expect, test, type Page } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; import { captureNativeWorkflowState, exposeWebTools, observeNative, performNativeAction, resolveProjectReplacement } from '../fixtures/native-ui-workflow'; import { expectModelingData, focusNativeViewport, nativeKeyFrame, nativeRequest, queryNativeJSON, readModelingMain, readNativeBlend, readNativeHistory, transformNative, waitNativeModal } from '../fixtures/native-modeling-workflow'; import { expectUILocation, openFromUI, showNativeObjectProperties, switchNativeWorkspace } from '../fixtures/wasm-console-workflow'; const root = path.resolve(import.meta.dirname, '../../..'), fixture = path.resolve(root, process.env.WBV2_CASE_FIXTURE ?? 'tests/files/web/basic_scene.blend'); async function readRecentOwner(page: Page) { return page.locator('main.blender-app').evaluate(element => Object.fromEntries(['data-project-id', 'data-save-committed-revision', 'data-save-committed-hash', 'data-dirty'].map(key => [key, element.getAttribute(key)]))); } async function observeRecentStorage(page: Page) { await page.addInitScript(() => { const Base = window.Worker, entries: any[] = []; (window as any).__recentStorage = entries; class ObservedWorker extends Base { constructor(url: string | URL, options?: WorkerOptions) { super(url, options); const pending = new Map(), post = this.postMessage; this.postMessage = ((message: any, ...rest: any[]) => { const command = message.command; if (['readProject', 'saveProject', 'saveSnapshot', 'listSnapshots', 'readSnapshot', 'listOperations'].includes(command?.type)) { const entry = { requestId: message.requestId, type: command.type, projectId: command.projectId, revision: command.revision, startedAt: performance.now() }; pending.set(message.requestId, entry); entries.push(entry); } return Reflect.apply(post, this, [message, ...rest]); }) as typeof this.postMessage; this.addEventListener('message', ({ data }) => { const entry = pending.get(data?.requestId); if (entry) Object.assign(entry, { ok: data.ok, error: data.error, resultRevision: data.result?.revision, finishedAt: performance.now() }); }); } } window.Worker = ObservedWorker; }); } test('Recent projects distinguish committed open from recovery and preserve replacement choices', async ({ page }, info) => { const evidence: Record = {}, record = (key: string, value: unknown) => { evidence[key] = value; fs.writeFileSync(info.outputPath('b3-recent-main.json'), JSON.stringify(evidence, null, 2)); }; await observeRecentStorage(page); await observeNative(page); await page.goto('/'); await expect.poll(() => page.evaluate(() => Array.isArray((window as any).__nativeWorkflowMain?.nodes)), { timeout: 60000 }).toBe(true); await exposeWebTools(page); await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(fixture)); await performNativeAction(page, { command: 'createPrimitive' }, () => page.getByRole('button', { name: '添加立方体', exact: true }).click()); const { value: name } = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps(bpy.context.active_object.name))"); expect(typeof name).toBe('string'); const saving = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); await (await saving).saveAs(info.outputPath('committed.blend')); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED'); const committed = await readModelingMain(page, name), committedOwner = await readRecentOwner(page); record('committed', committed); record('committedOwner', committedOwner); await focusNativeViewport(page); await transformNative(page, 'g', 'x', '1'); await exposeWebTools(page); const edited = await readModelingMain(page, name), editedOwner = await readRecentOwner(page), editedHistory = await readNativeHistory(page), editedBytes = await readNativeBlend(page); record('edited', edited); await expect.poll(() => page.evaluate(({ project, committedRevision }) => (window as any).__recentStorage.some((entry: any) => entry.type === 'saveSnapshot' && entry.projectId === project && entry.ok && entry.revision > committedRevision), { project: committedOwner['data-project-id'], committedRevision: Number(committedOwner['data-save-committed-revision']) }), { timeout: 30000 }).toBe(true); const project = committedOwner['data-project-id']!; const cancelStarted = await page.evaluate(() => performance.now()); await page.getByTestId('recent-projects').selectOption(project); await resolveProjectReplacement(page, 'cancel'); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false'); expect(await readRecentOwner(page)).toEqual(editedOwner); expect(await readNativeHistory(page)).toEqual(editedHistory); expect((await readNativeBlend(page)).equals(editedBytes)).toBe(true); expectModelingData((await readModelingMain(page, name)).value, edited.value); const cancelledReads = await page.evaluate(started => (window as any).__recentStorage.filter((entry: any) => entry.startedAt >= started && ['readProject', 'readSnapshot', 'listOperations'].includes(entry.type)), cancelStarted); expect(cancelledReads).toEqual([]); record('cancelled', { owner: await readRecentOwner(page), reads: cancelledReads }); await expect.poll(() => page.evaluate(started => (window as any).__recentStorage.some((entry: any) => entry.type === 'saveSnapshot' && entry.startedAt >= started && entry.ok), cancelStarted), { timeout: 30000 }).toBe(true); const openStarted = await page.evaluate(() => performance.now()); await openFromUI(page, async () => { await page.getByTestId('recent-projects').selectOption(project); await resolveProjectReplacement(page, 'discard'); }); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false', { timeout: 60000 }); record('recentBeforeRead', { owner: await readRecentOwner(page), history: await readNativeHistory(page) }); fs.writeFileSync(info.outputPath('before-recent-read.blend'), await readNativeBlend(page)); const recentHistory = await readNativeHistory(page); if (recentHistory.cursor > 0) { const snapshot = await nativeRequest(page, { type: 'jsonCommand', cmd: 'history', params: { baseRevision: recentHistory.revision, snapshotIndex: 0 } }); expect(Array.isArray(snapshot.result?.json?.snapshotBlend)).toBe(true); fs.writeFileSync(info.outputPath('recent-open-baseline.blend'), Buffer.from(snapshot.result.json.snapshotBlend)); } const recent = await readModelingMain(page, name); record('recentCommitted', recent); record('recentAfterRead', { owner: await readRecentOwner(page), history: await readNativeHistory(page) }); fs.writeFileSync(info.outputPath('after-recent-read.blend'), await readNativeBlend(page)); expectModelingData(recent.value, committed.value); expect(await readRecentOwner(page)).toEqual(committedOwner); const openReads = await page.evaluate(started => (window as any).__recentStorage.filter((entry: any) => entry.startedAt >= started && ['readProject', 'listSnapshots', 'readSnapshot', 'listOperations'].includes(entry.type)), openStarted); expect(openReads.some((entry: any) => entry.type === 'readProject' && entry.ok)).toBe(true); expect(openReads.every((entry: any) => entry.type === 'readProject')).toBe(true); record('committedReadPath', openReads); const restoreStarted = await page.evaluate(() => performance.now()); await openFromUI(page, () => page.getByRole('button', { name: '恢复项目', exact: true }).click()); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false'); const restored = await readModelingMain(page, name); record('explicitRecovery', restored); expectModelingData(restored.value, edited.value); await expect(page.locator('main.blender-app')).toHaveAttribute('data-dirty', 'true'); const restoreReads = await page.evaluate(started => (window as any).__recentStorage.filter((entry: any) => entry.startedAt >= started && ['readSnapshot', 'listOperations'].includes(entry.type)), restoreStarted); expect(restoreReads.some((entry: any) => entry.type === 'readSnapshot' && entry.ok)).toBe(true); record('recoveryReadPath', restoreReads); await exposeWebTools(page); await page.getByRole('button', { name: '文件', exact: true }).click(); await page.getByRole('menuitem', { name: /^另存为/ }).click(); const targetDialog = page.getByRole('dialog', { name: '另存为', exact: true }); await targetDialog.getByRole('textbox', { name: '另存为文件名', exact: true }).fill('recent-target.blend'); const targetDownload = page.waitForEvent('download'); await targetDialog.getByRole('button', { name: '确认', exact: true }).click(); await (await targetDownload).saveAs(info.outputPath('target-before.blend')); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-as-status', 'SUCCEEDED'); const targetOwner = await readRecentOwner(page), target = targetOwner['data-project-id']!; expect(target).not.toBe(project); record('targetOwner', targetOwner); await focusNativeViewport(page); await transformNative(page, 'g', 'x', '1'); const targetEdited = await readModelingMain(page, name); record('targetEdited', targetEdited); await exposeWebTools(page); const saveStarted = await page.evaluate(() => performance.now()); await openFromUI(page, async () => { await page.getByTestId('recent-projects').selectOption(project); await resolveProjectReplacement(page, 'save'); }); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false'); expectModelingData((await readModelingMain(page, name)).value, committed.value); const saveReads = await page.evaluate(started => (window as any).__recentStorage.filter((entry: any) => entry.startedAt >= started && ['saveProject', 'readProject'].includes(entry.type)), saveStarted); const savedTarget = saveReads.find((entry: any) => entry.type === 'saveProject' && entry.projectId === target && entry.ok); expect(savedTarget).toBeTruthy(); expect(saveReads.find((entry: any) => entry.type === 'readProject' && entry.projectId === project)?.startedAt).toBeGreaterThanOrEqual(savedTarget.finishedAt); record('saveBeforeOpen', saveReads); await openFromUI(page, () => page.getByTestId('recent-projects').selectOption(target)); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false'); const savedRecent = await readModelingMain(page, name); record('savedRecent', savedRecent); expectModelingData(savedRecent.value, targetEdited.value); await focusNativeViewport(page); await transformNative(page, 'g', 'x', '2'); const finalEdit = await readModelingMain(page, name); await nativeKeyFrame(page, 'Control+z', 90); expectModelingData((await readModelingMain(page, name)).value, savedRecent.value); await nativeKeyFrame(page, 'Control+Shift+z', 90); expectModelingData((await readModelingMain(page, name)).value, finalEdit.value); await exposeWebTools(page); const downloading = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const saved = info.outputPath('recent-roundtrip.blend'); await (await downloading).saveAs(saved); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED'); await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(saved)); const reopened = await readModelingMain(page, name); record('reopened', reopened); expectModelingData(reopened.value, finalEdit.value); await showNativeObjectProperties(page); record('reopenedUI', await expectUILocation(page, name, finalEdit.value.location)); await page.screenshot({ path: info.outputPath('recent-reopened.png') }); record('result', { status: 'PASS', kind: process.env.WBV2_CASE_KIND, scope: 'Recent Cancel/Discard/Save, committed-only open, explicit snapshot restore, native UndoRedo and actual SaveReopen' }); }); test.afterEach(async ({ page }, info) => { fs.writeFileSync(info.outputPath('b3-recent-last-state.json'), JSON.stringify(await captureNativeWorkflowState(page).catch(error => ({ captureError: String(error) })), null, 2)); fs.writeFileSync(info.outputPath('b3-recent-storage-state.json'), JSON.stringify(await page.evaluate(() => ({ storage: (window as any).__recentStorage, main: Object.fromEntries(Array.from(document.querySelector('main.blender-app')?.attributes ?? []).filter(a => a.name.startsWith('data-') || a.name === 'aria-busy').map(a => [a.name, a.value])) })).catch(error => ({ captureError: String(error) })))); }); test('Recent project target failure retains the current Main and unapplied text draft', async ({ page }, info) => { const evidence: Record = {}, record = (key: string, value: unknown) => { evidence[key] = value; fs.writeFileSync(info.outputPath('b3-recent-draft.json'), JSON.stringify(evidence, null, 2)); }; await observeRecentStorage(page); await observeNative(page); await page.goto('/'); await expect.poll(() => page.evaluate(() => Array.isArray((window as any).__nativeWorkflowMain?.nodes)), { timeout: 60000 }).toBe(true); await exposeWebTools(page); await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(fixture)); await switchNativeWorkspace(page, 'Scripting'); const editor = page.getByRole('region', { name: '文本编辑器', exact: true }); await editor.getByRole('textbox', { name: '新文本名称', exact: true }).fill('RecentDraft.py'); await performNativeAction(page, { command: 'createText' }, () => editor.getByRole('button', { name: '新建', exact: true }).click()); await waitNativeModal(page, false, await page.evaluate(() => (window as any).__nativeWorkflowGPU?.requestId)); await editor.getByRole('combobox', { name: '文本数据块', exact: true }).selectOption({ label: 'RecentDraft.py' }); await expect(editor.getByRole('textbox', { name: '文本内容', exact: true })).toBeEnabled(); const saving = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const committedFile = info.outputPath('draft-committed.blend'); await (await saving).saveAs(committedFile); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED'); const owner = await readRecentOwner(page), project = owner['data-project-id']!, history = await readNativeHistory(page), before = await readNativeBlend(page); fs.writeFileSync(info.outputPath('before-target-failure.blend'), before); const draft = '# retain this unapplied draft'; await editor.getByRole('textbox', { name: '文本内容', exact: true }).fill(draft); await page.getByTestId('recent-projects').selectOption(project); await resolveProjectReplacement(page, 'cancel'); expect(await readRecentOwner(page)).toEqual(owner); expect(await readNativeHistory(page)).toEqual(history); expect((await readNativeBlend(page)).equals(before)).toBe(true); await expect(editor.getByRole('textbox', { name: '文本内容', exact: true })).toHaveValue(draft); record('draftCancelled', { owner, history }); const replaceCommit = async (bytes: number[]) => page.evaluate(async ({ project, bytes }) => { const root = await navigator.storage.getDirectory(), projects = await root.getDirectoryHandle('projects'), folder = await projects.getDirectoryHandle(project), file = await folder.getFileHandle('scene.blend'), stream = await file.createWritable(); await stream.write(Uint8Array.from(bytes)); await stream.close(); }, { project, bytes }); await replaceCommit(Array.from(Buffer.from('B3_CORRUPT_COMMITTED_BLEND'))); const failedAt = await page.evaluate(() => performance.now()); try { const resets = await page.evaluate(() => (window as any).__nativeWorkflowRequests.filter((request: any) => request.jsonCommand === 'pythonReset').length); await page.getByTestId('recent-projects').selectOption(project); await resolveProjectReplacement(page, 'discard'); await expect(page.getByTestId('engine-status')).toHaveText('Recovery: project could not be restored', { timeout: 30000 }); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false'); expect(await readRecentOwner(page)).toEqual(owner); expect(await readNativeHistory(page)).toEqual(history); expect((await readNativeBlend(page)).equals(before)).toBe(true); await expect(editor.getByRole('textbox', { name: '文本内容', exact: true })).toHaveValue(draft); expect(await page.evaluate(() => (window as any).__nativeWorkflowRequests.filter((request: any) => request.jsonCommand === 'pythonReset').length)).toBe(resets); const failedReads = await page.evaluate(started => (window as any).__recentStorage.filter((entry: any) => entry.startedAt >= started && ['readProject', 'readSnapshot', 'listOperations'].includes(entry.type)), failedAt); expect(failedReads.some((entry: any) => entry.type === 'readProject' && entry.ok === false)).toBe(true); expect(failedReads.every((entry: any) => entry.type === 'readProject')).toBe(true); record('targetReadFailed', { owner: await readRecentOwner(page), reads: failedReads, visibleDraft: draft }); await page.screenshot({ path: info.outputPath('draft-retained-after-error.png') }); } finally { await replaceCommit(Array.from(fs.readFileSync(committedFile))); } await openFromUI(page, async () => { await page.getByTestId('blend-file-input').setInputFiles(committedFile); await resolveProjectReplacement(page, 'discard'); }); await expect(page.getByRole('textbox', { name: '文本内容', exact: true })).toHaveValue(''); const finalSource = '# committed after recovery'; await page.getByRole('textbox', { name: '文本内容', exact: true }).fill(finalSource); await performNativeAction(page, { command: 'setTextSource' }, () => page.getByRole('region', { name: '文本编辑器', exact: true }).getByRole('button', { name: '应用', exact: true }).click()); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false'); await performNativeAction(page, { command: 'undo' }, () => page.getByRole('button', { name: '撤销', exact: true }).click()); await expect(page.getByRole('textbox', { name: '文本内容', exact: true })).toHaveValue(''); await waitNativeModal(page, false, await page.evaluate(() => (window as any).__nativeWorkflowGPU?.requestId)); await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy', 'false'); await performNativeAction(page, { command: 'redo' }, () => page.getByRole('button', { name: '重做', exact: true }).click()); await expect(page.getByRole('textbox', { name: '文本内容', exact: true })).toHaveValue(finalSource); expect((await queryNativeJSON(page, "import bpy,json\nprint(json.dumps(bpy.data.texts['RecentDraft.py'].as_string()))")).value).toBe(finalSource); const downloading = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const saved = info.outputPath('draft-recovered.blend'); await (await downloading).saveAs(saved); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED'); await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(saved)); await expect(page.getByRole('textbox', { name: '文本内容', exact: true })).toHaveValue(finalSource); expect((await queryNativeJSON(page, "import bpy,json\nprint(json.dumps(bpy.data.texts['RecentDraft.py'].as_string()))")).value).toBe(finalSource); record('result', { status: 'PASS', kind: process.env.WBV2_CASE_KIND, scope: 'Unapplied draft cancellation, real corrupt committed target with no Main/history/Python reset, valid discard retry, text UndoRedo and actual SaveReopen' }); }); test('Worker restart restores the newer recovery Main and allows continued editing', async ({ page }, info) => { const evidence: Record = {}, record = (key: string, value: unknown) => { evidence[key] = value; fs.writeFileSync(info.outputPath('b3-recent-restart.json'), JSON.stringify(evidence, null, 2)); }; await observeRecentStorage(page); await observeNative(page); await page.goto('/?worker-fault=engine'); await expect.poll(() => page.evaluate(() => Array.isArray((window as any).__nativeWorkflowMain?.nodes)), { timeout: 60000 }).toBe(true); await exposeWebTools(page); await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(fixture)); await performNativeAction(page, { command: 'createPrimitive' }, () => page.getByRole('button', { name: '添加立方体', exact: true }).click()); const { value: name } = await queryNativeJSON(page, "import bpy,json\nprint(json.dumps(bpy.context.active_object.name))"); const saving = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); await (await saving).saveAs(info.outputPath('restart-committed.blend')); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED'); const committedOwner = await readRecentOwner(page); await focusNativeViewport(page); await transformNative(page, 'g', 'x', '1'); const edited = await readModelingMain(page, name); record('beforeCrash', edited); await exposeWebTools(page); await showNativeObjectProperties(page); await expectUILocation(page, name, [1, 0, 0]); const checkpointRevision = Number(await page.locator('main.blender-app').getAttribute('data-current-main-revision')); record('checkpointRevision', checkpointRevision); await expect.poll(() => page.evaluate(({ project, revision }) => (window as any).__recentStorage.some((entry: any) => entry.type === 'saveSnapshot' && entry.projectId === project && entry.ok && entry.revision >= revision), { project: committedOwner['data-project-id'], revision: checkpointRevision }), { timeout: 30000 }).toBe(true); record('checkpointReceipts', await page.evaluate(() => (window as any).__recentStorage)); const owner = await readRecentOwner(page); await page.getByTestId('inject-worker-crash').click(); await expect(page.locator('main.blender-app')).toHaveAttribute('data-worker-fault-source', 'engine'); await expect(page.locator('main.blender-app')).toHaveAttribute('data-worker-fault-code', 'WORKER_TERMINATED'); await page.getByTestId('restart-and-recover').click(); await expect(page.locator('main.blender-app')).toHaveAttribute('data-worker-recovery-status', 'SUCCEEDED', { timeout: 60000 }); await expect(page.getByRole('dialog', { name: '保存当前工程的修改?', exact: true })).toBeHidden(); const recovered = await readModelingMain(page, name); record('recovered', recovered); expectModelingData(recovered.value, edited.value); expect(await readRecentOwner(page)).toEqual(owner); await focusNativeViewport(page); await transformNative(page, 'g', 'x', '2'); const finalEdit = await readModelingMain(page, name); await nativeKeyFrame(page, 'Control+z', 90); expectModelingData((await readModelingMain(page, name)).value, recovered.value); await nativeKeyFrame(page, 'Control+Shift+z', 90); expectModelingData((await readModelingMain(page, name)).value, finalEdit.value); await exposeWebTools(page); const downloading = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const saved = info.outputPath('restart-roundtrip.blend'); await (await downloading).saveAs(saved); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED'); await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(saved)); const reopened = await readModelingMain(page, name); expectModelingData(reopened.value, finalEdit.value); record('reopened', reopened); await showNativeObjectProperties(page); record('reopenedUI', await expectUILocation(page, name, finalEdit.value.location)); await page.screenshot({ path: info.outputPath('restart-reopened.png') }); record('result', { status: 'PASS', kind: process.env.WBV2_CASE_KIND, scope: 'Actual engine Worker termination/restart, newer recovery Main, native continued edit/UndoRedo and actual SaveReopen' }); });