import { chromium } from 'playwright'; import { readFile, writeFile, mkdir } from 'node:fs/promises'; import assert from 'node:assert/strict'; // 通过实际浏览器、HTTP API 与持久数据验证,生成模型调用次数为零。 const credentials = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8')); const base = process.env.FRONTEND_URL || 'http://127.0.0.1:18762'; 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 }); const results = [], errors = []; const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); const page = await context.newPage(); page.on('pageerror', error => errors.push(error.message)); const content = page.locator('.merchant-pages'); async function waitLoaded() { await page.waitForTimeout(200); await page.getByText('正在读取数据…', { exact: true }).waitFor({ state: 'hidden' }); } 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(); } 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(); } async function confirm() { await page.getByRole('dialog').getByRole('button', { name: '确认操作', exact: true }).click(); await page.getByRole('dialog').waitFor({ state: 'hidden' }); await waitLoaded(); } async function check(name, action) { await action(); results.push(name); console.log(`通过:${name}`); } const routes = { merchant: ['overview','products','orders','after-sales','customers','marketing','service','knowledge','rules','release','billing','access','channels','notifications','notification-detail'], staff: ['queue','ticket','customers','reference','quick-replies','statistics','notifications','conversation','draft','notification-detail'], supervisor: ['approvals','history','team','statistics','notifications','review','notification-detail'], }; try { for (const [role, account] of [['merchant','admin01'],['staff','staff01'],['supervisor','supervisor01']]) { await login(account); await check(`${role} 菜单与 ${routes[role].length} 个页面`, async () => { for (const target of routes[role]) { await route(role, target); assert.equal(await content.count(), 1, `${role}/${target}`); assert.equal(await content.locator('.error-state').count(), 0, `${role}/${target} 加载失败:${await content.innerText()}`); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `${role}/${target} 页面宽度`); } }); await check(`${role} 通知详情、未读与业务跳转`, async () => { await route(role, 'notifications'); assert.doesNotMatch(await content.innerText(), /mock/); if (await content.getByRole('button', { name: '查看详情', exact: true }).count()) { await content.getByRole('button', { name: '查看详情', exact: true }).first().click(); await waitLoaded(); assert.match(await content.innerText(), /已读/); await content.locator('.actions button').first().click(); await waitLoaded(); assert.equal(await content.locator('.error-state').count(),0,await content.innerText()); assert.doesNotMatch(page.url(),/notification-detail$/); await route(role,'notifications'); } await content.getByRole('button', { name: '未读', exact: true }).click(); await waitLoaded(); 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(); } assert.match(await content.innerText(), /暂无未读通知/); assert.match(await page.locator('.admin-session').innerText(), /消息通知(\d+)/); }); await page.setViewportSize({ width: 390, height: 844 }); 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}`); } }); await page.setViewportSize({ width: 1440, height: 1000 }); } await login('admin01'); await check('管理员员工邀请持久化、停用确认与恢复', async () => { 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(), /已登记,等待身份接入/); await page.reload(); await login('admin01'); await route('merchant','access'); assert.match(await content.innerText(), new RegExp(account)); const employee = content.getByRole('row').filter({hasText:'staff01'}); await employee.getByRole('button', { name: '停用账户', exact:true }).click(); await confirm(); await employee.getByRole('button', { name: '启用账户', exact:true }).waitFor(); await employee.getByRole('button', { name: '启用账户', exact:true }).click(); await confirm(); await employee.getByRole('button', { name: '停用账户', exact:true }).waitFor(); }); await check('渠道配置读取与 HTTPS 地址校验', async () => { 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(); const rejected = page.waitForResponse(response => response.request().method() === 'PUT' && response.url().endsWith('/channels')); await content.getByRole('button', { name: '保存接入配置' }).click(); assert.equal((await rejected).status(),422); await content.locator('.error-state').waitFor(); await content.getByRole('button',{name:'重新读取资料'}).click(); await waitLoaded(); assert.match(await content.innerText(), /接入检查/); }); await check('知识草稿真实保存与正文预览', async () => { 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(); }); await check('客服真实会话接管、回复与释放', async () => { // 仅创建会话,不发送客户生成请求;避免触发付费模型调用。 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(); 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); 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); 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(); 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(); 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); 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); await context.request.delete(`${base}/openapi/v1/auth/sessions/current`, { headers: { Authorization: `Bearer ${identity.access_token}` } }); }); assert.deepEqual(errors, []); await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true }); 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)); } finally { await browser.close(); }