// Isolated browser acceptance against local builds and fixtures; no online writes. import {chromium} from 'playwright'; import {createServer} from 'node:http'; import {readFileSync,existsSync,mkdirSync,writeFileSync} from 'node:fs'; import {resolve,extname} from 'node:path'; import assert from 'node:assert/strict'; const output=resolve('tmp/support-requests-qa');mkdirSync(output,{recursive:true}); async function serve(project){ const root=resolve('dist/'+project+'/browser'); const server=createServer((req,res)=>{let file=resolve(root,'.'+new URL(req.url,'http://local').pathname);if(!file.startsWith(root+'/')||!existsSync(file)||!extname(file))file=root+'/index.html';res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.woff2':'font/woff2'})[extname(file)]||'application/octet-stream');res.end(readFileSync(file));}); await new Promise(r=>server.listen(0,'127.0.0.1',r));return {server,origin:'http://127.0.0.1:'+server.address().port}; } const admin=await serve('xiaoshu-admin'),mobile=await serve('xiaoshu-mobile'); const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}); const errors=[],checks=[],calls=[];let fail=false; const records=Array.from({length:25},(_,i)=>({id:'request'+i,userId:String(i%2+1),accountName:i%2?'张老师':'李同学',title:(i%3===0?'【休假申请】':i%3===1?'【意见反馈】':'历史提交 ')+(i%2?'张老师':'李同学'),content:i===0?'10月16日至17日因事申请休假。\n请管理员查看并协调安排。':i===1?'\n希望优化课程提醒。':'希望调整上课时间,感谢老师的帮助。',submittedAt:'2026-10-15T04:30:00Z',kind:['leave','feedback','legacy'][i%3],role:i%2?'teacher':'student',status:'pending',response:'',version:'fixture-version',unread:false})); try{ const context=await browser.newContext({viewport:{width:1440,height:1000}}); await context.addInitScript(()=>{const auth={objectId:'fixture-admin',sessionToken:'fixture-only',username:'验收管理员',operationsRole:'ops-manager'};localStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth));}); await context.route('**/*',async route=>{ const u=new URL(route.request().url());if(u.origin!==admin.origin)return route.abort();if(!u.pathname.startsWith('/cloud-functions/'))return route.continue(); const {params:p}=route.request().postDataJSON();calls.push(p); if(p.operation==='ops/support-requests/process'){const row=records.find(r=>r.id===p.targetId);assert.ok(p.comment);assert.equal(p.version,'fixture-version');Object.assign(row,{status:p.status,response:p.comment,handledAt:new Date().toISOString(),actorName:'验收管理员',unread:true});return route.fulfill({json:{success:true,data:row}});} if(p.operation!=='ops/support-requests/list')return route.fulfill({json:{success:true,data:{}}}); if(fail)return route.fulfill({status:500,json:{success:false,message:'模拟读取失败'}}); const filtered=records.filter(r=>(!p.role||r.role===p.role)&&(!p.kind||r.kind===p.kind)&&(!p.search||(r.title+r.accountName+r.content).includes(p.search))); await route.fulfill({json:{success:true,data:{canProcess:true,items:filtered.slice((p.page-1)*20,p.page*20),total:filtered.length,page:p.page,pageSize:20,summary:{feedback:filtered.filter(r=>r.kind==='feedback').length,leave:filtered.filter(r=>r.kind==='leave').length,legacy:filtered.filter(r=>r.kind==='legacy').length},refreshedAt:'2026-10-15T04:45:00Z'}}}); }); const page=await context.newPage();page.setDefaultTimeout(12000);page.on('pageerror',e=>errors.push(e.message));await page.goto(admin.origin+'/admin/support-requests');await page.locator('.request-row').first().waitFor(); assert.equal(await page.getByRole('link',{name:'反馈与休假申请',exact:true}).count(),1); assert.equal(await page.locator('.request-row').count(),20);assert.match(await page.locator('.summary-grid').innerText(),/25/);assert.equal(await page.evaluate(()=>window.injected),undefined); await page.screenshot({path:output+'/admin-desktop.png',fullPage:true});await page.screenshot({path:output+'/admin-overview.png'}); await page.getByRole('button',{name:'下一页',exact:true}).click();await page.waitForFunction(()=>document.querySelectorAll('.request-row').length===5);checks.push('菜单入口、全部记录、分页及内容按纯文本显示'); await page.getByLabel('提交身份').selectOption('teacher');await page.getByLabel('类型',{exact:true}).selectOption('leave');await page.getByRole('button',{name:'查询',exact:true}).click();await page.waitForFunction(()=>document.querySelectorAll('.request-row').length===4); assert.equal(calls.at(-1).page,1);assert.ok((await page.locator('.request-meta').allTextContents()).every(t=>t.includes('老师')&&t.includes('休假申请')));checks.push('身份与类型组合筛选,查询回到第一页'); await page.getByLabel('搜索',{exact:true}).fill('不存在的内容');await page.getByRole('button',{name:'查询',exact:true}).click();await page.getByText('暂无符合条件的反馈或申请').waitFor(); await page.getByRole('button',{name:'重置',exact:true}).click();await page.locator('.request-row').first().waitFor(); fail=true;await page.getByRole('button',{name:'查询',exact:true}).click();await page.getByRole('alert').filter({hasText:'模拟读取失败'}).waitFor();assert.equal(await page.locator('.request-row').count(),0); fail=false;await page.getByRole('button',{name:'重新加载',exact:true}).click();await page.locator('.request-row').first().waitFor();checks.push('空状态、失败不伪装为空记录、重试恢复'); for(const width of [768,390]){await page.setViewportSize({width,height:900});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));await page.screenshot({path:output+'/admin-'+width+'.png',fullPage:true});}checks.push('1440/768/390宽度布局'); for(const [index,decision,comment] of [[0,'approved','已同意休假,请提前完成交接。'],[3,'rejected','当天已有排课,请另行选择时间。'],[1,'processed','提醒设置已调整,感谢反馈。']]){ const row=page.locator('.request-row').nth(index);await row.getByRole('button',{name:/处理申请|处理反馈/}).click(); if(decision!=='processed')await row.getByLabel('处理结果').selectOption(decision); await row.getByLabel('处理意见(提交人可见)').fill(comment); await row.getByRole('button',{name:/并发送回复/}).click();await page.locator('.request-row').nth(index).locator('.resolution').waitFor(); assert.ok((await page.locator('.request-row').nth(index).innerText()).includes(comment)); } checks.push('后台处理意见反馈、同意及拒绝休假,提交处理意见并显示未读状态'); await page.screenshot({path:output+'/admin-processed.png',fullPage:true}); for(const role of [{id:1,group:1,name:'李同学',type:'feedback'},{id:2,group:3,name:'张老师',type:'leave'}]){ const c=await browser.newContext({viewport:{width:390,height:844}});await c.addInitScript(role=>localStorage.setItem('xiaoshu.angular.session',JSON.stringify({token:'r:fixture-only',user:{userId:role.id,groupId:role.group,honeyName:role.name}})),role); let submitted; await c.route('**/*',async route=>{const u=new URL(route.request().url());if(u.origin!==mobile.origin)return route.abort();if(!u.pathname.startsWith('/cloud-functions/'))return route.continue();const {params:p}=route.request().postDataJSON();if(p.action==='app_account_overview')return route.fulfill({json:{retcode:1,result:{profile:{userId:role.id,groupId:role.group,honeyName:role.name},refreshedAt:new Date().toISOString()}}});if(p.action==='app_support_requests_list'){const own=records.filter(r=>Number(r.userId)===role.id);return route.fulfill({json:{retcode:1,result:{items:own.slice(0,10),total:own.length,unread:own.filter(r=>r.unread).length}}});}if(p.action==='app_support_request_read'){const row=records.find(r=>r.id===p.id);assert.equal(Number(row.userId),role.id);row.unread=false;return route.fulfill({json:{retcode:1,result:{read:true}}});}if(p.action==='guestbook_add'){submitted=JSON.parse(p.model);records.unshift({id:'submitted-'+role.id,userId:String(role.id),title:submitted.Title,content:submitted.TContent,kind:role.type,status:'pending',unread:false,submittedAt:new Date().toISOString()});return route.fulfill({json:{retcode:1,result:{GID:123}}});}return route.fulfill({json:{retcode:1,result:{valid:true}}});}); const p=await c.newPage();p.setDefaultTimeout(12000);p.on('pageerror',e=>errors.push(e.message));await p.goto(mobile.origin+'/pages/home/ucenter');await p.getByRole('link',{name:/条新的处理回复/}).click();await p.getByRole('heading',{name:'我的提交与回复'}).waitFor();await p.getByRole('button',{name:'提交反馈 / 申请',exact:true}).click();await p.locator('#name').fill(role.name);await p.getByRole('combobox',{name:/提交类型/}).click();await p.getByRole('option',{name:role.type==='leave'?'休假申请':'意见反馈',exact:true}).click();await p.locator('#content').fill('浏览器提交验收');await p.screenshot({path:output+'/mobile-'+role.type+'.png',fullPage:true});await p.getByRole('button',{name:'提交',exact:true}).click();await p.getByText('提交成功,管理员处理后会在这里回复。',{exact:true}).waitFor(); assert.equal(submitted.UserID,role.id);assert.equal(submitted.Cateid,22);assert.equal(submitted.Title,`【${role.type==='leave'?'休假申请':'意见反馈'}】${role.name}`);assert.equal(submitted.TContent,'浏览器提交验收'); const reply=p.locator('.reply-card').filter({has:p.getByText('新回复',{exact:true})}).first();await reply.waitFor(); const ownRecord=records.find(r=>Number(r.userId)===role.id&&r.unread);await reply.getByRole('button',{name:'查看处理意见'}).click();await p.locator('.response').waitFor();assert.ok((await p.locator('.response').innerText()).includes(ownRecord.response)); assert.equal(ownRecord.unread,false);assert.equal(await p.evaluate(()=>window.injected),undefined);assert.ok(await p.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));await p.screenshot({path:output+'/mobile-reply-'+role.type+'.png',fullPage:true});await c.close(); } checks.push('学生和老师个人中心新回复提示、跳转我的记录、提交、查看本人处理意见及已读,390宽度无溢出'); assert.deepEqual(errors,[]);writeFileSync(output+'/result.json',JSON.stringify({passed:true,checks,errors},null,2));console.log(JSON.stringify({passed:true,checks,output})); }finally{await browser.close();admin.server.close();mobile.server.close();}