Skip to content

Commit b6f3283

Browse files
authored
test: screenshot harness for the interactive UI (xterm-headless + PNG render) (#122)
1 parent f0fbeb7 commit b6f3283

6 files changed

Lines changed: 512 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ pnpm-lock.yaml
1010

1111
# local dev worktrees
1212
.worktrees/
13+
14+
# screenshot-test render artifacts (regenerated by every test run)
15+
test/__screenshots__/

bun.lock

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,11 @@
3434
"yaml": "^2.9.0"
3535
},
3636
"devDependencies": {
37+
"@resvg/resvg-js": "^2.6.2",
3738
"@types/node": "^22.5.0",
3839
"@types/react": "^19.2.0",
3940
"@types/ws": "^8.5.12",
41+
"@xterm/headless": "^6.0.0",
4042
"react-devtools-core": "^6.1.2",
4143
"tsup": "^8.3.0",
4244
"tsx": "^4.19.0",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`interactive UI screenshots > a bare \`agent\` opens on the new-session composer 1`] = `
4+
"
5+
6+
ellipsis.dev @hunter in acme
7+
8+
9+
10+
11+
12+
What are we shipping today?
13+
14+
A fixed fact for stable screenshots.
15+
16+
17+
18+
19+
Repository: acme/cli
20+
Agent: Default
21+
Model: Default
22+
23+
▶ Start a cloud agent…"
24+
`;
25+
26+
exports[`interactive UI screenshots > enter on a picked session opens its chat 1`] = `
27+
" ✦ Cloud agent session on ellipsis.dev
28+
⎿ Sandbox started · 1 log line
29+
30+
● I found the login bug: the token refresh races the redirect.
31+
32+
● Fixed in auth.ts; opening a PR now.
33+
34+
35+
36+
37+
waiting · $0.42 total · session_a · v1.3.0"
38+
`;
39+
40+
exports[`interactive UI screenshots > esc opens the session picker; ↓ moves the highlight to the first session 1`] = `
41+
"
42+
43+
ellipsis.dev filtering to acme/cli · last 7 days
44+
45+
46+
▶ New session
47+
● fix the login bug 12.4k · $0.42 · 5m ago
48+
● write release notes 12.4k · $0.42 · 1h ago
49+
● refactor the theme module 12.4k · $0.42 · 5h ago
50+
51+
52+
53+
54+
55+
56+
57+
58+
59+
60+
61+
62+
↑↓ move · enter open · n new · esc back to the chat · q quit"
63+
`;
64+
65+
exports[`interactive UI screenshots > esc opens the session picker; ↓ moves the highlight to the first session 2`] = `
66+
"
67+
68+
ellipsis.dev filtering to acme/cli · last 7 days
69+
70+
71+
+ New session
72+
▶ fix the login bug 12.4k · $0.42 · 5m ago
73+
● write release notes 12.4k · $0.42 · 1h ago
74+
● refactor the theme module 12.4k · $0.42 · 5h ago
75+
76+
77+
78+
79+
80+
81+
82+
83+
84+
85+
86+
87+
↑↓ move · enter open · n new · esc back to the chat · q quit"
88+
`;

test/screenshot.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import React from 'react'
3+
import { SESSION_BAR_DEFAULTS } from '../src/lib/config'
4+
import { SessionsApp } from '../src/ui/SessionsApp'
5+
import type { AgentSession } from '../src/lib/types'
6+
import { launchPage } from './screenshot'
7+
8+
// The composer shows a random fact; pin it so the text snapshots are stable.
9+
vi.mock('../src/lib/facts', () => ({
10+
randomFact: () => 'A fixed fact for stable screenshots.',
11+
}))
12+
13+
// The first two screenshot tests of the interactive UI, driven through the
14+
// harness in screenshot.ts: assertions run on the emulated screen's text;
15+
// each test also saves a PNG of the same grid to test/__screenshots__/ for
16+
// human (and agent) review. All network edges are stubbed.
17+
18+
const h = React.createElement
19+
20+
function stubSession(id: string, prompt: string, minutesAgo: number): AgentSession {
21+
const at = new Date(Date.now() - minutesAgo * 60_000).toISOString()
22+
return {
23+
id,
24+
status: 'waiting',
25+
prompt,
26+
source: 'cli',
27+
created_at: at,
28+
updated_at: at,
29+
last_activity_at: at,
30+
prompting: { enabled: true },
31+
tokens: { total: 12_400 },
32+
cost: { total: 42_000 },
33+
} as unknown as AgentSession
34+
}
35+
36+
const SESSIONS = [
37+
stubSession('session_a', 'fix the login bug', 5),
38+
stubSession('session_b', 'write release notes', 60),
39+
stubSession('session_c', 'refactor the theme module', 300),
40+
]
41+
42+
// A claude_code assistant-message record — what the chat renders as a ● prose
43+
// row (same shape as connect-render.test.ts).
44+
let seq = 0
45+
function say(text: string): Record<string, unknown> {
46+
return {
47+
feed_seq: ++seq,
48+
source: 'claude_code',
49+
record_type: 'event',
50+
payload: {
51+
type: 'assistant',
52+
message: { role: 'assistant', content: [{ type: 'text', text }] },
53+
},
54+
}
55+
}
56+
57+
const RECORDS: Record<string, Record<string, unknown>[]> = {
58+
session_a: [
59+
{ feed_seq: ++seq, source: 'lifecycle', record_type: 'sandbox_ready', payload: {} },
60+
say('I found the login bug: the token refresh races the redirect.'),
61+
say('Fixed in auth.ts; opening a PR now.'),
62+
],
63+
}
64+
65+
// SessionsApp's whole API surface for these screens: the sidebar poll, the
66+
// composer's three picker fetches, and the chat's session + records load.
67+
// Empty picker lists are a real state (the composer falls back to its
68+
// built-ins).
69+
const api = {
70+
sessions: {
71+
list: async () => ({ items: SESSIONS }),
72+
get: async (id: string) => ({ session: SESSIONS.find((s) => s.id === id) }),
73+
records: async (id: string) => ({
74+
response: { records: RECORDS[id] ?? [], earliest_feed_seq: null, messages: [] },
75+
}),
76+
},
77+
agents: { configs: { list: async () => ({ configs: [] }) } },
78+
integrations: { github: { repos: async () => ({ repositories: [] }) } },
79+
models: { list: async () => ({ models: [] }) },
80+
}
81+
82+
function app() {
83+
return h(SessionsApp, {
84+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
85+
api: api as any,
86+
openSocket: () => new Promise(() => {}),
87+
appBase: 'https://app.ellipsis.dev',
88+
customerLogin: 'acme',
89+
ghLogin: 'hunter',
90+
authorId: 1,
91+
detectedRepo: 'acme/cli',
92+
sessionBar: { ...SESSION_BAR_DEFAULTS },
93+
buildStartRequest: (prompt: string) => (prompt ? { prompt } : { idle_start: true }),
94+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
95+
} as any)
96+
}
97+
98+
describe('interactive UI screenshots', () => {
99+
it('a bare `agent` opens on the new-session composer', async () => {
100+
const page = await launchPage(app())
101+
const screen = page.text()
102+
expect(screen).toContain('ellipsis.dev')
103+
expect(screen).toContain('@hunter in acme')
104+
// The composer panel: its three option rows and the detected repo as the
105+
// Repository row's resting value.
106+
expect(screen).toContain('Repository')
107+
expect(screen).toContain('acme/cli')
108+
expect(screen).toContain('Model')
109+
expect(screen).toMatchSnapshot()
110+
await page.png('new-session-composer')
111+
page.unmount()
112+
})
113+
114+
it('esc opens the session picker; ↓ moves the highlight to the first session', async () => {
115+
const page = await launchPage(app())
116+
await page.press('escape')
117+
const picker = page.text()
118+
// The highlight opens on the pinned new-session row: its "+ " gutter is
119+
// replaced by the ▶ selection glyph while the cursor is on it.
120+
expect(picker).toContain('▶ New session')
121+
for (const s of SESSIONS) expect(picker).toContain(s.prompt as string)
122+
expect(picker).toMatchSnapshot()
123+
await page.png('sessions-picker')
124+
125+
await page.press('down')
126+
const moved = page.text()
127+
// The ▶ selection glyph left the "+ New session" row and sits on the
128+
// newest session (sort is status band, then newest first).
129+
const cursorLine = moved.split('\n').find((l) => l.includes('▶'))
130+
expect(cursorLine).toBeDefined()
131+
expect(cursorLine).toContain('fix the login bug')
132+
expect(moved).toMatchSnapshot()
133+
await page.png('sessions-picker-down')
134+
page.unmount()
135+
})
136+
137+
it('enter on a picked session opens its chat', async () => {
138+
const page = await launchPage(app())
139+
await page.press('escape')
140+
await page.press('down')
141+
await page.press('enter')
142+
const chat = page.text()
143+
// The picker's alt screen closed and the chat printed session_a's
144+
// transcript into the primary buffer, with the composer underneath.
145+
expect(chat).toContain('the token refresh races the redirect')
146+
expect(chat).toContain('opening a PR now')
147+
expect(chat).toContain('session_a')
148+
expect(chat).toMatchSnapshot()
149+
await page.png('session-chat-open')
150+
page.unmount()
151+
})
152+
})

0 commit comments

Comments
 (0)