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>
87 lines
10 KiB
TypeScript
87 lines
10 KiB
TypeScript
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<string, unknown> = {}, 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')); }
|
|
}
|
|
});
|