Files
workinf_Blender_Wasm/web/tests/e2e/original-glb-native-export.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

68 lines
8.0 KiB
TypeScript

import { expect, test } from '@playwright/test';
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { unzipSync } from 'fflate';
import { observeNative, exposeWebTools, performNativeAction, captureNativeWorkflowState } from '../fixtures/native-ui-workflow';
import { queryNativeJSON, readNativeHistory, readNativeBlend, focusNativeViewport, waitNativeModal } from '../fixtures/native-modeling-workflow';
import { openFromUI } from '../fixtures/wasm-console-workflow';
const root = path.resolve(import.meta.dirname, '../../..');
const original = path.join(root, 'blender-5.2.0/tests/files/io_tests/blend_geometry/all_quads.blend');
const empty = path.join(root, 'blender-5.2.0/tests/files/io_tests/empty.blend');
const readback = fs.readFileSync(path.join(root, 'web/tests/fixtures/original-gltf-resource-readback.py'), 'utf8');
test.afterEach(async ({ page }, info) => fs.writeFileSync(info.outputPath('glb-export-state.json'), JSON.stringify(await captureNativeWorkflowState(page))));
test('Original GLB native export delivers desktop-equivalent data and preserves Main', async ({ page }, info) => {
test.setTimeout(240000);
const oracle = info.outputPath('desktop-original.glb');
const script = `import bpy,addon_utils\naddon_utils.enable('io_scene_gltf2',default_set=False)\nbpy.ops.export_scene.gltf(filepath=${JSON.stringify(oracle)},export_format='GLB')\nbpy.ops.wm.open_mainfile(filepath=${JSON.stringify(empty)})\nbpy.ops.import_scene.gltf(filepath=${JSON.stringify(oracle)})\n${readback}`;
const command = ['--background','--factory-startup','--disable-autoexec',original,'--python-exit-code','1','--python-expr',script];
const output = execFileSync(process.env.BLENDER_BIN!,command,{encoding:'utf8',timeout:60000,maxBuffer:4*1024*1024});
fs.writeFileSync(info.outputPath('desktop-export.log'),output);
const desktop = JSON.parse(output.split('\n').find(line=>line.startsWith('{"objects":'))!); expect(desktop.objects.filter((o:any)=>o.type==='MESH')).toHaveLength(1);
fs.writeFileSync(info.outputPath('desktop-export.json'),JSON.stringify({command,desktop}));
await observeNative(page); await page.goto('/?ui=native'); await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready','true',{timeout:60000});
await exposeWebTools(page); await openFromUI(page,()=>page.getByTestId('blend-file-input').setInputFiles(original)); await focusNativeViewport(page);
const before = (await queryNativeJSON(page,readback)).value, history = await readNativeHistory(page);
const interactionSource = "import bpy,json\nprint(json.dumps({'filepath':bpy.data.filepath,'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.scene.objects if o.select_get())}))", interaction = (await queryNativeJSON(page,interactionSource)).value;
const browserOpen = () => page.evaluate(()=>(window as any).__nativeWorkflowEditor?.context?.activeEditor==='FILE_BROWSER');
const chooseExport = async (invalidName=false) => {
await exposeWebTools(page); await page.getByRole('button',{name:'操作搜索',exact:true}).click();
const dialog = page.getByRole('dialog',{name:'操作搜索',exact:true}); await dialog.getByRole('checkbox',{name:'Desktop Context',exact:true}).check(); await dialog.getByRole('checkbox',{name:'Interactive Session',exact:true}).check();
await dialog.getByRole('textbox',{name:'搜索 Blender 操作'}).fill('Export glTF'); const operator = dialog.getByRole('button').filter({hasText:/export_scene\.gltf/});
await expect(operator).toHaveCount(1); await operator.click();
const start = await page.evaluate(()=>(window as any).__nativeWorkflowRequests.length);
await dialog.getByRole('textbox',{name:/^(File name|File Path)$/}).fill(invalidName?'../escape.glb':'Exported.glb'); await dialog.getByRole('button',{name:'执行',exact:true}).click();
if (invalidName) {
await expect(dialog.getByRole('alert')).toContainText('不含目录分隔符'); expect(await page.evaluate(start=>(window as any).__nativeWorkflowRequests.slice(start).some((r:any)=>r.command==='editorResourcePrepare'||r.gpuOperation==='invoke'),start)).toBe(false);
await dialog.getByRole('button',{name:'关闭操作搜索',exact:true}).click(); return;
}
await expect.poll(browserOpen,{timeout:30000}).toBe(true);
const canvas = page.locator('canvas[aria-label="Blender desktop"]'); await expect(canvas).toHaveAttribute('data-native-input-ready','true'); await canvas.focus(); const bounds = await canvas.boundingBox(); expect(bounds).not.toBeNull(); await page.mouse.move(bounds!.x+bounds!.width/2,bounds!.y+bounds!.height/2);
};
fs.writeFileSync(info.outputPath('main-before-export.blend'),await readNativeBlend(page));
await chooseExport(); fs.writeFileSync(info.outputPath('history-invoked.json'),JSON.stringify(await readNativeHistory(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 queryNativeJSON(page,readback)).value).toEqual(before);
fs.writeFileSync(info.outputPath('main-after-cancel.blend'),await readNativeBlend(page));
const cancelledHistory = await readNativeHistory(page); fs.writeFileSync(info.outputPath('cancel-history.json'),JSON.stringify({before:history,after:cancelledHistory})); expect(cancelledHistory.cursor).toBe(history.cursor); expect(cancelledHistory.entries).toEqual(history.entries);
expect((await queryNativeJSON(page,interactionSource)).value).toEqual(interaction);
await chooseExport();
const [,download] = await Promise.all([performNativeAction(page,{type:'gpuUI',gpuOperation:'frame',keyPressed:13},()=>page.keyboard.press('Enter')),page.waitForEvent('download',{timeout:60000})]);
await expect.poll(browserOpen).toBe(false); await waitNativeModal(page,false);
const delivered = info.outputPath('native-export-download'); await download.saveAs(delivered);
const bytes = fs.readFileSync(delivered), name = download.suggestedFilename(); const files = name.endsWith('.zip')?unzipSync(bytes):{[name]:bytes};
const glbs = Object.entries(files).filter(([file])=>file.toLowerCase().endsWith('.glb')); expect(glbs).toHaveLength(1);
const exported = info.outputPath('browser-export.glb'); fs.writeFileSync(exported,glbs[0][1]);
const importCommand = ['--background','--factory-startup','--disable-autoexec',empty,'--python-exit-code','1','--python-expr',`import bpy,addon_utils\naddon_utils.enable('io_scene_gltf2',default_set=False)\nbpy.ops.import_scene.gltf(filepath=${JSON.stringify(exported)})\n${readback}`];
const imported = execFileSync(process.env.BLENDER_BIN!,importCommand,{encoding:'utf8',timeout:60000,maxBuffer:4*1024*1024}); fs.writeFileSync(info.outputPath('desktop-browser-export.log'),imported);
const actual = JSON.parse(imported.split('\n').find(line=>line.startsWith('{"objects":'))!); expect(actual).toEqual(desktop);
fs.writeFileSync(info.outputPath('native-export-comparison.json'),JSON.stringify({importCommand,exported,expected:desktop,actual}));
const native = (await queryNativeJSON(page,"import glob,json,hashlib\nprint(json.dumps([{'path':p,'sha256':hashlib.sha256(open(p,'rb').read()).hexdigest()} for p in sorted(glob.glob('/browser/cache/*/Exported.glb'))]))")).value;
expect(native).toHaveLength(1); expect(native[0].sha256).toBe(createHash('sha256').update(glbs[0][1]).digest('hex'));
expect((await queryNativeJSON(page,readback)).value).toEqual(before); expect((await queryNativeJSON(page,interactionSource)).value).toEqual(interaction);
const after = await readNativeHistory(page); expect(after.cursor).toBe(history.cursor); expect(after.entries).toEqual(history.entries);
await chooseExport(true); expect((await queryNativeJSON(page,readback)).value).toEqual(before);
const rejectedHistory = await readNativeHistory(page); expect(rejectedHistory.cursor).toBe(history.cursor); expect(rejectedHistory.entries).toEqual(history.entries);
});