workspace-recency.e2e.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. /** Recency and saved manual order through the shipped Web composition. */
  2. import { readFile } from 'node:fs/promises'
  3. import { basename, join } from 'node:path'
  4. import { fileURLToPath } from 'node:url'
  5. import { chromium, type Browser, type Page, type WebSocketRoute } from 'playwright'
  6. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  7. import {
  8. acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
  9. launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
  10. } from './scaffold.ts'
  11. import { newEnglishPage, saveFailureShot } from './support.ts'
  12. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/workspace-recency', import.meta.url))
  13. const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
  14. const MODE = webSnapshotMode()
  15. const TITLES = ['Newest conversation', 'Middle conversation', 'Oldest conversation']
  16. describe('web e2e: workspace recency', () => {
  17. let scaffold: WebScaffold
  18. let browser: Browser
  19. let page: Page
  20. let tripwire: ReturnType<typeof watchConsole>
  21. let socket: WebSocketRoute
  22. let holdWorkspace = false
  23. let releaseWorkspace: (() => void) | undefined
  24. beforeAll(async () => {
  25. scaffold = await launchWebScaffold({})
  26. const fixture = await readFile(SEED, 'utf8')
  27. const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd)
  28. const now = Date.now()
  29. const ids = []
  30. for (const [index, title] of TITLES.entries()) {
  31. const id = await seedSession(scaffold, fixture, `workspace-recency-${index}`, undefined, {
  32. createdAt: now - (index + 1) * 60_000,
  33. })
  34. await workspace.attachSession(id)
  35. await scaffold.ctx.sessionController.rename({ sessionId: id, title })
  36. ids.push(id)
  37. }
  38. browser = await chromium.launch()
  39. page = await newEnglishPage(browser)
  40. await page.routeWebSocket('**/api/remote.mux', (route) => {
  41. socket = route
  42. const server = route.connectToServer()
  43. let heldStream: string | undefined
  44. const pending: (string | Buffer)[] = []
  45. server.onMessage((message) => {
  46. const frame = JSON.parse(String(message)) as {
  47. type: string
  48. streamId: string
  49. value?: { type: string; value?: { archivedSessionIds?: string[] } }
  50. }
  51. if (holdWorkspace && frame.type === 'item' && frame.value?.type === 'baseline'
  52. && frame.value.value?.archivedSessionIds !== undefined) {
  53. heldStream = frame.streamId
  54. releaseWorkspace = () => {
  55. heldStream = undefined
  56. for (const buffered of pending.splice(0)) route.send(buffered)
  57. }
  58. }
  59. if (frame.streamId === heldStream) pending.push(message)
  60. else route.send(message)
  61. })
  62. })
  63. await page.clock.setFixedTime(now)
  64. tripwire = watchConsole(page)
  65. await page.addInitScript(({ account, ids }) => {
  66. if (localStorage.getItem('dsh.workspace.view.v5') !== null) return
  67. localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: ids[0] }))
  68. localStorage.setItem('dsh.workspace.view.v5', JSON.stringify({
  69. groupBy: 'workspace', orderBy: 'updated', groupExpansion: { [account]: true },
  70. sessionOrderByAccount: { [account]: [...ids].reverse(), __flat_session_order__: [...ids].reverse() },
  71. }))
  72. }, { account: workspace.id, ids })
  73. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  74. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  75. })
  76. afterAll(async () => {
  77. await browser?.close()
  78. await scaffold?.close()
  79. })
  80. it('pauses current recency in Manual, preserves drags on reload, and resets on Last updated', async () => {
  81. onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-recency'))
  82. const titles = () => page.locator('[role="treeitem"]:not([aria-expanded]) [class*="title"]').allTextContents()
  83. const pick = async (name: string): Promise<void> => {
  84. await page.getByRole('button', { name: 'View options' }).click()
  85. await page.getByRole('menuitem', { name, exact: true }).click()
  86. }
  87. const captureSidebar = async (): Promise<string> => {
  88. await page.getByRole('button', { name: 'View options' }).hover()
  89. await expect.poll(() => page.getByRole('button', { name: /^Session actions for / }).count()).toBe(0)
  90. return captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd)
  91. }
  92. await expect.poll(titles).toEqual(TITLES)
  93. await compareOrRefreshGolden(
  94. join(SNAPSHOT_DIR, 'sidebar.expected.md'),
  95. await captureSidebar(), MODE,
  96. )
  97. await pick('Manual')
  98. await expect.poll(titles).toEqual(TITLES)
  99. await pick('Last updated')
  100. await expect.poll(titles).toEqual(TITLES)
  101. const source = page.getByRole('treeitem').filter({ has: page.getByText(TITLES[2]!, { exact: true }) })
  102. const target = page.getByRole('treeitem').filter({ has: page.getByText(TITLES[0]!, { exact: true }) })
  103. await source.dragTo(target, { targetPosition: { x: 30, y: 3 } })
  104. const dragged = [TITLES[2], TITLES[0], TITLES[1]]
  105. await expect.poll(titles).toEqual(dragged)
  106. const manualWarningStart = tripwire.warnings.length
  107. await page.reload({ waitUntil: 'load' })
  108. await expect.poll(titles).toEqual(dragged)
  109. acknowledgeReloadConnectionLoss(tripwire, manualWarningStart)
  110. await pick('Last updated')
  111. await expect.poll(titles).toEqual(TITLES)
  112. await pick('In one list')
  113. await expect.poll(titles).toEqual(TITLES)
  114. const recencyWarningStart = tripwire.warnings.length
  115. await page.reload({ waitUntil: 'load' })
  116. await expect.poll(titles).toEqual(TITLES)
  117. acknowledgeReloadConnectionLoss(tripwire, recencyWarningStart)
  118. await pick('Manual')
  119. await expect.poll(titles).toEqual(TITLES)
  120. await pick('Last updated')
  121. await pick('WorkSpace')
  122. await pick('Manual')
  123. const reconnectWarningStart = tripwire.warnings.length
  124. holdWorkspace = true
  125. await socket.close()
  126. await expect.poll(() => releaseWorkspace !== undefined).toBe(true)
  127. const workspaceTitle = basename(scaffold.workspaceCwd)
  128. // Session titles can fall back to the workspace name while reconnect projections reload.
  129. await page.locator('[role="treeitem"][aria-expanded]')
  130. .filter({ has: page.getByText(workspaceTitle, { exact: true }) }).hover()
  131. await page.getByRole('button', { name: `New session in ${workspaceTitle}` }).click()
  132. await pick('In one list')
  133. await expect.poll(titles).toEqual(['New Session', ...TITLES])
  134. await expect.poll(() => page.evaluate(() => {
  135. const { sessionId } = JSON.parse(localStorage.getItem('dsh.sessions.current')!) as { sessionId: string }
  136. const { sessionOrderByAccount } = JSON.parse(localStorage.getItem('dsh.workspace.view.v5')!) as {
  137. sessionOrderByAccount: Record<string, string[]>
  138. }
  139. return sessionOrderByAccount.__flat_session_order__?.[0] === sessionId
  140. })).toBe(true)
  141. holdWorkspace = false
  142. releaseWorkspace!()
  143. await pick('WorkSpace')
  144. acknowledgeReloadConnectionLoss(tripwire, reconnectWarningStart)
  145. await expect.poll(titles).toEqual(['New Session', ...TITLES])
  146. await compareOrRefreshGolden(
  147. join(SNAPSHOT_DIR, 'manual-blank.expected.md'),
  148. await captureSidebar(), MODE,
  149. )
  150. const blank = page.getByRole('treeitem').filter({ has: page.getByText('New Session', { exact: true }) })
  151. await expect.poll(() => blank.getAttribute('draggable')).toBe('false')
  152. const blankWarningStart = tripwire.warnings.length
  153. await page.reload({ waitUntil: 'load' })
  154. await expect.poll(titles).toEqual(['New Session', ...TITLES])
  155. acknowledgeReloadConnectionLoss(tripwire, blankWarningStart)
  156. await pick('Last updated')
  157. await expect.poll(titles).toEqual(['New Session', ...TITLES])
  158. await pick('Manual')
  159. await expect.poll(titles).toEqual(['New Session', ...TITLES])
  160. await assertFixtureInventory(SNAPSHOT_DIR, ['sidebar.expected.md', 'manual-blank.expected.md'])
  161. expect(tripwire.pageErrors).toEqual([])
  162. expect(tripwire.warnings).toEqual([])
  163. })
  164. })