From e472974ba773c10d02fb5b17992af571d0a1f23f Mon Sep 17 00:00:00 2001 From: Xiao Liu Date: Sun, 30 Aug 2026 05:19:06 +0800 Subject: [PATCH] test(desktop): story-cover the terminal panel's failure and edge states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth surface under #3944 (one surface per PR): extend the 终端 panel (SessionTerminalPanel, via Product/Session Workbar) with the states the fresh-shell Terminal story never shows, driving the real panel through the fake terminal service. - TerminalLoadFailed — `terminal.attach` resolves to null: the load-failed Banner over the terminal surface. - TerminalLongScrollback — hundreds of lines of scrollback (very many rows). - TerminalRichOutput — coloured, bold, and carriage-return progress output. - TerminalWriteFailed — a keystroke whose `terminal.write` rejects raises the write-failed Banner; input is driven through xterm's helper textarea, so the state is reachable from a mounted story (review feedback). `bridge()` gains `terminalAttach` / `terminalBuffer` / `terminalWriteFails` options. The empty state needs a null-ref terminal tab the workbar never creates, so it is left out rather than faked. Refs #3944, #3893 Generated-by: Claude Code --- .../stories/session-workbar.stories.tsx | 98 +++++++++++++++++-- 1 file changed, 90 insertions(+), 8 deletions(-) diff --git a/apps/desktop/stories/session-workbar.stories.tsx b/apps/desktop/stories/session-workbar.stories.tsx index 77039bc2a1..8df2bef6ba 100644 --- a/apps/desktop/stories/session-workbar.stories.tsx +++ b/apps/desktop/stories/session-workbar.stories.tsx @@ -120,6 +120,31 @@ const SIDE_CHAT_SESSION: SessionSummary = { const TERMINAL_REF = 'shell-run:storybook-terminal'; const SIDE_CHAT_PANEL_ID = 'storybook-side-chat'; +// A terminal whose scrollback runs long — the "very many rows" state a fresh +// shell never shows. +const LONG_TERMINAL_BUFFER = [ + '$ npm run build --workspaces', + ...Array.from( + { length: 600 }, + (_, index) => `\x1b[2m[${String(index + 1).padStart(4, '0')}]\x1b[0m compiled module ${index + 1}/600`, + ), + '$ ', +].join('\r\n'); + +// Colours, bold, and a carriage-return progress redraw — the control sequences +// a plain "npm test" line never exercises. +const RICH_TERMINAL_BUFFER = [ + '$ npm test', + '\x1b[1m\x1b[34mRUNS\x1b[0m packages/ui/src/toast.test.ts', + '\x1b[32m✓\x1b[0m renders every variant \x1b[2m(12 ms)\x1b[0m', + '\x1b[33m●\x1b[0m skipped: flaky under load', + '\x1b[31m✗\x1b[0m confirm queue focus \x1b[2m(3 ms)\x1b[0m', + '\x1b[31m Expected the cancel button to hold focus\x1b[0m', + 'Progress: [\x1b[32m##########\x1b[0m----------] 50%\rProgress: [\x1b[32m####################\x1b[0m] 100%', + '\x1b[1mTests:\x1b[0m \x1b[32m41 passed\x1b[0m, \x1b[31m1 failed\x1b[0m, \x1b[33m1 skipped\x1b[0m', + '$ ', +].join('\r\n'); + // ---- ledgers ------------------------------------------------------------- // Mirrors the `task-ledger` e2e fixture (apps/desktop/src/main/e2e-fixture/ @@ -801,6 +826,12 @@ function bridge(options: { review?: GitReviewReadResult; /** Make `review.read` reject, so the panel shows its load-error banner. */ reviewFail?: boolean; + /** Make `terminal.attach` resolve to null, so the panel shows its load-failed Banner. */ + terminalAttach?: 'missing'; + /** The scrollback buffer the attached terminal hydrates with. */ + terminalBuffer?: string; + /** Make `terminal.write` reject, so typing into the terminal shows the write-failed Banner. */ + terminalWriteFails?: boolean; } = {}): Decorator { const browserState = options.browserState ?? EMPTY_BROWSER_STATE; const services = createFakeWorkbarServices({ @@ -862,15 +893,21 @@ function bridge(options: { throw new Error('Terminal stories mount an existing resource'); }, stop: async () => null, - attach: async () => ({ - sessionId: SESSION_ID, - ref: TERMINAL_REF, - sequence: 1, - buffer: '$ npm test\r\n✓ workbar controller\r\n', - size: { cols: 80, rows: 24 }, - }), + attach: async () => { + if (options.terminalAttach === 'missing') return null; + return { + sessionId: SESSION_ID, + ref: TERMINAL_REF, + sequence: 1, + buffer: options.terminalBuffer ?? '$ npm test\r\n✓ workbar controller\r\n', + size: { cols: 80, rows: 24 }, + }; + }, detach: async () => undefined, - write: async () => null, + write: async () => { + if (options.terminalWriteFails) throw new Error('write failed'); + return null; + }, subscribePtyData: unsubscribe, subscribeResync: unsubscribe, }, @@ -1126,6 +1163,51 @@ export const Terminal: Story = { render: () => , }; +// Real path: 任务工作栏 → 终端 when `terminal.attach` finds no live resource +// (it resolves to null); the panel raises its load-failed Banner over the +// terminal surface instead of a silently blank pane. +export const TerminalLoadFailed: Story = { + decorators: [bridge({ terminalAttach: 'missing' })], + render: () => , + play: async ({ canvasElement }) => { + await waitFor(() => expect(canvasElement.textContent).toContain('无法读取终端运行')); + }, +}; + +// Real path: 任务工作栏 → 终端 after a long-running build — hundreds of lines of +// scrollback the fresh-shell story never shows. +export const TerminalLongScrollback: Story = { + decorators: [bridge({ terminalBuffer: LONG_TERMINAL_BUFFER })], + render: () => , +}; + +// Real path: 任务工作栏 → 终端 rendering coloured, bold, and carriage-return +// progress output — the ANSI control sequences a plain command line omits. +export const TerminalRichOutput: Story = { + decorators: [bridge({ terminalBuffer: RICH_TERMINAL_BUFFER })], + render: () => , +}; + +// Real path: 任务工作栏 → 终端 when a keystroke cannot reach the PTY +// (`terminal.write` rejects). Typing surfaces the panel's write-failed Banner. +// Input is driven through xterm's own helper textarea — the same handle the +// accessibility-runtime story uses — so this is genuinely reachable from a +// mounted story. +export const TerminalWriteFailed: Story = { + decorators: [bridge({ terminalWriteFails: true })], + render: () => , + play: async ({ canvasElement }) => { + const textarea = await waitFor(() => { + const el = canvasElement.querySelector('.xterm-helper-textarea'); + if (!el) throw new Error('xterm helper textarea not ready'); + return el; + }); + textarea.focus(); + await userEvent.keyboard('echo hi'); + await waitFor(() => expect(canvasElement.textContent).toContain('无法发送终端输入')); + }, +}; + // Real path: sidebar → a session → 展开任务工作栏, landing on the tab the app // restored. Tasks is the default: an in-progress root, a child claimed and // blocked by a subagent, and the finished ones folded into 最近结束.