import { expect, test } from "@playwright/test"; import fs from "node:fs"; import path from "node:path"; import { observeNative, resolveProjectReplacement } from '../fixtures/native-ui-workflow'; import { expectWebLocation, receiptAfter, webMain } from '../fixtures/console-main-workflow'; const root = path.resolve(import.meta.dirname, "../../.."); const fixture = path.resolve(root, process.env.WBV2_CASE_FIXTURE ?? "tests/files/web/basic_scene.blend"); test('Console continuity preserves typed Main through error, workspace and reopen', async ({ page }, testInfo) => { await observeNative(page); await page.goto('/?ui=web'); await page.getByTestId('blend-file-input').setInputFiles(fixture); await expect(page.getByTestId('engine-status')).toContainText('SceneIR'); const evidence: Record = {}; const record = (stage: string, value: unknown) => { evidence[stage] = value; fs.writeFileSync(testInfo.outputPath('b1-main-readback.json'), JSON.stringify(evidence, null, 2)); }; const opened = await webMain(page), target = opened.nodes.find(node => node.type === 'MESH'); expect(target).toBeDefined(); record('opened', opened); const name = target!.name, quotedName = JSON.stringify(name); await page.getByRole('button', { name: 'Scripting', exact: true }).click(); await page.getByRole('button', { name: 'Connect', exact: true }).click(); await expect(page.locator('.native-workflow-controls output')).toHaveText('READY', { timeout: 30000 }); const input = page.getByLabel('Persistent Python command'); const python = async (source: string, status = 'SUCCEEDED') => { await input.fill(source); return receiptAfter(page, () => page.getByRole('button', { name: 'Execute', exact: true }).click(), status); }; const initial = await python('import bpy\nb1_context_token = 42\nprint("B1_SUM", 1 + 1)'); record('initial', initial); await expect(page.locator('.native-workflow-output')).toContainText('B1_SUM 2'); const before = initial.objects.find(object => object.name === name); expect(before).toBeDefined(); const changedX = before!.location[0] === 3 ? 4 : 3; const changed = await python(`bpy.data.objects[${quotedName}].location.x = ${changedX}`); record('changed', changed); const changedLocation = changed.objects.find(object => object.name === name)!.location; expect(changedLocation).toEqual([changedX, before!.location[1], before!.location[2]]); await expectWebLocation(page, name, changedLocation); const undone = await receiptAfter(page, () => page.getByRole('button', { name: 'Undo Main', exact: true }).click()); record('undone', undone); expect(undone.objects.find(object => object.name === name)!.location).toEqual(before!.location); await expectWebLocation(page, name, before!.location); const redone = await receiptAfter(page, () => page.getByRole('button', { name: 'Redo Main', exact: true }).click()); record('redone', redone); expect(redone.objects.find(object => object.name === name)!.location).toEqual(changedLocation); await expectWebLocation(page, name, changedLocation); const rejected = await python('def b1_invalid(:', 'PYTHON_ERROR'); record('syntaxError', rejected); expect(rejected.objects).toEqual(redone.objects); await expectWebLocation(page, name, changedLocation); const draft = 'print("B1_CONTEXT", b1_context_token)'; await input.fill(draft); await page.getByRole('button', { name: 'Layout', exact: true }).click(); await expect(input).toBeHidden(); await page.getByRole('button', { name: 'Scripting', exact: true }).click(); await expect(input).toHaveValue(draft); await expect(page.locator('.native-workflow-controls output')).toHaveText('READY'); const resumed = await python(draft); record('workspaceResumed', resumed); await expect(page.locator('.native-workflow-output')).toContainText('B1_CONTEXT 42'); expect(resumed.objects).toEqual(redone.objects); const committed = await receiptAfter(page, () => page.getByRole('button', { name: 'Apply to Web Scene', exact: true }).click()); record('committed', committed); await expectWebLocation(page, name, changedLocation); const download = page.waitForEvent('download'); await page.getByRole('link', { name: 'session.blend', exact: true }).click(); const saved = testInfo.outputPath('b1-continuity.blend'); await (await download).saveAs(saved); const beforeOpen = await webMain(page), previousRead = beforeOpen.requestId; await page.getByTestId('blend-file-input').setInputFiles(saved); await resolveProjectReplacement(page, 'cancel'); const cancelledOpen = await webMain(page); record('cancelledOpen', cancelledOpen); expect(cancelledOpen.revision).toBe(beforeOpen.revision); expect(cancelledOpen.nodes).toEqual(beforeOpen.nodes); await page.getByTestId('blend-file-input').setInputFiles(saved); await resolveProjectReplacement(page, 'discard'); await expect.poll(async () => (await webMain(page)).requestId).not.toBe(previousRead); await expect(page.getByTestId('engine-status')).toContainText('SceneIR'); await expectWebLocation(page, name, changedLocation); record('reopened', await webMain(page)); await expect(page.getByRole('treeitem').filter({ hasText: name }).first()).toBeVisible(); record('result', { status: 'PASS', target: name, before: before!.location, expected: changedLocation, saved }); }); test("Console Main mutation restores through undo/redo and save/reopen", async ({ page }) => { await page.goto("/"); const result = await page.evaluate(async (input) => { const { WebEngineClient } = await import("/src/engine-client/WebEngineClient.ts"); const client = new WebEngineClient(); const getCubeX = (snapshot: any) => snapshot.nodes.find((node: any) => node.name === "BasicCube")?.transform.translation[0]; try { const opened = await client.openBlend(Uint8Array.from(input).buffer); const python = await client.executeEmbeddedPython("import bpy\nbpy.data.objects['BasicCube'].location.x = 3.0\nprint(1 + 1)", "ConsoleCase.py", true); const undone = (await client.applyCommand({ type: "undo" })).snapshot; const redone = (await client.applyCommand({ type: "redo" })).snapshot; const saved = await client.saveBlend(); // One browser filesystem has a single native-file writer. Restart the same // client so the reopen runs in a fresh worker after the old one releases it. await client.restart(); const reopenedResult = await client.openBlend(saved.slice(0)); return { output: python.python.output, before: getCubeX(opened.snapshot), mutated: getCubeX(python.snapshot), undo: getCubeX(undone), redo: getCubeX(redone), reopened: getCubeX(reopenedResult.snapshot) }; } catch (error) { // bpy/Python failures reject with a structured report; surface its code here // so the run records a diagnosable failure instead of a bare "Object". const code = error && typeof error === 'object' && 'code' in error ? String((error as { code?: unknown }).code) : 'UNKNOWN'; const detail = error && typeof error === 'object' && 'message' in error ? String((error as { message?: unknown }).message) : String(error); throw new Error(`${code}: ${detail}`); } finally { client.terminate(); } }, Array.from(fs.readFileSync(fixture))); expect(typeof result.output).toBe("string"); expect(result.output).toContain("2"); for (const value of [result.before, result.mutated, result.undo, result.redo, result.reopened]) { expect(typeof value).toBe("number"); expect(Number.isFinite(value)).toBe(true); } expect(result.mutated).toBe(3); expect(result.undo).toBe(result.before); expect(result.redo).toBe(result.mutated); expect(result.reopened).toBe(result.redo); }); test("Web Scripting workflow saves and reopens the native Main", async ({ page }, testInfo) => { await page.goto("/?ui=web"); await page.getByTestId("blend-file-input").setInputFiles(fixture); await expect(page.getByTestId("engine-status")).toContainText("SceneIR"); await page.getByRole("button", { name: "Scripting", exact: true }).click(); await page.getByRole("button", { name: "Connect", exact: true }).click(); await expect(page.locator(".native-workflow-controls output")).toHaveText("READY", { timeout: 30_000 }); const input = page.getByLabel("Persistent Python command"); await input.fill("import bpy\nbpy.data.objects['BasicCube'].location.x = 3\nbpy.data.objects['BasicCamera'].location.x = 3\nprint('B1_SUM', 1 + 1)\nprint('B1_X', bpy.data.objects['BasicCube'].location.x)"); await page.getByRole("button", { name: "Execute", exact: true }).click(); await expect(page.locator(".native-workflow-output")).toContainText("B1_SUM 2"); const undone = await receiptAfter(page, () => page.getByRole('button', { name: 'Undo Main', exact: true }).click()); expect(undone.objects.find(object => object.name === 'BasicCube')?.location[0]).toBe(0); const redone = await receiptAfter(page, () => page.getByRole('button', { name: 'Redo Main', exact: true }).click()); expect(redone.objects.find(object => object.name === 'BasicCube')?.location[0]).toBe(3); await page.getByRole("button", { name: "Apply to Web Scene", exact: true }).click(); await expect(page.locator(".native-session-panel")).toHaveAttribute("data-command-status", "SUCCEEDED", { timeout: 30_000 }); await expect(page.locator(".native-workflow-artifacts a")).toHaveCount(2); const download = page.waitForEvent("download"); await page.getByRole("link", { name: "session.blend", exact: true }).click(); const saved = testInfo.outputPath("b1-native-session.blend"); await (await download).saveAs(saved); page.on("dialog", dialog => void dialog.accept()); await page.getByTestId("blend-file-input").setInputFiles(saved); await expect(page.getByTestId("engine-status")).toContainText("SceneIR", { timeout: 30_000 }); }); test("Browser-equivalent File menu saves and reopens the Main", async ({ page }, testInfo) => { await page.goto("/?ui=web"); await page.getByTestId("blend-file-input").setInputFiles(fixture); await expect(page.getByTestId("engine-status")).toContainText("SceneIR"); await page.getByRole("button", { name: "文件", exact: true }).click(); const download = page.waitForEvent("download"); await page.getByRole("menuitem", { name: "保存", exact: true }).click(); const file = await download; const saved = testInfo.outputPath("b1-file-menu-browser-equivalent.blend"); await file.saveAs(saved); await page.setInputFiles("[data-testid=blend-file-input]", saved); await expect(page.getByTestId("engine-status")).toContainText("SceneIR"); });