183 lines
11 KiB
JavaScript
183 lines
11 KiB
JavaScript
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()
|
|
}
|