workspace-management.e2e.ts 34 KB

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