changed-files-turn.e2e.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. /** A turn that edits, creates, and shell-appends files in a git workspace ends with the changed-files card; its rows open the review. */
  2. import { execFileSync } from 'node:child_process'
  3. import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { fileURLToPath } from 'node:url'
  7. import { chromium, type Browser, type Page } from 'playwright'
  8. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  9. import type {} from '@deepseek-ai/dsh-workspace-changes'
  10. import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
  11. import {
  12. assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden,
  13. fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
  14. webSnapshotMode, type WebScaffold,
  15. } from './scaffold.ts'
  16. import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
  17. const DIR = fileURLToPath(new URL('../../../snapshots/web/changed-files-turn', import.meta.url))
  18. const FIXTURE = join(DIR, 'session.v3.jsonl')
  19. const MODE = webSnapshotMode()
  20. const PROMPT = '不用先查看目录,直接做四件事:把 intro.md 里的标题「示例项目」改成「项目说明」,新建 src/util.ts 导出一个两数相加的 add 函数,新建 app.local 写一行 mode=demo,最后用 bash 在 notes.txt 末尾追加一行 done。'
  21. /** Seed a committed repository so the turn's own edits are the only difference between its snapshots; `*.local` stays ignored. */
  22. async function seedRepository(cwd: string): Promise<void> {
  23. await mkdir(cwd, { recursive: true })
  24. await writeFile(join(cwd, 'intro.md'), '# 示例项目\n\n一个用于演示的仓库。\n')
  25. await writeFile(join(cwd, 'notes.txt'), 'start\n')
  26. await writeFile(join(cwd, '.gitignore'), '*.local\n')
  27. const git = (...args: string[]) => execFileSync('git', ['-c', 'user.email=seed@example.com', '-c', 'user.name=seed', '-c', 'commit.gpgsign=false', ...args], { cwd, stdio: 'ignore' })
  28. git('init', '-q', '-b', 'main')
  29. git('add', '-A')
  30. git('commit', '-q', '-m', 'seed')
  31. }
  32. describe('web e2e: a git workspace turn ends with its changed files', () => {
  33. let scaffold: WebScaffold
  34. let browser: Browser
  35. let page: Page
  36. let tripwire: ReturnType<typeof watchConsole>
  37. let cwd: string
  38. let replayRoot: string | undefined
  39. beforeAll(async () => {
  40. let replayOverride: string | undefined
  41. if (MODE !== 'record') {
  42. replayRoot = await mkdtemp(join(tmpdir(), 'dsh-changed-files-turn-replay-'))
  43. replayOverride = join(replayRoot, 'replay.override.json')
  44. const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
  45. // Recorded absolute paths must follow each isolated Session's working directory.
  46. const cwdToken = '{{fromRequest:Your working directory is ([^\\n]+)\\.}}'
  47. await writeFile(replayOverride, JSON.stringify(script).replaceAll('{{cwd}}', JSON.stringify(cwdToken).slice(1, -1)))
  48. }
  49. scaffold = await launchWebScaffold({
  50. compareReplaySession: true,
  51. extraOverlayPath: fileURLToPath(new URL('./changed-files-turn.overlay.yml', import.meta.url)),
  52. ...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }),
  53. })
  54. await seedRepository(join(scaffold.workspaceCwd, 'workspace'))
  55. browser = await chromium.launch()
  56. page = await browser.newPage({
  57. viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai',
  58. })
  59. tripwire = watchConsole(page)
  60. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  61. await page.waitForSelector('[class*="frame"]')
  62. await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
  63. })
  64. afterAll(async () => {
  65. try {
  66. await browser?.close()
  67. } finally {
  68. try {
  69. await scaffold?.close()
  70. } finally {
  71. if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true })
  72. }
  73. }
  74. })
  75. it('records the edited, created, and shell-appended files with their line counts', async () => {
  76. if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
  77. const settled = scaffold.whenTurnSettled()
  78. const input = page.locator('[data-composer-input]').first()
  79. await input.fill(PROMPT)
  80. await input.press('Enter')
  81. const sessionId = await settled
  82. const session = scaffold.ctx.agents.get(sessionId)?.session
  83. if (session?.header.cwd === undefined) throw new Error('changed-files Session has no workspace')
  84. cwd = session.header.cwd
  85. if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
  86. const announced = session.snapshotEvents().filter(event => event.type === 'workspace/changes').at(-1)
  87. expect(announced, 'the turn must announce its changed files').toBeDefined()
  88. if (announced === undefined) throw new Error('no changed-files announcement')
  89. expect(announced.data).toEqual({ turn: 1 })
  90. // The log carries only the turn; the Host serves the summary for the announcing event while the Session lives.
  91. const summary = scaffold.ctx.workspaceChanges.summary(sessionId, announced.seq)
  92. if (summary === undefined) throw new Error('the Host serves no summary for the announcement')
  93. // app.local is ignored by the repository, so its counts come from the write call rather than git.
  94. expect(summary.files.map(file => file.display)).toEqual(['app.local', 'intro.md', 'notes.txt', 'src/util.ts'])
  95. expect(summary.total).toBe(4)
  96. for (const file of summary.files) expect(file.added).toBeGreaterThan(0)
  97. expect(summary.files[0]).toMatchObject({ path: 'app.local', added: 1, deleted: 0 })
  98. expect(summary.files[2]).toMatchObject({ path: 'notes.txt', added: 1, deleted: 0 })
  99. expect(await readFile(join(cwd, 'notes.txt'), 'utf8')).toBe('start\ndone\n')
  100. const card = page.locator('[data-changed-files]')
  101. await card.waitFor({ state: 'visible' })
  102. expect(await card.getByText('已编辑 4 个文件', { exact: true }).count()).toBe(1)
  103. expect(await card.getByRole('listitem').count()).toBe(3)
  104. expect(await card.getByRole('button', { name: '展开全部 4 个改动文件' }).count()).toBe(1)
  105. // The header and every row open the turn's review in the Sidebar, with or without a Host desktop.
  106. expect(await card.getByRole('button', { name: '在侧边栏查看本轮改动' }).count()).toBe(1)
  107. expect(await card.getByRole('button', { name: '查看 notes.txt 的改动' }).count()).toBe(1)
  108. expect(tripwire.pageErrors).toEqual([])
  109. expect(tripwire.warnings).toEqual([])
  110. })
  111. it('reviews a shell-appended file from the snapshots and an ignored file from its captured copies in one tab', async () => {
  112. const card = page.locator('[data-changed-files]')
  113. const column = page.locator('[data-rightbar-col]')
  114. const drawn = (root: ReturnType<typeof column.locator>) =>
  115. root.locator('[data-diff-line]').evaluateAll(lines => lines.map(line => `${line.getAttribute('data-diff-line')}:${line.textContent}`))
  116. const review = column.locator('[data-changes-review]')
  117. // The header lands on the first listed file; a row lands on its own.
  118. await card.getByRole('button', { name: '在侧边栏查看本轮改动' }).click()
  119. await review.locator('[data-review-file="app.local"]').waitFor({ state: 'visible' })
  120. await card.getByRole('button', { name: '查看 notes.txt 的改动' }).click()
  121. await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' })
  122. expect(await column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' }).count()).toBe(1)
  123. await expect.poll(() => drawn(review)).toEqual(['context:11 start', 'add:2+done'])
  124. // The ignored file has no snapshot; its comparison comes from the copies captured around the write call.
  125. await review.getByRole('button', { name: '选择要查看的文件' }).click()
  126. await page.getByRole('menuitem').filter({ hasText: 'app.local' }).click()
  127. await review.locator('[data-review-file="app.local"]').waitFor({ state: 'visible' })
  128. await expect.poll(() => drawn(review)).toEqual(['add:1+mode=demo'])
  129. expect(await review.getByText('本轮新建的文件').count()).toBe(1)
  130. // A card row opens the same tab on another file; the split and wrap choices switch the drawing.
  131. await card.getByRole('button', { name: '查看 intro.md 的改动' }).click()
  132. await review.locator('[data-review-file="intro.md"]').waitFor({ state: 'visible' })
  133. expect(await column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' }).count()).toBe(1)
  134. await review.getByRole('button', { name: '左右对比' }).click()
  135. await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' })
  136. await expect.poll(() => drawn(review.locator('[data-diff-side="left"]'))).toEqual(['del:1# 示例项目', 'context:2', 'context:3一个用于演示的仓库。'])
  137. expect(await drawn(review.locator('[data-diff-side="right"]'))).toEqual(['del:1# 项目说明', 'context:2', 'context:3一个用于演示的仓库。'])
  138. await review.getByRole('button', { name: '自动换行' }).click()
  139. await review.locator('[data-review-view][data-review-wrap]').waitFor({ state: 'visible' })
  140. await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。'])
  141. // No desktop, so the tools offer the sidebar file but no native open.
  142. expect(await review.locator('[data-review-tool="open-file"]').count()).toBe(1)
  143. expect(await review.locator('[data-review-tool="open-native"]').count()).toBe(0)
  144. expect(tripwire.pageErrors).toEqual([])
  145. expect(tripwire.warnings).toEqual([])
  146. })
  147. it.skipIf(MODE === 'record')('replays the workspace and the Chinese conversation', async () => {
  148. await assertFinalWorkspaceSnapshot(DIR, cwd, { ignoredRootEntries: ['.git'] })
  149. const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
  150. await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
  151. })
  152. })