smoke.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. import { chromium, devices } from 'playwright';
  2. import { mkdir, writeFile, readdir } from 'node:fs/promises';
  3. import { resolve, dirname } from 'node:path';
  4. import { fileURLToPath } from 'node:url';
  5. import assert from 'node:assert/strict';
  6. const root = dirname(fileURLToPath(import.meta.url));
  7. const out = resolve(root, 'test-results');
  8. await mkdir(out, { recursive: true });
  9. const launchOptions = { headless: true };
  10. if (process.env.DEMO_CHROME) launchOptions.executablePath = process.env.DEMO_CHROME;
  11. else if (process.platform === 'win32') {
  12. const cache = resolve(process.env.LOCALAPPDATA, 'ms-playwright');
  13. const installed = (await readdir(cache)).filter(x => /^chromium-\d+$/.test(x)).sort().reverse();
  14. if (installed.length) launchOptions.executablePath = resolve(cache, installed[0], 'chrome-win64', 'chrome.exe');
  15. }
  16. const browser = await chromium.launch(launchOptions);
  17. const base = process.env.DEMO_URL || 'http://localhost:4300';
  18. const results = [];
  19. const errors = [];
  20. const requests = [];
  21. const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' });
  22. const mobile = await browser.newContext({ ...devices['iPhone 13'], viewport: { width: 390, height: 844 }, reducedMotion: 'reduce' });
  23. const pc = await desktop.newPage();
  24. const phone = await mobile.newPage();
  25. for (const page of [pc, phone]) {
  26. page.on('pageerror', error => errors.push(error.message));
  27. page.on('requestfailed', request => requests.push(`${request.url()} ${request.failure()?.errorText}`));
  28. }
  29. async function test(name, action) {
  30. try { await action(); results.push({ name, passed: true }); process.stdout.write(`PASS ${name}\n`); }
  31. catch (error) { results.push({ name, passed: false, error: error.message }); process.stdout.write(`FAIL ${name}: ${error.message}\n`); }
  32. }
  33. async function go(page, path) {
  34. await page.goto(base + path);
  35. await page.locator('#app').waitFor();
  36. }
  37. async function click(page, action, value) {
  38. await page.locator(`[data-action="${action}"]${value !== undefined ? `[data-value="${value}"]` : ''}`).first().click();
  39. }
  40. async function checkLayout(page) {
  41. const layout = await page.evaluate(() => ({
  42. windowWidth: innerWidth, documentWidth: document.documentElement.scrollWidth,
  43. mainWidth: document.querySelector('#main')?.clientWidth,
  44. mainScrollWidth: document.querySelector('#main')?.scrollWidth,
  45. missingNames: [...document.querySelectorAll('button')].filter(b => b.getBoundingClientRect().width && !b.textContent.trim() && !b.getAttribute('aria-label')).length,
  46. }));
  47. assert.ok(layout.documentWidth <= layout.windowWidth + 1, JSON.stringify(layout));
  48. assert.ok(!layout.mainWidth || layout.mainScrollWidth <= layout.mainWidth + 1, JSON.stringify(layout));
  49. assert.equal(layout.missingNames, 0);
  50. }
  51. const routes = ['chat', 'contacts', 'projects', 'projects/autumn', 'events', 'workbench', 'tasks', 'tasks/T-106', 'deliverables', 'agent/zhixia', 'agent/mobai', 'mine'];
  52. for (const [name, page, prefix, paths] of [['desktop', pc, '/desktop/', routes], ['mobile', phone, '/mobile/', ['home', 'messages', ...routes.map(x => x === 'chat' ? 'chat/zhixia' : x)]]]) {
  53. for (const route of paths) {
  54. await test(`${name} route ${route}`, async () => {
  55. await go(page, prefix + route);
  56. assert.ok((await page.locator('#main').innerText()).length > 30);
  57. await checkLayout(page);
  58. if (['chat', 'chat/zhixia', 'home', 'workbench', 'tasks/T-106', 'deliverables'].includes(route)) {
  59. await page.screenshot({ path: resolve(out, `${name}-${route.replaceAll('/', '-')}.png`) });
  60. }
  61. });
  62. }
  63. }
  64. await test('desktop four-column baseline', async () => {
  65. await go(pc, '/desktop/chat');
  66. assert.ok(await pc.locator('.result-rail').isVisible());
  67. const widths = await pc.locator('.conversation-layout').evaluate(el => getComputedStyle(el).gridTemplateColumns);
  68. assert.equal(widths.split(' ').length, 3);
  69. });
  70. for (const width of [1280, 1150, 1149, 940, 939, 768]) {
  71. await test(`desktop breakpoint ${width}`, async () => {
  72. await pc.setViewportSize({ width, height: 900 });
  73. await go(pc, '/desktop/chat');
  74. await checkLayout(pc);
  75. assert.equal(await pc.locator('.result-rail').isVisible(), width >= 1150);
  76. });
  77. }
  78. await pc.setViewportSize({ width: 1440, height: 900 });
  79. await test('mobile bottom tabs and home counts', async () => {
  80. await go(phone, '/mobile/home');
  81. assert.equal(await phone.locator('.mobile-nav a').count(), 5);
  82. for (const section of await phone.locator('.home-section').all()) {
  83. const count = Number(await section.locator('.section-count').innerText());
  84. assert.equal(await section.locator('.artifact-row, .chat-row').count(), count);
  85. }
  86. });
  87. await test('contacts tabs search and unconnected agent routing', async () => {
  88. await go(pc, '/desktop/contacts');
  89. await click(pc, 'contacts-tab', 'ai');
  90. await pc.getByRole('searchbox').fill('墨');
  91. assert.equal(await pc.locator('.contact-row').count(), 1);
  92. await click(pc, 'agent-open', 'mobai');
  93. assert.match(pc.url(), /agent\/mobai$/);
  94. assert.ok(await pc.getByRole('button', { name: '尚未接入会话' }).isDisabled());
  95. });
  96. await test('message send and simulated reply', async () => {
  97. await go(pc, '/desktop/chat/zhixia');
  98. await pc.locator('#message-input').fill('请补充渠道执行节奏。');
  99. await pc.locator('#message-input').press('Enter');
  100. await pc.getByText('这是演示回复,未调用真实数字生命。', { exact: false }).waitFor();
  101. assert.equal(await pc.locator('#message-input').inputValue(), '');
  102. });
  103. await test('message failure and retry', async () => {
  104. await click(pc, 'lab');
  105. await click(pc, 'fail-next');
  106. await pc.locator('#message-input').fill('失败重试测试');
  107. await pc.locator('#message-input').press('Enter');
  108. await pc.locator('.send-error').waitFor();
  109. await click(pc, 'retry-message');
  110. assert.equal(await pc.locator('.send-error').count(), 0);
  111. });
  112. await test('composer Shift+Enter and auto height', async () => {
  113. const input = pc.locator('#message-input');
  114. await input.fill('第一行');
  115. const before = await input.evaluate(el => el.clientHeight);
  116. for (let i = 0; i < 5; i++) { await input.press('Shift+Enter'); await input.press('a'); }
  117. assert.ok((await input.inputValue()).includes('\n'));
  118. assert.ok(await input.evaluate(el => el.clientHeight) > before);
  119. });
  120. await test('file upload progress and card', async () => {
  121. await pc.locator('#file-input').setInputFiles({ name: 'demo-notes.txt', mimeType: 'text/plain', buffer: Buffer.from('Demo attachment') });
  122. await pc.locator('progress').waitFor();
  123. await pc.getByText('demo-notes.txt', { exact: true }).waitFor();
  124. assert.ok(await pc.getByText('本地文件,不上传', { exact: false }).isVisible());
  125. });
  126. await test('preview download and Escape focus return', async () => {
  127. await go(pc, '/desktop/deliverables');
  128. const item = pc.locator('[data-action="preview"]').first();
  129. await item.click();
  130. await pc.getByRole('dialog').waitFor();
  131. const downloadPromise = pc.waitForEvent('download');
  132. await click(pc, 'download');
  133. const download = await downloadPromise;
  134. assert.ok(download.suggestedFilename().endsWith('.md'));
  135. await pc.keyboard.press('Escape');
  136. assert.equal(await pc.getByRole('dialog').count(), 0);
  137. assert.equal(await item.evaluate(el => el === document.activeElement), true);
  138. });
  139. await test('results project and timeline toggle', async () => {
  140. await click(pc, 'result-view', 'timeline');
  141. assert.equal(await pc.locator('[data-testid="results-timeline"]').count(), 1);
  142. assert.equal(await pc.locator('[data-testid="results-project"]').count(), 0);
  143. });
  144. await test('task status and agent scoping, read-only detail', async () => {
  145. await go(pc, '/desktop/tasks');
  146. await pc.locator('#task-agent').selectOption('qiyun');
  147. await click(pc, 'task-filter', 'blocked');
  148. assert.equal(await pc.locator('.task-row').count(), 1);
  149. assert.match(await pc.locator('.task-detail').innerText(), /已阻塞 4 小时/);
  150. assert.equal(await pc.getByRole('button', { name: /派发|发布评论|提交评论|新建任务/ }).count(), 0);
  151. await pc.screenshot({ path: resolve(out, 'desktop-tasks.png') });
  152. });
  153. await test('event category filtering', async () => {
  154. await go(pc, '/desktop/events');
  155. await click(pc, 'event-filter', 'alert');
  156. const cats = await pc.locator('[data-category]').evaluateAll(els => els.map(el => el.dataset.category));
  157. assert.deepEqual(cats, ['alert']);
  158. });
  159. await test('page error has retry and not empty copy', async () => {
  160. await go(pc, '/desktop/workbench');
  161. await click(pc, 'lab'); await click(pc, 'scenario', 'error');
  162. assert.ok(await pc.locator('[data-state="error"]').isVisible());
  163. assert.equal(await pc.locator('[data-state="empty"]').count(), 0);
  164. await click(pc, 'retry-state');
  165. assert.equal(await pc.locator('.agent-card').count(), 5);
  166. });
  167. await test('mobile independent homepage states', async () => {
  168. await go(phone, '/mobile/home');
  169. await click(phone, 'lab');
  170. await phone.locator('[data-home-state="home-results"]').selectOption('error');
  171. await phone.keyboard.press('Escape');
  172. assert.equal(await phone.locator('.home-section').first().locator('[data-state="error"]').count(), 1);
  173. assert.equal(await phone.locator('.home-section').last().locator('.chat-row').count(), 4);
  174. await click(phone, 'retry-state', 'home-results');
  175. assert.equal(await phone.locator('.home-section').first().locator('.artifact-row').count(), 3);
  176. });
  177. await test('mobile results drawer and modal keyboard trap', async () => {
  178. await go(phone, '/mobile/chat/zhixia');
  179. await click(phone, 'results-drawer', 'zhixia');
  180. assert.ok(await phone.locator('.drawer').isVisible());
  181. for (let i = 0; i < 12; i++) await phone.keyboard.press('Tab');
  182. assert.ok(await phone.evaluate(() => !!document.activeElement.closest('.dialog')));
  183. await phone.screenshot({ path: resolve(out, 'mobile-results-drawer.png') });
  184. await phone.keyboard.press('Escape');
  185. });
  186. await test('four complete palettes switch, contrast, and persist', async () => {
  187. await go(pc, '/desktop/mine');
  188. const paletteIds = ['platinum', 'ice', 'clay', 'slate'];
  189. assert.equal(await pc.locator('[data-action="palette"]').count(), paletteIds.length);
  190. assert.equal(await pc.locator('.palette-thumbnail').count(), paletteIds.length);
  191. const contrast = value => {
  192. const rawChannels = value.startsWith('#')
  193. ? value.slice(1).match(/.{2}/g).map(channel => parseInt(channel, 16))
  194. : value.match(/\d+/g).slice(0, 3).map(Number);
  195. const channels = rawChannels.map(channel => {
  196. const normalized = channel / 255;
  197. return normalized <= .04045 ? normalized / 12.92 : ((normalized + .055) / 1.055) ** 2.4;
  198. });
  199. return channels[0] * .2126 + channels[1] * .7152 + channels[2] * .0722;
  200. };
  201. for (const theme of ['light', 'dark']) {
  202. await click(pc, 'theme', theme);
  203. for (const palette of paletteIds) {
  204. await click(pc, 'palette', palette);
  205. const values = await pc.evaluate(() => {
  206. const style = getComputedStyle(document.documentElement);
  207. return Object.fromEntries(['--bg', '--surface', '--nav-bg', '--ink', '--ink-muted', '--accent', '--on-accent', '--nav-text', '--nav-muted', '--success', '--warning', '--danger'].map(token => [token, style.getPropertyValue(token).trim()]));
  208. });
  209. assert.equal(await pc.locator('html').getAttribute('data-palette'), palette);
  210. assert.equal(await pc.locator('html').getAttribute('data-theme'), theme);
  211. assert.notEqual(values['--bg'], values['--surface']);
  212. assert.notEqual(values['--nav-bg'], values['--surface']);
  213. const ratio = (a, b) => (Math.max(contrast(a), contrast(b)) + .05) / (Math.min(contrast(a), contrast(b)) + .05);
  214. assert.ok(ratio(values['--accent'], values['--on-accent']) >= 4.5, `${palette}/${theme} primary action contrast`);
  215. assert.ok(ratio(values['--nav-text'], values['--nav-bg']) >= 4.5, `${palette}/${theme} navigation contrast`);
  216. assert.ok(ratio(values['--nav-muted'], values['--nav-bg']) >= 4.5, `${palette}/${theme} secondary navigation contrast`);
  217. assert.ok(ratio(values['--ink-muted'], values['--bg']) >= 4.5, `${palette}/${theme} secondary text contrast`);
  218. assert.equal(values['--success'], theme === 'dark' ? '#8ed2ac' : '#207550');
  219. assert.equal(values['--warning'], theme === 'dark' ? '#e8bf77' : '#91600e');
  220. assert.equal(values['--danger'], theme === 'dark' ? '#f2a0a5' : '#c33f42');
  221. }
  222. }
  223. await click(pc, 'palette', 'slate');
  224. await go(pc, '/desktop/chat');
  225. await pc.reload();
  226. assert.equal(await pc.locator('html').getAttribute('data-palette'), 'slate');
  227. assert.equal(await pc.locator('html').getAttribute('data-theme'), 'dark');
  228. await pc.screenshot({ path: resolve(out, 'desktop-slate-dark.png') });
  229. await go(pc, '/desktop/mine');
  230. await click(pc, 'theme', 'light');
  231. await click(pc, 'palette', 'clay');
  232. await pc.screenshot({ path: resolve(out, 'desktop-clay-light.png') });
  233. await go(phone, '/mobile/mine');
  234. await click(phone, 'palette', 'ice');
  235. assert.equal(await phone.locator('html').getAttribute('data-palette'), 'ice');
  236. await phone.reload();
  237. assert.equal(await phone.locator('html').getAttribute('data-palette'), 'ice');
  238. await checkLayout(phone);
  239. });
  240. await test('logout confirmation and login validation', async () => {
  241. await click(pc, 'logout');
  242. await click(pc, 'close');
  243. assert.ok(pc.url().includes('/mine'));
  244. await click(pc, 'logout'); await click(pc, 'confirm-logout');
  245. assert.ok(pc.url().endsWith('/login'));
  246. await pc.locator('#phone').fill('123');
  247. await pc.locator('#login-form [type="submit"]').click();
  248. assert.match(await pc.locator('#login-error').innerText(), /有效/);
  249. await pc.locator('#phone').fill('13800138000');
  250. await pc.locator('#code').fill('000000');
  251. await pc.locator('#login-form [type="submit"]').click();
  252. assert.match(await pc.locator('#login-error').innerText(), /过期/);
  253. await pc.locator('#code').fill('123456');
  254. await pc.locator('#login-form [type="submit"]').click();
  255. await pc.waitForURL(/desktop\/chat$/);
  256. });
  257. await test('login hash token cleaned and invalid token visible', async () => {
  258. await go(pc, '/login#token=demo-fmode');
  259. assert.ok(!pc.url().includes('token'));
  260. assert.ok(pc.url().includes('/desktop/chat'));
  261. await go(pc, '/login?token=invalid');
  262. assert.ok(!pc.url().includes('token'));
  263. assert.match(await pc.locator('#login-error').innerText(), /无效/);
  264. await pc.screenshot({ path: resolve(out, 'desktop-login.png') });
  265. });
  266. await test('legacy route mapping and retired call notice', async () => {
  267. await go(pc, '/call/zhixia');
  268. assert.ok(pc.url().includes('/desktop/agent/zhixia'));
  269. await pc.getByRole('status').filter({ hasText: '语音通话功能暂未开放' }).waitFor();
  270. });
  271. await test('root UA routing', async () => {
  272. await go(phone, '/');
  273. assert.ok(phone.url().includes('/mobile/home'));
  274. await go(pc, '/');
  275. assert.ok(pc.url().includes('/desktop/chat'));
  276. });
  277. await test('all visible touch targets at least 44px', async () => {
  278. const violations = [];
  279. for (const path of ['/mobile/home', '/mobile/contacts', '/mobile/chat/zhixia', '/mobile/tasks', '/mobile/mine']) {
  280. await go(phone, path);
  281. const tiny = await phone.evaluate(() => [...document.querySelectorAll('button, a, select')].flatMap(el => {
  282. const r = el.getBoundingClientRect();
  283. if (!r.width || !r.height || r.bottom < 0 || r.top > innerHeight || el.classList.contains('skip-link')) return [];
  284. return r.width < 43.5 || r.height < 43.5 ? [{ text: el.textContent.trim().slice(0, 25) || el.getAttribute('aria-label'), width: r.width, height: r.height }] : [];
  285. }));
  286. violations.push(...tiny.map(x => ({ path, ...x })));
  287. }
  288. assert.deepEqual(violations, []);
  289. });
  290. await test('no browser runtime errors or failed resources', async () => {
  291. assert.deepEqual(errors, []); assert.deepEqual(requests, []);
  292. });
  293. await writeFile(resolve(out, 'report.json'), JSON.stringify({ date: new Date().toISOString(), passed: results.filter(r => r.passed).length, total: results.length, results, errors, requests }, null, 2));
  294. await browser.close();
  295. process.stdout.write(`\n${results.filter(r => r.passed).length}/${results.length} passed. Screenshots: ${out}\n`);
  296. if (results.some(r => !r.passed)) process.exitCode = 1;