feedback-release.e2e.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. // Recorded model replay drives the shipped feedback UI and canonical log.
  2. // The loopback collector must receive each authorized suffix before the next UI action.
  3. import { readFile } from 'node:fs/promises'
  4. import { fileURLToPath } from 'node:url'
  5. import { join } from 'node:path'
  6. import { createServer, type Server } from 'node:http'
  7. import { once } from 'node:events'
  8. import { gunzipSync } from 'node:zlib'
  9. import type { Browser, Page } from 'playwright'
  10. import { chromium } from 'playwright'
  11. import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
  12. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  13. import {
  14. acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
  15. compareOrRefreshGolden, fixtureUserPrompts,
  16. launchWebScaffold, readPersistedEvents, watchConsole, webSnapshotMode, type WebScaffold,
  17. } from './scaffold.ts'
  18. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  19. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-release', import.meta.url))
  20. // Both routes borrow the same settled turn; this manifest references its owner.
  21. const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.v3.jsonl', import.meta.url))
  22. const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
  23. const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md')
  24. const RELEASE_EXPECTED = join(SNAPSHOT_DIR, 'feedback-release.expected.json')
  25. const MODE = webSnapshotMode()
  26. interface OtlpCapture {
  27. resourceLogs: { scopeLogs: { logRecords: {
  28. attributes: { key: string; value: { stringValue?: string; intValue?: number | string } }[]
  29. }[] }[] }[]
  30. }
  31. const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
  32. describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official', 'feedback-mock'])('web e2e: feedback release for %s', (provider) => {
  33. const official = provider === 'deepseek-official'
  34. let scaffold: WebScaffold
  35. let browser: Browser
  36. let page: Page
  37. let tripwire: ReturnType<typeof watchConsole>
  38. let collector: Server
  39. let sessionId: SessionId
  40. let authorized: readonly SessionEvent[] = []
  41. let releasedCount = 0
  42. const suffixes: string[][] = []
  43. const uploads: string[] = []
  44. let headerlessExpected: [string, number, string][] = []
  45. function captured(): [string | undefined, number, string | undefined][] {
  46. return uploads.flatMap((upload) => {
  47. const capture = JSON.parse(upload) as OtlpCapture
  48. return capture.resourceLogs.flatMap(resource => resource.scopeLogs.flatMap(scope =>
  49. scope.logRecords.map((record) => {
  50. const attribute = (key: string) => record.attributes.find(value => value.key === key)?.value
  51. return [attribute('session.id')?.stringValue, Number(attribute('event.seq')?.intValue),
  52. attribute('event.type')?.stringValue] as [string | undefined, number, string | undefined]
  53. })))
  54. })
  55. }
  56. async function expectFeedbackRelease(type: SessionEvent['type'], count: number): Promise<void> {
  57. const agent = scaffold.ctx.agents.get(sessionId)
  58. if (agent === undefined) throw new Error('feedback session has no active agent')
  59. await scaffold.ctx.sessions.flush(agent.session)
  60. const events = await readPersistedEvents(scaffold, sessionId)
  61. const feedback = events.filter(event => event.type === type)
  62. expect(feedback).toHaveLength(count)
  63. const boundary = feedback.at(-1)!
  64. authorized = events.filter(event => event.seq <= boundary.seq)
  65. const expected = authorized.map(event => [sessionId, event.seq, event.type])
  66. // No teardown, flush hook, or subsequent interaction may cause this delivery.
  67. await expect.poll(captured, { timeout: 10_000 }).toEqual(expected)
  68. suffixes.push(authorized.slice(releasedCount).map(event => event.type))
  69. releasedCount = authorized.length
  70. expect(scaffold.ctx.agents.get(sessionId)).toBe(agent)
  71. }
  72. async function selectModel(name: string): Promise<void> {
  73. const trigger = page.getByRole('button', { name: /^Select model, current/ })
  74. await trigger.click()
  75. await page.getByRole('menuitem', { name: /^Model\b/ }).click()
  76. await page.getByRole('menuitemradio', { name, exact: true }).click()
  77. await expect.poll(() => trigger.getAttribute('aria-label')).toContain(name)
  78. // The durable projection can update the label before the selection reply closes the menu.
  79. await expect.poll(() => trigger.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false')
  80. }
  81. beforeAll(async () => {
  82. collector = createServer((request, response) => {
  83. const chunks: Buffer[] = []
  84. request.on('data', chunk => chunks.push(chunk as Buffer))
  85. request.on('end', () => {
  86. const raw = Buffer.concat(chunks)
  87. uploads.push((request.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw).toString())
  88. response.writeHead(200, { 'content-type': 'application/json' }).end('{}')
  89. })
  90. })
  91. collector.listen(0, '127.0.0.1')
  92. await once(collector, 'listening')
  93. const address = collector.address()
  94. if (address === null || typeof address === 'string') throw new Error('collector has no port')
  95. scaffold = await launchWebScaffold({
  96. telemetryUrl: `http://127.0.0.1:${address.port}/v1/logs`,
  97. telemetryMode: 'FEEDBACK_ONLY',
  98. telemetryScheduledDelayMillis: 10,
  99. replayProviders: [
  100. { id: 'deepseek-official', name: 'DeepSeek', models: [
  101. { id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', contextWindow: 128_000 },
  102. ] },
  103. { id: 'feedback-mock', name: 'Feedback mock', models: [
  104. { id: 'feedback-mock', name: 'Feedback mock', contextWindow: 128_000 },
  105. ] },
  106. ],
  107. // The replayed session.v3.jsonl belongs to the feedback-command scenario;
  108. // comparing (or refreshing) the persisted session here would rewrite
  109. // that shared source with this lane's feedback events. Persistence and
  110. // collector assertions belong to this lane.
  111. compareReplaySession: false,
  112. ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 5 }),
  113. })
  114. browser = await chromium.launch()
  115. page = await newEnglishPage(browser)
  116. tripwire = watchConsole(page)
  117. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  118. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  119. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  120. }, 120_000)
  121. afterAll(async () => {
  122. try {
  123. await browser?.close()
  124. await scaffold?.close()
  125. // Shutdown drains any illicitly queued tail too; delivery assertions run in the tests.
  126. expect(captured()).toEqual([...authorized.map(event => [sessionId, event.seq, event.type]), ...headerlessExpected])
  127. } finally {
  128. if (collector?.listening) {
  129. await new Promise<void>((resolve, reject) => {
  130. collector.close((error) => {
  131. if (error) reject(error)
  132. else resolve()
  133. })
  134. collector.closeAllConnections()
  135. })
  136. }
  137. }
  138. })
  139. it('drives the recorded prompt to a settled turn (all modes)', async () => {
  140. onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-drive'))
  141. if (MODE !== 'record') {
  142. // Drift guard: the shared fixture must carry exactly the drive prompt.
  143. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  144. }
  145. const input = page.locator('[data-composer-input]').first()
  146. await input.waitFor({ timeout: 10_000 })
  147. if (!official) await selectModel('Feedback mock')
  148. const settled = scaffold.whenTurnSettled()
  149. await input.fill(PROMPT)
  150. await input.press('Enter')
  151. sessionId = await settled
  152. const agent = scaffold.ctx.agents.get(sessionId)
  153. expect(agent?.session.requestHeader()?.config.provider).toBe(provider)
  154. expect(uploads).toEqual([])
  155. // Both routes render the same composer without changing the actual request header.
  156. if (MODE !== 'record') {
  157. await selectModel('Feedback mock')
  158. await selectModel('DeepSeek-V4-Flash')
  159. }
  160. expect(agent?.session.requestHeader()?.config.provider).toBe(provider)
  161. expect(uploads).toEqual([])
  162. }, 60_000)
  163. it.skipIf(MODE === 'record')('records feedback locally and acknowledges its session and anonymous user ids', async () => {
  164. onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release'))
  165. await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
  166. expect(uploads).toEqual([])
  167. const input = page.locator('[data-composer-input]').first()
  168. await input.fill('/feedback the diff view is unreadable')
  169. await input.press('Enter')
  170. await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 })
  171. expect(await page.getByText(/Anonymous user: [0-9a-f-]+\.$/i).count()).toBe(1)
  172. await expectFeedbackRelease('feedback/record', 1)
  173. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  174. await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
  175. const expanded = await captureExpandedTurnProcessAria(
  176. page,
  177. '[class*="centerCol"]',
  178. scaffold.workspaceCwd,
  179. )
  180. await compareOrRefreshGolden(ACK_EXPANDED_EXPECTED, expanded, MODE)
  181. expect(tripwire.pageErrors).toEqual([])
  182. expect(tripwire.warnings).toEqual([])
  183. }, 60_000)
  184. it.skipIf(MODE === 'record')('does not release command tails, provider changes, or browser reloads', async () => {
  185. const events = await readPersistedEvents(scaffold, sessionId)
  186. expect(events.at(-1)?.type).toBe('command/done')
  187. expect(events.at(-1)!.seq).toBeGreaterThan(authorized.at(-1)!.seq)
  188. await selectModel('Feedback mock')
  189. await selectModel('DeepSeek-V4-Flash')
  190. const warningStart = tripwire.warnings.length
  191. await page.reload({ waitUntil: 'load' })
  192. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  193. await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
  194. expect(captured()).toHaveLength(releasedCount)
  195. })
  196. it.skipIf(MODE === 'record')('persists text, ratings, notes, and retractions in the canonical session', async () => {
  197. onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-suffix'))
  198. const input = page.locator('[data-composer-input]').first()
  199. await input.fill('/feedback the second remark')
  200. await input.press('Enter')
  201. await expect.poll(() => page.getByText(/Feedback recorded for session/).count()).toBe(2)
  202. await expectFeedbackRelease('feedback/record', 2)
  203. const like = page.getByRole('button', { name: 'Good response' })
  204. await like.hover()
  205. await like.click()
  206. const rated = page.getByRole('button', { name: 'Remove rating' })
  207. await expect.poll(() => rated.getAttribute('aria-pressed')).toBe('true')
  208. await expectFeedbackRelease('feedback/message-put', 1)
  209. // Dislike collects the category and note in the dialog; typing releases nothing.
  210. await page.getByRole('button', { name: 'Bad response' }).click()
  211. const dialog = page.getByRole('dialog', { name: 'Submit feedback' })
  212. await dialog.getByRole('button', { name: 'Task result', exact: true }).click()
  213. await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Read both files before answering.')
  214. expect(captured()).toHaveLength(releasedCount)
  215. await dialog.getByRole('button', { name: 'Submit', exact: true }).click()
  216. await expect.poll(() => dialog.count()).toBe(0)
  217. await expectFeedbackRelease('feedback/message-put', 2)
  218. await rated.click()
  219. await expect.poll(() => page.getByRole('button', { name: 'Bad response' }).getAttribute('aria-pressed')).toBe('false')
  220. await expectFeedbackRelease('feedback/message-delete', 1)
  221. const agent = scaffold.ctx.agents.get(sessionId)
  222. if (agent === undefined) throw new Error('feedback session has no active agent')
  223. await scaffold.ctx.sessions.flush(agent.session)
  224. const events = await readPersistedEvents(scaffold, sessionId)
  225. expect(events.filter(event => event.type === 'feedback/record')).toMatchObject([
  226. { data: { text: 'the diff view is unreadable' } },
  227. { data: { text: 'the second remark' } },
  228. ])
  229. expect(events.filter(event => event.type === 'feedback/message-put')).toMatchObject([
  230. { data: { sessionId, item: { rating: 'positive' } } },
  231. { data: { sessionId, item: { rating: 'negative', note: 'Read both files before answering.', category: 'task-result' } } },
  232. ])
  233. expect(events.filter(event => event.type === 'feedback/message-delete')).toMatchObject([{ data: { sessionId } }])
  234. expect(events.filter(event => event.type === 'turn/end')).toHaveLength(1)
  235. expect(agent.session.requestHeader()?.config.provider).toBe(provider)
  236. expect(captured()).toHaveLength(releasedCount)
  237. const wire = uploads.join('\n')
  238. for (const text of ['the diff view is unreadable', 'the second remark',
  239. 'Read both files before answering.']) expect(wire).toContain(text)
  240. const feedback = events.flatMap<Record<string, string | undefined>>((event) => {
  241. switch (event.type) {
  242. case 'feedback/record': return [{ type: event.type, text: event.data.text }]
  243. case 'feedback/message-put': return [{
  244. type: event.type, rating: event.data.item.rating, note: event.data.item.note, category: event.data.item.category,
  245. }]
  246. case 'feedback/message-delete': return [{ type: event.type }]
  247. default: return []
  248. }
  249. })
  250. await compareOrRefreshGolden(RELEASE_EXPECTED, JSON.stringify({
  251. mode: 'FEEDBACK_ONLY', feedback,
  252. // The prefix is compared with each provider's actual canonical log above.
  253. laterSubmissionSuffixes: suffixes.slice(1),
  254. }, null, 2), MODE)
  255. }, 60_000)
  256. it.skipIf(MODE === 'record')('releases headerless feedback without capturing another session’s provider-change tail', async () => {
  257. await selectModel('Feedback mock')
  258. await selectModel('DeepSeek-V4-Flash')
  259. expect(captured()).toHaveLength(releasedCount)
  260. await page.getByRole('button', { name: 'New session', exact: true }).last().click()
  261. const input = page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
  262. await input.waitFor({ timeout: 15_000 })
  263. await input.fill('/feedback Feedback before any model request.')
  264. expect(captured()).toHaveLength(releasedCount)
  265. await input.press('Enter')
  266. const findHeaderless = () => scaffold.ctx.sessions.list().find(session => session.id !== sessionId
  267. && session.snapshotEvents().some(event => event.type === 'feedback/record'))
  268. // A command-only session keeps the hero view; its durable event confirms submission.
  269. await expect.poll(findHeaderless, { timeout: 10_000 }).toBeDefined()
  270. const headerless = findHeaderless()
  271. if (headerless === undefined) throw new Error('headerless feedback session not found')
  272. expect(headerless.requestHeader()).toBeUndefined()
  273. await scaffold.ctx.sessions.flush(headerless)
  274. const events = await readPersistedEvents(scaffold, headerless.id)
  275. const feedback = events.find(event => event.type === 'feedback/record')!
  276. headerlessExpected = events.filter(event => event.seq <= feedback.seq)
  277. .map(event => [headerless.id, event.seq, event.type])
  278. await expect.poll(captured, { timeout: 10_000 }).toEqual([
  279. ...authorized.map(event => [sessionId, event.seq, event.type]), ...headerlessExpected,
  280. ])
  281. expect(uploads.join('\n')).toContain('Feedback before any model request.')
  282. expect(tripwire.pageErrors).toEqual([])
  283. expect(tripwire.warnings).toEqual([])
  284. })
  285. it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
  286. await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md', 'ack-expanded.expected.md', 'feedback-release.expected.json'])
  287. })
  288. })