changed-files.e2e.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. // Web e2e scenario: the changed-files card a finished turn ends with.
  2. // Cold-seeds one recorded change summary (zero model calls), then verifies the
  3. // real assembled card folds after three rows, expands to the complete list,
  4. // and offers native file and folder actions when the Host desktop is pinned on.
  5. import { fileURLToPath } from 'node:url'
  6. import type { Browser, Page } from 'playwright'
  7. import { chromium } from 'playwright'
  8. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  9. import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  10. import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
  11. import type {} from '@deepseek-ai/dsh-session-title'
  12. import type {} from '@deepseek-ai/dsh-workspace-changes'
  13. import {
  14. launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
  15. } from './scaffold.ts'
  16. import { newEnglishPage, saveFailureShot } from './support.ts'
  17. const MODE = webSnapshotMode()
  18. const OVERLAY = fileURLToPath(new URL('./changed-files.overlay.yml', import.meta.url))
  19. const SEED_ID = 'changed-files-web-e2e'
  20. const DONE = 'CHANGED_FILES_DONE'
  21. /** Eleven files in display order, as the Host records them. */
  22. const CHANGED = [
  23. ['config/design-token', 42, 11],
  24. ['config/feature-flags.json', 143, 32],
  25. ['config/launch-plan.yaml', 654, 9],
  26. ['src/app.ts', 120, 80],
  27. ['src/index.html', 30, 4],
  28. ['src/manifest.yaml', 12, 0],
  29. ['src/notes.txt', 8, 8],
  30. ['src/preview.svg', 0, 0],
  31. ['src/schema.json', 90, 60],
  32. ['src/styles.css', 100, 100],
  33. ['关于我.md', 33, 22],
  34. ] as const
  35. /** Build one settled turn whose recorded summary lists eleven changed files. */
  36. function changedFixture(): string {
  37. const session = Session.create(SessionId('changed-files-source'))
  38. const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
  39. session.append('turn/start', { turn: 1 })
  40. const user = session.append('user/message', createUserMessage({
  41. content: [{ type: 'text', text: 'Update the site files.' }],
  42. source: { kind: 'user' },
  43. }), { surfaceOp: 'append' })
  44. session.append('session/title', {
  45. title: 'Changed files fold', messageSeqs: [user.seq], source: { kind: 'fallback' },
  46. })
  47. session.append('step/start', { turn: 1, step: 1 })
  48. session.append('assistant/message', {
  49. stream: [],
  50. turn: 1,
  51. step: 1,
  52. message: createAssistantMessage({
  53. content: [{ type: 'text', text: `Updated the site.\n\n${DONE}` }],
  54. source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  55. }),
  56. }, { surfaceOp: 'append' })
  57. session.append('step/end', { turn: 1, step: 1 })
  58. session.append('workspace/changes', {
  59. turn: 1,
  60. total: CHANGED.length,
  61. snapshot: { before: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', after: 'c1a2b3d4e5f60718293a4b5c6d7e8f9012345678' },
  62. files: CHANGED.map(([path, added, deleted]) => ({
  63. path, display: path, added, deleted, ...(path.endsWith('.svg') ? { binary: true as const } : {}),
  64. })),
  65. })
  66. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  67. return [
  68. JSON.stringify({
  69. type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
  70. createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
  71. }),
  72. ...session.snapshotEvents().map(event => JSON.stringify({
  73. ...event, time: eventTimeOrigin + event.seq * 1_000,
  74. })),
  75. '',
  76. ].join('\n')
  77. }
  78. describe('web e2e: a finished turn ends with the files it changed', () => {
  79. let scaffold: WebScaffold
  80. let browser: Browser
  81. let page: Page
  82. let tripwire: ReturnType<typeof watchConsole>
  83. beforeAll(async () => {
  84. scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
  85. await seedSession(scaffold, changedFixture(), SEED_ID)
  86. browser = await chromium.launch()
  87. page = await newEnglishPage(browser)
  88. await page.setViewportSize({ width: 1400, height: 900 })
  89. tripwire = watchConsole(page)
  90. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  91. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  92. }, 120_000)
  93. afterAll(async () => {
  94. await browser?.close()
  95. await scaffold?.close()
  96. })
  97. it.skipIf(MODE === 'record')('folds an eleven-file summary after three rows and expands it in place', async () => {
  98. onTestFailed(() => saveFailureShot(page, 'web-e2e-changed-files'))
  99. const groupRow = page.locator('[role="treeitem"]').first()
  100. await groupRow.waitFor({ timeout: 15_000 })
  101. if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click()
  102. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  103. await sessionRow.waitFor({ timeout: 10_000 })
  104. await sessionRow.click()
  105. await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
  106. const card = page.locator('[data-changed-files]')
  107. await card.waitFor({ timeout: 15_000 })
  108. await expect.poll(() => card.getByText('Edited 11 files', { exact: true }).count()).toBe(1)
  109. expect(await card.getByText('+1,232', { exact: true }).count()).toBe(1)
  110. expect(await card.getByText('-326', { exact: true }).count()).toBe(1)
  111. const rows = card.getByRole('listitem')
  112. await expect.poll(() => rows.count()).toBe(3)
  113. expect(await rows.nth(0).innerText()).toContain('config/design-token')
  114. expect(await rows.nth(0).innerText()).toContain('+42')
  115. expect(await rows.nth(2).innerText()).toContain('config/launch-plan.yaml')
  116. // The pinned desktop turns the header into the folder action and rows into default-app opens.
  117. await expect.poll(() => card.getByRole('button', { name: 'Open the folder containing the changed files' }).count()).toBe(1)
  118. expect(await card.getByRole('button', { name: 'Open config/design-token in default app' }).count()).toBe(1)
  119. const expand = card.getByRole('button', { name: 'Show all 11 changed files' })
  120. expect(await expand.innerText()).toContain('All 11 files')
  121. await expand.click()
  122. await expect.poll(() => rows.count()).toBe(11)
  123. expect(await rows.nth(7).innerText()).toContain('binary')
  124. expect(await rows.nth(10).innerText()).toContain('关于我.md')
  125. const collapse = card.getByRole('button', { name: 'Collapse changed files' })
  126. expect(await collapse.getAttribute('aria-expanded')).toBe('true')
  127. // The collapse control is the card's last element after expansion.
  128. expect(await card.evaluate(element => element.lastElementChild?.getAttribute('aria-expanded'))).toBe('true')
  129. await collapse.click()
  130. await expect.poll(() => rows.count()).toBe(3)
  131. expect(await page.getByText('Files changed', { exact: true }).count()).toBe(0)
  132. const geometry = await card.evaluate(element => ({ clientWidth: element.clientWidth, scrollWidth: element.scrollWidth }))
  133. expect(geometry.scrollWidth).toBeLessThanOrEqual(geometry.clientWidth)
  134. expect(tripwire.pageErrors).toEqual([])
  135. expect(tripwire.warnings).toEqual([])
  136. }, 90_000)
  137. })