import { expect, test, type Page } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; import { captureNativeWorkflowState, exposeWebTools, observeNative, performNativeAction } from '../fixtures/native-ui-workflow'; import { expectModelingData, focusNativeViewport, nativeKeyFrame, queryNativeJSON, readModelingMain, readNativeBlend, readNativeHistory, transformNative } from '../fixtures/native-modeling-workflow'; import { expectUILocation, openFromUI, showNativeObjectProperties } 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 readOwner(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', 'data-current-main-revision', 'data-committed-main-revision'].map(key => [key, element.getAttribute(key)]))); } async function openCopyDialog(page: Page) { await exposeWebTools(page); await page.getByRole('button', { name: '文件', exact: true }).click(); await page.getByRole('menuitem', { name: '保存副本', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '保存副本', exact: true }); await expect(dialog.getByRole('textbox', { name: '副本文件名', exact: true })).toBeVisible(); return dialog; } async function observeCopyRecovery(page: Page) { await page.addInitScript(() => { const Base = window.Worker, snapshots: unknown[] = []; (window as any).__copySnapshots = snapshots; 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[]) => { if (message.command?.type === 'saveSnapshot') { const entry = { requestId: message.requestId, projectId: message.command.projectId, revision: message.command.revision, startedAt: performance.now() }; pending.set(message.requestId, entry); snapshots.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, { response: data, finishedAt: performance.now() }); }); } } window.Worker = ObservedWorker; }); } test('Save Copy preserves the original project and delivers a reopenable blend', async ({ page }, testInfo) => { const evidence: Record = {}, downloads: string[] = [], record = (key: string, value: unknown) => { evidence[key] = value; fs.writeFileSync(testInfo.outputPath('b3-copy-main.json'), JSON.stringify(evidence, null, 2)); }; page.on('download', download => downloads.push(download.suggestedFilename())); await observeCopyRecovery(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 initialDownload = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const initial = await initialDownload; expect(initial.suggestedFilename()).toBe(path.basename(fixture)); await initial.saveAs(testInfo.outputPath('original-before.blend')); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED'); record('originalOwner', await readOwner(page)); await focusNativeViewport(page); const unmoved = await readModelingMain(page, name); await transformNative(page, 'g', 'x', '1'); await exposeWebTools(page); await showNativeObjectProperties(page); await expectUILocation(page, name, [1, 0, 0]); const before = await readModelingMain(page, name), owner = await readOwner(page), history = await readNativeHistory(page), bytes = await readNativeBlend(page); record('before', before); record('owner', owner); record('history', history); expect(owner['data-dirty']).toBe('true'); fs.writeFileSync(testInfo.outputPath('before-copy.blend'), bytes); let dialog = await openCopyDialog(page); await dialog.getByRole('textbox', { name: '副本文件名', exact: true }).fill('cancelled-copy.blend'); await dialog.getByRole('button', { name: '取消', exact: true }).click(); expect(await readOwner(page)).toEqual(owner); expect(await readNativeHistory(page)).toEqual(history); expect((await readNativeBlend(page)).equals(bytes)).toBe(true); expect(downloads).toHaveLength(1); record('cancelled', { owner: await readOwner(page), downloads: [...downloads] }); await page.evaluate(() => { const original = HTMLAnchorElement.prototype.click; (window as any).__copyOriginalClick = original; HTMLAnchorElement.prototype.click = function () { if (this.download.endsWith('.blend')) throw new Error('B3_COPY_DOWNLOAD_BLOCKED'); return original.call(this); }; }); try { dialog = await openCopyDialog(page); await dialog.getByRole('button', { name: '确认', exact: true }).click(); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-as-status', 'FAILED'); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-as-error', 'SAVE_AS_DOWNLOAD_FAILED'); expect(await readOwner(page)).toEqual(owner); expect(await readNativeHistory(page)).toEqual(history); expect((await readNativeBlend(page)).equals(bytes)).toBe(true); expect(downloads).toHaveLength(1); record('downloadFailed', { owner: await readOwner(page), error: 'SAVE_AS_DOWNLOAD_FAILED' }); } finally { await page.evaluate(() => { HTMLAnchorElement.prototype.click = (window as any).__copyOriginalClick; delete (window as any).__copyOriginalClick; }); } dialog = await openCopyDialog(page); await dialog.getByRole('textbox', { name: '副本文件名', exact: true }).fill('b3-copied.blend'); const copyStarted = await page.evaluate(() => performance.now()), copying = page.waitForEvent('download', { timeout: 60000 }); await dialog.getByRole('button', { name: '确认', exact: true }).click(); const copyDownload = await copying, copied = testInfo.outputPath('copied.blend'); expect(copyDownload.suggestedFilename()).toBe('b3-copied.blend'); await copyDownload.saveAs(copied); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-as-status', 'SUCCEEDED'); expect(await readOwner(page)).toEqual(owner); expect(await readNativeHistory(page)).toEqual(history); expect((await readNativeBlend(page)).equals(bytes)).toBe(true); expect(fs.readFileSync(copied).equals(bytes)).toBe(true); record('copied', { path: copied, owner: await readOwner(page), bytes: fs.statSync(copied).size }); await expect.poll(() => page.evaluate(({ started, project }) => ((window as any).__copySnapshots ?? []).some((entry: any) => entry.startedAt >= started && entry.projectId === project && entry.response?.ok === true), { started: copyStarted, project: owner['data-project-id'] }), { timeout: 30000 }).toBe(true); record('copyRecovery', await page.evaluate(() => (window as any).__copySnapshots)); expect(await readOwner(page)).toEqual(owner); await focusNativeViewport(page); await nativeKeyFrame(page, 'Control+z', 90); const undone = await readModelingMain(page, name); record('undone', undone); expectModelingData(undone.value, unmoved.value); await nativeKeyFrame(page, 'Control+Shift+z', 90); const redone = await readModelingMain(page, name); record('redone', redone); expectModelingData(redone.value, before.value); await exposeWebTools(page); const savingOriginal = page.waitForEvent('download'); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const original = await savingOriginal; expect(original.suggestedFilename()).toBe(path.basename(fixture)); await original.saveAs(testInfo.outputPath('original-after.blend')); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED'); const savedOwner = await readOwner(page); expect(savedOwner['data-project-id']).toBe(owner['data-project-id']); expect(savedOwner['data-dirty']).toBe('false'); expect(Number(savedOwner['data-save-committed-revision'])).toBeGreaterThan(Number(owner['data-save-committed-revision'])); record('originalSaved', savedOwner); expectModelingData((await readModelingMain(page, name)).value, before.value); await openFromUI(page, () => page.getByTestId('blend-file-input').setInputFiles(copied)); const reopened = await readModelingMain(page, name); record('reopened', reopened); expectModelingData(reopened.value, before.value); await showNativeObjectProperties(page); record('reopenedUI', await expectUILocation(page, name, [1, 0, 0])); await page.screenshot({ path: testInfo.outputPath('copy-reopened.png') }); record('result', { status: 'PASS', kind: process.env.WBV2_CASE_KIND, route: 'BROWSER_EQUIVALENT', scope: 'Save Copy cancellation/download failure and exact owner/Main/history preservation; real copy download, recovery checkpoint, native UndoRedo, original Save and copy Reopen' }); }); test.afterEach(async ({ page }, info) => { fs.writeFileSync(info.outputPath('b3-copy-last-state.json'), JSON.stringify(await captureNativeWorkflowState(page).catch(error => ({ captureError: String(error) })), null, 2)); });