| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533 |
- /** Shipped sidebar terminal over the real Loader, Remote mux, Chromium and local PTY. */
- import { mkdir } from 'node:fs/promises'
- import { fileURLToPath } from 'node:url'
- import { chromium, type Browser, type Page } from 'playwright'
- import { afterEach, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest'
- import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
- import type {} from '@deepseek-ai/dsh-api-terminal-controller'
- import type { SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess'
- import { createProcessInspector, type ProcessIdentity } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
- import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
- import { connectFreshWorkspace, saveFailureShot } from './support.ts'
- const expected = fileURLToPath(new URL('./expected/sidebar-terminal/running.expected.md', import.meta.url))
- const shots = fileURLToPath(new URL('../../../.artifacts/screenshots/sidebar-terminal/', import.meta.url))
- async function openTerminal(page: Page, waitForShell = true): Promise<void> {
- const expand = page.locator('[data-sidebar-right-expand]')
- if (await expand.isVisible()) await expand.click()
- const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
- if (!await entry.isVisible()) await page.locator('[data-dockkit-add-tab]').click()
- await entry.getByRole('button', { name: /^New terminal/u }).click()
- if (waitForShell) await expect.poll(async () => await page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
- }
- async function command(page: Page, text: string): Promise<void> {
- await page.locator('.xterm-helper-textarea:visible').click()
- await page.keyboard.insertText(text)
- await page.keyboard.press('Enter')
- }
- async function controlTransport(page: Page) {
- let blocked = false
- let close: (() => Promise<void>) | undefined
- await page.routeWebSocket('**/api/remote.mux', async (socket) => {
- if (blocked) { await socket.close(); return }
- const upstream = socket.connectToServer()
- close = async () => { await upstream.close(); await socket.close() }
- })
- return {
- async disconnect() {
- blocked = true
- if (close === undefined) throw new Error('Window did not establish its Remote mux')
- await close()
- },
- reconnect() { blocked = false },
- }
- }
- async function selectTerminalTheme(page: Page, name: string): Promise<void> {
- await page.getByRole('button', { name: 'Settings', exact: true }).click()
- const dialog = page.getByRole('dialog', { name: 'Settings' })
- const [response] = await Promise.all([
- page.waitForResponse(candidate => new URL(candidate.url()).pathname === '/api/settings/mutate' && candidate.request().method() === 'POST'),
- dialog.getByRole('button', { name, exact: true }).click(),
- ])
- expect(response.ok()).toBe(true)
- await page.keyboard.press('Escape')
- await dialog.waitFor({ state: 'hidden' })
- }
- describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
- let scaffold: WebScaffold
- let browser: Browser
- let page: Page
- let tripwire: ReturnType<typeof watchConsole>
- let handles: SubprocessTerminalHandle[]
- const inspector = createProcessInspector()
- const alive = (identity: ProcessIdentity) => inspector.isAlive(identity)
- const processIdentity = (index: number): ProcessIdentity => {
- const pid = handles[index]!.pid
- const identity = inspector.snapshot().tree(pid).find(member => member.pid === pid)
- if (identity === undefined) throw new Error(`Terminal process ${pid} is missing`)
- return identity
- }
- beforeEach(async () => {
- scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) })
- browser = await chromium.launch()
- const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: 'en-US', timezoneId: 'Asia/Shanghai' })
- page = await context.newPage()
- tripwire = watchConsole(page)
- await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
- await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
- await connectFreshWorkspace(page, scaffold.workspaceCwd)
- const agent = scaffold.ctx.agents.list()[0]
- if (agent === undefined) throw new Error('Workspace did not create a Session')
- handles = []
- const subprocess = agent.ctx.get('subprocess')
- if (subprocess === undefined) throw new Error('Session subprocess provider is missing')
- const spawn = subprocess.spawnTerminal.bind(subprocess)
- vi.spyOn(subprocess, 'spawnTerminal').mockImplementation(async (spec) => {
- const handle = await spawn(spec)
- handles.push(handle)
- return handle
- })
- agent.session.append('turn/start', { turn: 1 })
- agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Open a terminal.' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
- agent.session.append('step/start', { turn: 1, step: 1 })
- 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' })
- agent.session.append('step/end', { turn: 1, step: 1 })
- agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await scaffold.ctx.sessions.flush(agent.session)
- await page.getByText('Ready for terminal input.').waitFor()
- await mkdir(shots, { recursive: true })
- }, 180_000)
- afterEach(async () => {
- try { await browser?.close() } finally {
- try { await scaffold?.close() } finally { vi.restoreAllMocks() }
- }
- })
- it('preserves program palettes and keeps ANSI text and cursors readable across DSH themes', async () => {
- onTestFailed(() => saveFailureShot(page, 'terminal-colors'))
- await page.emulateMedia({ colorScheme: 'light' })
- await openTerminal(page)
- const terminal = page.locator('[data-sidebar-terminal]')
- const screen = page.locator('.xterm-rows:visible')
- await command(page, "PS1=''; printf '\\033c\\033[97mBRIGHT_WHITE\\033[0m\\n'")
- const colorOf = async (text: string) => {
- const cell = screen.getByText(text, { exact: true })
- await cell.waitFor()
- return cell.evaluate(element => ({
- foreground: getComputedStyle(element).color, background: getComputedStyle(element).backgroundColor,
- }))
- }
- const lightText = await colorOf('BRIGHT_WHITE')
- // Compare the rendered glyph with its real surface; the raw ANSI palette remains untouched.
- expect(contrastRatio(lightText.foreground, 'rgb(255, 255, 255)')).toBeGreaterThanOrEqual(4.5)
- await command(page, "printf '\\033]4;1;#009900;255;#990099\\007\\033[31mANSI_CUSTOM\\033[38;5;255mEXTENDED_CUSTOM\\033[0m\\n'")
- const customLight = { ansi: await colorOf('ANSI_CUSTOM'), extended: await colorOf('EXTENDED_CUSTOM') }
- await selectTerminalTheme(page, 'Dark')
- await expect.poll(() => screen.evaluate(element => getComputedStyle(element).color)).toBe('rgb(249, 250, 251)')
- await selectTerminalTheme(page, 'Light')
- await expect.poll(() => screen.evaluate(element => getComputedStyle(element).color)).toBe('rgb(15, 17, 21)')
- expect({ ansi: await colorOf('ANSI_CUSTOM'), extended: await colorOf('EXTENDED_CUSTOM') }).toEqual(customLight)
- await command(page, "printf '\\033]104;1;255\\007'")
- await expect.poll(async () => (await colorOf('ANSI_CUSTOM')).foreground).not.toBe(customLight.ansi.foreground)
- await expect.poll(async () => (await colorOf('EXTENDED_CUSTOM')).foreground).not.toBe(customLight.extended.foreground)
- await command(page, "printf '\\033]10;#112233;#ddeeff;#990099\\007'")
- const defaults = () => terminal.evaluate(root => ({
- foreground: getComputedStyle(root.querySelector('.xterm-rows')!).color,
- background: getComputedStyle(root.querySelector('.xterm-scrollable-element')!).backgroundColor,
- }))
- const applicationDefaults = { foreground: 'rgb(17, 34, 51)', background: 'rgb(221, 238, 255)' }
- await expect.poll(defaults).toEqual(applicationDefaults)
- await selectTerminalTheme(page, 'Dark')
- await expect.poll(() => terminal.evaluate(root => getComputedStyle(root.querySelector('.xterm')!.parentElement!).backgroundColor))
- .toBe('rgb(21, 21, 23)')
- expect(await defaults()).toEqual(applicationDefaults)
- await command(page, "printf '\\033]110\\007\\033]111\\007\\033]112\\007'")
- await expect.poll(defaults).toEqual({ foreground: 'rgb(249, 250, 251)', background: 'rgb(21, 21, 23)' })
- await selectTerminalTheme(page, 'Light')
- await expect.poll(defaults).toEqual({ foreground: 'rgb(15, 17, 21)', background: 'rgb(255, 255, 255)' })
- const cursorColors = () => screen.locator('.xterm-cursor').evaluate((cursor) => {
- const style = getComputedStyle(cursor)
- return {
- background: style.backgroundColor, foreground: style.color,
- shadow: style.boxShadow, border: style.borderBottomColor, outline: style.outlineColor,
- }
- })
- const paintCursor = async (sgr: string, shape = 2) => {
- await command(page, `printf '\\033[0m\\033[2J\\033[H\\033[${sgr}mCURSOR\\033[1G\\033[${shape} q'`)
- await expect.poll(() => screen.locator('.xterm-cursor').innerText()).toBe('C')
- }
- // ron uses foreground 51 and background 16; no installed Vim is required by CI.
- await paintCursor('38;5;51;48;5;16')
- await expect.poll(async () => (await cursorColors()).background).toBe('rgb(255, 255, 255)')
- const ronLight = await cursorColors()
- expect(ronLight.foreground).toBe('rgb(0, 0, 0)')
- await terminal.screenshot({ path: `${shots}/ron-light.png`, animations: 'disabled' })
- await selectTerminalTheme(page, 'Dark')
- await page.locator('.xterm-helper-textarea:visible').click()
- await expect.poll(async () => (await cursorColors()).background).toBe('rgb(249, 250, 251)')
- await paintCursor('38;2;0;0;0;48;2;255;255;255')
- await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
- const whiteInDark = await cursorColors()
- await paintCursor('0;7')
- await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
- for (const shape of [4, 6]) {
- await paintCursor('38;5;51;48;5;16', shape)
- await expect.poll(async () => shape === 4 ? (await cursorColors()).border : (await cursorColors()).shadow).toContain('rgb(249, 250, 251)')
- }
- await paintCursor('38;2;0;0;0;48;2;255;255;255', 1)
- await page.addStyleTag({ content: '.xterm-cursor { animation-delay: -0.1s !important; animation-play-state: paused !important; }' })
- await expect.poll(async () => (await cursorColors()).background).toBe('rgb(0, 0, 0)')
- await page.addStyleTag({ content: '.xterm-cursor { animation-delay: -0.6s !important; }' })
- await expect.poll(async () => (await cursorColors()).background).toBe('rgb(255, 255, 255)')
- await page.locator('.xterm-helper-textarea:visible').evaluate((element) =>{ element.blur() })
- await expect.poll(async () => (await cursorColors()).outline).toBe('rgb(0, 0, 0)')
- await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/colors.expected.md', import.meta.url)),
- JSON.stringify({ lightText, customLight, ronLight, whiteInDark }, null, 2), webSnapshotMode())
- expect(handles).toHaveLength(1)
- expect(tripwire.pageErrors).toEqual([])
- })
- it('follows light, dark and system themes while preserving the running shell and its output', async () => {
- await page.emulateMedia({ colorScheme: 'light' })
- await openTerminal(page)
- const process = processIdentity(0)
- const terminal = page.locator('[data-sidebar-terminal]')
- const screen = page.locator('.xterm-rows:visible')
- await command(page, "DSH_THEME_PROBE=retained; PS1=''; printf '\\033cTHEME_CONTENT_RETAINED\\n'")
- await expect.poll(() => screen.innerText()).toContain('THEME_CONTENT_RETAINED')
- const readColors = () => terminal.evaluate((root) => {
- const xterm = root.querySelector('.xterm')!
- const rows = root.querySelector('.xterm-rows')!
- return {
- surface: getComputedStyle(xterm.parentElement!).backgroundColor,
- viewport: getComputedStyle(root.querySelector('.xterm-scrollable-element')!).backgroundColor,
- underlay: getComputedStyle(root.querySelector('.xterm-viewport')!).backgroundColor,
- foreground: getComputedStyle(rows).color,
- }
- })
- const light = await readColors()
- expect(light.viewport).toBe(light.surface)
- expect(light.underlay).toBe(light.surface)
- await terminal.screenshot({ path: `${shots}/theme-light.png`, animations: 'disabled' })
- await selectTerminalTheme(page, 'Dark')
- await expect.poll(async () => (await readColors()).viewport).not.toBe(light.viewport)
- const dark = await readColors()
- expect(dark.viewport).toBe(dark.surface)
- expect(dark.underlay).toBe(dark.surface)
- expect(dark.foreground).not.toBe(light.foreground)
- await terminal.screenshot({ path: `${shots}/theme-dark.png`, animations: 'disabled' })
- await selectTerminalTheme(page, 'Light')
- await expect.poll(readColors).toEqual(light)
- await selectTerminalTheme(page, 'System')
- await page.emulateMedia({ colorScheme: 'dark' })
- await expect.poll(readColors).toEqual(dark)
- await page.emulateMedia({ colorScheme: 'light' })
- await expect.poll(readColors).toEqual(light)
- await expect.poll(() => screen.innerText()).toContain('THEME_CONTENT_RETAINED')
- await command(page, 'printf "THEME_STATE:%s\\n" "$DSH_THEME_PROBE"')
- await expect.poll(() => screen.innerText()).toContain('THEME_STATE:retained')
- expect(handles).toHaveLength(1)
- expect(alive(process)).toBe(true)
- await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/theme.expected.md', import.meta.url)),
- JSON.stringify({ light, dark }, null, 2), webSnapshotMode())
- expect(tripwire.pageErrors).toEqual([])
- })
- it('completes commands, preserves the process through collapse and reload, resizes, and kills on tab close', async () => {
- onTestFailed(() => saveFailureShot(page, 'sidebar-terminal'))
- await openTerminal(page)
- const terminal = page.locator('[data-sidebar-terminal]')
- await command(page, "PS1=''; printf '\\033cTERMINAL_READY\\n'")
- const screen = page.locator('.xterm-rows:visible')
- await expect.poll(async () => await screen.innerText()).toContain('TERMINAL_READY')
- const aria = await terminal.ariaSnapshot()
- await compareOrRefreshGolden(expected, aria, webSnapshotMode())
- await command(page, "printf 'DSH_PID:%s\\n' \"$$\"")
- await expect.poll(async () => await screen.innerText()).toMatch(/DSH_PID:\d+/u)
- const pid = Number((await screen.innerText()).match(/DSH_PID:(\d+)/u)?.[1])
- const firstProcess = processIdentity(0)
- expect(alive(firstProcess)).toBe(true)
- await command(page, 'dsh_terminal_completion_probe(){ printf "completed_from_shell\\n"; }')
- await page.keyboard.press('Control+l')
- await page.keyboard.insertText('dsh_terminal_completion_pro')
- await page.keyboard.press('Tab')
- await page.keyboard.press('Enter')
- await expect.poll(async () => await screen.innerText()).toContain('completed_from_shell')
- await command(page, "printf 'PERSIST:%s\\n' \"$TERM\"")
- await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
- await page.locator('[data-dockkit-tab-title]').getByText('bash', { exact: true }).dblclick()
- await page.getByRole('textbox', { name: 'Terminal name', exact: true }).fill('Development')
- await page.getByRole('textbox', { name: 'Terminal name', exact: true }).press('Enter')
- await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toContain('Development')
- await openTerminal(page)
- await command(page, "printf 'SECOND_PID:%s\\n' \"$$\"")
- await expect.poll(async () => await screen.innerText()).toMatch(/SECOND_PID:\d+/u)
- const secondPid = Number((await screen.innerText()).match(/SECOND_PID:(\d+)/u)?.[1])
- // Shell PIDs belong to the sandbox namespace; process liveness uses Host identities.
- const secondProcess = processIdentity(1)
- expect(secondProcess.pid).not.toBe(firstProcess.pid)
- await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).click()
- await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
- expect(alive(secondProcess)).toBe(true)
- await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
- expect(alive(firstProcess)).toBe(true)
- await page.locator('[data-sidebar-right-expand]').click()
- const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id)
- const dockedCols = terminals()[0]!.cols
- await page.getByRole('button', { name: 'Fullscreen', exact: true }).click()
- await expect.poll(() => terminals()[0]!.cols).toBeGreaterThan(dockedCols)
- await command(page, "printf 'SIZE:'; stty size")
- await expect.poll(async () => await screen.innerText()).toContain(`SIZE:${terminals()[0]!.rows} ${terminals()[0]!.cols}`)
- await page.screenshot({ path: `${shots}/fullscreen.png`, fullPage: true })
- const tabIds = await page.locator('[data-dockkit-tab]').evaluateAll(tabs => tabs.map(tab => tab.getAttribute('data-dockkit-tab')))
- await page.reload({ waitUntil: 'load' })
- await page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' }).waitFor({ timeout: 15_000 })
- await expect.poll(async () => await page.locator('[data-dockkit-tab-title]').allInnerTexts()).toEqual(['Development', 'bash'])
- expect(terminals()).toHaveLength(2)
- expect(alive(firstProcess)).toBe(true)
- expect(alive(secondProcess)).toBe(true)
- expect(await page.locator('[data-dockkit-tab]').evaluateAll(tabs => tabs.map(tab => tab.getAttribute('data-dockkit-tab')))).toEqual(tabIds)
- await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
- await page.getByRole('button', { name: 'Exit fullscreen', exact: true }).waitFor()
- await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
- await page.reload({ waitUntil: 'load' })
- await page.locator('[data-sidebar-right-expand]').waitFor()
- expect(await page.locator('[data-sidebar-terminal]:visible').count()).toBe(0)
- expect(terminals()).toHaveLength(2)
- await page.locator('[data-sidebar-right-expand]').click()
- await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
- const secondTab = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' })
- await secondTab.click()
- await expect.poll(async () => await screen.innerText()).toContain(`SECOND_PID:${secondPid}`)
- await secondTab.hover()
- await secondTab.locator('[data-dockkit-tab-close]').click()
- await expect.poll(async () => await secondTab.count()).toBe(0)
- await expect.poll(() => alive(secondProcess), { timeout: 10_000 }).toBe(false)
- await expect.poll(async () => await screen.innerText()).toContain('PERSIST:xterm-256color')
- await command(page, "printf 'RECOVERED_PID:%s\\n' \"$$\"")
- await expect.poll(async () => await screen.innerText()).toContain(`RECOVERED_PID:${pid}`)
- await page.screenshot({ path: `${shots}/recovered.png`, fullPage: true })
- const previousMembers = new Set(inspector.snapshot().tree(firstProcess.pid).map(member => member.pid))
- await command(page, "sleep 120 & printf 'CHILD_PID:%s\\n' $!")
- await expect.poll(async () => await screen.innerText()).toMatch(/CHILD_PID:\d+/u)
- const descendants = inspector.snapshot().tree(firstProcess.pid).filter(member => member.pid !== firstProcess.pid)
- expect(descendants.some(member => !previousMembers.has(member.pid))).toBe(true)
- expect(descendants.every(alive)).toBe(true)
- const tab = page.locator('[data-dockkit-tab]').filter({ hasText: 'Development' })
- await tab.hover()
- await tab.locator('[data-dockkit-tab-close]').click()
- await expect.poll(() => alive(firstProcess), { timeout: 10_000 }).toBe(false)
- await expect.poll(() => descendants.some(alive), { timeout: 10_000 }).toBe(false)
- await expect.poll(() => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).length).toBe(0)
- expect(tripwire.pageErrors).toEqual([])
- })
- it('chooses a shell from the guide menu, opens it directly, and remembers it after reload', async () => {
- onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shell-choice'))
- await page.locator('[data-sidebar-right-expand]').click()
- const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
- await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' })
- const selector = entry.getByRole('button', { name: 'Choose shell', exact: true })
- const cardBox = (await entry.boundingBox())!
- const triggerBox = (await selector.boundingBox())!
- expect(Math.abs(cardBox.x + cardBox.width - triggerBox.x - triggerBox.width)).toBeLessThanOrEqual(2)
- await page.emulateMedia({ colorScheme: 'dark' })
- await selector.click()
- await page.getByRole('menuitem', { name: 'bash', exact: true }).waitFor()
- expect(handles).toHaveLength(0)
- await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/guide.expected.md', import.meta.url)),
- await entry.ariaSnapshot(), webSnapshotMode())
- await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-terminal/shell-menu.expected.md', import.meta.url)),
- await page.getByRole('menu').ariaSnapshot(), webSnapshotMode())
- await page.screenshot({ path: `${shots}/shell-menu.png`, fullPage: true })
- await page.keyboard.press('Escape')
- expect(handles).toHaveLength(0)
- await selector.click()
- await page.getByRole('menuitem', { name: 'sh', exact: true }).click()
- expect(await page.evaluate(() => localStorage.getItem('dsh.terminal.shell'))).toBe('/bin/sh')
- await page.locator('.xterm-helper-textarea:visible').waitFor()
- await command(page, "printf 'CHOSEN_SHELL:%s\\n' \"$0\"")
- const screen = page.locator('.xterm-rows:visible')
- await expect.poll(() => screen.innerText()).toContain('CHOSEN_SHELL:/bin/sh')
- const retained = processIdentity(0)
- await page.reload({ waitUntil: 'load' })
- await page.locator('.xterm-helper-textarea:visible').waitFor()
- expect(alive(retained)).toBe(true)
- await page.locator('[data-dockkit-add-tab]').click()
- await selector.click()
- await page.getByRole('menuitem', { name: 'sh', exact: true }).waitFor()
- expect(await page.getByRole('menuitem', { name: 'sh', exact: true }).locator('svg').count()).toBe(1)
- await page.keyboard.press('Escape')
- await entry.getByRole('button', { name: /^New terminal/u }).click()
- await expect.poll(() => handles.length).toBe(2)
- expect(scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id).map(info => info.shell.path)).toEqual(['/bin/sh', '/bin/sh'])
- expect(tripwire.pageErrors).toEqual([])
- })
- it('explains that exited terminals count toward the quota and permits creation after closing one', async () => {
- onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-quota'))
- const terminals = () => scaffold.ctx.terminalController.list(scaffold.ctx.agents.list()[0]!.id)
- for (let count = 1; count <= 2; count++) {
- await openTerminal(page)
- await command(page, 'exit')
- await expect.poll(() => terminals().filter(info => info.state === 'exited').length).toBe(count)
- }
- await openTerminal(page, false)
- const alert = page.getByRole('alert')
- await expect.poll(async () => await alert.innerText()).toContain('Exited terminals also count toward the limit.')
- const expectedLimit = fileURLToPath(new URL('./expected/sidebar-terminal/limit.expected.md', import.meta.url))
- await compareOrRefreshGolden(expectedLimit, await alert.ariaSnapshot(), webSnapshotMode())
- const failed = page.locator('[data-dockkit-tab][aria-selected="true"]')
- await failed.hover()
- await failed.locator('[data-dockkit-tab-close]').click()
- const exited = page.locator('[data-dockkit-tab]').filter({ hasText: 'bash' }).first()
- await exited.hover()
- await exited.locator('[data-dockkit-tab-close]').click()
- await expect.poll(() => terminals().length).toBe(1)
- await openTerminal(page)
- await expect.poll(() => terminals().filter(info => info.state === 'running').length).toBe(1)
- expect(tripwire.pageErrors).toEqual([])
- })
- it('keeps new terminals independent when two same-origin windows mint the same tab id', async () => {
- onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-shared-storage'))
- const second = await page.context().newPage()
- const secondErrors = watchConsole(second)
- await second.goto(page.url(), { waitUntil: 'load' })
- await second.getByText('Ready for terminal input.').waitFor()
- await openTerminal(page)
- const firstTab = await page.locator('[data-dockkit-tab][aria-selected="true"]').getAttribute('data-dockkit-tab')
- const firstProcess = processIdentity(0)
- await command(page, "printf 'WINDOW_A\\n'")
- await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A')
- await openTerminal(second)
- const secondTab = await second.locator('[data-dockkit-tab][aria-selected="true"]').getAttribute('data-dockkit-tab')
- expect(secondTab).toBe(firstTab)
- expect(handles).toHaveLength(2)
- const secondProcess = processIdentity(1)
- expect(secondProcess.pid).not.toBe(firstProcess.pid)
- await command(second, "printf 'WINDOW_B\\n'")
- await expect.poll(() => second.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_B')
- expect(await second.locator('.xterm-rows:visible').innerText()).not.toContain('WINDOW_A')
- expect(await page.locator('.xterm-rows:visible').innerText()).not.toContain('WINDOW_B')
- const bindings = () => page.evaluate(() => Object.keys(localStorage)
- .filter(key => key.startsWith('dsh.terminal.binding.v1.')).map(key => localStorage.getItem(key)))
- const saved = await bindings()
- expect(saved).toHaveLength(2)
- const selected = second.locator('[data-dockkit-tab][aria-selected="true"]')
- await selected.hover()
- await selected.locator('[data-dockkit-tab-close]').click()
- await expect.poll(() => alive(secondProcess)).toBe(false)
- expect(alive(firstProcess)).toBe(true)
- await expect.poll(() => bindings()).toHaveLength(1)
- expect(saved).toContain((await bindings())[0])
- await page.reload({ waitUntil: 'load' })
- await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A')
- await command(page, "printf 'WINDOW_A_RECONNECTED\\n'")
- await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('WINDOW_A_RECONNECTED')
- expect(handles).toHaveLength(2)
- expect(alive(firstProcess)).toBe(true)
- expect(tripwire.pageErrors).toEqual([])
- expect(secondErrors.pageErrors).toEqual([])
- })
- it('holds a collapsed layout across windows and reclaims only after the last transport disappears', async () => {
- onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-window-holds'))
- const retains = vi.spyOn(scaffold.ctx.terminalController, 'retain')
- await openTerminal(page)
- const original = processIdentity(0)
- const sessionId = scaffold.ctx.agents.list()[0]!.id
- // Shell activity is exercised with real PTYs in the provider tests; this scenario isolates window ownership.
- vi.spyOn(handles[0]!, 'inspectActivity').mockResolvedValue({ state: 'idle', revision: 1 })
- await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
- const second = await page.context().newPage()
- const transport = await controlTransport(second)
- await second.goto(page.url(), { waitUntil: 'load' })
- await second.locator('[data-sidebar-right-expand]').waitFor()
- await expect.poll(() => retains.mock.calls.filter(call => !call[2].aborted).length).toBe(2)
- await page.close()
- page = second
- tripwire = watchConsole(page)
- await expect.poll(() => retains.mock.calls.filter(call => !call[2].aborted).length).toBe(1)
- const disconnectedAt = performance.now()
- await expect.poll(() => performance.now() - disconnectedAt, { timeout: 10_000 }).toBeGreaterThan(2500)
- expect(alive(original)).toBe(true)
- expect(await page.locator('[data-sidebar-terminal]:visible').count()).toBe(0)
- await page.context().setOffline(true)
- await transport.disconnect()
- await expect.poll(() => retains.mock.calls.every(call => call[2].aborted), { timeout: 15_000 }).toBe(true)
- await expect.poll(() => scaffold.ctx.terminalController.list(sessionId), { timeout: 15_000 }).toEqual([])
- expect(alive(original)).toBe(false)
- transport.reconnect()
- await page.context().setOffline(false)
- await page.reload({ waitUntil: 'load' })
- await page.locator('[data-sidebar-right-expand]').click()
- await expect.poll(() => page.getByRole('alert').innerText()).toContain('no longer exists')
- expect(handles).toHaveLength(1)
- const unavailable = fileURLToPath(new URL('./expected/sidebar-terminal/unavailable.expected.md', import.meta.url))
- const terminal = page.locator('[data-sidebar-terminal]')
- await compareOrRefreshGolden(unavailable, await terminal.ariaSnapshot(), webSnapshotMode())
- await terminal.screenshot({ path: `${shots}/unavailable.png`, animations: 'disabled' })
- const tabCount = await page.locator('[data-dockkit-tab]').count()
- const create = terminal.getByRole('button', { name: 'New terminal', exact: true })
- await create.focus()
- await page.keyboard.press('Enter')
- await expect.poll(() => handles.length).toBe(2)
- await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('bash-')
- expect(await page.locator('[data-dockkit-tab]').count()).toBe(tabCount)
- expect(scaffold.ctx.terminalController.list(sessionId)).toHaveLength(1)
- expect(alive(processIdentity(1))).toBe(true)
- await command(page, "printf 'NEW_TERMINAL_READY\\n'")
- await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('NEW_TERMINAL_READY')
- expect(tripwire.pageErrors).toEqual([])
- })
- it('offers reconnection after transport loss and resumes the same process without a new terminal', async () => {
- onTestFailed(() => saveFailureShot(page, 'sidebar-terminal-reconnect'))
- await openTerminal(page)
- const original = processIdentity(0)
- await command(page, "printf 'RECONNECT_READY\\n'")
- await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECT_READY')
- const transport = await controlTransport(page)
- await page.reload({ waitUntil: 'load' })
- await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECT_READY')
- await transport.disconnect()
- const reconnect = page.getByRole('button', { name: 'Reconnect', exact: true })
- await reconnect.waitFor()
- expect(await page.getByRole('alert').count()).toBe(0)
- expect(alive(original)).toBe(true)
- const disconnected = fileURLToPath(new URL('./expected/sidebar-terminal/disconnected.expected.md', import.meta.url))
- await compareOrRefreshGolden(disconnected, await page.getByRole('status').ariaSnapshot(), webSnapshotMode())
- await page.screenshot({ path: `${shots}/disconnected.png`, fullPage: true })
- await reconnect.click()
- transport.reconnect()
- await page.locator('[data-sidebar-terminal]').getByRole('status').waitFor({ state: 'hidden' })
- await command(page, "printf 'RECONNECTED_INPUT\\n'")
- await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECTED_INPUT')
- expect(handles).toHaveLength(1)
- expect(alive(original)).toBe(true)
- expect(tripwire.pageErrors).toEqual([])
- })
- })
- function contrastRatio(first: string, second: string): number {
- const luminance = (color: string) => {
- const [r, g, b] = color.match(/[\d.]+/gu)!.map(Number).map(channel => channel / 255)
- .map(value => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4)
- return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
- }
- const a = luminance(first), b = luminance(second)
- return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
- }
|