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>
103 lines
24 KiB
TypeScript
103 lines
24 KiB
TypeScript
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<string, any>(), 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<string, unknown> = {}, 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<string, unknown> = {}, 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<string, unknown> = {}, 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' });
|
|
});
|