Files
hermes-desktop/tests/e2e/chat-cyber-semantics.spec.ts
toor 01d9e301fc
Build / build (push) Canceled after 0s
Playwright / browser (push) Canceled after 0s
initialize Electron desktop app and remove unused files
2026-10-25 01:09:01 +08:00

290 lines
13 KiB
TypeScript

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<string, string> = {}
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<string>)
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()
})
})