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