workspace-management.e2e.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. // Web e2e scenarios: workspace management — adding a workspace through the
  2. // composed directory dialog (its own New folder affordance is the product's
  3. // one creation route), the dialog's path editor walking the panes with the
  4. // typed draft, same-basename directory adoption, the rename round
  5. // trip over the real wire (workspace.rename RPC + durable registry), the
  6. // duplicate-name pre-check, the
  7. // flat "In one list" view with its persisted group-by preference, the session
  8. // hover card and row action menu, and the session archive round trip (row
  9. // menu → workspace.archiveSession RPC → durable global set → row hidden
  10. // across reload). Zero model calls: workspace.create/rename/archiveSession
  11. // are host RPCs with no model involvement, and the one session row the
  12. // flat/hover/menu/archive scenarios need comes from a seeded fixture (the
  13. // seeded-history seed reused verbatim — no new recording).
  14. import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
  15. import { fileURLToPath } from 'node:url'
  16. import { join, sep } from 'node:path'
  17. import type { Browser, Locator, Page } from 'playwright'
  18. import { chromium } from 'playwright'
  19. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  20. import { SessionId } from '@deepseek-ai/dsh-session'
  21. import { logPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts'
  22. import {
  23. acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
  24. launchWebScaffold, readPersistedEvents, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
  25. } from './scaffold.ts'
  26. import { newEnglishPage, saveFailureShot } from './support.ts'
  27. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/workspace-management', import.meta.url))
  28. // The seed is another scenario's committed fixture, reused read-only: this
  29. // spec needs any one cold session row, not new recorded content.
  30. const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
  31. const MODE = webSnapshotMode()
  32. const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
  33. const SEED_ID = 'workspace-management-web-e2e'
  34. // Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them above
  35. // that value if the shared setting changes.
  36. const POINTER_TRANSIT_MS = 300
  37. const POINTER_HOLD_MS = 600
  38. describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => {
  39. let scaffold: WebScaffold
  40. let browser: Browser
  41. let page: Page
  42. let tripwire: ReturnType<typeof watchConsole>
  43. let holdAttachment = false
  44. let releaseAttachment: (() => void) | undefined
  45. /**
  46. * Raise the region header's directory dialog and drive it to a directory via
  47. * the path-edit affordance. Adding is the header button's only action, so
  48. * the click lands in the dialog with no menu in between.
  49. */
  50. async function browseTo(path: string): Promise<Locator> {
  51. await page.getByRole('button', { name: 'Add workspace' }).click()
  52. const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
  53. await dialog.waitFor({ timeout: 10_000 })
  54. await dialog.getByRole('button', { name: 'Edit path' }).click()
  55. const pathInput = dialog.locator('input[aria-label="Edit path"]')
  56. await pathInput.fill(path)
  57. // Enter's keydown can retire the editor before keyup; target the focused keyboard, not that retiring node.
  58. await page.keyboard.press('Enter')
  59. await pathInput.waitFor({ state: 'detached', timeout: 10_000 })
  60. await dialog.getByRole('button', { name: 'Edit path', exact: true }).waitFor()
  61. return dialog
  62. }
  63. /**
  64. * Create a folder inside `parent` through the dialog and adopt it — the
  65. * product's only route to a brand-new workspace directory.
  66. */
  67. async function addNewFolderWorkspace(parent: string, name: string): Promise<void> {
  68. const dialog = await browseTo(parent)
  69. await dialog.getByRole('button', { name: 'New folder' }).click()
  70. await page.getByLabel('Folder name').fill(name)
  71. await page.getByRole('button', { name: 'Create', exact: true }).click()
  72. // Creating selects the new folder in the listing; Open adopts it.
  73. await dialog.getByRole('button', { name: 'Open', exact: true }).click()
  74. await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
  75. await expect.poll(
  76. () => scaffold.ctx.workspaceRegistry.resolveByPath(join(parent, name)),
  77. { timeout: 10_000 },
  78. ).not.toBeUndefined()
  79. // Adoption also opens a blank Session. Its selected row must reach the
  80. // browser before a later workspace action can depend on the row positions.
  81. const row = page.getByRole('treeitem').filter({ hasText: name }).first()
  82. const section = row.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]')
  83. await section.locator('[role="treeitem"][aria-selected="true"]').waitFor({ timeout: 10_000 })
  84. }
  85. /**
  86. * Adopt an existing directory. Fresh-agent callers also wait for the
  87. * browser's Session switch and composer focus before starting another flow.
  88. */
  89. async function adoptDirectory(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
  90. const agentsBefore = scaffold.ctx.agents.list().length
  91. const dialog = await browseTo(path)
  92. await dialog.getByRole('button', { name: 'Open', exact: true }).click()
  93. await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
  94. await expect.poll(
  95. () => scaffold.ctx.workspaceRegistry.resolveByPath(path),
  96. { timeout: 10_000 },
  97. ).not.toBeUndefined()
  98. // First adoption births a blank Session+Agent whose workspace attach must
  99. // settle before a test may delete the registration; re-registration after
  100. // a delete mints a fresh blank Session+Agent too (no cwd-based reuse
  101. // exists), so callers opt in only where a fresh attach is possible.
  102. if (options.waitForAgent === true) {
  103. await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
  104. .toBeGreaterThan(agentsBefore)
  105. // Host publication precedes the create RPC response. A late Session
  106. // switch focuses the composer and cancels an open path editor on blur.
  107. await expect.poll(
  108. () => page.locator('[data-composer-input][contenteditable="true"]')
  109. .evaluate(element => element === document.activeElement),
  110. { timeout: 10_000 },
  111. ).toBe(true)
  112. }
  113. }
  114. /**
  115. * Reveal and click a row action, re-hovering if a projection update replaces
  116. * the row before its hover-only button becomes visible.
  117. */
  118. async function clickHoverAction(row: Locator, name: string): Promise<void> {
  119. const button = row.getByRole('button', { name })
  120. await expect.poll(async () => {
  121. await row.hover()
  122. return await button.isVisible()
  123. }, { timeout: 10_000 }).toBe(true)
  124. await button.click()
  125. }
  126. beforeAll(async () => {
  127. scaffold = await launchWebScaffold({})
  128. // Seed one cold session (Ungrouped bucket) for the flat view + hover card.
  129. const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
  130. await mkdir(sessionCwd, { recursive: true })
  131. await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
  132. await writeFile(join(sessionCwd, 'b.txt'), 'beta\n')
  133. await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID)
  134. browser = await chromium.launch()
  135. page = await newEnglishPage(browser)
  136. await page.routeWebSocket('**/api/remote.mux', (route) => {
  137. const server = route.connectToServer()
  138. server.onMessage((message) => {
  139. const frame = JSON.parse(String(message)) as {
  140. type: string
  141. value?: { type: string; workspace?: { sessionIds: string[] } }
  142. }
  143. if (holdAttachment && frame.type === 'item' && frame.value?.type === 'upsert'
  144. && frame.value.workspace?.sessionIds.includes(SEED_ID) === true) {
  145. holdAttachment = false
  146. releaseAttachment = () => { route.send(message) }
  147. return
  148. }
  149. route.send(message)
  150. })
  151. })
  152. tripwire = watchConsole(page)
  153. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  154. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  155. }, 120_000)
  156. afterAll(async () => {
  157. await browser?.close()
  158. releaseAttachment = undefined
  159. await scaffold?.close()
  160. })
  161. it('adds two workspaces through the dialog, each on a folder it created', async () => {
  162. onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-create'))
  163. const add = async (name: string): Promise<void> => {
  164. await addNewFolderWorkspace(scaffold.workspaceCwd, name)
  165. // The real workspace materializes in the tree as a group row.
  166. await expect.poll(() => page.getByText(name, { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
  167. }
  168. await add('alpha-ws')
  169. await add('beta-ws')
  170. // Durable on the host: both registered, newest first (create prepends),
  171. // each titled after the folder the dialog made.
  172. const titles = scaffold.ctx.workspaceRegistry.list().map(workspace => workspace.title)
  173. expect(titles.slice(0, 2)).toEqual(['beta-ws', 'alpha-ws'])
  174. expect(tripwire.pageErrors).toEqual([])
  175. }, 90_000)
  176. it('renames a workspace over the wire with a duplicate-name pre-check', async () => {
  177. onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-rename'))
  178. const alphaRow = page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first()
  179. await clickHoverAction(alphaRow, 'Workspace actions for alpha-ws')
  180. await page.getByRole('menuitem', { name: 'Rename' }).click()
  181. const dialog = page.getByRole('dialog', { name: 'Rename workspace' })
  182. await dialog.waitFor({ timeout: 10_000 })
  183. const input = dialog.getByLabel('Workspace name')
  184. // Client pre-check: a name colliding with another live workspace raises
  185. // the inline alert and blocks the primary button before any wire call.
  186. await input.fill('beta-ws')
  187. await expect.poll(() => dialog.getByRole('alert').count(), { timeout: 5_000 }).toBe(1)
  188. expect(await dialog.getByRole('button', { name: 'Rename' }).isDisabled()).toBe(true)
  189. // A fresh name goes through workspace.rename to the durable registry.
  190. await input.fill('gamma-ws')
  191. await expect.poll(() => dialog.getByRole('alert').count(), { timeout: 5_000 }).toBe(0)
  192. await dialog.getByRole('button', { name: 'Rename' }).click()
  193. await expect.poll(() => page.getByRole('dialog', { name: 'Rename workspace' }).count(), { timeout: 10_000 }).toBe(0)
  194. await expect.poll(() => page.getByText('gamma-ws', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
  195. expect(await page.getByText('alpha-ws', { exact: true }).count()).toBe(0)
  196. // Host durability, then reload: the projection is rebuilt from the wire.
  197. expect(scaffold.ctx.workspaceRegistry.list().map(workspace => workspace.title)).toContain('gamma-ws')
  198. const warningStart = tripwire.warnings.length
  199. await page.reload({ waitUntil: 'load' })
  200. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  201. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  202. await expect.poll(() => page.getByText('gamma-ws', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
  203. // Session restoration focuses the composer; the Workspace list can arrive
  204. // first. Do not let that focus cancel the next directory dialog's path draft.
  205. const composer = page.locator('[data-composer-input][contenteditable="true"]')
  206. await expect.poll(() => composer.evaluate(element => document.activeElement === element), { timeout: 10_000 }).toBe(true)
  207. expect(tripwire.pageErrors).toEqual([])
  208. }, 90_000)
  209. it('deletes only the Workspace registration and keeps its current Session, folder, and log', async () => {
  210. onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-delete'))
  211. const slotConsoleErrors: string[] = []
  212. const transientSlotErrors: string[] = []
  213. page.on('console', (message) => {
  214. if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) {
  215. slotConsoleErrors.push(message.text())
  216. }
  217. })
  218. await page.exposeFunction('recordDshSlotError', (key: string) => {
  219. if (!transientSlotErrors.includes(key)) transientSlotErrors.push(key)
  220. })
  221. await page.evaluate(() => {
  222. const target = window as unknown as { recordDshSlotError(key: string): Promise<void> }
  223. const seen = new Set<string>()
  224. const collect = (): void => {
  225. for (const node of document.querySelectorAll<HTMLElement>('[data-slot-error]')) {
  226. const key = node.dataset.slotError ?? ''
  227. if (!seen.has(key)) {
  228. seen.add(key)
  229. void target.recordDshSlotError(key)
  230. }
  231. }
  232. }
  233. new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true })
  234. collect()
  235. })
  236. // Register the scaffold's existing project directory through the real UI.
  237. await adoptDirectory(scaffold.workspaceCwd, { waitForAgent: true })
  238. const workspace = await scaffold.ctx.workspaceRegistry.resolveByPath(scaffold.workspaceCwd)
  239. if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
  240. holdAttachment = true
  241. await workspace.attachSession(SessionId(SEED_ID))
  242. const header = (await scaffold.ctx.sessionPersistence.list())
  243. .map(snapshot => snapshot.header)
  244. .find(candidate => candidate.id === SEED_ID)
  245. if (header === undefined) throw new Error('seeded Session log disappeared before deletion')
  246. const seededLogPath = logPath(scaffold.persistenceRoot, header.cwd, header.id, 'zstd')
  247. expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
  248. await stat(seededLogPath)
  249. // The attachment stream can lag the Host commit while New Session is
  250. // already visible. A row count or position cannot identify the seed.
  251. const groupRow = page.locator('[role="treeitem"]').filter({ hasText: workspace.title }).first()
  252. await groupRow.waitFor({ timeout: 10_000 })
  253. // The header row is wrapped by its HoverCard anchor span, so the section
  254. // is the nearest groupSection ancestor, not the immediate parent.
  255. const groupSection = groupRow.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]')
  256. if (await groupRow.getAttribute('aria-expanded') !== 'true') await groupRow.click()
  257. const blankRow = groupSection.getByRole('treeitem', { name: 'New Session', exact: true })
  258. await expect.poll(() => blankRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
  259. // The seed is this account's only non-blank Session; its title changes on resume.
  260. const seededRow = groupSection.locator('[role="treeitem"][aria-selected]')
  261. .filter({ hasNot: page.getByText('New Session', { exact: true }) })
  262. await expect.poll(() => releaseAttachment, { timeout: 10_000 }).toBeDefined()
  263. expect(await seededRow.count()).toBe(0)
  264. const deliverAttachment = releaseAttachment!
  265. releaseAttachment = undefined
  266. deliverAttachment()
  267. await seededRow.click({ timeout: 10_000 })
  268. await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
  269. await clickHoverAction(groupRow, `Workspace actions for ${workspace.title}`)
  270. await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
  271. const dialog = page.getByRole('dialog', { name: 'Delete workspace' })
  272. await dialog.waitFor({ timeout: 10_000 })
  273. const copy = await dialog.textContent()
  274. expect(copy).toContain('workspace list')
  275. expect(copy).toContain('folder and session logs will be kept')
  276. expect(copy).toContain('sessions will appear under Ungrouped')
  277. await dialog.getByRole('button', { name: 'Delete workspace' }).click()
  278. await expect.poll(() => dialog.count(), { timeout: 10_000 }).toBe(0)
  279. expect(scaffold.ctx.workspaceRegistry.get(workspace.id)).toBeUndefined()
  280. await expect.poll(
  281. () => page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).count(),
  282. { timeout: 10_000 },
  283. ).toBe(0)
  284. await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
  285. .toBeGreaterThanOrEqual(1)
  286. await expect.poll(
  287. () => page.locator('[role="treeitem"][aria-selected="true"]').count(),
  288. { timeout: 10_000 },
  289. ).toBe(1)
  290. expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
  291. await stat(seededLogPath)
  292. expect((await readPersistedEvents(scaffold, SessionId(SEED_ID))).length).toBeGreaterThan(0)
  293. // Re-registering the exact deleted path immediately, without a reload, is
  294. // a supported reversible flow. It creates a fresh Workspace id and does
  295. // NOT re-adopt the retained (non-blank) Session; the New Session flow
  296. // mints a fresh blank session and attaches it to the new registration
  297. // (no cwd-based blank reuse exists, so the account is never empty).
  298. await adoptDirectory(scaffold.workspaceCwd)
  299. await expect.poll(
  300. () => scaffold.ctx.workspaceRegistry.resolveByPath(scaffold.workspaceCwd),
  301. { timeout: 10_000 },
  302. ).not.toBeUndefined()
  303. const reregistered = await scaffold.ctx.workspaceRegistry.resolveByPath(scaffold.workspaceCwd)
  304. expect(reregistered?.id).toBeDefined()
  305. expect(reregistered?.id).not.toBe(workspace.id)
  306. await expect.poll(
  307. () => reregistered?.sessionIds ?? [],
  308. { timeout: 10_000 },
  309. ).not.toEqual([])
  310. expect(reregistered?.sessionIds).not.toContain(SEED_ID)
  311. await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 })
  312. .toBeGreaterThanOrEqual(1)
  313. expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
  314. await stat(seededLogPath)
  315. // Restore the deleted-registry state so reload still verifies deletion
  316. // persistence independently of the successful re-registration above.
  317. if (reregistered === undefined) throw new Error('same-path re-registration did not materialize')
  318. await scaffold.ctx.workspaceRegistry.delete(reregistered.id)
  319. await expect.poll(
  320. () => page.getByRole('button', { name: `Workspace actions for ${reregistered.title}` }).count(),
  321. { timeout: 10_000 },
  322. ).toBe(0)
  323. const warningStart = tripwire.warnings.length
  324. await page.reload({ waitUntil: 'load' })
  325. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  326. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  327. await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 })
  328. .toBeGreaterThanOrEqual(1)
  329. await expect.poll(
  330. () => page.locator('[role="treeitem"][aria-selected="true"]').count(),
  331. { timeout: 15_000 },
  332. ).toBe(1)
  333. expect(scaffold.ctx.workspaceRegistry.get(workspace.id)).toBeUndefined()
  334. expect(await readFile(join(scaffold.workspaceCwd, 'workspace', 'a.txt'), 'utf8')).toBe('alpha\n')
  335. await stat(seededLogPath)
  336. expect((await readPersistedEvents(scaffold, SessionId(SEED_ID))).length).toBeGreaterThan(0)
  337. expect(transientSlotErrors).toEqual([])
  338. expect(slotConsoleErrors).toEqual([])
  339. expect(tripwire.pageErrors).toEqual([])
  340. }, 90_000)
  341. it('reuses a deleted title for a different new directory without any transient error surface', async () => {
  342. onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-reuse-title'))
  343. const title = 'same-name'
  344. const oldPath = join(scaffold.workspaceCwd, 'adopted', title)
  345. await mkdir(oldPath, { recursive: true })
  346. const transientErrors: string[] = []
  347. const consoleErrors: string[] = []
  348. page.on('console', (message) => {
  349. if (message.type() === 'error') consoleErrors.push(message.text())
  350. })
  351. await page.exposeFunction('recordDshTransientWorkspaceError', (message: string) => {
  352. if (!transientErrors.includes(message)) transientErrors.push(message)
  353. })
  354. await page.evaluate(() => {
  355. const target = window as unknown as {
  356. recordDshTransientWorkspaceError(message: string): Promise<void>
  357. }
  358. const collect = (): void => {
  359. for (const node of document.querySelectorAll<HTMLElement>('[data-slot-error], [role="alert"]')) {
  360. const message = node.dataset.slotError ?? node.textContent?.trim() ?? ''
  361. if (message !== '') void target.recordDshTransientWorkspaceError(message)
  362. }
  363. }
  364. new MutationObserver(collect).observe(document.documentElement, { childList: true, subtree: true })
  365. collect()
  366. })
  367. await adoptDirectory(oldPath)
  368. await expect.poll(
  369. () => scaffold.ctx.workspaceRegistry.resolveByPath(oldPath),
  370. { timeout: 10_000 },
  371. ).not.toBeUndefined()
  372. const oldWorkspace = await scaffold.ctx.workspaceRegistry.resolveByPath(oldPath)
  373. if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered')
  374. const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first()
  375. await clickHoverAction(oldRow, `Workspace actions for ${title}`)
  376. await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
  377. await page.getByRole('dialog', { name: 'Delete workspace' })
  378. .getByRole('button', { name: 'Delete workspace' }).click()
  379. await expect.poll(() => scaffold.ctx.workspaceRegistry.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined()
  380. await addNewFolderWorkspace(scaffold.workspaceCwd, title)
  381. const fresh = scaffold.ctx.workspaceRegistry.list().find(workspace => workspace.title === title)
  382. expect(fresh?.id).toBeDefined()
  383. expect(fresh?.id).not.toBe(oldWorkspace.id)
  384. expect(fresh?.path).toBe(join(scaffold.workspaceCwd, title))
  385. expect(transientErrors).toEqual([])
  386. expect(consoleErrors).toEqual([])
  387. expect(tripwire.pageErrors).toEqual([])
  388. }, 90_000)
  389. it('switches to the flat "In one list" view and persists the preference', async () => {
  390. onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-flat'))
  391. // Grouped default: workspace group rows render (the seeded session sits
  392. // under Ungrouped; the created workspaces are empty groups).
  393. await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  394. // Grouping and ordering moved into the View options menu.
  395. await page.getByRole('button', { name: 'View options' }).click()
  396. await page.getByRole('menuitem', { name: 'In one list' }).click()
  397. // Flat mode: the section label flips and the seeded session is a
  398. // top-level row with no group headers above it.
  399. await expect.poll(() => page.getByText('Sessions', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
  400. await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
  401. await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
  402. expect(await page.evaluate(() => localStorage.getItem('dsh.workspace.view.v5'))).toContain('flat')
  403. // Persisted across reload; then restore grouped for inter-spec hygiene.
  404. const warningStart = tripwire.warnings.length
  405. await page.reload({ waitUntil: 'load' })
  406. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  407. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  408. await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 15_000 }).toBe(0)
  409. await page.getByRole('button', { name: 'View options' }).click()
  410. await page.getByRole('menuitem', { name: 'WorkSpace' }).click()
  411. await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
  412. expect(tripwire.pageErrors).toEqual([])
  413. }, 90_000)
  414. it('matches the directory-browser dialog aria golden at a staged directory', async () => {
  415. // A staged subtree under the scaffold cwd keeps the listing deterministic
  416. // (normalizeAria scrubs the cwd), and pointing the in-process host's HOME
  417. // at the cwd collapses the breadcrumb ancestry into the Home crumb — no
  418. // machine-specific path segments or real $HOME contents enter the golden.
  419. const staged = join(scaffold.workspaceCwd, 'browse-golden')
  420. await mkdir(join(staged, 'alpha'), { recursive: true })
  421. await mkdir(join(staged, 'beta'), { recursive: true })
  422. // homedir() reads HOME on POSIX and USERPROFILE on Windows: root both
  423. // at the scaffold cwd so the golden's ancestry collapses everywhere.
  424. const realHome = process.env.HOME
  425. const realUserProfile = process.env.USERPROFILE
  426. process.env.HOME = scaffold.workspaceCwd
  427. process.env.USERPROFILE = scaffold.workspaceCwd
  428. try {
  429. const dialog = await browseTo(staged)
  430. await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  431. const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
  432. await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE)
  433. await dialog.getByRole('button', { name: 'Cancel' }).click()
  434. await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
  435. } finally {
  436. if (realHome === undefined) delete process.env.HOME
  437. else process.env.HOME = realHome
  438. if (realUserProfile === undefined) delete process.env.USERPROFILE
  439. else process.env.USERPROFILE = realUserProfile
  440. }
  441. expect(tripwire.pageErrors).toEqual([])
  442. }, 60_000)
  443. it('walks the panes with the typed path: deeper past a separator, back up on erase, whole on a miss', async () => {
  444. // The panes must track the draft without leaving the editor, so the
  445. // typed text and what is listed under it never disagree.
  446. // Staged by this scenario itself (mkdir is recursive and idempotent), so
  447. // running it alone through -t sees the same tree the assertions describe.
  448. const staged = join(scaffold.workspaceCwd, 'browse-golden')
  449. await mkdir(join(staged, 'alpha', 'only-under-alpha'), { recursive: true })
  450. await mkdir(join(staged, 'beta'), { recursive: true })
  451. const dialog = await browseTo(staged)
  452. await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  453. await dialog.getByRole('button', { name: 'Edit path' }).click()
  454. const path = dialog.getByLabel('Edit path')
  455. // A directory part no pane lists: the panes walk to it, landing the
  456. // ordinary two-pane Miller view (level | its children) with the editor
  457. // still up and the draft intact.
  458. await path.fill(`${join(staged, 'alpha')}${sep}`)
  459. await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  460. await expect.poll(() => dialog.getByRole('list').count(), { timeout: 10_000 }).toBe(2)
  461. expect(await path.inputValue()).toBe(`${join(staged, 'alpha')}${sep}`)
  462. // Erasing back past the separator walks the panes up, so the level being
  463. // typed is the last pane again (its children no longer stand to its
  464. // right) and the tail filters it.
  465. await path.fill(`${staged}${sep}al`)
  466. await expect.poll(() => dialog.getByText('only-under-alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
  467. expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1)
  468. expect(await dialog.getByText('beta', { exact: true }).count()).toBe(0)
  469. await expect.poll(() => dialog.getByRole('list').count(), { timeout: 10_000 }).toBe(2)
  470. // A tail nobody matches is a name still being spelled: the level shows
  471. // whole instead of emptying under it.
  472. await path.fill(`${staged}${sep}zzz`)
  473. await expect.poll(() => dialog.getByText('beta', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
  474. expect(await dialog.getByText('alpha', { exact: true }).count()).toBe(1)
  475. await dialog.getByRole('button', { name: 'Cancel' }).click()
  476. await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
  477. expect(tripwire.pageErrors).toEqual([])
  478. }, 60_000)
  479. /**
  480. * Expand Ungrouped and return its only non-blank session row. A selected
  481. * blank Session from a deleted Workspace may also be visible, without actions.
  482. * @returns the session row locator, already present.
  483. */
  484. async function seededSessionRow() {
  485. const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
  486. const ungroupedSection = ungroupedRow.locator('..')
  487. // Initial-current auto-expansion can race this gesture; converge on
  488. // expanded rather than assuming which update wins first.
  489. await expect.poll(async () => {
  490. if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
  491. await page.getByText('Ungrouped', { exact: true }).click()
  492. await page.waitForTimeout(50)
  493. }
  494. return await ungroupedRow.getAttribute('aria-expanded')
  495. }, { timeout: 5_000 }).toBe('true')
  496. // CSS includes the actions button while it is hidden until row hover.
  497. const rows = ungroupedSection.locator('[role="treeitem"]')
  498. .filter({ has: page.locator('button[aria-label^="Session actions for "]') })
  499. await expect.poll(() => rows.count(), { timeout: 10_000 }).toBe(1)
  500. return rows.first()
  501. }
  502. it('shows the session hover card after a dwell on the row', async () => {
  503. onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
  504. // Dwell on the seeded row; the card opens after a 500ms hover delay,
  505. // portaled to body.
  506. const sessionRow = await seededSessionRow()
  507. const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
  508. await sessionRow.hover()
  509. // Card content: the full title plus the Idle status line (no aria role —
  510. // text anchors are the stable selector).
  511. await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
  512. // The card is REACHABLE: it sits 8px off the row, so getting to it means
  513. // crossing ground that belongs to neither. Hovering it must not dismiss
  514. // it — the hazard this scenario pins.
  515. const card = page.getByRole('button', { name: `Copy: ${rowTitle}` })
  516. await card.hover()
  517. await page.waitForTimeout(POINTER_HOLD_MS)
  518. expect(await page.getByText('Idle', { exact: true }).count()).toBeGreaterThanOrEqual(1)
  519. // The full title is the card's primary value: activating anywhere on the
  520. // card writes it through the browser clipboard and localizes the success
  521. // feedback through the English locale seat.
  522. await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
  523. const cardHeight = (await card.boundingBox())?.height
  524. await card.click()
  525. const copied = page.getByRole('status').getByText('Copied', { exact: true })
  526. await copied.waitFor({ timeout: 5_000 })
  527. await page.waitForTimeout(POINTER_HOLD_MS)
  528. expect((await card.boundingBox())?.height).toBe(cardHeight)
  529. expect(await copied.isVisible()).toBe(true)
  530. expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(rowTitle)
  531. // Leaving anchor and card together closes it after the grace.
  532. await page.getByRole('button', { name: 'Settings' }).hover()
  533. await expect.poll(() => card.count(), { timeout: 5_000 }).toBe(0)
  534. expect(tripwire.pageErrors).toEqual([])
  535. }, 60_000)
  536. it('keeps an open row menu up while the pointer moves between trigger and list', async () => {
  537. onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu'))
  538. const sessionRow = await seededSessionRow()
  539. // The trigger is display:none until its row hovers.
  540. const trigger = sessionRow.locator('button[aria-label^="Session actions for "]')
  541. const triggerName = await trigger.getAttribute('aria-label')
  542. if (triggerName === null) throw new Error('seeded Session row has no actions label')
  543. await clickHoverAction(sessionRow, triggerName)
  544. const item = page.getByRole('menuitem', { name: 'Rename' })
  545. await item.waitFor({ timeout: 5_000 })
  546. // Into the list, then back up to the trigger across the 4px gap below it:
  547. // without the gap-crossing grace, that return trip fires the list's
  548. // pointerleave and closes the menu — a hesitating pointer loses it.
  549. // Order matters — clicking leaves the pointer ON the trigger, so entering
  550. // the list has to come first for the return to be a real departure.
  551. await item.hover()
  552. await page.waitForTimeout(POINTER_TRANSIT_MS)
  553. await trigger.hover()
  554. await page.waitForTimeout(POINTER_HOLD_MS)
  555. expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
  556. // ...and back down into the list, which must still be there to enter.
  557. await item.hover()
  558. await page.waitForTimeout(POINTER_HOLD_MS)
  559. expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
  560. // Pointer-leave dismissal still applies once the pointer genuinely leaves.
  561. await page.getByRole('button', { name: 'Settings' }).hover()
  562. await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0)
  563. expect(tripwire.pageErrors).toEqual([])
  564. }, 60_000)
  565. it('archives the seeded session from its row menu, hiding it durably across reload', async () => {
  566. onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive'))
  567. const initialRow = await seededSessionRow()
  568. // Selecting the seed hides any blank stray left by Workspace deletion,
  569. // so archiving this last visible Ungrouped Session must remove the bucket.
  570. await initialRow.click()
  571. const { title } = await scaffold.ctx.sessionController.rename({
  572. sessionId: SessionId(SEED_ID), title: `Archive target ${SEED_ID}`,
  573. })
  574. // A user-owned title binds the locator to this seed across restoration.
  575. const sessionRow = page.getByRole('treeitem').filter({
  576. has: page.getByText(title, { exact: true }),
  577. })
  578. await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(1)
  579. await expect.poll(() => sessionRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
  580. const ungroupedSection = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..').locator('..')
  581. await expect.poll(() => ungroupedSection.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(2)
  582. // Row menu: hover reveals the actions button; Archive session commits
  583. // without a confirmation dialog (non-destructive: log + accounting stay).
  584. await clickHoverAction(sessionRow, `Session actions for ${title}`)
  585. await page.getByRole('menuitem', { name: 'Archive session' }).click()
  586. // The row disappears on the archive-set echo; with no other visible
  587. // stray, the whole Ungrouped bucket withdraws.
  588. await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(0)
  589. await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
  590. // Durable on the host: the registry-global set carries the id while the
  591. // session log itself stays in persistence untouched.
  592. expect([...scaffold.ctx.workspaceRegistry.archivedSessionIds]).toEqual([SessionId(SEED_ID)])
  593. expect((await scaffold.ctx.sessionPersistence.list()).map(snapshot => snapshot.header.id)).toContain(SessionId(SEED_ID))
  594. // Reload: the hidden state is rebuilt from the workspace.list baseline.
  595. const warningStart = tripwire.warnings.length
  596. await page.reload({ waitUntil: 'load' })
  597. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  598. acknowledgeReloadConnectionLoss(tripwire, warningStart)
  599. await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
  600. // Initial Workspace reconnection can focus the composer after the tree renders.
  601. // Finish that navigation before the next test opens a path editor.
  602. await page.locator('[role="treeitem"][aria-selected="true"]').waitFor({ timeout: 15_000 })
  603. await expect.poll(
  604. () => page.locator('[data-composer-input][contenteditable="true"]')
  605. .evaluate(element => element === document.activeElement),
  606. { timeout: 15_000 },
  607. ).toBe(true)
  608. // The archived row must not resurface (the Ungrouped bucket itself may
  609. // reappear if selection restore lands on another stray — not this test's
  610. // concern).
  611. expect(await sessionRow.count()).toBe(0)
  612. expect(tripwire.pageErrors).toEqual([])
  613. }, 90_000)
  614. it('opens folders with identical basenames as distinct workspaces', async () => {
  615. onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-duplicate-basename'))
  616. const firstPath = join(scaffold.workspaceCwd, 'same-basename-a', 'xx')
  617. const secondPath = join(scaffold.workspaceCwd, 'same-basename-b', 'xx')
  618. await mkdir(firstPath, { recursive: true })
  619. await mkdir(secondPath, { recursive: true })
  620. await adoptDirectory(firstPath, { waitForAgent: true })
  621. await adoptDirectory(secondPath, { waitForAgent: true })
  622. const matchingWorkspaces = scaffold.ctx.workspaceRegistry.list()
  623. .filter(workspace => workspace.title === 'xx')
  624. expect(matchingWorkspaces.map(workspace => workspace.path).sort())
  625. .toEqual([firstPath, secondPath].sort())
  626. await expect.poll(
  627. () => page.locator('button[aria-label="Workspace actions for xx"]').count(),
  628. { timeout: 10_000 },
  629. ).toBe(2)
  630. expect(tripwire.pageErrors).toEqual([])
  631. }, 90_000)
  632. it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
  633. expect(tripwire.warnings).toEqual([])
  634. // The directory-browser aria golden is this spec's one owned artifact;
  635. // the seed it reuses is owned (and inventory-guarded) by seeded-history.
  636. await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md'])
  637. })
  638. })