/** * @fileoverview The exited-agent badge on a session tab (Ark0N/Codeman#446). * * The server publishes `session.paneExit` when the agent inside a local tmux * pane has exited while `remain-on-exit` kept the pane. These cover the three * things the browser owns: turning that field into a label, getting the label * onto and off a tab, and what colour the tab's status dot ends up once the * exit, the alert rules and the rich rail's own rules have all had a say. * * The incremental render path is the only one a live session ever reaches. * Going from live to exited adds and removes no tab, so the full rebuild never * runs for it, which is why `applyPaneExitBadge()` is a named function rather * than a block inside the render loop. * * Port: N/A */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { JSDOM } from 'jsdom'; import postcss from 'postcss'; import { describe, expect, it } from 'vitest'; describe('the exited-agent tab label', () => { const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); const load = (name: string) => { const source = appJs.match(new RegExp(`function ${name}\\([\\s\\S]*?\\n\\}`))?.[0]; if (!source) throw new Error(`${name} not found in app.js`); return new Function(`${source}\nreturn ${name};`)() as T; }; const paneExitLabel = load<(p: unknown) => string>('paneExitLabel'); it('renders nothing for an unknown answer, which must never read as alive', () => { expect(paneExitLabel(undefined)).toBe(''); expect(paneExitLabel(null)).toBe(''); }); it('names the exit code', () => { expect(paneExitLabel({ status: 137, at: 1 })).toBe('exited (137)'); }); it('shows a clean exit as 0 rather than hiding it', () => { expect(paneExitLabel({ status: 0, at: 1 })).toBe('exited (0)'); }); it('names a signal death, which the maintainer wants kept on screen', () => { expect(paneExitLabel({ signal: 9, at: 1 })).toBe('exited (signal 9)'); }); it('says only "exited" when tmux knew the pane died but not how', () => { // Measured on tmux 3.2a: a SIGKILLed pane reports neither status nor signal. // Showing that as "exited (0)" would make an unexplained death look clean. expect(paneExitLabel({ at: 1 })).toBe('exited'); }); }); describe('the exited-agent badge in a tab', () => { // The incremental render path is the only one a live session reaches: going // from live to exited adds and removes no tab, so the full rebuild never runs // for it. These drive that path's DOM work against a real tab element. const appJs = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); const source = [ appJs.match(/function paneExitLabel\([\s\S]*?\n\}/)?.[0], appJs.match(/function paneExitAriaLabel\([\s\S]*?\n\}/)?.[0], appJs.match(/function applyPaneExitBadge\([\s\S]*?\n\}/)?.[0], ].join('\n'); const dom = new JSDOM(''); const applyPaneExitBadge = new Function('document', `${source}\nreturn applyPaneExitBadge;`)(dom.window.document) as ( tab: unknown, paneExit: unknown ) => void; const makeTab = () => { const tab = dom.window.document.createElement('div'); tab.className = 'session-tab'; tab.setAttribute('aria-label', 'w1-case session'); tab.innerHTML = 'w1-case'; return tab; }; const badge = (tab: { querySelector: (s: string) => { textContent: string | null } | null }) => tab.querySelector('.tab-exited-badge'); it('draws no badge while the answer is unknown', () => { const tab = makeTab(); applyPaneExitBadge(tab, undefined); expect(badge(tab)).toBeNull(); }); it('adds the badge after the name once the agent exits', () => { const tab = makeTab(); applyPaneExitBadge(tab, { status: 137, at: 1 }); expect(badge(tab)?.textContent).toBe('exited (137)'); expect(tab.querySelector('.tab-name')?.nextElementSibling?.className).toBe('tab-exited-badge'); }); it('leaves the badge to the translator (zh-CN) and remembers its English', () => { const tab = makeTab(); applyPaneExitBadge(tab, { status: 0, at: 1 }); expect(badge(tab)?.hasAttribute('data-i18n-skip')).toBe(false); expect((badge(tab) as unknown as HTMLElement).dataset.label).toBe('exited (0)'); }); it('a translated badge and accessible name survive an unchanged pass; a change writes the new English', () => { // The translator rewrites both in zh-CN. Comparing with the DOM would write // the English back on every incremental pass for it to translate again. const tab = makeTab(); applyPaneExitBadge(tab, { status: 3, at: 1 }); const el = badge(tab) as unknown as HTMLElement; el.textContent = '已退出(3)'; tab.setAttribute('aria-label', 'w1-case 会话,智能体已退出(3)'); applyPaneExitBadge(tab, { status: 3, at: 1 }); expect(el.textContent).toBe('已退出(3)'); expect(tab.getAttribute('aria-label')).toBe('w1-case 会话,智能体已退出(3)'); applyPaneExitBadge(tab, { signal: 9, at: 2 }); expect(el.textContent).toBe('exited (signal 9)'); expect(tab.getAttribute('aria-label')).toBe('w1-case session, agent exited (signal 9)'); }); it('hides the badge from assistive technology, like its sibling badges', () => { const tab = makeTab(); applyPaneExitBadge(tab, { status: 0, at: 1 }); expect(badge(tab)?.getAttribute('aria-hidden')).toBe('true'); }); it('carries the exit on the tab accessible name instead, and drops it again', () => { // The tab's aria-label overrides its contents, so the badge alone would leave // a screen reader announcing an exited tab exactly like a live one. const tab = makeTab(); applyPaneExitBadge(tab, { status: 137, at: 1 }); expect(tab.getAttribute('aria-label')).toBe('w1-case session, agent exited (137)'); applyPaneExitBadge(tab, undefined); expect(tab.getAttribute('aria-label')).toBe('w1-case session'); }); it('builds the full render path accessible name from the same helper', () => { expect(appJs).toContain( 'aria-label="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}" data-aria-source="${escapeHtml(paneExitAriaLabel(name, paneExitBadge))}"' ); expect(appJs).toContain( '