merchant-functional.mjs 11 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. import { chromium } from 'playwright';
  2. import { readFile, writeFile, mkdir } from 'node:fs/promises';
  3. import assert from 'node:assert/strict';
  4. // 通过实际浏览器、HTTP API 与持久数据验证,生成模型调用次数为零。
  5. const credentials = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8'));
  6. const base = process.env.FRONTEND_URL || 'http://127.0.0.1:18762';
  7. const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || 'C:/Users/Administrator/AppData/Local/ms-playwright/chromium-1228/chrome-win64/chrome.exe', headless: true });
  8. const results = [], errors = [];
  9. const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
  10. const page = await context.newPage();
  11. page.on('pageerror', error => errors.push(error.message));
  12. const content = page.locator('.merchant-pages');
  13. async function waitLoaded() { await page.waitForTimeout(200); await page.getByText('正在读取数据…', { exact: true }).waitFor({ state: 'hidden' }); }
  14. async function route(role, target) { const path = role === 'staff' ? `merchant/service/${target}` : role === 'supervisor' ? `merchant/approval/${target}` : `merchant/${target}`; await page.evaluate(hash => { location.hash = hash; }, `#/${path}`); await waitLoaded(); }
  15. async function login(account) { await page.goto(`${base}/#/merchant/login`); await page.getByLabel('商家标识').fill(credentials.tenant_id); await page.getByLabel('员工账户').fill(account); await page.getByLabel('密码', { exact: true }).fill(credentials.password); await page.getByRole('button', { name: '登录', exact: true }).click(); await page.locator('.admin-shell,.login-panel [role="alert"]').first().waitFor(); assert.equal(await page.locator('.login-panel [role="alert"]').count(), 0, await page.locator('body').innerText()); await waitLoaded(); }
  16. async function confirm() { await page.getByRole('dialog').getByRole('button', { name: '确认操作', exact: true }).click(); await page.getByRole('dialog').waitFor({ state: 'hidden' }); await waitLoaded(); }
  17. async function check(name, action) { await action(); results.push(name); console.log(`通过:${name}`); }
  18. const routes = {
  19. merchant: ['overview','products','orders','after-sales','customers','marketing','service','knowledge','rules','release','billing','access','channels','notifications','notification-detail'],
  20. staff: ['queue','ticket','customers','reference','quick-replies','statistics','notifications','conversation','draft','notification-detail'],
  21. supervisor: ['approvals','history','team','statistics','notifications','review','notification-detail'],
  22. };
  23. try {
  24. for (const [role, account] of [['merchant','admin01'],['staff','staff01'],['supervisor','supervisor01']]) {
  25. await login(account);
  26. await check(`${role} 菜单与 ${routes[role].length} 个页面`, async () => {
  27. for (const target of routes[role]) {
  28. await route(role, target); assert.equal(await content.count(), 1, `${role}/${target}`);
  29. assert.equal(await content.locator('.error-state').count(), 0, `${role}/${target} 加载失败:${await content.innerText()}`);
  30. assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `${role}/${target} 页面宽度`);
  31. }
  32. });
  33. await check(`${role} 通知详情、未读与业务跳转`, async () => {
  34. await route(role, 'notifications');
  35. assert.doesNotMatch(await content.innerText(), /mock/);
  36. if (await content.getByRole('button', { name: '查看详情', exact: true }).count()) {
  37. await content.getByRole('button', { name: '查看详情', exact: true }).first().click();
  38. await waitLoaded(); assert.match(await content.innerText(), /已读/);
  39. await content.locator('.actions button').first().click(); await waitLoaded();
  40. assert.equal(await content.locator('.error-state').count(),0,await content.innerText());
  41. assert.doesNotMatch(page.url(),/notification-detail$/);
  42. await route(role,'notifications');
  43. }
  44. await content.getByRole('button', { name: '未读', exact: true }).click();
  45. await waitLoaded();
  46. if (await content.getByRole('button', { name: '全部标为已读' }).isEnabled()) { const marked = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith('/notifications/read-all')); await content.getByRole('button', { name: '全部标为已读' }).click(); await confirm(); const response = await marked; assert.equal(response.status(),200,await response.text()); await waitLoaded(); }
  47. assert.match(await content.innerText(), /暂无未读通知/);
  48. assert.match(await page.locator('.admin-session').innerText(), /消息通知(\d+)/);
  49. });
  50. await page.setViewportSize({ width: 390, height: 844 });
  51. await check(`${role} 手机宽度全部页面`, async () => { for (const target of routes[role]) { await route(role, target); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `${role}/${target}`); } });
  52. await page.setViewportSize({ width: 1440, height: 1000 });
  53. }
  54. await login('admin01');
  55. await check('管理员员工邀请持久化、停用确认与恢复', async () => {
  56. await route('merchant', 'access'); const account = `browser.staff.${Date.now()}`; await content.getByLabel('员工账户').fill(account); await content.getByRole('button', { name: '保存邀请与授权' }).click(); await page.locator('.app-notice').filter({hasText:'邀请与授权已登记'}).waitFor(); assert.match(await content.innerText(), /已登记,等待身份接入/);
  57. await page.reload(); await login('admin01'); await route('merchant','access'); assert.match(await content.innerText(), new RegExp(account));
  58. const employee = content.getByRole('row').filter({hasText:'staff01'});
  59. await employee.getByRole('button', { name: '停用账户', exact:true }).click(); await confirm();
  60. await employee.getByRole('button', { name: '启用账户', exact:true }).waitFor();
  61. await employee.getByRole('button', { name: '启用账户', exact:true }).click(); await confirm();
  62. await employee.getByRole('button', { name: '停用账户', exact:true }).waitFor();
  63. });
  64. await check('渠道配置读取与 HTTPS 地址校验', async () => {
  65. await route('merchant','channels'); await content.getByLabel('事件回调地址').fill('http://127.0.0.1/events'); if (!await content.getByRole('checkbox').isChecked()) await content.locator('.el-checkbox').click();
  66. const rejected = page.waitForResponse(response => response.request().method() === 'PUT' && response.url().endsWith('/channels'));
  67. await content.getByRole('button', { name: '保存接入配置' }).click(); assert.equal((await rejected).status(),422);
  68. await content.locator('.error-state').waitFor(); await content.getByRole('button',{name:'重新读取资料'}).click(); await waitLoaded();
  69. assert.match(await content.innerText(), /接入检查/);
  70. });
  71. await check('知识草稿真实保存与正文预览', async () => {
  72. await route('merchant','knowledge'); await content.getByRole('button', { name: '新建资料', exact: true }).click(); const title = `浏览器功能验收资料 ${Date.now()}`; await content.getByLabel('资料标题').fill(title); await content.getByLabel('文本内容').fill('键盘清洁前请断开电源。换货条件以当前有效售后规则为准。'); await content.getByRole('button', { name: '保存草稿', exact: true }).click(); await page.locator('.app-notice').filter({hasText:'知识草稿已保存'}).or(page.getByRole('alert')).waitFor(); assert.equal(await page.getByRole('alert').count(),0,await page.locator('body').innerText()); assert.match(await content.innerText(), new RegExp(title)); await content.getByRole('button', { name: '预览检索内容' }).click(); assert.match(await page.getByRole('dialog').innerText(), /键盘清洁前请断开电源/); await confirm();
  73. });
  74. await check('客服真实会话接管、回复与释放', async () => {
  75. // 仅创建会话,不发送客户生成请求;避免触发付费模型调用。
  76. const auth = await context.request.post(`${base}/openapi/v1/auth/sessions`, { data: { tenant_id: credentials.tenant_id, username: 'customer01', password: credentials.password } }); assert.equal(auth.status(), 200); const identity = await auth.json();
  77. const conversationResponse = await context.request.post(`${base}/openapi/v1/conversations`, { headers: { Authorization: `Bearer ${identity.access_token}`, 'Idempotency-Key': crypto.randomUUID() }, data: { store_id: credentials.store_id } }); assert.equal(conversationResponse.status(), 201); const conversation = await conversationResponse.json(); assert.equal(conversation.subject_id,credentials.actors.customer01); assert.equal(conversation.assigned_staff_id,null);
  78. const forbiddenOrders = await context.request.get(`${base}/openapi/v1/orders?store_id=${credentials.store_id}&subject_id=${credentials.actors.staff01}`,{headers:{Authorization:`Bearer ${identity.access_token}`}}); assert.equal(forbiddenOrders.status(),403);
  79. await login('staff01'); await route('staff','queue'); await content.getByRole('button').filter({ hasText: conversation.conversation_id }).click(); await waitLoaded(); await content.getByRole('button', { name: '接管会话', exact: true }).click(); await confirm();
  80. await content.getByRole('button',{name:'返回会话队列'}).click(); await waitLoaded(); await content.getByRole('button',{name:'我的会话',exact:true}).click(); await content.getByRole('button').filter({hasText:conversation.conversation_id}).click(); await waitLoaded();
  81. const filtered = page.waitForResponse(response=>response.request().method()==='GET' && response.url().includes('/openapi/v1/orders?') && response.url().includes(`subject_id=${credentials.actors.customer01}`)); await content.getByRole('button',{name:'选择授权订单'}).click(); assert.equal((await filtered).status(),200);
  82. const reply = `人工回复验收 ${Date.now()}`; await content.getByLabel('人工回复').fill(reply); await content.getByRole('button', { name: '发送回复', exact: true }).click(); await page.locator('.app-notice').filter({hasText:'回复已发送'}).waitFor(); assert.match(await content.innerText(), new RegExp(reply)); await content.getByRole('button', { name: '结束接管', exact: true }).click(); await confirm(); assert.equal(await content.getByRole('button', { name: '发送回复' }).isDisabled(), true);
  83. await context.request.delete(`${base}/openapi/v1/auth/sessions/current`, { headers: { Authorization: `Bearer ${identity.access_token}` } });
  84. });
  85. assert.deepEqual(errors, []);
  86. await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true });
  87. await writeFile(new URL('../../.work/frontend-verification/merchant.json', import.meta.url), JSON.stringify({ date: new Date().toISOString(), pages: 32, checks: results, browserErrors: errors, generatedModelCalls: 0 }, null, 2));
  88. } finally { await browser.close(); }