sidebar-terminal.e2e.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. /** Shipped sidebar terminal over the real Loader, Remote mux, Chromium and local PTY. */
  2. import { mkdir } from 'node:fs/promises'
  3. import { fileURLToPath } from 'node:url'
  4. import { chromium, type Browser, type Page } from 'playwright'
  5. import { afterEach, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest'
  6. import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  7. import type {} from '@deepseek-ai/dsh-api-terminal-controller'
  8. import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
  9. import { createProcessInspector, type ProcessIdentity } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
  10. import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
  11. import { connectFreshWorkspace, saveFailureShot } from './support.ts'
  12. const expected = fileURLToPath(new URL('./expected/sidebar-terminal/running.expected.md', import.meta.url))
  13. const shots = fileURLToPath(new URL('../../../.artifacts/screenshots/sidebar-terminal/', import.meta.url))
  14. async function openTerminal(page: Page, waitForShell = true): Promise<void> {
  15. const expand = page.locator('[data-sidebar-right-expand]')
  16. if (await expand.isVisible()) await expand.click()
  17. const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
  18. if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click()
  19. await entry.getByRole('button', { name: /^New terminal/u }).click()
  20. if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
  21. }
  22. async function command(page: Page, text: string): Promise<void> {
  23. await page.locator('.xterm-helper-textarea:visible').click()
  24. await page.keyboard.insertText(text)
  25. await page.keyboard.press('Enter')
  26. }
  27. async function controlTransport(page: Page) {
  28. let blocked = false
  29. let close: (() => Promise<void>) | undefined
  30. await page.routeWebSocket('**/api/remote.mux', async (socket) => {
  31. if (blocked) { await socket.close(); return }
  32. const upstream = socket.connectToServer()
  33. close = async () => { await upstream.close(); await socket.close() }
  34. })
  35. return {
  36. async disconnect() {
  37. blocked = true
  38. if (close === undefined) throw new Error('Window did not establish its Remote mux')
  39. await close()
  40. },
  41. reconnect() { blocked = false },
  42. }
  43. }
  44. async function selectTerminalTheme(page: Page, name: string): Promise<void> {
  45. await page.getByRole('button', { name: 'Settings', exact: true }).click()
  46. const dialog = page.getByRole('dialog', { name: 'Settings' })
  47. const [response] = await Promise.all([
  48. page.waitForResponse(candidate => new URL(candidate.url()).pathname === '/api/settings/mutate' && candidate.request().method() === 'POST'),
  49. dialog.getByRole('button', { name, exact: true }).click(),
  50. ])
  51. expect(response.ok()).toBe(true)
  52. await page.keyboard.press('Escape')
  53. await dialog.waitFor({ state: 'hidden' })
  54. }
  55. describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
  56. let scaffold: WebScaffold
  57. let browser: Browser
  58. let page: Page
  59. let tripwire: ReturnType<typeof watchConsole>
  60. let handles: SubprocessTerminalHandle[]
  61. const inspector = createProcessInspector()
  62. const alive = (identity: ProcessIdentity) => inspector.isAlive(identity)
  63. const processIdentity = (index: number): ProcessIdentity => {
  64. const pid = handles[index]!.pid
  65. const identity = inspector.snapshot().tree(pid).find(member => member.pid === pid)
  66. if (identity === undefined) throw new Error(`Terminal process ${pid} is missing`)
  67. return identity
  68. }
  69. beforeEach(async () => {
  70. scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) })
  71. browser = await chromium.launch()
  72. const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: 'en-US', timezoneId: 'Asia/Shanghai' })
  73. page = await context.newPage()
  74. tripwire = watchConsole(page)
  75. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  76. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  77. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  78. const agent = scaffold.ctx.agents.list()[0]
  79. if (agent === undefined) throw new Error('Workspace did not create a Session')
  80. handles = []
  81. const subprocess = agent.ctx.get('subprocess')
  82. if (subprocess === undefined) throw new Error('Session subprocess provider is missing')
  83. const spawn = subprocess.spawnTerminal.bind(subprocess)
  84. vi.spyOn(subprocess, 'spawnTerminal').mockImplementation(async (spec) => {
  85. const handle = await spawn(spec)
  86. handles.push(handle)
  87. return handle
  88. })
  89. agent.session.append('turn/start', { turn: 1 })
  90. agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Open a terminal.' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
  91. agent.session.append('step/start', { turn: 1, step: 1 })
  92. agent.session.append('assistant/message', { stream: [], turn: 1, step: 1, message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'Ready for terminal input.' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }) }, { surfaceOp: 'append' })
  93. agent.session.append('step/end', { turn: 1, step: 1 })
  94. agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  95. await scaffold.ctx.sessions.flush(agent.session)
  96. await page.getByText('Ready for terminal input.').waitFor()
  97. await mkdir(shots, { recursive: true })
  98. }, 180_000)
  99. afterEach(async () => {
  100. try { await browser?.close() } finally {
  101. try { await scaffold?.close() } finally { vi.restoreAllMocks() }
  102. }
  103. })
  104. it('preserves program palettes and keeps ANSI text and cursors readable across DSH themes', async () => {
  105. onTestFailed(() => saveFailureShot(page, 'terminal-colors'))
  106. await page.emulateMedia({ colorScheme: 'light' })
  107. await openTerminal(page)
  108. const terminal = page.locator('[data-sidebar-terminal]')
  109. const screen = page.locator('.xterm-rows:visible')
  110. await command(page, "PS1=''; printf '\\033c\\033[97mBRIGHT_WHITE\\033[0m\\n'")
  111. const colorOf = async (text: string) => {
  112. const cell = screen.getByText(text, { exact: true })
  113. await cell.waitFor()
  114. return cell.evaluate(element => ({
  115. foreground: getComputedStyle(element).color, background: getComputedStyle(element).backgroundColor,
  116. }))
  117. }
  118. const lightText = await colorOf('BRIGHT_WHITE')
  119. // Compare the rendered glyph with its real surface; the raw ANSI palette remains untouched.
  120. expect(contrastRatio(lightText.foreground, 'rgb(255, 255, 255)')).toBeGreaterThanOrEqual(4.5)
  121. await command(page, "printf '\\033]4;1;#009900;255;#990099\\007\\033[31mANSI_CUSTOM\\033[38;5;255mEXTENDED_CUSTOM\\033[0m\\n'")
  122. const customLight = { ansi: await colorOf('ANSI_CUSTOM'), extended: await colorOf('EXTENDED_CUSTOM') }
  123. await selectTerminalTheme(page, 'Dark')
  124. await expect.poll(() => screen.evaluate(element => getComputedStyle(element).color)).toBe('rgb(249, 250, 251)')
  125. await selectTerminalTheme(page, 'Light')
  126. await expect.poll(() => screen.evaluate(element => getComputedStyle(element).color)).toBe('rgb(15, 17, 21)')
  127. expect({ ansi: await colorOf('ANSI_CUSTOM'), extended: await colorOf('EXTENDED_CUSTOM') }).toEqual(customLight)
  128. await command(page, "printf '\\033]104;1;255\\007'")
  129. await expect.poll(async () => (await colorOf('ANSI_CUSTOM')).foreground).not.toBe(customLight.ansi.foreground)
  130. await expect.poll(async () => (await colorOf('EXTENDED_CUSTOM')).foreground).not.toBe(customLight.extended.foreground)
  131. await command(page, "printf '\\033]10;#112233;#ddeeff;#990099\\007'")
  132. const defaults = () => terminal.evaluate(root => ({
  133. foreground: getComputedStyle(root.querySelector('.xterm-rows')!).color,
  134. background: getComputedStyle(root.querySelector('.xterm-scrollable-element')!).backgroundColor,
  135. }))
  136. const applicationDefaults = { foreground: 'rgb(17, 34, 51)', background: 'rgb(221, 238, 255)' }
  137. await expect.poll(defaults).toEqual(applicationDefaults)
  138. await selectTerminalTheme(page, 'Dark')
  139. await expect.poll(() => terminal.evaluate(root => getComputedStyle(root.querySelector('.xterm')!.parentElement!).backgroundColor))
  140. .toBe('rgb(21, 21, 23)')
  141. expect(await defaults()).toEqual(applicationDefaults)
  142. await command(page, "printf '\\033]110\\007\\033]111\\007\\033]112\\007'")
  143. await expect.poll(defaults).toEqual({ foreground: 'rgb(249, 250, 251)', background: 'rgb(21, 21, 23)' })
  144. await selectTerminalTheme(page, 'Light')
  145. await expect.poll(defaults).toEqual({ foreground: 'rgb(15, 17, 21)', background: 'rgb(255, 255, 255)' })
  146. const cursorColors = () => screen.locator('.xterm-cursor').evaluate((cursor) => {
  147. const style = getComputedStyle(cursor)
  148. return {
  149. background: style.backgroundColor, foreground: style.color,
  150. shadow: style.boxShadow, border: style.borderBottomColor, outline: style.outlineColor,
  151. }
  152. })
  153. const paintCursor = async (sgr: string, shape = 2) => {
  154. await command(page, `printf '\\033[0m\\033[2J\\033[H\\033[${sgr}mCURSOR\\033[1G\\033[${shape} q'`)
  155. await expect.poll(() => screen.locator('.xterm-cursor').innerText()).toBe('C')
  156. }
  157. // ron uses foreground 51 and background 16; no installed Vim is required by CI.
  158. await paintCursor('38;5;51;48;5;16')
  159. await expect.poll(async () => (await cursorColors()).background).toBe('rgb(255, 255, 255)')
  160. const ronLight = await cursorColors()
  161. expect(ronLight.foreground).toBe('rgb(0, 0, 0)')
  162. await terminal.screenshot({ path: `${shots}/ron-light.png`, animations: 'disabled' })
  163. await selectTerminalTheme(page, 'Dark')
  164. await page.locator('.xterm-helper-textarea:visible').click()
  165. await expect.poll(async () => (await cursorColors()).background).toBe('rgb(249, 250, 251)')
  166. await paintCursor('38;2;0;0;0;48;2;255;255;255')
  167. await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
  168. const whiteInDark = await cursorColors()
  169. await paintCursor('0;7')
  170. await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
  171. for (const shape of [4, 6]) {
  172. await paintCursor('38;5;51;48;5;16', shape)
  173. await expect.poll(async () => shape === 4 ? (await cursorColors()).border : (await cursorColors()).shadow).toContain('rgb(249, 250, 251)')
  174. }
  175. await paintCursor('38;2;0;0;0;48;2;255;255;255', 1)
  176. await page.addStyleTag({ content: '.xterm-cursor { animation-delay: -0.1s !important; animation-play-state: paused !important; }' })
  177. await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
  178. await page.addStyleTag({ content: '.xterm-cursor { animation-delay: -0.6s !important; }' })
  179. await expect.poll(async () => (await cursorColors()).background).toBe('rgb(255, 255, 255)')
  180. await page.locator('.xterm-helper-textarea:visible').evaluate((element) =>{ element.blur() })
  181. await expect.poll(async () => (await cursorColors()).outline).toBe('rgb(0, 0, 0)')
  182. await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/colors.expected.md', import.meta.url)),
  183. JSON.stringify({ lightText, customLight, ronLight, whiteInDark }, null, 2), webSnapshotMode())
  184. expect(handles).toHaveLength(1)
  185. expect(tripwire.pageErrors).toEqual([])
  186. })
  187. it('follows light, dark and system themes while preserving the running shell and its output', async () => {
  188. await page.emulateMedia({ colorScheme: 'light' })
  189. await openTerminal(page)
  190. const process = processIdentity(0)
  191. const terminal = page.locator('[data-sidebar-terminal]')
  192. const screen = page.locator('.xterm-rows:visible')
  193. await command(page, "DSH_THEME_PROBE=retained; PS1=''; printf '\\033cTHEME_CONTENT_RETAINED\\n'")
  194. await expect.poll(() => screen.innerText()).toContain('THEME_CONTENT_RETAINED')
  195. const readColors = () => terminal.evaluate((root) => {
  196. const xterm = root.querySelector('.xterm')!
  197. const rows = root.querySelector('.xterm-rows')!
  198. return {
  199. surface: getComputedStyle(xterm.parentElement!).backgroundColor,
  200. viewport: getComputedStyle(root.querySelector('.xterm-scrollable-element')!).backgroundColor,
  201. underlay: getComputedStyle(root.querySelector('.xterm-viewport')!).backgroundColor,
  202. foreground: getComputedStyle(rows).color,
  203. }
  204. })
  205. const light = await readColors()
  206. expect(light.viewport).toBe(light.surface)
  207. expect(light.underlay).toBe(light.surface)
  208. await terminal.screenshot({ path: `${shots}/theme-light.png`, animations: 'disabled' })
  209. await selectTerminalTheme(page, 'Dark')
  210. await expect.poll(async () => (await readColors()).viewport).not.toBe(light.viewport)
  211. const dark = await readColors()
  212. expect(dark.viewport).toBe(dark.surface)
  213. expect(dark.underlay).toBe(dark.surface)
  214. expect(dark.foreground).not.toBe(light.foreground)
  215. await terminal.screenshot({ path: `${shots}/theme-dark.png`, animations: 'disabled' })
  216. await selectTerminalTheme(page, 'Light')
  217. await expect.poll(readColors).toEqual(light)
  218. await selectTerminalTheme(page, 'System')
  219. await page.emulateMedia({ colorScheme: 'dark' })
  220. await expect.poll(readColors).toEqual(dark)
  221. await page.emulateMedia({ colorScheme: 'light' })
  222. await expect.poll(readColors).toEqual(light)
  223. await expect.poll(() => screen.innerText()).toContain('THEME_CONTENT_RETAINED')
  224. await command(page, 'printf "THEME_STATE:%s\\n" "$DSH_THEME_PROBE"')
  225. await expect.poll(() => screen.innerText()).toContain('THEME_STATE:retained')
  226. expect(handles).toHaveLength(1)
  227. expect(alive(process)).toBe(true)
  228. await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/theme.expected.md', import.meta.url)),
  229. JSON.stringify({ light, dark }, null, 2), webSnapshotMode())
  230. expect(tripwire.pageErrors).toEqual([])
  231. })
  232. it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => {
  233. onTestFailed(() => saveFailureShot(page, 'sidebar-terminal'))
  234. await openTerminal(page)
  235. const terminal = page.locator('[data-sidebar-terminal]')
  236. await command(page, "PS1=''; printf '\\033cTERMINAL_READY\\n'")
  237. const screen = page.locator('.xterm-rows:visible')
  238. await expect.poll(async () => await screen.innerText()).toContain('TERMINAL_READY')
  239. const aria = await terminal.ariaSnapshot()
  240. await compareOrRefreshGolden(expected, aria, webSnapshotMode())
  241. await command(page, "printf 'DSH_PID:%s\\n' \"$$\"")
  242. await expect.poll(async () => await screen.innerText()).toMatch(/DSH_PID:\d+/u)
  243. const pid = Number((await screen.innerText()).match(/DSH_PID:(\d+)/u)?.[1])
  244. const firstProcess = processIdentity(0)
  245. expect(alive(firstProcess)).toBe(true)
  246. await command(page, 'dsh_terminal_completion_probe(){ printf "completed_from_shell\\n"; }')
  247. await page.keyboard.press('Control+l')
  248. await page.keyboard.insertText('dsh_terminal_completion_pro')
  249. await page.keyboard.press('Tab')
  250. await page.keyboard.press('Enter')
  251. await expect.poll(async () => await screen.innerText()).toContain('completed_from_shell')
  252. await command(page, "printf 'PERSIST:%s\\n' \"$TERM\"")
  253. await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
  254. await page.locator('[data-dockkit-tab-title]').getByText('bash', { exact: true }).dblclick()
  255. await page.getByRole('textbox', { name: 'Terminal name', exact: true }).fill('Development')
  256. await page.getByRole('textbox', { name: 'Terminal name', exact: true }).press('Enter')
  257. await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toContain('Development')
  258. await openTerminal(page)
  259. await command(page, "printf 'SECOND_PID:%s\\n' \"$$\"")
  260. await expect.poll(async () => await screen.innerText()).toMatch(/SECOND_PID:\d+/u)
  261. const secondPid = Number((await screen.innerText()).match(/SECOND_PID:(\d+)/u)?.[1])
  262. // Shell PIDs belong to the sandbox namespace; process liveness uses Host identities.
  263. const secondProcess = processIdentity(1)
  264. expect(secondProcess.pid).not.toBe(firstProcess.pid)
  265. await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).click()
  266. await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
  267. expect(alive(secondProcess)).toBe(true)
  268. await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
  269. expect(alive(firstProcess)).toBe(true)
  270. await page.locator('[data-sidebar-right-expand]').click()
  271. const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id)
  272. const dockedCols = terminals()[0]!.cols
  273. await page.getByRole('button', { name: 'Fullscreen', exact: true }).click()
  274. await expect.poll(() => terminals()[0]!.cols).toBeGreaterThan(dockedCols)
  275. await command(page, "printf 'SIZE:'; stty size")
  276. await expect.poll(async () => await screen.innerText()).toContain(`SIZE:${terminals()[0]!.rows} ${terminals()[0]!.cols}`)
  277. await page.screenshot({ path: `${shots}/fullscreen.png`, fullPage: true })
  278. const tabIds = await page.locator('[data-dockkit-tab]').evaluateAll(tabs => tabs.map(tab => tab.getAttribute('data-dockkit-tab')))
  279. await page.reload({ waitUntil: 'load' })
  280. await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).waitFor({ timeout: 15_000 })
  281. await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Development', 'bash'])
  282. expect(terminals()).toHaveLength(2)
  283. expect(alive(firstProcess)).toBe(true)
  284. expect(alive(secondProcess)).toBe(true)
  285. expect(await page.locator('[data-dockkit-tab]').evaluateAll(tabs => tabs.map(tab => tab.getAttribute('data-dockkit-tab')))).toEqual(tabIds)
  286. await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
  287. await page.getByRole('button', { name: 'Exit fullscreen', exact: true }).waitFor()
  288. await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
  289. await page.reload({ waitUntil: 'load' })
  290. await page.locator('[data-sidebar-right-expand]').waitFor()
  291. expect(await page.locator('[data-sidebar-terminal]:visible').count()).toBe(0)
  292. expect(terminals()).toHaveLength(2)
  293. await page.locator('[data-sidebar-right-expand]').click()
  294. await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
  295. const secondTab = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' })
  296. await secondTab.click()
  297. await expect.poll(async () => await screen.innerText()).toContain(`SECOND_PID:${secondPid}`)
  298. await secondTab.hover()
  299. await secondTab.locator('[data-dockkit-tab-close]').click()
  300. await expect.poll(async () => await secondTab.count()).toBe(0)
  301. await expect.poll(() => alive(secondProcess), { timeout: 10_000 }).toBe(false)
  302. await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
  303. await command(page, "printf 'RECOVERED_PID:%s\\n' \"$$\"")
  304. await expect.poll(async () => await screen.innerText()).toContain(`RECOVERED_PID:${pid}`)
  305. await page.screenshot({ path: `${shots}/recovered.png`, fullPage: true })
  306. const previousMembers = new Set(inspector.snapshot().tree(firstProcess.pid).map(member => member.pid))
  307. await command(page, "sleep 120 & printf 'CHILD_PID:%s\\n' $!")
  308. await expect.poll(async () => await screen.innerText()).toMatch(/CHILD_PID:\d+/u)
  309. const descendants = inspector.snapshot().tree(firstProcess.pid).filter(member => member.pid !== firstProcess.pid)
  310. expect(descendants.some(member => !previousMembers.has(member.pid))).toBe(true)
  311. expect(descendants.every(alive)).toBe(true)
  312. const tab = page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' })
  313. await tab.hover()
  314. await tab.locator('[data-dockkit-tab-close]').click()
  315. await expect.poll(() => alive(firstProcess), { timeout: 10_000 }).toBe(false)
  316. await expect.poll(() => descendants.some(alive), { timeout: 10_000 }).toBe(false)
  317. await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).length).toBe(0)
  318. expect(tripwire.pageErrors).toEqual([])
  319. })
  320. it('chooses a shell from the guide menu, opens it directly, and remembers it after reload', async () => {
  321. onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice'))
  322. await page.locator('[data-sidebar-right-expand]').click()
  323. const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
  324. await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' })
  325. const selector = entry.getByRole('button', { name: 'Choose shell', exact: true })
  326. const cardBox = (await entry.boundingBox())!
  327. const triggerBox = (await selector.boundingBox())!
  328. expect(Math.abs(cardBox.x + cardBox.width - triggerBox.x - triggerBox.width)).toBeLessThanOrEqual(2)
  329. await page.emulateMedia({ colorScheme: 'dark' })
  330. await selector.click()
  331. await page.getByRole('menuitem', { name: 'bash', exact: true }).waitFor()
  332. expect(handles).toHaveLength(0)
  333. await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/guide.expected.md', import.meta.url)),
  334. await entry.ariaSnapshot(), webSnapshotMode())
  335. await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/shell-menu.expected.md', import.meta.url)),
  336. await page.getByRole('menu').ariaSnapshot(), webSnapshotMode())
  337. await page.screenshot({ path: `${shots}/shell-menu.png`, fullPage: true })
  338. await page.keyboard.press('Escape')
  339. expect(handles).toHaveLength(0)
  340. await selector.click()
  341. await page.getByRole('menuitem', { name: 'sh', exact: true }).click()
  342. expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh')
  343. await page.locator('.xterm-helper-textarea:visible').waitFor()
  344. await command(page, "printf 'CHOSEN_SHELL:%s\\n' \"$0\"")
  345. const screen = page.locator('.xterm-rows:visible')
  346. await expect.poll(() => screen.innerText()).toContain('CHOSEN_SHELL:/bin/sh')
  347. const retained = processIdentity(0)
  348. await page.reload({ waitUntil: 'load' })
  349. await page.locator('.xterm-helper-textarea:visible').waitFor()
  350. expect(alive(retained)).toBe(true)
  351. await page.locator('[data-dockkit-add-tab]').click()
  352. await selector.click()
  353. await page.getByRole('menuitem', { name: 'sh', exact: true }).waitFor()
  354. expect(await page.getByRole('menuitem', { name: 'sh', exact: true }).locator('svg').count()).toBe(1)
  355. await page.keyboard.press('Escape')
  356. await entry.getByRole('button', { name: /^New terminal/u }).click()
  357. await expect.poll(() => handles.length).toBe(2)
  358. expect(scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).map(info => info.shell.path)).toEqual(['/bin/sh', '/bin/sh'])
  359. expect(tripwire.pageErrors).toEqual([])
  360. })
  361. it('explains that exited terminals count toward the quota and permits creation after closing one', async () => {
  362. onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota'))
  363. const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id)
  364. for (let count = 1; count <= 2; count++) {
  365. await openTerminal(page)
  366. await command(page, 'exit')
  367. await expect.poll(() => terminals().filter(info => info.state === 'exited').length).toBe(count)
  368. }
  369. await openTerminal(page, false)
  370. const alert = page.getByRole('alert')
  371. await expect.poll(async () => await alert.innerText()).toContain('Exited terminals also count toward the limit.')
  372. const expectedLimit = fileURLToPath(new URL('./expected/sidebar-terminal/limit.expected.md', import.meta.url))
  373. await compareOrRefreshGolden(expectedLimit, await alert.ariaSnapshot(), webSnapshotMode())
  374. const failed = page.locator('[data-dockkit-tab][aria-selected="true"]')
  375. await failed.hover()
  376. await failed.locator('[data-dockkit-tab-close]').click()
  377. const exited = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }).first()
  378. await exited.hover()
  379. await exited.locator('[data-dockkit-tab-close]').click()
  380. await expect.poll(() => terminals().length).toBe(1)
  381. await openTerminal(page)
  382. await expect.poll(() => terminals().filter(info => info.state === 'running').length).toBe(1)
  383. expect(tripwire.pageErrors).toEqual([])
  384. })
  385. it('keeps new terminals independent when two same-origin windows mint the same tab id', async () => {
  386. onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shared-storage'))
  387. const second = await page.context().newPage()
  388. const secondErrors = watchConsole(second)
  389. await second.goto(page.url(), { waitUntil: 'load' })
  390. await second.getByText('Ready for terminal input.').waitFor()
  391. await openTerminal(page)
  392. const firstTab = await page.locator('[data-dockkit-tab][aria-selected="true"]').getAttribute('data-dockkit-tab')
  393. const firstProcess = processIdentity(0)
  394. await command(page, "printf 'WINDOW_A\\n'")
  395. await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A')
  396. await openTerminal(second)
  397. const secondTab = await second.locator('[data-dockkit-tab][aria-selected="true"]').getAttribute('data-dockkit-tab')
  398. expect(secondTab).toBe(firstTab)
  399. expect(handles).toHaveLength(2)
  400. const secondProcess = processIdentity(1)
  401. expect(secondProcess.pid).not.toBe(firstProcess.pid)
  402. await command(second, "printf 'WINDOW_B\\n'")
  403. await expect.poll(() => second.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_B')
  404. expect(await second.locator('.xterm-rows:visible').innerText()).not.toContain('WINDOW_A')
  405. expect(await page.locator('.xterm-rows:visible').innerText()).not.toContain('WINDOW_B')
  406. const bindings = () => page.evaluate(() => Object.keys(localStorage)
  407. .filter(key => key.startsWith('dsh.terminal.binding.v1.')).map(key => localStorage.getItem(key)))
  408. const saved = await bindings()
  409. expect(saved).toHaveLength(2)
  410. const selected = second.locator('[data-dockkit-tab][aria-selected="true"]')
  411. await selected.hover()
  412. await selected.locator('[data-dockkit-tab-close]').click()
  413. await expect.poll(() => alive(secondProcess)).toBe(false)
  414. expect(alive(firstProcess)).toBe(true)
  415. await expect.poll(() => bindings()).toHaveLength(1)
  416. expect(saved).toContain((await bindings())[0])
  417. await page.reload({ waitUntil: 'load' })
  418. await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A')
  419. await command(page, "printf 'WINDOW_A_RECONNECTED\\n'")
  420. await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A_RECONNECTED')
  421. expect(handles).toHaveLength(2)
  422. expect(alive(firstProcess)).toBe(true)
  423. expect(tripwire.pageErrors).toEqual([])
  424. expect(secondErrors.pageErrors).toEqual([])
  425. })
  426. it('holds a collapsed layout across windows and reclaims only after the last transport disappears', async () => {
  427. onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-window-holds'))
  428. const retains = vi.spyOn(scaffold.ctx.terminalController, 'retain')
  429. await openTerminal(page)
  430. const original = processIdentity(0)
  431. const sessionId = scaffold.ctx.agents.list()[0]!.id
  432. // Shell activity is exercised with real PTYs in the provider tests; this scenario isolates window ownership.
  433. vi.spyOn(handles[0]!, 'inspectActivity').mockResolvedValue({ state: 'idle', revision: 1 })
  434. await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
  435. const second = await page.context().newPage()
  436. const transport = await controlTransport(second)
  437. await second.goto(page.url(), { waitUntil: 'load' })
  438. await second.locator('[data-sidebar-right-expand]').waitFor()
  439. await expect.poll(() => retains.mock.calls.filter(call => !call[2].aborted).length).toBe(2)
  440. await page.close()
  441. page = second
  442. tripwire = watchConsole(page)
  443. await expect.poll(() => retains.mock.calls.filter(call => !call[2].aborted).length).toBe(1)
  444. const disconnectedAt = performance.now()
  445. await expect.poll(() => performance.now() - disconnectedAt, { timeout: 10_000 }).toBeGreaterThan(2500)
  446. expect(alive(original)).toBe(true)
  447. expect(await page.locator('[data-sidebar-terminal]:visible').count()).toBe(0)
  448. await page.context().setOffline(true)
  449. await transport.disconnect()
  450. await expect.poll(() => retains.mock.calls.every(call => call[2].aborted), { timeout: 15_000 }).toBe(true)
  451. await expect.poll(() => scaffold.ctx.terminalController.list(sessionId), { timeout: 15_000 }).toEqual([])
  452. expect(alive(original)).toBe(false)
  453. transport.reconnect()
  454. await page.context().setOffline(false)
  455. await page.reload({ waitUntil: 'load' })
  456. await page.locator('[data-sidebar-right-expand]').click()
  457. await expect.poll(() => page.getByRole('alert').innerText()).toContain('no longer exists')
  458. expect(handles).toHaveLength(1)
  459. const unavailable = fileURLToPath(new URL('./expected/sidebar-terminal/unavailable.expected.md', import.meta.url))
  460. const terminal = page.locator('[data-sidebar-terminal]')
  461. await compareOrRefreshGolden(unavailable, await terminal.ariaSnapshot(), webSnapshotMode())
  462. await terminal.screenshot({ path: `${shots}/unavailable.png`, animations: 'disabled' })
  463. const tabCount = await page.locator('[data-dockkit-tab]').count()
  464. const create = terminal.getByRole('button', { name: 'New terminal', exact: true })
  465. await create.focus()
  466. await page.keyboard.press('Enter')
  467. await expect.poll(() => handles.length).toBe(2)
  468. await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
  469. expect(await page.locator('[data-dockkit-tab]').count()).toBe(tabCount)
  470. expect(scaffold.ctx.terminalController.list(sessionId)).toHaveLength(1)
  471. expect(alive(processIdentity(1))).toBe(true)
  472. await command(page, "printf 'NEW_TERMINAL_READY\\n'")
  473. await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('NEW_TERMINAL_READY')
  474. expect(tripwire.pageErrors).toEqual([])
  475. })
  476. it('offers reconnection after transport loss and resumes the same process without a new terminal', async () => {
  477. onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-reconnect'))
  478. await openTerminal(page)
  479. const original = processIdentity(0)
  480. await command(page, "printf 'RECONNECT_READY\\n'")
  481. await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECT_READY')
  482. const transport = await controlTransport(page)
  483. await page.reload({ waitUntil: 'load' })
  484. await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECT_READY')
  485. await transport.disconnect()
  486. const reconnect = page.getByRole('button', { name: 'Reconnect', exact: true })
  487. await reconnect.waitFor()
  488. expect(await page.getByRole('alert').count()).toBe(0)
  489. expect(alive(original)).toBe(true)
  490. const disconnected = fileURLToPath(new URL('./expected/sidebar-terminal/disconnected.expected.md', import.meta.url))
  491. await compareOrRefreshGolden(disconnected, await page.getByRole('status').ariaSnapshot(), webSnapshotMode())
  492. await page.screenshot({ path: `${shots}/disconnected.png`, fullPage: true })
  493. await reconnect.click()
  494. transport.reconnect()
  495. await page.locator('[data-sidebar-terminal]').getByRole('status').waitFor({ state: 'hidden' })
  496. await command(page, "printf 'RECONNECTED_INPUT\\n'")
  497. await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECTED_INPUT')
  498. expect(handles).toHaveLength(1)
  499. expect(alive(original)).toBe(true)
  500. expect(tripwire.pageErrors).toEqual([])
  501. })
  502. })
  503. function contrastRatio(first: string, second: string): number {
  504. const luminance = (color: string) => {
  505. const [r, g, b] = color.match(/[\d.]+/gu)!.map(Number).map(channel => channel / 255)
  506. .map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)
  507. return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
  508. }
  509. const a = luminance(first), b = luminance(second)
  510. return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
  511. }