import {createServer} from 'vite' import {_electron as electron,expect} from '@playwright/test' import electronPath from 'electron' import ExcelJS from 'exceljs' import fs from 'node:fs/promises' import path from 'node:path' // All data and dialog results belong to this isolated run. const root=process.cwd(),qa=path.join(root,'.qa','loading') await fs.mkdir(qa,{recursive:true}) const run=await fs.mkdtemp(path.join(qa,'run-')),profile=path.join(run,'profile'),seals=path.join(run,'seals') await fs.mkdir(seals) const file=path.join(run,'动画验证.xlsx'),template=path.join(run,'模板.xlsx') const book=new ExcelJS.Workbook(),sheet=book.addWorksheet('收据数据') sheet.addRow(['收款日期','学校','年级','姓名','收费项目','金额']) sheet.addRow(['2026-10-16','测试学校','高2028届','测试姓名','服务费',3500]) await book.xlsx.writeFile(file) const server=await createServer({server:{host:'127.0.0.1',port:5176,strictPort:true}});await server.listen() const env={...process.env,ELECTRON_RENDERER_URL:'http://127.0.0.1:5176'};delete env.ELECTRON_RUN_AS_NODE const checks=[],frames=[],errors=[] let desktop try{ desktop=await electron.launch({executablePath:electronPath,args:['.','--test-mode','--user-data-dir='+profile],cwd:root,env}) const page=await desktop.firstWindow();page.setDefaultTimeout(15000);page.on('pageerror',e=>errors.push(e.message)) await page.getByText('把表格交给收据工坊').waitFor() // Hidden Chromium windows do not advance CSS timelines, so inspect a visible, unfocused test window. await desktop.evaluate(({BrowserWindow,dialog,shell})=>{ BrowserWindow.getAllWindows()[0].showInactive() const picker=()=>new Promise(resolve=>{globalThis.finishPicker=resolve}) dialog.showOpenDialog=picker;dialog.showSaveDialog=picker shell.openPath=async()=>new Promise(resolve=>{globalThis.finishOpen=()=>resolve('')}) }) async function arm(scope){ await page.evaluate(scope=>{ window.loadingTrace=new Promise((resolve,reject)=>{ let start const transforms=[],deadline=performance.now()+10000 function sample(now){ const region=document.querySelector('[data-region="'+scope+'"]'),spinner=region?.querySelector('.region-spinner') if(spinner&®ion.getAttribute('aria-busy')==='true'){ start??=now;transforms.push(getComputedStyle(spinner).transform) }else if(start!==undefined){resolve({duration:now-start,frames:transforms.length,uniqueTransforms:new Set(transforms).size});return} if(now>deadline){reject(Error('No loading cycle in '+scope));return} requestAnimationFrame(sample) } requestAnimationFrame(sample) }) },scope) } async function checkTrace(name){ const result=await page.evaluate(()=>window.loadingTrace) expect(result.duration).toBeGreaterThanOrEqual(490) expect(result.duration).toBeLessThan(1800) expect(result.frames).toBeGreaterThan(8);expect(result.uniqueTransforms).toBeGreaterThan(5) frames.push({name,...result}) await expect(page.getByRole('status')).toHaveCount(0) } async function noLoading(scope){ const region=page.locator('[data-region="'+scope+'"]') await expect(region).not.toHaveAttribute('aria-busy','true') await expect(region.locator('.region-loading')).toHaveCount(0) expect(await region.locator('.region-content').evaluate(el=>el.inert)).toBe(false) } async function resolvePicker(value){ await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function') await desktop.evaluate((_,value)=>{globalThis.finishPicker(value);delete globalThis.finishPicker},value) } async function watchNoMasks(){ await page.evaluate(()=>{ window.maskCount=0 window.maskObserver=new MutationObserver(records=>{ for(const r of records)for(const n of r.addedNodes)if(n instanceof Element&&(n.matches('.region-loading')||n.querySelector('.region-loading')))window.maskCount++ }) window.maskObserver.observe(document.body,{childList:true,subtree:true}) }) } async function stopWatching(){ const count=await page.evaluate(()=>{window.maskObserver.disconnect();return window.maskCount}) expect(count).toBe(0) } await page.getByRole('button',{name:'偏好设置',exact:true}).click() for(const reducedMotion of ['no-preference','reduce']){ await page.emulateMedia({reducedMotion}) await page.getByLabel('默认出纳',{exact:true}).fill('动画验证 '+reducedMotion) await arm('settings');await page.locator('button[data-task="settings"]').click() await checkTrace('save / '+reducedMotion) } checks.push('Real fast saves rotate continuously for at least 500 ms with both motion preferences') // A pending native folder picker must leave the form clear and avoid a delayed mask on cancellation. await watchNoMasks() await page.locator('[data-task="settings-folder"][data-task-target="output"]').click() await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function') await noLoading('settings') expect(await page.getByLabel('默认出纳',{exact:true}).isEditable()).toBe(true) await page.evaluate(()=>{window.pickerCancelledAt=performance.now()}) await resolvePicker({canceled:true,filePaths:[]}) await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('settings-folder'))).toBe(true) const cancelMs=await page.evaluate(()=>performance.now()-window.pickerCancelledAt) expect(cancelMs).toBeLessThan(450);await stopWatching() checks.push('Folder selection has no mask; cancellation adds no 500 ms wait') // Opening a folder should also leave the form interactive. await watchNoMasks() await page.evaluate(async()=>{ const {actions,state}=await import('/state.ts') void actions.open(state.dataDirectory) }) await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishOpen)).toBe('function') await noLoading('settings') await desktop.evaluate(()=>{globalThis.finishOpen()}) await desktop.evaluate(({shell})=>{shell.openPath=async()=>''}) await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('open'))).toBe(true) await stopWatching() checks.push('Opening local folders has no mask') await page.getByRole('button',{name:'印章管理',exact:true}).click() await arm('seals');await page.locator('[data-task="refresh-seals"]').click();await checkTrace('refresh seals') // Binding a folder includes saving its path and reloading images, without a synthetic loading mask. await watchNoMasks() await page.locator('.panel-heading [data-task="folder"]').click() await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function') await noLoading('seals') await resolvePicker({canceled:false,filePaths:[seals]}) await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('folder'))).toBe(true) await stopWatching() checks.push('Choosing and binding a stamp folder has no mask') await page.getByRole('button',{name:/^生成记录/}).click() await arm('history');await page.locator('[data-task="refresh-records"]').click();await checkTrace('refresh history') checks.push('Both manual refresh actions show half a second of real rotation') await page.getByRole('button',{name:'收据工作台',exact:true}).click() await arm('preview');await page.locator('[data-region="preview"] [data-task="refresh-seals"]').click();await checkTrace('refresh seals from workbench') await page.locator('.panel-heading [data-task="import"]').click() await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function') await noLoading('data') await resolvePicker({canceled:true,filePaths:[]}) await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('import'))).toBe(true) await noLoading('data') await page.locator('.panel-heading [data-task="import"]').click() await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function') await noLoading('data');await arm('data') await resolvePicker({canceled:false,filePaths:[file]}) await checkTrace('import after file chosen') await expect(page.getByText('测试学校 高2028届 测试姓名',{exact:true})).toBeVisible() checks.push('Import rotates during processing only, after accepting the file picker') await page.locator('[data-task="template"]').click() await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function') await noLoading('data');await arm('data') await resolvePicker({canceled:false,filePath:template}) await checkTrace('template after destination chosen') expect((await fs.stat(template)).size).toBeGreaterThan(0) checks.push('Template starts loading after accepting its picker') // Generate one isolated batch to verify that deleting waits for confirmation before animating. await page.evaluate(async output=>{ const {actions,state}=await import('/state.ts') await window.receipts.saveSettings({...state.settings,outputDirectory:output,exportPdf:true,exportPng:false,exportPrint:false}) await actions.reload() },path.join(run,'output')) await page.getByRole('button',{name:'收据工作台',exact:true}).click() await page.locator('button.export-button').click() await expect.poll(()=>page.evaluate(async()=>(await window.receipts.listBatches())[0]?.status),{timeout:30000}).toBe('completed') await page.getByRole('button',{name:/^生成记录/}).click() await page.locator('[data-task="delete-batch"]').click() await expect(page.getByRole('alertdialog')).toBeVisible();await noLoading('history') await page.getByRole('alertdialog').getByRole('button',{name:'取消',exact:true}).click() await expect(page.getByRole('alertdialog')).toHaveCount(0) await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('delete-batch'))).toBe(true) await noLoading('history') expect((await page.evaluate(()=>window.receipts.listBatches())).length).toBe(1) await page.locator('[data-task="delete-batch"]').click() await expect(page.getByRole('alertdialog')).toBeVisible();await noLoading('history') await arm('history') await page.getByRole('alertdialog').getByRole('button',{name:'删除记录',exact:true}).click() await checkTrace('delete after confirmation') expect((await page.evaluate(()=>window.receipts.listBatches())).length).toBe(0) expect((await page.evaluate(()=>window.receipts.bootstrap())).nextNumber).toBe('SJ00000002') checks.push('Delete cancellation is immediate; confirmed deletion rotates and preserves reserved numbers') await expect(page.locator('.main-content > .feedback')).toHaveCount(0) expect(errors).toEqual([]) await fs.writeFile(path.join(qa,'result.json'),JSON.stringify({passed:true,checks,frames,folderCancellationMs:cancelMs,rendererErrors:errors},null,2)) console.log(JSON.stringify({passed:true,checks,frames,folderCancellationMs:cancelMs},null,2)) }finally{ if(desktop){await desktop.evaluate(({app})=>app.exit(0)).catch(()=>{});await desktop.close().catch(()=>{})} await server.close() }