Files
workinf_Blender_Wasm/web/tests/e2e/obj-bundle-native-production.spec.ts
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
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>
2026-11-10 11:18:01 +08:00

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')); }
}
});