290 lines
13 KiB
TypeScript
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()
|
|
})
|
|
})
|