Files
hermes-ui/tests/e2e/chat-cyber-semantics.spec.ts
toor 5e1003ceb7
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
replace the monochrome theme with the CYBER neon-cyan skin
The project shipped a black-and-white theme that was only half converted:
the login page looked right, the message page was halfway, and the
settings page had not been started. This finishes the move to the CYBER
reference in prototype_reference/.

Colors now come from a single token source (styles/variables.scss), where
every semantic color is declared twice: once as OKLCH for the CSS, and
once as an RGB triple so translucent variants can be built as
rgba(var(--*-rgb), alpha). Cyan is the system itself, amber means the user
has to intervene, green means the machine is working, and red only ever
means something broke. The RGB triples are derived from the OKLCH values
so the two declarations cannot drift apart.

Pages adopt the .cy-* shell classes from the reference (view, panel,
kpis, tabs, grid, table, field) instead of ad-hoc markup, which is what
makes 19 of 26 views consistent. The remaining seven are deliberately
bespoke: chat streams, the kanban board, the workflow canvas, the
terminal, and the missing-agent screen.

The chart palette and the kanban column colors move into single-source
modules so they cannot drift either. A new guard test rejects raw hex
colors, named colors, and literal RGB triples anywhere in the client
source, with an explicit exemption list for the places that genuinely
cannot read a CSS variable. The existing chat semantics spec keeps the
chat chain honest.

No behavior changes: no logic, no i18n keys, no data structures, and the
/chat-run event contract is untouched.
2026-10-20 11:17:46 +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()
})
})