seeded-history.e2e.ts 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827
  1. // Web e2e scenario: seeded history. A recorded session seeded cold through
  2. // the REAL persistence API renders purely from the log — the surface nothing
  3. // else covers: sidebar cold listing, cold history paging without Agent
  4. // activation, history-page tool views, and the client's log-ordered transcript
  5. // events — with ZERO model calls in replay (no replay fixture; a stray stream
  6. // fails loud on the open llm seam). The cold session also carries keyless
  7. // command-row surfaces: the seeded manual `/compact` lifecycle folds into its
  8. // checkpoint, an Access-chip pick later runs `/permission` on the host, and
  9. // `/feedback` pins its expandable correlation ids. The seed is a recorded
  10. // fixture under the same record discipline as every other: DSH_SNAPSHOT=record drives the turn
  11. // live through the composer (real read tool against seeded workspace files)
  12. // and harvests session.v3.jsonl; replay/refresh seed it cold and only render.
  13. import { readFile, writeFile, mkdir } from 'node:fs/promises'
  14. import { fileURLToPath } from 'node:url'
  15. import type { Browser, Page } from 'playwright'
  16. import { chromium } from 'playwright'
  17. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  18. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  19. import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
  20. import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
  21. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  22. import type { TokenMeter } from '@deepseek-ai/dsh-token-meter'
  23. import type {} from '@deepseek-ai/dsh-api-terminal-controller'
  24. import { join } from 'node:path'
  25. import {
  26. acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
  27. compareOrRefreshGolden, fixtureUserPrompts,
  28. launchWebScaffold, parseSeedFixture, realizeSeedFixture, recordFixture, renderSeedFixture, seedSession, watchConsole,
  29. webSnapshotMode, type WebScaffold,
  30. } from './scaffold.ts'
  31. import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
  32. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/seeded-history', import.meta.url))
  33. const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
  34. const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/ui.expected.md', import.meta.url))
  35. const UI_EXPANDED_EXPECTED = fileURLToPath(
  36. new URL('../../../snapshots/web/seeded-history/ui-expanded.expected.md', import.meta.url),
  37. )
  38. const THINKING_EXPECTED = join(SNAPSHOT_DIR, 'thinking-expanded.expected.md')
  39. // Command-row goldens over the same conversation after direct host commands.
  40. const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url))
  41. const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url))
  42. const FILE_PREVIEW_EXPECTED = join(SNAPSHOT_DIR, 'file-preview.expected.md')
  43. // The pinned-header geometry golden: a pure-CSS, user-visible behavior that
  44. // changes no DOM and no accessible name, so the aria goldens cannot capture it
  45. // (docs/testing.md, "when a snapshot test is required", still requires a
  46. // keyless snapshot). Following composer-draft-scroll's geometry golden, it
  47. // records platform-independent semantic booleans about the pinned compaction
  48. // header, no absolute pixels.
  49. const STICKY_GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'sticky-geometry.expected.md')
  50. const MODE = webSnapshotMode()
  51. const SEED_ID = 'seeded-history-web-e2e'
  52. const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
  53. /**
  54. * Append a complete manual `/compact` lifecycle and valid compaction transaction
  55. * over the recorded turn's own surface. The recording stays model-authentic and
  56. * reusable; replay adds this deterministic condition before seeding it cold, so
  57. * the scenario pins both the log-preserving marker and its single-card command
  58. * presentation through the real host and browser.
  59. * @param raw - the seed fixture text, already realized (placeholder-free) so
  60. * the shadow price below is computed from the exact strings the host folds.
  61. * @param meter - the composed token meter; the appended `compaction/summary`'s
  62. * shadow price must be the exact heuristic price of the shadowed nodes, the
  63. * way compaction-basic derives it, because the token-meter projections subtract
  64. * it verbatim.
  65. * @returns the fixture with a manual compaction lifecycle appended.
  66. */
  67. function withCompaction(raw: string, meter: TokenMeter): string {
  68. const decoded = parseSeedFixture(raw)
  69. const events = decoded.events as unknown as Array<{
  70. type: string
  71. seq: number
  72. time: number
  73. surfaceOp?: unknown
  74. data?: { turn?: unknown; message?: unknown; content?: unknown; callId?: unknown; isError?: unknown }
  75. }>
  76. const surfaceSeqs = events
  77. .filter(event => event.surfaceOp === 'append'
  78. && (event.type === 'user/message'
  79. || event.type === 'assistant/message'
  80. || event.type === 'tool/result'))
  81. .map(event => event.seq)
  82. const first = surfaceSeqs[0]
  83. const last = surfaceSeqs.at(-1)
  84. const tail = events.at(-1)
  85. if (first === undefined || last === undefined || tail === undefined) {
  86. throw new Error('seeded-history compaction requires a non-empty closed surface')
  87. }
  88. const lastTurn = events.filter(event => event.type === 'turn/end').at(-1)?.data?.turn
  89. if (typeof lastTurn !== 'number') {
  90. throw new Error('seeded-history compaction requires a recording ending on a closed turn')
  91. }
  92. let seq = tail.seq + 1
  93. let time = tail.time + 1
  94. /**
  95. * Append one event at the next seq/time.
  96. * @param event - the event body, without seq/time.
  97. * @returns the assigned seq, so later `sourceEventSeqs` cite the pushed event directly.
  98. */
  99. const at = (event: { type: string } & Record<string, unknown>): number => {
  100. const taken = seq++
  101. events.push({ ...event, seq: taken, time: time++ })
  102. return taken
  103. }
  104. const commandId = 'cmd-seeded-manual-compact'
  105. const compactionId = 'compact-seeded-manual-compact'
  106. at({
  107. type: 'command/run',
  108. data: { commandId, name: 'compact', args: '', source: { kind: 'user' } },
  109. })
  110. const startSeq = at({
  111. type: 'compaction/start',
  112. data: { compactionId, sourceCommandId: commandId, turn: null },
  113. })
  114. // Load-bearing exactness: the projections subtract this count verbatim, so
  115. // it must equal what the host's fold prices for these nodes. The estimator
  116. // prices message CONTENT only, so a minimal wrapper for each stored event format is
  117. // exact — pre-identity rows carry bare `content` (the persistence read path
  118. // upgrades them), a current row carries the full `message` envelope.
  119. const priceRow = (row: (typeof events)[number]): number => {
  120. if (row.data?.message !== undefined) {
  121. const message = deriveEventMessage(row as unknown as SessionEvent)
  122. return message === null ? 0 : meter.estimateMessage(message)
  123. }
  124. const content = row.data?.content as ContentBlock[]
  125. if (row.type === 'tool/result') {
  126. return meter.estimateMessage({
  127. content: [{ type: 'tool-result', toolCallId: row.data?.callId, content, isError: row.data?.isError === true }],
  128. } as unknown as Message)
  129. }
  130. // An empty-content assistant message derives no transcript entry.
  131. if (row.type === 'assistant/message' && content.length === 0) return 0
  132. return meter.estimateMessage({ content } as unknown as Message)
  133. }
  134. const shadowedTokenCount = surfaceSeqs.reduce((total, surfaceSeq) => {
  135. const event = events.find(candidate => candidate.seq === surfaceSeq)
  136. if (event === undefined) throw new Error(`seeded-history compaction: shadowed seq ${surfaceSeq} is not in the seed`)
  137. return total + priceRow(event)
  138. }, 0)
  139. const summarySeq = at({
  140. type: 'compaction/summary',
  141. data: {
  142. compactionId,
  143. sourceCommandId: commandId,
  144. summary: [{
  145. type: 'text',
  146. text: '## Cold resume compact summary\n\n- The exact summary remains available.\n\n'
  147. // A fenced code block gives the summary body a sticky-bannered
  148. // descendant (CodeBlock pins its banner at z-index 6). The block is
  149. // long enough that its banner has room to hold below the pinned
  150. // header, which is where its Copy control must stay clickable; the
  151. // list makes the body overflow the shrunk viewport.
  152. + '```ts\nfunction resume(): boolean {\n'
  153. + Array.from({ length: 26 }, (_, index) => ` const step${index + 1} = read(${index + 1})`).join('\n')
  154. + '\n return true\n}\n```\n\n'
  155. + Array.from({ length: 40 }, (_, index) => `- Retained fact ${index + 1}: the reader still sees the pre-compaction surface.`).join('\n'),
  156. }],
  157. shadowedRange: { start: first, end: last },
  158. shadowedSeqs: surfaceSeqs,
  159. shadowedTokenCount,
  160. provider: 'snapshot',
  161. model: 'snapshot-compactor',
  162. },
  163. })
  164. at({
  165. type: 'user/message',
  166. data: createUserMessage({
  167. content: [{
  168. type: 'text',
  169. text: '<context_checkpoint>Model-only compact checkpoint.</context_checkpoint>',
  170. }],
  171. source: {
  172. kind: 'plugin', plugin: 'compact', compactionId, sourceCommandId: commandId,
  173. },
  174. }),
  175. surfaceOp: { op: 'replace', startSeq: first, endSeq: last },
  176. sourceEventSeqs: [startSeq, summarySeq, ...surfaceSeqs],
  177. })
  178. at({
  179. type: 'compaction/end',
  180. data: { compactionId, sourceCommandId: commandId, turn: null },
  181. })
  182. at({
  183. type: 'command/done',
  184. data: {
  185. commandId,
  186. kind: 'success',
  187. text: `Compacted ${surfaceSeqs.length} history items (~${shadowedTokenCount} tokens).`,
  188. sourceEventSeq: summarySeq,
  189. },
  190. })
  191. // The persistence seed helper requires a terminal turn/end. Keep the manual
  192. // command standalone, then add a closed zero-step turn after it.
  193. const closureTurn = lastTurn + 1
  194. at({ type: 'turn/start', data: { turn: closureTurn } })
  195. at({ type: 'turn/end', data: { turn: closureTurn, reason: { kind: 'completed' } } })
  196. return renderSeedFixture(decoded.headerLine, events)
  197. }
  198. describe('web e2e: seeded history renders through cold resume', () => {
  199. let scaffold: WebScaffold
  200. let browser: Browser
  201. let page: Page
  202. let tripwire: ReturnType<typeof watchConsole>
  203. let seededThroughSeq = -1
  204. beforeAll(async () => {
  205. scaffold = await launchWebScaffold(process.platform === 'win32' ? {} : {
  206. extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)),
  207. })
  208. // Composer recording uses a child workspace; seedSession owns the scaffold root.
  209. const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd
  210. await mkdir(sessionCwd, { recursive: true })
  211. await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
  212. await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
  213. if (MODE !== 'record') {
  214. const raw = await readFile(SEED, 'utf8')
  215. expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT])
  216. // The meter is host-plane — it takes no configuration and keys every
  217. // fold by Session — so pricing fixture content needs no agent at all.
  218. const meter = scaffold.ctx.get('tokenMeter')
  219. if (meter === undefined) throw new Error('seeded-history requires the host token meter')
  220. const realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter)
  221. seededThroughSeq = parseSeedFixture(realizedWithCompaction).events.at(-1)?.seq ?? -1
  222. await seedSession(scaffold, realizedWithCompaction, SEED_ID)
  223. }
  224. browser = await chromium.launch()
  225. page = await newEnglishPage(browser)
  226. tripwire = watchConsole(page)
  227. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  228. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  229. }, 120_000)
  230. afterAll(async () => {
  231. await browser?.close()
  232. await scaffold?.close()
  233. })
  234. it.skipIf(MODE !== 'record')('records the seed turn live through the composer', async () => {
  235. onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-record'))
  236. const input = page.locator('[data-composer-input]').first()
  237. await input.waitFor({ timeout: 10_000 })
  238. const settled = scaffold.whenTurnSettled()
  239. await input.fill(PROMPT)
  240. await input.press('Enter')
  241. const sessionId = await settled
  242. await recordFixture(scaffold, sessionId, SEED)
  243. }, 200_000)
  244. it.skipIf(MODE === 'record')('serves the projections baseline on the real composition opening snapshot', async () => {
  245. // Composition regression tripwire: the projection registry must be a row
  246. // in the SHIPPED cordis.yml — with it absent every domain unit's optional
  247. // injection stays silent and this block disappears (no titles/todos on
  248. // the web), while fixture-level suites stay green. Assert through the
  249. // production Session Controller against the booted real host.
  250. const controller = new AbortController()
  251. const stream = scaffold.ctx.sessionController.follow({
  252. address: { kind: 'session', sessionId: SessionId(SEED_ID) },
  253. }, controller.signal)[Symbol.asyncIterator]()
  254. const first = await stream.next()
  255. controller.abort()
  256. if (first.done || first.value.type !== 'snapshot') {
  257. throw new Error('session follow did not publish its opening snapshot')
  258. }
  259. expect(first.value.cursor).toBe(seededThroughSeq)
  260. const projections = first.value.projections
  261. expect(projections.asOfSeq).toBe(seededThroughSeq)
  262. // The seed carries a session/title event: the title unit is host-plane, so
  263. // it folds the detached log and serves the value with nothing composed.
  264. expect(typeof projections.values.title).toBe('string')
  265. // `todos` is absent because its unit belongs to the agent preset and this
  266. // directly seeded session never composed that preset. History computes
  267. // the baseline through the standard projection registry without mounting
  268. // an Agent composition as a read side effect.
  269. expect(projections.values).not.toHaveProperty('todos')
  270. // The session-stats unit is a shipped web-app bundle row: whole-log
  271. // turn/step counts ride the same tail block (the stats strip's source).
  272. const sessionStats = projections.values.sessionStats as { turns: number; steps: number } | undefined
  273. expect(sessionStats).toBeDefined()
  274. expect(sessionStats?.turns).toBeGreaterThanOrEqual(1)
  275. expect(sessionStats?.steps).toBeGreaterThanOrEqual(sessionStats?.turns ?? 0)
  276. })
  277. it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => {
  278. onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-history'))
  279. // The sidebar tree collapses workspace groups by default: click the group
  280. // row (treeitem 0) to expand, then the revealed session row.
  281. const groupRow = page.locator('[role="treeitem"]').first()
  282. await groupRow.waitFor({ timeout: 15_000 })
  283. await groupRow.click()
  284. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  285. await sessionRow.waitFor({ timeout: 10_000 })
  286. await sessionRow.click()
  287. // Settled barrier for history: the recorded final assistant text renders.
  288. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
  289. await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  290. await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), {
  291. timeout: 10_000,
  292. }).toBe(1)
  293. const process = page.locator('[data-turn-process="1"]')
  294. await process.waitFor({ state: 'visible', timeout: 10_000 })
  295. expect(await process.getAttribute('aria-expanded')).toBe('false')
  296. const processBottom = await process.evaluate(element => element.getBoundingClientRect().bottom)
  297. const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element =>
  298. element.getBoundingClientRect().top)
  299. // Collapsed control row keeps its own 8px margin plus the 8px flow gap.
  300. expect(answerTop).toBe(processBottom + 16)
  301. expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0)
  302. // Tool cards render from logged tool/call + tool/result alone (views are
  303. // host-recomputed per page; the generic card is the documented default).
  304. const toolRows = page.locator('[data-variant], [data-sample]')
  305. await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
  306. expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0)
  307. // The pinned hazard: compaction shadows the surface on the model side
  308. // only — the prompt and full tool output must stay on screen.
  309. expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(1)
  310. await expect.poll(
  311. () => scaffold.ctx.agents.get(SessionId(SEED_ID)) !== undefined,
  312. { timeout: 10_000 },
  313. ).toBe(true)
  314. const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
  315. if (agent === undefined) throw new Error('seeded session did not attach an agent')
  316. agent.session.append('user/message', createUserMessage({
  317. content: [{
  318. type: 'text',
  319. text: '<system-reminder>\n'
  320. + 'The following workspace instructions may be relevant to your work. '
  321. + 'Use them as guidance when applicable.\n\n'
  322. + Array.from({ length: 24 }, (_, index) => `Instruction ${index + 1}: preserve the logged context contract.`).join('\n')
  323. + '\n</system-reminder>',
  324. }],
  325. source: {
  326. kind: 'agent-instructions',
  327. form: 'instructions',
  328. baseline: true,
  329. changes: [{
  330. action: 'set',
  331. scope: '.\u0000AGENTS.md',
  332. path: 'AGENTS.md',
  333. digest: 'context-injection-browser-snapshot',
  334. }],
  335. },
  336. }), { surfaceOp: 'append' })
  337. // The header names the producer the durable source records, so the
  338. // reconciled instruction file is readable without expanding the row.
  339. await page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
  340. .waitFor({ timeout: 10_000 })
  341. }, 60_000)
  342. it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
  343. onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria'))
  344. // This scenario issues zero model calls — the scaffold's route-only
  345. // adapter serves the catalog and refuses to stream — so history restores
  346. // the routed id and the seat resolves it against an advertised row.
  347. await page.getByRole('button', { name: /^Select model, current/ })
  348. .waitFor({ timeout: 10_000 })
  349. const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
  350. .split(SEED_ID).join('{{seededId}}')
  351. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  352. const expanded = (await captureExpandedTurnProcessAria(
  353. page,
  354. '[class*="centerCol"]',
  355. scaffold.workspaceCwd,
  356. )).split(SEED_ID).join('{{seededId}}')
  357. await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
  358. })
  359. it.skipIf(MODE === 'record')('renders recorded reasoning as secondary Markdown when expanded', async () => {
  360. onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-thinking'))
  361. const turnProcess = page.locator('[data-turn-process]').first()
  362. const wasExpanded = await turnProcess.getAttribute('aria-expanded') === 'true'
  363. if (!wasExpanded) await turnProcess.click()
  364. const thinking = page.locator('[data-variant="think"]').first()
  365. const toggle = thinking.getByRole('button').first()
  366. const secondarySize = await thinking.locator('[class*="summaryText"]').evaluate(element => getComputedStyle(element).fontSize)
  367. await toggle.click()
  368. try {
  369. await thinking.locator('p').waitFor({ timeout: 10_000 })
  370. const snapshot = await captureStableAria(page, '[data-variant="think"][data-expanded]', scaffold.workspaceCwd)
  371. const typography = await thinking.locator('p').evaluate((paragraph, expectedSize) => {
  372. const style = getComputedStyle(paragraph)
  373. return {
  374. secondarySize: style.fontSize === expectedSize,
  375. wraps: style.whiteSpace === 'normal',
  376. contained: paragraph.scrollWidth <= paragraph.clientWidth + 1,
  377. }
  378. }, secondarySize)
  379. expect(typography).toEqual({ secondarySize: true, wraps: true, contained: true })
  380. await compareOrRefreshGolden(THINKING_EXPECTED, [
  381. snapshot,
  382. '',
  383. `- Secondary font size: ${String(typography.secondarySize)}`,
  384. `- Markdown paragraph wrapping: ${String(typography.wraps)}`,
  385. `- Content stays within the reasoning column: ${String(typography.contained)}`,
  386. ].join('\n'), MODE)
  387. } finally {
  388. await toggle.click()
  389. if (!wasExpanded) await turnProcess.click()
  390. }
  391. })
  392. it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
  393. onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection'))
  394. const disclosure = page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
  395. expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
  396. const collapsedIcon = disclosure.locator('svg').first()
  397. const collapsedIconBox = await collapsedIcon.boundingBox()
  398. expect(collapsedIconBox?.width).toBe(14)
  399. expect(collapsedIconBox?.height).toBe(14)
  400. await disclosure.click()
  401. await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
  402. const body = page.locator('[data-context-injection-body]')
  403. await body.waitFor({ timeout: 5_000 })
  404. // The instructions form names the file it reconciled above the text, and
  405. // the text keeps the framing the model read rather than a cleaned excerpt.
  406. expect(await body.locator('[data-context-files] li').allInnerTexts()).toEqual(['AGENTS.md\nloaded'])
  407. expect(await body.locator('[data-context-text]').innerText()).toContain('<system-reminder>')
  408. const headerBox = await disclosure.boundingBox()
  409. const bodyBox = await body.boundingBox()
  410. if (headerBox === null || bodyBox === null) throw new Error('context disclosure geometry is not measurable')
  411. expect(headerBox.height).toBe(24)
  412. expect(bodyBox.x - headerBox.x).toBe(22)
  413. expect(bodyBox.y - headerBox.y - headerBox.height).toBe(4)
  414. expect(bodyBox.height).toBe(141)
  415. const style = await body.evaluate((element) => {
  416. const computed = getComputedStyle(element)
  417. return {
  418. backgroundColor: computed.backgroundColor,
  419. borderRadius: computed.borderRadius,
  420. color: computed.color,
  421. fontSize: computed.fontSize,
  422. lineHeight: computed.lineHeight,
  423. padding: [
  424. computed.paddingTop,
  425. computed.paddingRight,
  426. computed.paddingBottom,
  427. computed.paddingLeft,
  428. ],
  429. scrolls: element.scrollHeight > element.clientHeight,
  430. }
  431. })
  432. expect(style).toEqual({
  433. backgroundColor: 'rgb(249, 250, 251)',
  434. borderRadius: '8px',
  435. color: 'rgb(129, 133, 140)',
  436. fontSize: '11px',
  437. lineHeight: '16px',
  438. padding: ['10px', '16px', '12px', '12px'],
  439. scrolls: true,
  440. })
  441. await disclosure.click()
  442. await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
  443. })
  444. it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log open the right Sidebar', async () => {
  445. onTestFailed(async () => {
  446. await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/0907-2205-sidebar', import.meta.url)), { recursive: true })
  447. await saveFailureShot(page, `screenshots/0907-2205-sidebar/seeded-toolrow-${process.pid}`)
  448. })
  449. // Interaction over cold-resumed history: read summaries are file links
  450. // that open a text-preview tab in the right Sidebar (not expand-in-place).
  451. // Runs after the golden capture; still zero model calls.
  452. const fileLink = page.locator('[data-variant="read"] button').first()
  453. await expandOwningTurnProcess(page, fileLink)
  454. await fileLink.waitFor({ timeout: 10_000 })
  455. const frame = page.locator('[style*="grid-template-columns"]').first()
  456. expect(await frame.getAttribute('data-rightbar-collapsed')).toBe('true')
  457. const column = page.locator('[data-rightbar-col]')
  458. try {
  459. await fileLink.click()
  460. await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe(null)
  461. await expect.poll(() => column.locator('[data-dockkit-tab-title]').allTextContents(), { timeout: 5_000 }).toEqual(['a.txt'])
  462. // Path label survives from the recorded args (a.txt).
  463. await expect.poll(() => page.getByText('a.txt', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0)
  464. const path = column.locator('[data-textpreview-path]')
  465. const absolutePath = join(scaffold.workspaceCwd, 'a.txt')
  466. await expect.poll(() => path.textContent()).toBe(absolutePath)
  467. expect(await path.getAttribute('title')).toBe(absolutePath)
  468. await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
  469. const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
  470. await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE)
  471. } finally {
  472. // Later cases share this page and require the sidebar closed even after a failed assertion.
  473. if (await frame.getAttribute('data-rightbar-collapsed') !== 'true') {
  474. await column.locator('[data-sidebar-right-toggle]').click()
  475. await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
  476. }
  477. await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor({ state: 'visible' })
  478. await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' })
  479. }
  480. })
  481. it.skipIf(MODE === 'record')('expands the cold-resumed compact summary and pins its header while scrolling', async () => {
  482. onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-compaction'))
  483. const marker = page.getByRole('button', { name: /compact Compacted \d+ history items/ })
  484. await marker.waitFor({ timeout: 10_000 })
  485. expect(await marker.getAttribute('aria-expanded')).toBe('false')
  486. // Collapsed, the marker is not pinned: the sticky rule's `:has()` gate
  487. // needs the body sibling, which only exists while open. jsdom computes no
  488. // sticky layout, so this real-browser layer proves the CSS resolves.
  489. const collapsedPosition = await marker.evaluate(element => getComputedStyle(element).position)
  490. expect(collapsedPosition).not.toBe('sticky')
  491. const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 }
  492. // Captured so a failure in the cleanup below cannot replace the assertion
  493. // that actually failed.
  494. let bodyError: unknown
  495. try {
  496. await marker.click()
  497. await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
  498. await expect.poll(() => page.getByRole('heading', { name: 'Cold resume compact summary' }).count(), {
  499. timeout: 5_000,
  500. }).toBe(1)
  501. expect(await page.getByText('The exact summary remains available.', { exact: false }).count()).toBeGreaterThan(0)
  502. // Open, the toggle pins to the scroll container's top.
  503. const openStyle = await marker.evaluate((element) => {
  504. const style = getComputedStyle(element)
  505. return { position: style.position, top: style.top, zIndex: Number.parseInt(style.zIndex, 10) }
  506. })
  507. expect(openStyle.position).toBe('sticky')
  508. expect(openStyle.top).toBe('0px')
  509. // The summary body carries a fenced code block whose own banner pins at
  510. // z-index 6; the toggle must outrank it, or a code-block summary would
  511. // re-bury the toggle. Sample the banner inside THIS summary body, not a
  512. // code block elsewhere on the page.
  513. const bannerZ = await page.locator('[class*="compactionBody"] [class*="bannerWrap"]').first().evaluate(
  514. element => Number.parseInt(getComputedStyle(element).zIndex, 10),
  515. )
  516. expect(openStyle.zIndex).toBeGreaterThan(bannerZ)
  517. // Hovering the open toggle must keep an OPAQUE fill: the default hover
  518. // token is translucent and would let the scrolling prose bleed through
  519. // the moment the pointer lands to collapse it. The alpha token, if
  520. // present, is the fourth comma value (`rgba(r, g, b, a)`) or the value
  521. // after `/` in the space form; three channels mean opaque. A color that
  522. // parses to neither returns -1, which fails loud instead of passing as
  523. // opaque.
  524. await marker.hover()
  525. const hoverAlpha = await marker.evaluate((element) => {
  526. const bg = getComputedStyle(element).backgroundColor
  527. const inner = /^rgba?\((.+)\)$/.exec(bg.trim())?.[1]
  528. if (inner === undefined) return -1
  529. const slashAlpha = inner.split('/')[1]
  530. if (slashAlpha !== undefined) return Number.parseFloat(slashAlpha)
  531. const channels = inner.split(/[\s,]+/).filter(token => token.length > 0)
  532. const commaAlpha = channels[3]
  533. if (commaAlpha !== undefined) return Number.parseFloat(commaAlpha)
  534. if (channels.length === 3) return 1
  535. return -1
  536. })
  537. expect(hoverAlpha).toBe(1)
  538. // Scroll so the summary's code banner reaches its own stuck position.
  539. // The banner's sticky offset holds it below the pinned header's band, so
  540. // the point this case samples is the banner's Copy control: the header
  541. // must not cover it. Shrinking the viewport first forces overflow
  542. // regardless of summary length.
  543. await page.setViewportSize({ width: originalViewport.width, height: 360 })
  544. const geom = await marker.evaluate((button) => {
  545. const container = button.closest('[data-conversation-scroll]') as HTMLElement
  546. const banner = container.querySelector('[class*="compactionBody"] [class*="bannerWrap"]') as HTMLElement
  547. const copy = banner.querySelector('button') as HTMLElement
  548. // Both the toggle and the code banner are sticky, so a rect taken while
  549. // either is stuck reports the stuck position rather than its content
  550. // offset. Measure both unstuck, so the target below does not depend on
  551. // where the scrollport happened to be when this case started.
  552. const markerInline = button.style.position
  553. const bannerInline = banner.style.position
  554. const bannerTopInline = banner.style.top
  555. button.style.position = 'static'
  556. banner.style.position = 'static'
  557. banner.style.top = 'auto'
  558. const containerTop = container.getBoundingClientRect().top
  559. const markerStaticTop = button.getBoundingClientRect().top - containerTop + container.scrollTop
  560. const bannerStaticTop = banner.getBoundingClientRect().top - containerTop + container.scrollTop
  561. const headerHeight = button.getBoundingClientRect().height
  562. button.style.position = markerInline
  563. banner.style.position = bannerInline
  564. banner.style.top = bannerTopInline
  565. // The banner sticks once its static top passes the band the toggle
  566. // occupies. Land the static top 8px above the scrollport top: if the
  567. // banner still pinned at top 0, 8px of it would sit under the toggle,
  568. // so this position distinguishes the offset from the uncovered case.
  569. // The banner must hold at the band's bottom edge, and its Copy control
  570. // must stay the topmost element at its own center.
  571. container.scrollTop = Math.max(0, bannerStaticTop + 8)
  572. const markerRect = button.getBoundingClientRect()
  573. const bannerRect = banner.getBoundingClientRect()
  574. const copyRect = copy.getBoundingClientRect()
  575. const currentContainerTop = container.getBoundingClientRect().top
  576. const markerProbe = document.elementFromPoint(
  577. markerRect.left + markerRect.width / 2,
  578. markerRect.top + markerRect.height / 2,
  579. )
  580. const copyProbe = document.elementFromPoint(
  581. copyRect.left + copyRect.width / 2,
  582. copyRect.top + copyRect.height / 2,
  583. )
  584. return {
  585. scrollTop: container.scrollTop,
  586. // The header's own content offset now lies above the scrollport top,
  587. // so its rect top can equal the scrollport top only through stickiness
  588. // — this is the precondition that makes the pinning assertion mean
  589. // something.
  590. staticAboveViewport: container.scrollTop > markerStaticTop,
  591. markerTop: markerRect.top,
  592. containerTop: currentContainerTop,
  593. bannerTop: bannerRect.top,
  594. bannerStuck: Math.abs(bannerRect.top - (currentContainerTop + headerHeight)) <= 1,
  595. bannerBelowHeader: bannerRect.top >= markerRect.bottom - 1,
  596. markerOwnsCenter: button.contains(markerProbe),
  597. copyOwnsCenter: copy.contains(copyProbe),
  598. }
  599. })
  600. expect(geom.scrollTop).toBeGreaterThan(0)
  601. expect(geom.staticAboveViewport).toBe(true)
  602. expect(Math.abs(geom.markerTop - geom.containerTop)).toBeLessThanOrEqual(1)
  603. expect(geom.bannerStuck).toBe(true)
  604. expect(geom.bannerBelowHeader).toBe(true)
  605. expect(geom.markerOwnsCenter).toBe(true)
  606. expect(geom.copyOwnsCenter).toBe(true)
  607. // Keyless geometry golden for this user-visible, DOM-invariant CSS
  608. // behavior: platform-independent semantic facts, no absolute pixels.
  609. // Every line is a value asserted just above, so a regression reddens the
  610. // expect first; compareOrRefreshGolden writes the file in refresh mode
  611. // and byte-compares it in replay.
  612. const stickyGolden = [
  613. '# Compaction marker sticky header (pinned over a code-block summary)',
  614. '',
  615. '## Collapsed',
  616. '',
  617. `- header is not sticky: ${String(collapsedPosition !== 'sticky')}`,
  618. '',
  619. '## Open, pinned at the scroll container top',
  620. '',
  621. `- header position is sticky: ${String(openStyle.position === 'sticky')}`,
  622. `- header pins to the top edge: ${String(openStyle.top === '0px')}`,
  623. `- header outranks the summary code-block banner: ${String(openStyle.zIndex > bannerZ)}`,
  624. `- hover fill stays fully opaque: ${String(hoverAlpha === 1)}`,
  625. '',
  626. '## Scrolled so the summary code banner reaches its sticky offset',
  627. '',
  628. `- container is scrolled off its top: ${String(geom.scrollTop > 0)}`,
  629. `- header's static position sits above the scrollport: ${String(geom.staticAboveViewport)}`,
  630. `- header holds at the scrollport top: ${String(Math.abs(geom.markerTop - geom.containerTop) <= 1)}`,
  631. `- header owns the center point (toggle stays clickable): ${String(geom.markerOwnsCenter)}`,
  632. `- summary code banner holds below the header band: ${String(geom.bannerStuck)}`,
  633. `- summary code banner stays clear of the header: ${String(geom.bannerBelowHeader)}`,
  634. `- banner Copy control owns its own center: ${String(geom.copyOwnsCenter)}`,
  635. ].join('\n').trimEnd()
  636. await compareOrRefreshGolden(STICKY_GEOMETRY_EXPECTED, stickyGolden, MODE)
  637. } catch (error) {
  638. bodyError = error
  639. }
  640. // Restore the shared page state whether or not the body failed. Order
  641. // matters: collapse the marker, restore the viewport, then re-enter
  642. // follow-bottom. The control is what clears the off-floor ownership a
  643. // programmatic `scrollTop` assignment leaves in ChatView's reader-movement
  644. // ledger, so click it when it is there. It appears only after that ledger
  645. // settles (`scrollend` or the sampling interval), and it never renders at
  646. // all when the collapse's shrink clamp already re-entered follow, so the
  647. // assertion is the restored state — no control, and the scrollport on its
  648. // floor — rather than the control's presence.
  649. try {
  650. if (await marker.getAttribute('aria-expanded') === 'true') await marker.click()
  651. await expect.poll(() => marker.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false')
  652. await page.setViewportSize(originalViewport)
  653. const scrollport = page.locator('[data-conversation-scroll]')
  654. const backToBottom = page.getByRole('button', { name: 'Back to bottom', exact: true })
  655. await expect.poll(async () => {
  656. if (await backToBottom.count() > 0) await backToBottom.click()
  657. const atFloor = await scrollport.evaluate((host: HTMLElement) =>
  658. Math.abs(host.scrollHeight - host.clientHeight - host.scrollTop) <= 1)
  659. return await backToBottom.count() === 0 && atFloor
  660. }, { timeout: 15_000 }).toBe(true)
  661. } catch (cleanupError) {
  662. // The body's own assertion is the diagnosis; a cleanup failure would
  663. // replace it, and the state it failed to restore shows up in the next
  664. // case's golden.
  665. if (bodyError === undefined) throw cleanupError
  666. }
  667. if (bodyError !== undefined) throw bodyError
  668. })
  669. it.skipIf(MODE === 'record')('an Access-chip switch lands one command row: bare name, non-repeating settlement text', async () => {
  670. onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-command-row'))
  671. // The Access chip submits `/permission <preset>` — a host command with no
  672. // model call, so the settled row renders keylessly over this cold history.
  673. // The row copy is the assertion: `permission · preset read-only`,
  674. // where neither half repeats the other (the dispatched `/` and its
  675. // argument stay out of the title, and the settlement text never restates
  676. // the command's own name).
  677. await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).click()
  678. await page.getByRole('menuitem', { name: 'Read Only' }).click()
  679. const access = page.getByRole('button', { name: 'Access mode, current: Read Only' })
  680. await expect.poll(() => access.isEnabled(), { timeout: 10_000 }).toBe(true)
  681. // Scoped to the row itself, so unrelated page text that happens to read
  682. // `permission` (a future resident slash menu) cannot satisfy or break it.
  683. const row = page.locator('[data-variant="others"]').filter({ hasText: 'preset read-only' })
  684. await expect.poll(() => row.count(), { timeout: 10_000 }).toBe(1)
  685. expect(await row.getByText('permission', { exact: true }).count()).toBe(1)
  686. expect(await row.getByText('/permission read-only', { exact: true }).count()).toBe(0)
  687. const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
  688. .split(SEED_ID).join('{{seededId}}')
  689. await compareOrRefreshGolden(COMMAND_ROW_EXPECTED, snapshot, MODE)
  690. }, 60_000)
  691. it.skipIf(MODE === 'record')('reports full feedback correlation ids in an expandable two-line row', async () => {
  692. onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-feedback-row'))
  693. const previousDshHome = process.env.DSH_HOME
  694. process.env.DSH_HOME = scaffold.harnessHome
  695. try {
  696. const input = page.locator('[data-composer-input]').first()
  697. await input.fill('/feedback the diff view is unreadable')
  698. await input.press('Enter')
  699. const row = page.locator('[data-variant="others"]').filter({
  700. hasText: `Feedback recorded for session ${SEED_ID}`,
  701. })
  702. await row.waitFor({ timeout: 10_000 })
  703. const disclosure = row.locator('[data-expandable]')
  704. expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
  705. await disclosure.click()
  706. await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
  707. const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
  708. if (agent === undefined) throw new Error('seeded session did not attach an agent')
  709. const done = agent.session.snapshotEvents().filter(event => event.type === 'command/done').at(-1)
  710. if (done?.type !== 'command/done') throw new Error('feedback command did not settle')
  711. const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? []
  712. expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`)
  713. expect(userLine).toMatch(/^Anonymous user: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.$/i)
  714. expect(extraLine).toBeUndefined()
  715. const userId = userLine?.match(/^Anonymous user: ([0-9a-f-]+)/i)?.[1]
  716. if (userId === undefined) throw new Error('feedback command omitted the user id')
  717. // command/done can arrive before the submit reply releases the composer.
  718. await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('')
  719. await expect.poll(() => page.getByRole('button', { name: 'Add files or run commands' }).isEnabled(), { timeout: 10_000 }).toBe(true)
  720. const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
  721. .split(SEED_ID).join('{{seededId}}')
  722. .split(userId).join('{{userId}}')
  723. await compareOrRefreshGolden(FEEDBACK_ROW_EXPECTED, snapshot, MODE)
  724. } finally {
  725. if (previousDshHome === undefined) delete process.env.DSH_HOME
  726. else process.env.DSH_HOME = previousDshHome
  727. }
  728. }, 60_000)
  729. it.skipIf(MODE === 'record')('fits short logged context without a scrollport', async () => {
  730. const agent = scaffold.ctx.agents.get(SessionId(SEED_ID))
  731. if (agent === undefined) throw new Error('seeded session did not attach an agent')
  732. agent.session.append('user/message', createUserMessage({
  733. content: [{ type: 'text', text: 'Short injected context.' }],
  734. source: { kind: 'plugin', plugin: 'fixture' },
  735. }), { surfaceOp: 'append' })
  736. const disclosure = page.getByRole('button', { name: 'Context injection fixture', exact: true })
  737. await disclosure.waitFor({ timeout: 10_000 })
  738. await disclosure.click()
  739. await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('true')
  740. // The instructions row above stays expanded from the geometry case; the
  741. // opaque body is the one without a declared form.
  742. const body = page.locator('[data-context-injection-body]:not([data-context-form])')
  743. const bodyBox = await body.boundingBox()
  744. if (bodyBox === null) throw new Error('short context disclosure geometry is not measurable')
  745. expect(bodyBox.height).toBeLessThan(141)
  746. expect(await body.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(false)
  747. })
  748. it.skipIf(MODE === 'record')('restores the recorded file preview in its original tab after page reload', async () => {
  749. onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-sidebar-reload'))
  750. await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
  751. const column = page.locator('[data-rightbar-col]')
  752. await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
  753. const tabId = await column.locator('[data-dockkit-tab]').getAttribute('data-dockkit-tab')
  754. const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
  755. const warningStart = tripwire.warnings.length
  756. await page.reload({ waitUntil: 'load' })
  757. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  758. await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
  759. expect(await column.locator('[data-dockkit-tab]').getAttribute('data-dockkit-tab')).toBe(tabId)
  760. const restoredPreview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
  761. expect(restoredPreview).toBe(preview)
  762. })
  763. it.skipIf(MODE === 'record' || process.platform === 'win32')('offers explicit terminal replacement after restoring the recorded Session', async () => {
  764. onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-terminal-unavailable'))
  765. await page.locator('[data-dockkit-add-tab]').click()
  766. await page.locator('[data-sidebar-right-guide-entry="terminal"]').getByRole('button', { name: /^New terminal/u }).click()
  767. const sessionId = SessionId(SEED_ID)
  768. const terminals = () => scaffold.ctx.terminalController.list(sessionId)
  769. await expect.poll(() => terminals().length).toBe(1)
  770. const previous = terminals()[0]!.id
  771. const agent = scaffold.ctx.agents.get(sessionId)
  772. if (agent === undefined) throw new Error('Recorded Session did not attach an Agent for its terminal')
  773. await scaffold.ctx.terminalController.close(agent, previous)
  774. const warningStart = tripwire.warnings.length
  775. await page.reload({ waitUntil: 'load' })
  776. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  777. const terminal = page.locator('[data-sidebar-terminal]')
  778. await expect.poll(() => terminal.getByRole('alert').innerText()).toContain('no longer exists')
  779. expect(terminals()).toEqual([])
  780. const expected = fileURLToPath(new URL('./expected/sidebar-terminal/unavailable.expected.md', import.meta.url))
  781. await compareOrRefreshGolden(expected, await terminal.ariaSnapshot(), MODE)
  782. await terminal.getByRole('button', { name: 'New terminal', exact: true }).click()
  783. await expect.poll(() => terminals().length).toBe(1)
  784. expect(terminals()[0]!.id).not.toBe(previous)
  785. await terminal.getByRole('status').waitFor({ state: 'hidden' })
  786. await terminal.getByRole('textbox', { name: 'Terminal', exact: true }).waitFor()
  787. })
  788. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
  789. // No replay fixture was installed and the llm seam is open — any stray
  790. // stream would have failed the turn loudly. Cleanliness pins the wire.
  791. expect(tripwire.pageErrors).toEqual([])
  792. expect(tripwire.warnings).toEqual([])
  793. await assertFixtureInventory(SNAPSHOT_DIR, [
  794. 'command-row.expected.md', 'feedback-row.expected.md', 'file-preview.expected.md',
  795. 'session.v3.jsonl', 'sticky-geometry.expected.md', 'thinking-expanded.expected.md',
  796. 'ui.expected.md', 'ui-expanded.expected.md',
  797. ])
  798. })
  799. })