import { readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { expect, test, type Page } from '@playwright/test' import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures' // The chat view's CYBER treatment is rendering-layer only, so nothing about it // shows up in behavioural assertions — a regression here is invisible until a // human opens the page. These tests pin the two things that actually broke // before: duplicated keyframes (a structural fault no review reliably catches) // and `!important` overrides that flatten the neon tube back to a grey block. const clientSrc = join(process.cwd(), 'packages', 'client', 'src') function collectSourceFiles(dir: string, out: string[] = []): string[] { for (const entry of readdirSync(dir)) { const full = join(dir, entry) if (statSync(full).isDirectory()) { collectSourceFiles(full, out) } else if (/\.(vue|scss|css)$/.test(entry)) { out.push(full) } } return out } const styleFiles = collectSourceFiles(clientSrc).map((file) => ({ file, text: readFileSync(file, 'utf8'), })) function relative(file: string) { return file.slice(clientSrc.length + 1).replace(/\\/g, '/') } /** * Extract one CSS rule body by brace matching, starting at the selector. * Brace counting is used instead of a newline split because the rules under * test are written both as blocks and as single-line arrow bodies. */ function balancedBlock(source: string, selector: string): string { const start = source.indexOf(selector) expect(start, `${selector} not found in stylesheet`).toBeGreaterThan(-1) const open = source.indexOf('{', start) let depth = 0 for (let i = open; i < source.length; i += 1) { if (source[i] === '{') depth += 1 if (source[i] === '}') { depth -= 1 if (depth === 0) return source.slice(start, i + 1) } } return source.slice(start) } test.describe('chat CYBER semantics', () => { test('live-ring is defined exactly once and only ever uses the success hue', () => { const definitions = styleFiles.filter((entry) => /@keyframes\s+live-ring\b/.test(entry.text)) expect( definitions.map((entry) => relative(entry.file)), 'live-ring must have a single home; a second copy is how the four-colour cycle came back', ).toEqual(['styles/global.scss']) const globalStylesheet = definitions[0].text const block = globalStylesheet.slice( globalStylesheet.indexOf('@keyframes live-ring'), globalStylesheet.indexOf('@keyframes live-ring') + 600, ) expect(block).toContain('var(--success-rgb)') // The old cycle was #ff6b6b → #feca57 → #48dbfb → #ff9ff3. Any raw hex in // this keyframe means a multi-hue cycle crept back in. expect(block).not.toMatch(/#[0-9a-f]{3,8}\b/i) }) test('no component carries its own rainbow-glow keyframes', () => { const offenders = styleFiles .filter((entry) => relative(entry.file) !== 'styles/global.scss') .filter((entry) => /@keyframes\s+rainbow-glow\b/.test(entry.text)) .map((entry) => relative(entry.file)) expect(offenders).toEqual([]) }) test('every live-ring call site lives under a scoped style block', () => { const callSites = styleFiles.filter((entry) => /animation:\s*live-ring\b/.test(entry.text)) expect(callSites.map((entry) => relative(entry.file)).sort()).toEqual([ 'components/hermes/chat/MessageItem.vue', 'components/hermes/chat/SessionListItem.vue', 'components/hermes/group-chat/GroupMessageItem.vue', // 工作流列表里流式运行中的头像外圈。它原本是自写的一套红色光环 + // hue-rotate 动画 —— 色相在转,形态却和会话列表的「机器在动」对不上, // 同一语义长出两种样子。现在收敛到同一个 live-ring。 'views/hermes/WorkflowView.vue', ]) }) test('the send button is not flattened by an !important override', () => { const offenders = styleFiles .filter((entry) => /\.send-button\b/.test(entry.text)) .filter((entry) => /box-shadow:\s*none\s*!important|border:\s*0\s*!important/.test(entry.text)) .map((entry) => relative(entry.file)) expect(offenders).toEqual([]) }) test('the stop control reaches for the warning token, not the accent', () => { // The colour semantics are pinned here rather than in the browser because // rendering cannot answer the question: the palette declares each hue twice // (an OKLCH value and an RGB triple for alpha composition) and the two // forms sit ~30° apart, so any rendered-value comparison is unreliable. // A stylesheet, on the other hand, states plainly which token it used. const stopBlock = balancedBlock( styleFiles.find((entry) => relative(entry.file) === 'components/hermes/chat/ChatInput.vue')?.text ?? '', '.send-button.send-button--stop', ) expect(stopBlock).toContain('rgba(var(--warning-rgb)') expect(stopBlock).not.toContain('accent-primary') }) }) async function computedOf(page: Page, selector: string, properties: string[]) { return page.evaluate( ({ sel, props }) => { const el = document.querySelector(sel) if (!el) return null const cs = getComputedStyle(el) const out: Record = {} for (const p of props) out[p] = cs.getPropertyValue(p) return out }, { sel: selector, props: properties }, ) } /** * Resolve a rendered colour to RGB by painting it into a canvas. * * Note on colour assertions: comparing rendered RGB triples against the * palette does not work in this project and the failures say why. The palette * is declared in OKLCH (`--success: oklch(0.860 0.190 128)`) while the * alpha-composited glows are built from a separate RGB triple * (`--success-rgb: 173, 231, 78`). The two declarations are kept in sync now, * but any rendered-vs-token comparison still depends on the browser's OKLCH * conversion and reports brittle mismatches. * * That is why the *colour* semantics are asserted in the source-level tests * above, which can see which token a rule actually references. Here in the * browser the assertions stay on things rendering can answer unambiguously: * which class is active, which keyframe is running, whether a glow exists. */ async function rgbOf(page: Page, selector: string, property: string): Promise<[number, number, number] | null> { return page.evaluate( ({ sel, prop }) => { const el = document.querySelector(sel) if (!el) return null const value = getComputedStyle(el).getPropertyValue(prop) if (!value || value === 'none') return null const canvas = document.createElement('canvas') canvas.width = 1 canvas.height = 1 const ctx = canvas.getContext('2d') if (!ctx) return null ctx.fillStyle = value ctx.fillRect(0, 0, 1, 1) const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data return [r, g, b] }, { sel: selector, prop: property }, ) } /** * Assert a colour is not one of the greys the old overrides hardcoded. * This is the check that actually catches the regression these tests exist * for: `#9f9f9f` on the send key, or a multi-hue cycle on the live ring. */ async function expectNotGrey(page: Page, selector: string, property: string, what: string) { const rgb = await rgbOf(page, selector, property) expect(rgb, `${selector} has no ${property} to inspect`).not.toBeNull() const [r, g, b] = rgb as [number, number, number] const spread = Math.max(r, g, b) - Math.min(r, g, b) expect(spread, `${what} is grey rgb(${r},${g},${b}) — the neon treatment was flattened`).toBeGreaterThan(12) } test.describe('chat CYBER rendering', () => { test.beforeEach(async ({ page }) => { await authenticate(page, TEST_ACCESS_KEY, 'research') // A session with a workspace is what makes the header badge render at all // (`v-if="chatStore.activeSession?.workspace"`), and the badge is the // subject of the 900px breakpoint test below. await mockHermesApi(page, { sessions: [{ id: 's-1', title: 'Workspace run', workspace: 'C:/work/demo', updated_at: 0 }], }) await mockChatSocket(page) await page.goto('/#/hermes/chat') await expect(page.getByPlaceholder(/Type a message/)).toBeVisible() }) test('the header carries a connection LED that binds to real socket state', async ({ page }) => { const led = page.locator('.cy-led') await expect(led).toHaveCount(1) await expect(led).toHaveClass(/\bon\b/) const style = await computedOf(page, '.cy-led', ['width', 'height', 'border-radius', 'animation-name']) expect(style?.width).toBe('7px') expect(style?.height).toBe('7px') expect(style?.['border-radius']).toBe('50%') // A live connection is the one state allowed to breathe. expect(style?.['animation-name']).toBe('led-breathe') // The `.cy-led.on` rule sets $success (lime). Amber is reserved for "needs // you" and must not appear here; `off` uses $error and stays static. await expectNotGrey(page, '.cy-led', 'background-color', 'a connected LED') }) test('the send key is a lit tube once there is something to send', async ({ page }) => { const sendButton = page.getByRole('button', { name: 'Send' }) // Empty input is the disabled state, and the global primary-button rule // deliberately extinguishes the tube there (global.scss `.arco-btn-disabled`). // So the disabled look is asserted first, and the tube is asserted only // after the key becomes actionable. await expect(sendButton).toBeDisabled() const idle = await computedOf(page, '.send-button', ['box-shadow', 'background-color']) expect(idle?.['box-shadow'], 'disabled send key should be dark, not grey-blocked').toBe('none') expect(idle?.['background-color']).toBe('rgba(0, 0, 0, 0)') await page.getByPlaceholder(/Type a message/).fill('light the key') await expect(sendButton).toBeEnabled() const lit = await computedOf(page, '.send-button', ['box-shadow', 'border-color']) expect(lit?.['box-shadow'], 'send key lost its neon tube').not.toBe('none') // Not the hardcoded #9f9f9f grey the old override used. await expectNotGrey(page, '.send-button', 'border-color', 'the lit send key') }) test('the stop control turns amber because it needs the user', async ({ page }) => { const input = page.getByPlaceholder(/Type a message/) await input.fill('start a run') await page.getByRole('button', { name: 'Send' }).click() // The mocked socket records emits but never acknowledges a run, so the // store only enters the streaming state once run.started comes back. const sessionId = await page.waitForFunction(() => { const state = (window as any).__PW_CHAT_SOCKET__ const run = state?.emitted?.find((item: any) => item.event === 'run') return run?.payload?.session_id ?? null }).then((handle) => handle.jsonValue() as Promise) await page.evaluate((sid) => { const socket = (window as any).__PW_CHAT_SOCKET__.latest socket.__trigger('run.started', { event: 'run.started', session_id: sid, run_id: 'run-1' }) socket.__trigger('message.delta', { event: 'message.delta', session_id: sid, run_id: 'run-1', delta: 'thinking' }) }, sessionId) const stopButton = page.getByRole('button', { name: 'Stop' }) await expect(stopButton).toBeVisible() // The amber treatment lives entirely on this modifier class, so the class // is the assertion. Its hue is pinned at the source level instead, because // the palette declares OKLCH and RGB-triple forms of the same colour that // differ enough to make a rendered comparison unreliable. await expect(page.locator('.send-button')).toHaveClass(/send-button--stop/) const style = await computedOf(page, '.send-button', ['box-shadow']) expect(style?.['box-shadow']).not.toBe('none') await expectNotGrey(page, '.send-button', 'border-color', 'the stop control') }) test('the header press-light is a radial wash rather than an even strip', async ({ page }) => { const background = await computedOf(page, '.chat-header', ['background-image']) expect(background?.['background-image']).toContain('radial-gradient') }) test('the Outline panel becomes an overlay at 1180px', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }) await page.locator('.header-actions button').nth(1).click() await expect(page.locator('.outline-panel')).toBeVisible() expect((await computedOf(page, '.outline-panel', ['position']))?.position).not.toBe('absolute') await page.setViewportSize({ width: 1100, height: 900 }) await expect.poll(async () => (await computedOf(page, '.outline-panel', ['position']))?.position).toBe('absolute') }) test('the workspace badge yields to the title below 900px', async ({ page }) => { const badge = page.locator('.workspace-badge') await expect(badge).toBeVisible() await page.setViewportSize({ width: 900, height: 860 }) await expect(badge).toBeHidden() }) })