import { expect, test, type Page } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { unzipSync } from 'fflate'; import { captureNativeWorkflowState, exposeWebTools, observeNative, performNativeAction } from '../fixtures/native-ui-workflow'; import { focusNativeViewport, nativeKeyFrame, queryNativeJSON, readNativeBlend, readNativeHistory, waitNativeModal } from '../fixtures/native-modeling-workflow'; import { expectUILocation, openFromUI, showNativeObjectProperties } from '../fixtures/wasm-console-workflow'; const root = path.resolve(import.meta.dirname, '../../..'), bundle = path.join(root, 'tests/files/web/b3_obj_bundle_v1'); const fixture = path.resolve(root, process.env.WBV2_CASE_FIXTURE ?? 'tests/files/web/basic_scene.blend'); const textureHash = crypto.createHash('sha256').update(fs.readFileSync(path.join(bundle, 'textures/albedo.png'))).digest('hex'); async function chooseCommand(page: Page, 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); await dialog.getByRole('button', { name: label, exact: true }).click(); } async function focusNativeFileBrowser(page: Page) { const bounds = await page.locator('canvas[aria-label="Blender desktop"]').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)); } async function readBundleMain(page: Page) { return queryNativeJSON(page, [ 'import bpy, json, hashlib', "meshes = [{'name': o.name, 'location': list(o.location), 'vertices': [list(v.co) for v in o.data.vertices], 'faces': [list(p.vertices) for p in o.data.polygons], 'uv': [list(x.uv) for x in o.data.uv_layers.active.data] if o.data.uv_layers.active else [], 'materials': [m.name if m else None for m in o.data.materials]} for o in bpy.data.objects if o.type == 'MESH']", "materials = [{'name': m.name, 'saveEligible': bool(m.users), 'images': sorted(n.image.name for n in m.node_tree.nodes if n.type == 'TEX_IMAGE' and n.image)} for m in bpy.data.materials if m.node_tree]", "images = [{'name': i.name, 'saveEligible': bool(i.users), 'size': list(i.size), 'packed': bool(i.packed_file), 'hash': hashlib.sha256(bytes(i.packed_file.data)).hexdigest() if i.packed_file else None} for i in bpy.data.images if i.source == 'FILE']", "print(json.dumps({'objects': sorted(o.name for o in bpy.data.objects), 'active': bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None, 'selected': sorted(o.name for o in bpy.context.selected_objects), 'mode': bpy.context.mode, 'meshes': sorted(meshes, key=lambda x:x['name']), 'materials': sorted(materials, key=lambda x:x['name']), 'images': sorted(images, key=lambda x:x['name'])}))", ].join('\n')); } test('Native OBJ bundle UI preserves materials and file delivery', async ({ page }, testInfo) => { const evidence: Record = {}, record = (key: string, value: unknown) => { evidence[key] = value; fs.writeFileSync(testInfo.outputPath('b3-obj-main.json'), JSON.stringify(evidence, null, 2)); }; 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)); const before = await readBundleMain(page), historyBefore = await readNativeHistory(page); record('before', before); record('historyBefore', historyBefore); const browserOpen = () => page.evaluate(() => (window as any).__nativeWorkflowEditor?.context?.activeEditor === 'FILE_BROWSER'); const prepareImport = async () => { await chooseCommand(page, 'Import OBJ'); const dialog = page.getByRole('dialog', { name: '导入 OBJ 与材质', exact: true }); await dialog.getByLabel('选择文件夹', { exact: true }).setInputFiles(bundle); await expect(dialog.getByLabel('OBJ 模型', { exact: true })).toHaveValue('b3_obj_bundle_v1/triangle model.obj'); await dialog.getByRole('button', { name: '打开导入选项', exact: true }).click(); await expect(dialog).toBeHidden(); await waitNativeModal(page, true); // Known filepath uses Blender's import-properties popup. const canvas = page.locator('canvas[aria-label="Blender desktop"]'); await canvas.focus(); return canvas; }; await prepareImport(); await page.screenshot({ path: testInfo.outputPath('obj-cancel-dialog.png') }); await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 27 }, () => page.keyboard.press('Escape')); await expect.poll(browserOpen).toBe(false); await waitNativeModal(page, false); const cancelled = await readBundleMain(page), cancelledHistory = await readNativeHistory(page); record('cancelled', cancelled); record('cancelledHistory', cancelledHistory); expect(cancelled.value).toEqual(before.value); expect(cancelledHistory.cursor).toBe(historyBefore.cursor); expect(cancelledHistory.entries).toEqual(historyBefore.entries); await prepareImport(); await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 13 }, () => page.keyboard.press('Enter')); await expect.poll(browserOpen).toBe(false); await waitNativeModal(page, false); const imported = await readBundleMain(page); record('imported', imported); fs.writeFileSync(testInfo.outputPath('obj-imported-main.blend'), await readNativeBlend(page)); const added = imported.value.meshes.filter((mesh: any) => !before.value.objects.includes(mesh.name)); expect(added).toHaveLength(1); const mesh = added[0]; expect(mesh.vertices).toHaveLength(3); expect(mesh.faces).toHaveLength(1); expect(mesh.uv).toHaveLength(3); expect(mesh.materials).toContain('Painted'); const painted = imported.value.materials.find((material: any) => material.name === 'Painted'); expect(painted.images).toHaveLength(1); const image = imported.value.images.find((item: any) => item.name === painted.images[0]); expect(image.packed).toBe(true); expect(image.hash).toBe(textureHash); expect(image.size.every((n: number) => n > 0)).toBe(true); await exposeWebTools(page); await showNativeObjectProperties(page); record('importedUI', await expectUILocation(page, mesh.name, mesh.location)); await page.screenshot({ path: testInfo.outputPath('obj-imported-ui.png') }); await focusNativeViewport(page); record('undoFrame', await nativeKeyFrame(page, 'Control+z', 90)); const undone = await readBundleMain(page); record('undone', undone); expect(undone.value).toEqual(before.value); record('redoFrame', await nativeKeyFrame(page, 'Control+Shift+z', 90)); const redone = await readBundleMain(page); record('redone', redone); expect(redone.value).toEqual(imported.value); await exposeWebTools(page); const downloading = page.waitForEvent('download', { timeout: 60000 }); await page.getByRole('button', { name: '保存项目', exact: true }).click(); const saved = testInfo.outputPath('obj-roundtrip.blend'); await (await downloading).saveAs(saved); record('saved', { path: saved, bytes: fs.statSync(saved).size }); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status', 'SUCCEEDED'); await openFromUI(page, async () => { await page.getByTestId('blend-file-input').setInputFiles(saved); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-open-status', /SUCCEEDED|FAILED/); await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-open-status', 'SUCCEEDED'); }); // Blender writefile.cc excludes zero-user IDs for non-undo saves. Keep full-state checks for cancel and history. const persisted = { ...imported.value, materials: imported.value.materials.filter((m: { saveEligible: boolean }) => m.saveEligible), images: imported.value.images.filter((i: { saveEligible: boolean }) => i.saveEligible) }; const reopened = await readBundleMain(page); record('reopened', reopened); expect(reopened.value).toEqual(persisted); await chooseCommand(page, 'Export OBJ'); await expect.poll(browserOpen, { timeout: 30000 }).toBe(true); await focusNativeFileBrowser(page); await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 27 }, () => page.keyboard.press('Escape')); await expect.poll(browserOpen).toBe(false); await waitNativeModal(page, false); expect((await readBundleMain(page)).value).toEqual(reopened.value); await chooseCommand(page, 'Export OBJ'); await expect.poll(browserOpen, { timeout: 30000 }).toBe(true); await focusNativeFileBrowser(page); const exporting = page.waitForEvent('download', { timeout: 60000 }); await performNativeAction(page, { type: 'gpuUI', gpuOperation: 'frame', keyPressed: 13 }, () => page.keyboard.press('Enter')); const archive = await exporting, zipPath = testInfo.outputPath('obj-export.zip'); expect(archive.suggestedFilename()).toMatch(/\.zip$/i); await archive.saveAs(zipPath); const files = unzipSync(fs.readFileSync(zipPath)), names = Object.keys(files); record('exported', { zipPath, names }); expect(names.some(name => /\.obj$/i.test(name))).toBe(true); expect(names.some(name => /\.mtl$/i.test(name))).toBe(true); expect(Object.values(files).some(bytes => crypto.createHash('sha256').update(bytes).digest('hex') === textureHash)).toBe(true); await expect.poll(browserOpen).toBe(false); await waitNativeModal(page, false); expect((await readBundleMain(page)).value).toEqual(reopened.value); record('result', { status: 'PASS', kind: process.env.WBV2_CASE_KIND, scope: 'UI OBJ bundle import/cancel, material/UV/packed texture, keyboard UndoRedo, SaveReopen and actual OBJ/MTL/texture export' }); }); test.afterEach(async ({ page }, info) => { fs.writeFileSync(info.outputPath('b3-obj-last-state.json'), JSON.stringify(await captureNativeWorkflowState(page).catch(error => ({ captureError: String(error) })), null, 2)); if (info.status !== info.expectedStatus) { const bytes = await readNativeBlend(page).catch(() => null); if (bytes) fs.writeFileSync(info.outputPath('after-failure.blend'), bytes); await exposeWebTools(page); const button = page.getByTestId('export-diagnostics'); if (await button.isEnabled()) { const pending = page.waitForEvent('download'); await button.click(); await (await pending).saveAs(info.outputPath('app-diagnostics.json')); } } });