@robota-sdk/agent-ui-terminal 3.0.0-beta.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +27 -0
- package/dist/node/index.cjs +32 -0
- package/dist/node/index.d.cts +1066 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1066 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +33 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +93 -0
- package/src/App.tsx +146 -0
- package/src/AppPresentation.tsx +190 -0
- package/src/AppView.tsx +26 -0
- package/src/BackgroundTaskPanel.tsx +73 -0
- package/src/CjkTextInput.tsx +336 -0
- package/src/ConfirmPrompt.tsx +140 -0
- package/src/ContextWarningBanner.tsx +45 -0
- package/src/ExecutionWorkspaceDetailPane.tsx +85 -0
- package/src/ExecutionWorkspaceSwitcher.tsx +213 -0
- package/src/HistorySearchOverlay.tsx +150 -0
- package/src/InputArea.tsx +338 -0
- package/src/ListPicker.tsx +158 -0
- package/src/MenuSelect.tsx +159 -0
- package/src/MessageList.tsx +308 -0
- package/src/MultiSelectList.tsx +294 -0
- package/src/PendingActionPrompt.tsx +121 -0
- package/src/PermissionPrompt.tsx +186 -0
- package/src/PluginTUI.tsx +258 -0
- package/src/RoleLabel.tsx +72 -0
- package/src/SafeText.tsx +66 -0
- package/src/SessionEventNotices.tsx +27 -0
- package/src/SessionPicker.tsx +72 -0
- package/src/SessionStatusBar.tsx +76 -0
- package/src/SlashAutocomplete.tsx +154 -0
- package/src/StatusBar.tsx +284 -0
- package/src/StreamingIndicator.tsx +145 -0
- package/src/SupervisedSessionView.tsx +455 -0
- package/src/TextPrompt.tsx +112 -0
- package/src/ThemePicker.tsx +366 -0
- package/src/ToolCommandOutput.tsx +48 -0
- package/src/ToolDiffBlock.tsx +44 -0
- package/src/TransportTUI.tsx +166 -0
- package/src/TuiInteractionChannel.ts +567 -0
- package/src/UpdateNotice.tsx +18 -0
- package/src/UsageSummaryEntry.tsx +49 -0
- package/src/WaveText.tsx +70 -0
- package/src/__tests__/ListPicker.test.tsx +188 -0
- package/src/__tests__/MenuSelect.test.tsx +127 -0
- package/src/__tests__/PendingActionPrompt.test.tsx +193 -0
- package/src/__tests__/PluginTUI.test.tsx +167 -0
- package/src/__tests__/SlashAutocomplete.test.tsx +162 -0
- package/src/__tests__/SupervisedSessionView-render.test.tsx +24 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +629 -0
- package/src/__tests__/TextPrompt.test.tsx +112 -0
- package/src/__tests__/TransportTUI.test.tsx +124 -0
- package/src/__tests__/TuiInteractionChannel.askUser.test.ts +124 -0
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +284 -0
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +642 -0
- package/src/__tests__/UpdateNotice.test.tsx +15 -0
- package/src/__tests__/abort-after-permission.test.tsx +160 -0
- package/src/__tests__/abort-streaming-e2e.test.tsx +183 -0
- package/src/__tests__/attention-bell-and-marks.test.ts +144 -0
- package/src/__tests__/autocomplete-editing-keys.test.tsx +127 -0
- package/src/__tests__/background-task-panel-countdown.test.tsx +75 -0
- package/src/__tests__/background-task-panel.test.tsx +98 -0
- package/src/__tests__/background-task-row-format.test.ts +152 -0
- package/src/__tests__/channel-factory-integration.test.ts +150 -0
- package/src/__tests__/cjk-defer-submit.test.tsx +67 -0
- package/src/__tests__/cjk-fallback-render.test.tsx +78 -0
- package/src/__tests__/cjk-text-input-flow.test.ts +186 -0
- package/src/__tests__/cjk-text-input.test.ts +191 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +107 -0
- package/src/__tests__/command-output-summary.test.ts +95 -0
- package/src/__tests__/command-result-handler.test.ts +171 -0
- package/src/__tests__/compact-event-bridge.test.ts +20 -0
- package/src/__tests__/confirm-permission-flow.test.ts +149 -0
- package/src/__tests__/confirm-prompt.test.tsx +103 -0
- package/src/__tests__/contextual-keybindings-input.test.tsx +158 -0
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +45 -0
- package/src/__tests__/effort-reaches-the-session.test.ts +41 -0
- package/src/__tests__/execution-workspace-switcher.test.tsx +147 -0
- package/src/__tests__/execution-workspace-view-model.test.ts +94 -0
- package/src/__tests__/external-prompt-notice.test.tsx +100 -0
- package/src/__tests__/fixtures/command-handoff-driver.tsx +111 -0
- package/src/__tests__/fixtures/fake-editor.sh +7 -0
- package/src/__tests__/fixtures/terminal-handoff-driver.tsx +90 -0
- package/src/__tests__/fork-attach.test.tsx +235 -0
- package/src/__tests__/helpers/terminal-profiles.ts +152 -0
- package/src/__tests__/helpers/vt-cursor-interpreter.ts +175 -0
- package/src/__tests__/history-clear-broadcast.test.ts +66 -0
- package/src/__tests__/history-search-overlay.test.tsx +329 -0
- package/src/__tests__/humanize-tool-name.test.ts +30 -0
- package/src/__tests__/input-area-bottom-border.test.tsx +45 -0
- package/src/__tests__/input-area-flow.test.ts +199 -0
- package/src/__tests__/input-area-focus-handoff.test.tsx +77 -0
- package/src/__tests__/interaction-channel-charter.test.ts +13 -0
- package/src/__tests__/key-hint-consistency.test.tsx +249 -0
- package/src/__tests__/key-hint-footer.test.tsx +69 -0
- package/src/__tests__/live-trace-reaches-session.test.ts +14 -0
- package/src/__tests__/message-list-rendering.test.tsx +473 -0
- package/src/__tests__/org-policy-reaches-the-channel.test.ts +77 -0
- package/src/__tests__/output-style-reaches-session.test.ts +35 -0
- package/src/__tests__/palette-consistency.test.ts +330 -0
- package/src/__tests__/permission-prompt-availability.test.tsx +23 -0
- package/src/__tests__/permission-prompt-peer.test.tsx +34 -0
- package/src/__tests__/product-display-name.test.tsx +79 -0
- package/src/__tests__/prompt-queue.test.tsx +260 -0
- package/src/__tests__/provider-definitions-reach-the-session.test.ts +75 -0
- package/src/__tests__/pty/ask-user-question.ptytest.ts +72 -0
- package/src/__tests__/pty/background-work-switcher.ptytest.ts +53 -0
- package/src/__tests__/pty/behavior-2437-git.ptytest.ts +342 -0
- package/src/__tests__/pty/fixtures/ask-user-question.jsonl +3 -0
- package/src/__tests__/pty/fixtures/replay-conversation.jsonl +4 -0
- package/src/__tests__/pty/fixtures/screen-005-ask-shapes.jsonl +5 -0
- package/src/__tests__/pty/fixtures/screen-005-permission.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-006-colors.jsonl +3 -0
- package/src/__tests__/pty/fixtures/screen-2002-themes.jsonl +5 -0
- package/src/__tests__/pty/flag-tui.ptytest.ts +56 -0
- package/src/__tests__/pty/flow-2006-deep-link.ptytest.ts +291 -0
- package/src/__tests__/pty/ime-cursor-tmux.ptytest.ts +165 -0
- package/src/__tests__/pty/ime-cursor.ptytest.ts +186 -0
- package/src/__tests__/pty/isolated-home.ts +64 -0
- package/src/__tests__/pty/org-policy.ptytest.ts +71 -0
- package/src/__tests__/pty/provider-setup.ptytest.ts +61 -0
- package/src/__tests__/pty/pty-driver.ts +153 -0
- package/src/__tests__/pty/replay-conversation.ptytest.ts +68 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +134 -0
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +138 -0
- package/src/__tests__/pty/screen-010-scrollback.ptytest.ts +123 -0
- package/src/__tests__/pty/screen-1993-scrollback.ptytest.ts +124 -0
- package/src/__tests__/pty/screen-2002-themes.ptytest.ts +292 -0
- package/src/__tests__/pty/screen-2670-prepark.ptytest.ts +228 -0
- package/src/__tests__/pty/screen-reader-mode.ptytest.ts +228 -0
- package/src/__tests__/pty/settings-screen.ptytest.ts +49 -0
- package/src/__tests__/pty/spawn-pty.test.ts +120 -0
- package/src/__tests__/pty/spawn-pty.ts +229 -0
- package/src/__tests__/pty/terminal-handoff.ptytest.ts +79 -0
- package/src/__tests__/pty/tui-pty.ptytest.ts +65 -0
- package/src/__tests__/pty-driver-dispose.test.ts +43 -0
- package/src/__tests__/real-cursor-positioning.test.tsx +263 -0
- package/src/__tests__/rename-broadcast-persistence.test.tsx +140 -0
- package/src/__tests__/render-channel-options.test.ts +201 -0
- package/src/__tests__/render-lifecycle.test.ts +36 -0
- package/src/__tests__/render-markdown.test.ts +164 -0
- package/src/__tests__/rendered-markdown-styling.test.tsx +98 -0
- package/src/__tests__/safe-text-boundary.test.tsx +75 -0
- package/src/__tests__/screen-reader-input.test.tsx +106 -0
- package/src/__tests__/screen-reader-labels.test.ts +91 -0
- package/src/__tests__/screen-reader-menus.test.tsx +200 -0
- package/src/__tests__/screen-reader-pacing.test.ts +230 -0
- package/src/__tests__/screen-reader-prepark-echo.test.tsx +200 -0
- package/src/__tests__/screen-reader-render-options.test.ts +262 -0
- package/src/__tests__/screen-reader-stdout.test.ts +322 -0
- package/src/__tests__/screen-reader-turn-marks-port.test.tsx +85 -0
- package/src/__tests__/screen-reader-turn-signals.test.tsx +125 -0
- package/src/__tests__/sec-019-terminal-sanitizer.test.ts +276 -0
- package/src/__tests__/sec-019-tool-label-render.test.tsx +207 -0
- package/src/__tests__/selection-flow.test.ts +61 -0
- package/src/__tests__/session-event-notices.test.tsx +31 -0
- package/src/__tests__/session-init-poller.test.ts +102 -0
- package/src/__tests__/session-switch-channel.test.tsx +470 -0
- package/src/__tests__/shutdown-signal.test.ts +34 -0
- package/src/__tests__/status-activity.test.ts +71 -0
- package/src/__tests__/status-bar.test.tsx +282 -0
- package/src/__tests__/status-glyph.test.ts +30 -0
- package/src/__tests__/streaming-indicator.test.tsx +177 -0
- package/src/__tests__/terminal-capabilities.test.ts +153 -0
- package/src/__tests__/terminal-handoff-controller.test.ts +173 -0
- package/src/__tests__/terminal-handoff-pty-e2e.test.ts +77 -0
- package/src/__tests__/text-prompt-flow.test.ts +77 -0
- package/src/__tests__/theme-picker.test.tsx +345 -0
- package/src/__tests__/transport-boundary.test.ts +11 -0
- package/src/__tests__/tui-app-channel-port.test.ts +31 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +62 -0
- package/src/__tests__/tui-channel-lifecycle-coordinator.test.ts +258 -0
- package/src/__tests__/tui-interaction-queues.test.ts +139 -0
- package/src/__tests__/tui-preset-capabilities.test.ts +33 -0
- package/src/__tests__/tui-state-manager.error-stall.test.ts +169 -0
- package/src/__tests__/tui-state-manager.test.ts +451 -0
- package/src/__tests__/ui-intent-rendering.test.tsx +291 -0
- package/src/__tests__/wave-text.test.tsx +133 -0
- package/src/app-banner.tsx +42 -0
- package/src/app-static-items.ts +35 -0
- package/src/app-view-model.ts +136 -0
- package/src/attention/__tests__/attention-coordinator.test.ts +166 -0
- package/src/attention/__tests__/attention-tracker.test.ts +103 -0
- package/src/attention/__tests__/countdown.test.ts +15 -0
- package/src/attention/__tests__/focus-input-filter.test.ts +92 -0
- package/src/attention/__tests__/interval-recap.test.ts +49 -0
- package/src/attention/attention-coordinator.ts +71 -0
- package/src/attention/attention-tracker.ts +124 -0
- package/src/attention/countdown.ts +24 -0
- package/src/attention/focus-input-filter.ts +131 -0
- package/src/attention/interval-recap.ts +148 -0
- package/src/attention/time-units.ts +6 -0
- package/src/attention-bell.ts +79 -0
- package/src/attributed-user-echo.ts +30 -0
- package/src/background-task-row-format.ts +99 -0
- package/src/command-interaction.ts +8 -0
- package/src/command-output-summary.ts +129 -0
- package/src/create-default-tui-cli-adapter.ts +45 -0
- package/src/execution-workspace-switcher-selection.ts +118 -0
- package/src/execution-workspace-view-model.ts +139 -0
- package/src/external-prompt-notice.tsx +38 -0
- package/src/flows/__tests__/background-focus-flow.test.ts +48 -0
- package/src/flows/__tests__/defer-submit.test.ts +87 -0
- package/src/flows/__tests__/real-cursor-flow.test.ts +124 -0
- package/src/flows/background-focus-flow.ts +44 -0
- package/src/flows/cjk-cursor-motion.ts +75 -0
- package/src/flows/cjk-text-deletion.ts +53 -0
- package/src/flows/cjk-text-input-flow.ts +248 -0
- package/src/flows/cjk-text-input-types.ts +24 -0
- package/src/flows/confirm-prompt-flow.ts +90 -0
- package/src/flows/defer-submit.ts +67 -0
- package/src/flows/fork-attach-flow.ts +62 -0
- package/src/flows/input-area-flow.ts +188 -0
- package/src/flows/permission-prompt-flow.ts +108 -0
- package/src/flows/real-cursor-flow.ts +83 -0
- package/src/flows/selection-flow.ts +205 -0
- package/src/flows/session-init-poller.ts +76 -0
- package/src/flows/text-prompt-flow.ts +93 -0
- package/src/history-search/__tests__/history-search-flow.test.ts +86 -0
- package/src/history-search/history-search-flow.ts +94 -0
- package/src/history-search/useHistorySearch.ts +336 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +74 -0
- package/src/hooks/__tests__/use-plugin-callbacks.test.ts +18 -0
- package/src/hooks/__tests__/use-tui-channel-stability.test.tsx +100 -0
- package/src/hooks/__tests__/useAppThemeState.test.tsx +130 -0
- package/src/hooks/command-result-handler.ts +44 -0
- package/src/hooks/side-effects-types.ts +45 -0
- package/src/hooks/useAppController.ts +267 -0
- package/src/hooks/useAppInputBindings.ts +145 -0
- package/src/hooks/useAppInteractionState.ts +81 -0
- package/src/hooks/useAppLifecycleState.ts +81 -0
- package/src/hooks/useAppOverlays.ts +74 -0
- package/src/hooks/useAppScreenState.ts +81 -0
- package/src/hooks/useAppSubmissionState.ts +45 -0
- package/src/hooks/useAppThemeState.ts +157 -0
- package/src/hooks/useAppWorkspaceState.ts +149 -0
- package/src/hooks/useAppearanceSettings.ts +30 -0
- package/src/hooks/useAutocomplete.ts +101 -0
- package/src/hooks/useBackgroundPanel.ts +58 -0
- package/src/hooks/useCountdownTick.ts +24 -0
- package/src/hooks/useExecutionDetailPage.ts +69 -0
- package/src/hooks/useForkAttach.ts +60 -0
- package/src/hooks/useInputAreaHistorySearch.ts +72 -0
- package/src/hooks/useInputAreaKeys.ts +114 -0
- package/src/hooks/useNumberedSelection.ts +76 -0
- package/src/hooks/usePluginCallbacks.ts +38 -0
- package/src/hooks/usePluginScreenData.ts +85 -0
- package/src/hooks/useRealCursorPosition.ts +125 -0
- package/src/hooks/useRenderMarkdown.ts +38 -0
- package/src/hooks/useScreenReaderTurnSignals.ts +113 -0
- package/src/hooks/useSideEffects.ts +144 -0
- package/src/hooks/useStatusLineSettings.ts +50 -0
- package/src/hooks/useTerminalHandoffSuspension.ts +30 -0
- package/src/hooks/useTuiChannel.ts +129 -0
- package/src/humanize-tool-name.ts +51 -0
- package/src/index.ts +56 -0
- package/src/input-area-rules.tsx +71 -0
- package/src/json-value.ts +18 -0
- package/src/key-hint-footer.tsx +54 -0
- package/src/keybindings/__tests__/keybinding-registry.test.ts +228 -0
- package/src/keybindings/__tests__/node-keybindings-source.test.ts +111 -0
- package/src/keybindings/keybinding-catalogue.ts +171 -0
- package/src/keybindings/keybinding-registry.ts +303 -0
- package/src/keybindings/keybinding-syntax.ts +155 -0
- package/src/keybindings/keybindings-context.tsx +124 -0
- package/src/keybindings/node-keybindings-source.ts +189 -0
- package/src/numbered-list.tsx +113 -0
- package/src/plugin-tui-handlers.ts +163 -0
- package/src/product-display-name-context.tsx +30 -0
- package/src/render-markdown.ts +209 -0
- package/src/render.tsx +544 -0
- package/src/sanitize-terminal-text.ts +171 -0
- package/src/screen-reader-announcement.ts +52 -0
- package/src/screen-reader-context.tsx +32 -0
- package/src/screen-reader-labels.ts +64 -0
- package/src/screen-reader-pacing-context.tsx +47 -0
- package/src/screen-reader-pacing.ts +178 -0
- package/src/screen-reader-stdout.ts +328 -0
- package/src/shutdown-signal.ts +34 -0
- package/src/status-activity.ts +70 -0
- package/src/status-glyph.ts +78 -0
- package/src/terminal-capabilities-context.tsx +27 -0
- package/src/terminal-capabilities.ts +77 -0
- package/src/terminal-focus-reporting.ts +50 -0
- package/src/terminal-handoff-controller.ts +98 -0
- package/src/terminal-marks.ts +63 -0
- package/src/theme/__tests__/built-in-themes.test.ts +117 -0
- package/src/theme/__tests__/color-vision.test.ts +82 -0
- package/src/theme/__tests__/theme-context.test.tsx +146 -0
- package/src/theme/__tests__/theme-document.test.ts +222 -0
- package/src/theme/__tests__/theme-registry.test.ts +138 -0
- package/src/theme/__tests__/theme-styles.test.ts +233 -0
- package/src/theme/built-in-themes.ts +342 -0
- package/src/theme/color-vision.ts +171 -0
- package/src/theme/index.ts +45 -0
- package/src/theme/theme-context.tsx +81 -0
- package/src/theme/theme-contracts.ts +139 -0
- package/src/theme/theme-document.ts +278 -0
- package/src/theme/theme-registry.ts +127 -0
- package/src/theme/theme-styles.ts +177 -0
- package/src/tool-summary-status.ts +59 -0
- package/src/tui-app-channel-port.ts +83 -0
- package/src/tui-channel-lifecycle-coordinator.ts +149 -0
- package/src/tui-channel-options.ts +172 -0
- package/src/tui-cli-adapter-context.tsx +13 -0
- package/src/tui-cli-adapter.ts +24 -0
- package/src/tui-interaction-queues.ts +163 -0
- package/src/tui-session-binding.ts +52 -0
- package/src/tui-session-event-projector.ts +140 -0
- package/src/tui-session-events.ts +82 -0
- package/src/tui-session-notice-store.ts +43 -0
- package/src/tui-session-options.ts +118 -0
- package/src/tui-state-manager.ts +327 -0
- package/src/types.ts +19 -0
- package/src/use-terminal-title.ts +29 -0
- package/src/utils/__tests__/edit-diff.test.ts +426 -0
- package/src/utils/__tests__/paste-detection.test.ts +116 -0
- package/src/utils/__tests__/paste-labels.test.ts +46 -0
- package/src/utils/__tests__/tool-call-extractor.test.ts +227 -0
- package/src/utils/__tests__/tool-diff-summary.test.ts +104 -0
- package/src/utils/edit-diff.ts +151 -0
- package/src/utils/input-top-border.ts +19 -0
- package/src/utils/paste-labels.ts +9 -0
- package/src/utils/tool-call-extractor.ts +92 -0
- package/src/utils/tool-diff-summary.ts +75 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SCREEN-2670 TC-09 — the pre-write park on the BUILT robota binary, in a real PTY.
|
|
3
|
+
*
|
|
4
|
+
* A screen reader sees bytes and time, nothing else, so the claim is checked where both exist: the
|
|
5
|
+
* raw PTY stream is sampled every few milliseconds and each growth is stamped. A COMMIT the park
|
|
6
|
+
* released arrives as one growth (its chunks are written contiguously); the interval shows up as
|
|
7
|
+
* the gap between consecutive printable growths. The same fixture runs three ways — park 250 ms,
|
|
8
|
+
* park 0, mode off — so the interval is attributed to the park and not to the replay.
|
|
9
|
+
*
|
|
10
|
+
* Runs under `test:pty` against the built CLI (`pnpm build` first).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
19
|
+
|
|
20
|
+
import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
|
|
21
|
+
|
|
22
|
+
import type { IPtySession } from './pty-driver.js';
|
|
23
|
+
|
|
24
|
+
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
|
25
|
+
const REPLAY_FIXTURE = join(FIXTURES, 'replay-conversation.jsonl');
|
|
26
|
+
|
|
27
|
+
const WAIT_MS = 30_000;
|
|
28
|
+
const CASE_TIMEOUT_MS = 120_000;
|
|
29
|
+
/** The park under test — large against the frame cadence, small against the case budget. */
|
|
30
|
+
const PARK_MS = 250;
|
|
31
|
+
/** Sampling period; the gap resolution is twice this. */
|
|
32
|
+
const SAMPLE_MS = 5;
|
|
33
|
+
/** OS and PTY jitter allowance on the asserted interval. */
|
|
34
|
+
const SLACK_MS = 60;
|
|
35
|
+
/** Growths closer than this are one burst: a PTY read can split one contiguous write. */
|
|
36
|
+
const COALESCE_MS = 30;
|
|
37
|
+
|
|
38
|
+
/** Startup frames settle for this long before the keystroke is measured against a quiet stream. */
|
|
39
|
+
const STARTUP_SETTLE_MS = 750;
|
|
40
|
+
/* eslint-disable no-control-regex -- matching the escapes IS the point here */
|
|
41
|
+
const OSC_133 = /\x1b\]133;[ABCD]\x07/g;
|
|
42
|
+
const ESCAPES = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07|\x1b[()][A-Z0-9]|\x1b[=>]/g;
|
|
43
|
+
/* eslint-enable no-control-regex */
|
|
44
|
+
|
|
45
|
+
interface IGrowth {
|
|
46
|
+
at: number;
|
|
47
|
+
bytes: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sample the raw stream from `since` (an offset taken BEFORE the action — the driver's key pacing
|
|
52
|
+
* means output can land before this loop starts) until `until` matches or the budget ends; return
|
|
53
|
+
* every growth stamped.
|
|
54
|
+
*/
|
|
55
|
+
interface IMark {
|
|
56
|
+
/** Index into the RAW stream (growth detection). */
|
|
57
|
+
raw: number;
|
|
58
|
+
/** The driver's mark into the STRIPPED transcript (pattern matching) — the two are not the same. */
|
|
59
|
+
stripped: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function markNow(session: IPtySession): IMark {
|
|
63
|
+
return { raw: session.raw().length, stripped: session.outputOffset() };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function sampleUntil(
|
|
67
|
+
session: IPtySession,
|
|
68
|
+
since: IMark,
|
|
69
|
+
until: RegExp,
|
|
70
|
+
budgetMs: number,
|
|
71
|
+
): Promise<IGrowth[]> {
|
|
72
|
+
const growths: IGrowth[] = [];
|
|
73
|
+
let seen = since.raw;
|
|
74
|
+
const deadline = Date.now() + budgetMs;
|
|
75
|
+
while (Date.now() < deadline) {
|
|
76
|
+
const raw = session.raw();
|
|
77
|
+
if (raw.length > seen) {
|
|
78
|
+
growths.push({ at: Date.now(), bytes: raw.slice(seen) });
|
|
79
|
+
seen = raw.length;
|
|
80
|
+
}
|
|
81
|
+
if (until.test(session.snapshotSince(since.stripped))) break;
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, SAMPLE_MS));
|
|
83
|
+
}
|
|
84
|
+
return growths;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const visible = (bytes: string): string => bytes.replace(ESCAPES, '');
|
|
88
|
+
|
|
89
|
+
/** Merge growths that landed within `COALESCE_MS` of each other — one contiguous write, one burst. */
|
|
90
|
+
function bursts(growths: IGrowth[]): IGrowth[] {
|
|
91
|
+
const merged: IGrowth[] = [];
|
|
92
|
+
for (const growth of growths) {
|
|
93
|
+
const last = merged.at(-1);
|
|
94
|
+
if (last !== undefined && growth.at - last.at < COALESCE_MS) last.bytes += growth.bytes;
|
|
95
|
+
else merged.push({ ...growth });
|
|
96
|
+
}
|
|
97
|
+
return merged;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The marks travel through the owned path and keep their place. A replayed reply resolves inside
|
|
102
|
+
* one commit, so the turn's own B/C/D/A marks are not observable here (their ordering against a
|
|
103
|
+
* parked batch is pinned by the unit test); what this stream does carry is the mount-time
|
|
104
|
+
* prompt-start, which must precede the answer rather than surface inside the parked gap after it.
|
|
105
|
+
*/
|
|
106
|
+
function expectMarksOrderedWithTheTurn(raw: string): void {
|
|
107
|
+
const marks = [...raw.matchAll(OSC_133)].map((match) => ({
|
|
108
|
+
code: match[0],
|
|
109
|
+
at: match.index ?? 0,
|
|
110
|
+
}));
|
|
111
|
+
const answerAt = raw.indexOf('REPLAYED_ANSWER_42');
|
|
112
|
+
const promptStarts = marks.filter((mark) => mark.code.includes('133;A'));
|
|
113
|
+
expect(promptStarts.length).toBeGreaterThanOrEqual(1);
|
|
114
|
+
expect(promptStarts[0]?.at).toBeLessThan(answerAt);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One paced-free line: the echo commit (exempt) and the turn commit (parked) are provoked within
|
|
119
|
+
* milliseconds of each other, so the interval between them is the park and nothing else. The reply
|
|
120
|
+
* itself is replayed as a single chunk, so the turn is ONE commit — which is also what proves the
|
|
121
|
+
* chunks of a commit stay contiguous: the answer arrives inside one synchronized-output window.
|
|
122
|
+
*/
|
|
123
|
+
interface ITurnTiming {
|
|
124
|
+
typedAt: number;
|
|
125
|
+
echoAt: number;
|
|
126
|
+
answerAt: number;
|
|
127
|
+
answerBytes: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function typeAndSubmit(session: IPtySession): Promise<ITurnTiming> {
|
|
131
|
+
const mark = markNow(session);
|
|
132
|
+
const sampling = sampleUntil(session, mark, /REPLAYED_ANSWER_42[\s\S]*Type a message/, WAIT_MS);
|
|
133
|
+
// Characters are paced (a single chunk would be read as a paste); the echo latency is measured
|
|
134
|
+
// from the LAST key, so the driver's per-key pacing is not charged to the park. Enter follows the
|
|
135
|
+
// last echo immediately, so the turn's commit is provoked within milliseconds of an exempt one.
|
|
136
|
+
await session.sendKeys('hell');
|
|
137
|
+
const typedAt = Date.now();
|
|
138
|
+
await session.sendKeys('o');
|
|
139
|
+
session.writeRaw('\r');
|
|
140
|
+
const growths = bursts(await sampling);
|
|
141
|
+
const echo = growths.find((g) => visible(g.bytes).includes('hello'));
|
|
142
|
+
const answer = growths.find((g) => visible(g.bytes).includes('REPLAYED_ANSWER_42'));
|
|
143
|
+
expect(echo, 'the echo of the typed line').toBeDefined();
|
|
144
|
+
expect(answer, 'the replayed answer').toBeDefined();
|
|
145
|
+
return {
|
|
146
|
+
typedAt,
|
|
147
|
+
echoAt: (echo as IGrowth).at,
|
|
148
|
+
answerAt: (answer as IGrowth).at,
|
|
149
|
+
answerBytes: (answer as IGrowth).bytes,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function spawnScenario(projectDir: string, mode: 'park' | 'zero' | 'off'): IPtySession {
|
|
154
|
+
const screenReader = mode === 'off' ? [] : ['--screen-reader'];
|
|
155
|
+
const prepark = mode === 'zero' ? '0' : String(PARK_MS);
|
|
156
|
+
return spawnTui({
|
|
157
|
+
projectDir,
|
|
158
|
+
homeDir: join(projectDir, 'home'),
|
|
159
|
+
args: ['--session-log', REPLAY_FIXTURE, ...screenReader, '--name', 'prepark-scenario'],
|
|
160
|
+
env: { ROBOTA_SCREEN_READER_STARTUP_QUIET_MS: '0', ROBOTA_SCREEN_READER_PREPARK_MS: prepark },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function settled(session: IPtySession): Promise<void> {
|
|
165
|
+
await session.waitFor(/Type a message or \/help/, WAIT_MS);
|
|
166
|
+
await new Promise((resolve) => setTimeout(resolve, STARTUP_SETTLE_MS));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
describe('SCREEN-2670 the pre-write park through the real binary', () => {
|
|
170
|
+
let projectDir: string;
|
|
171
|
+
let session: IPtySession | undefined;
|
|
172
|
+
|
|
173
|
+
beforeEach(() => {
|
|
174
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-prepark-pty-')));
|
|
175
|
+
writeTuiProviderSettings(projectDir);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
afterEach(async () => {
|
|
179
|
+
await session?.disposeAsync();
|
|
180
|
+
session = undefined;
|
|
181
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it(
|
|
185
|
+
'TC-09: the echo is not delayed, the turn commit waits the interval after it, its chunks are contiguous, and the marks stay in order',
|
|
186
|
+
async () => {
|
|
187
|
+
session = spawnScenario(projectDir, 'park');
|
|
188
|
+
await settled(session);
|
|
189
|
+
|
|
190
|
+
const timing = await typeAndSubmit(session);
|
|
191
|
+
// The keystroke's commit is exempt: its echo lands well inside the interval.
|
|
192
|
+
expect(timing.echoAt - timing.typedAt).toBeLessThan(PARK_MS - SLACK_MS);
|
|
193
|
+
// The turn's commit is parked: at least the interval after the previous printable release.
|
|
194
|
+
// Assumption: with a single-chunk replay the answer commit is the LAST printable commit of the
|
|
195
|
+
// turn's burst; only the last waiting batch is parked, so a later commit inside the interval
|
|
196
|
+
// would make this measure turn latency instead.
|
|
197
|
+
expect(timing.answerAt - timing.echoAt).toBeGreaterThanOrEqual(PARK_MS - SLACK_MS);
|
|
198
|
+
// And it arrived whole: the synchronized-output window opened and closed in one growth.
|
|
199
|
+
expect(timing.answerBytes).toContain('\x1b[?2026h');
|
|
200
|
+
expect(timing.answerBytes).toContain('\x1b[?2026l');
|
|
201
|
+
expectMarksOrderedWithTheTurn(session.raw());
|
|
202
|
+
},
|
|
203
|
+
CASE_TIMEOUT_MS,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
it(
|
|
207
|
+
'TC-09: with the interval 0 no delay is injected',
|
|
208
|
+
async () => {
|
|
209
|
+
session = spawnScenario(projectDir, 'zero');
|
|
210
|
+
await settled(session);
|
|
211
|
+
const timing = await typeAndSubmit(session);
|
|
212
|
+
expect(timing.answerAt - timing.echoAt).toBeLessThan(PARK_MS - SLACK_MS);
|
|
213
|
+
},
|
|
214
|
+
CASE_TIMEOUT_MS,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
it(
|
|
218
|
+
'TC-09: with the mode off the park is absent — no interval, bordered UI as before',
|
|
219
|
+
async () => {
|
|
220
|
+
session = spawnScenario(projectDir, 'off');
|
|
221
|
+
await settled(session);
|
|
222
|
+
const timing = await typeAndSubmit(session);
|
|
223
|
+
expect(timing.answerAt - timing.echoAt).toBeLessThan(PARK_MS - SLACK_MS);
|
|
224
|
+
expect(session.snapshot()).toMatch(/[│─╭╮╰╯┌┐└┘]/u);
|
|
225
|
+
},
|
|
226
|
+
CASE_TIMEOUT_MS,
|
|
227
|
+
);
|
|
228
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-2004 TC-11 / TC-12 — screen-reader mode on the BUILT robota binary, in a real PTY.
|
|
3
|
+
*
|
|
4
|
+
* This is the only level at which the claim can be checked. A screen reader has no `aria-live`: the
|
|
5
|
+
* ONLY channel to it is which bytes land in the terminal buffer, so a component test asserting on a
|
|
6
|
+
* frame is asserting on something the reader never sees. Here the transcript is the evidence.
|
|
7
|
+
*
|
|
8
|
+
* TC-11 pairs the positive with the DEFAULT-OFF proof deliberately: the same fixture, the same
|
|
9
|
+
* binary, one flag apart. Without the negative half, "no box-drawing characters" would also pass if
|
|
10
|
+
* the fixture happened to render none.
|
|
11
|
+
*
|
|
12
|
+
* Runs under `test:pty` against the built CLI (`pnpm build` first).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { mkdtempSync, rmSync, realpathSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
21
|
+
|
|
22
|
+
import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
|
|
23
|
+
|
|
24
|
+
import type { IPtySession } from './pty-driver.js';
|
|
25
|
+
|
|
26
|
+
const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
|
27
|
+
const REPLAY_FIXTURE = join(FIXTURES, 'replay-conversation.jsonl');
|
|
28
|
+
|
|
29
|
+
/** Every box-drawing glyph the TUI draws chrome with, in one class. */
|
|
30
|
+
const BOX_DRAWING = /[│─╭╮╰╯┌┐└┘]/u;
|
|
31
|
+
|
|
32
|
+
/** OSC 133 prompt-start — the mark a terminal turns into jump-to-previous-turn. */
|
|
33
|
+
const OSC_133_PROMPT_START = '\x1b]133;A';
|
|
34
|
+
|
|
35
|
+
/** How long one `waitFor` may block, and how long a whole case may take, on a loaded CI runner. */
|
|
36
|
+
const WAIT_MS = 30_000;
|
|
37
|
+
const CASE_TIMEOUT_MS = 90_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Mark the isolated HOME as already onboarded. TC-12 asserts on the FIRST line the process writes,
|
|
41
|
+
* and the first-run welcome is a separate concern with its own onboarding copy — a returning user
|
|
42
|
+
* is the case where "the first line confirms the mode" is the claim being made.
|
|
43
|
+
*/
|
|
44
|
+
function markOnboarded(homeDir: string): void {
|
|
45
|
+
writeFileSync(join(homeDir, '.robota', 'onboarded'), new Date().toISOString());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('CLI-2004 screen-reader mode through the real binary', () => {
|
|
49
|
+
let projectDir: string;
|
|
50
|
+
let session: IPtySession | undefined;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-screenreader-pty-')));
|
|
54
|
+
writeTuiProviderSettings(projectDir);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
await session?.disposeAsync();
|
|
59
|
+
session = undefined;
|
|
60
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it(
|
|
64
|
+
'TC-11: --screen-reader emits no box-drawing chrome, role labels, and OSC 133 marks',
|
|
65
|
+
async () => {
|
|
66
|
+
session = spawnTui({
|
|
67
|
+
projectDir,
|
|
68
|
+
homeDir: join(projectDir, 'home'),
|
|
69
|
+
args: ['--session-log', REPLAY_FIXTURE, '--screen-reader'],
|
|
70
|
+
// The waits are exercised by their own unit test; a real one here only slows the fixture.
|
|
71
|
+
env: { ROBOTA_SCREEN_READER_STARTUP_QUIET_MS: '0' },
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await session.waitFor(/Type a message or \/help/, WAIT_MS);
|
|
75
|
+
await session.sendKeys('hello');
|
|
76
|
+
await session.pressEnter();
|
|
77
|
+
await session.waitFor(/REPLAYED_ANSWER_42/, WAIT_MS);
|
|
78
|
+
await session.waitFor(/Type a message or \/help/, WAIT_MS);
|
|
79
|
+
|
|
80
|
+
const snapshot = session.snapshot();
|
|
81
|
+
const raw = session.raw();
|
|
82
|
+
|
|
83
|
+
// The transcript is labelled by ROLE, not by vendor.
|
|
84
|
+
expect(snapshot).toContain('assistant:');
|
|
85
|
+
expect(snapshot).toContain('you:');
|
|
86
|
+
expect(snapshot).toContain('REPLAYED_ANSWER_42');
|
|
87
|
+
|
|
88
|
+
// Not one box-drawing character reaches the terminal.
|
|
89
|
+
expect(snapshot).not.toMatch(BOX_DRAWING);
|
|
90
|
+
|
|
91
|
+
// The turn boundary is marked for terminals that navigate by it.
|
|
92
|
+
expect(raw).toContain(OSC_133_PROMPT_START);
|
|
93
|
+
},
|
|
94
|
+
CASE_TIMEOUT_MS,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('CLI-2004 the same fixture with the mode off', () => {
|
|
99
|
+
let projectDir: string;
|
|
100
|
+
let session: IPtySession | undefined;
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-screenreader-pty-')));
|
|
104
|
+
writeTuiProviderSettings(projectDir);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterEach(async () => {
|
|
108
|
+
await session?.disposeAsync();
|
|
109
|
+
session = undefined;
|
|
110
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it(
|
|
114
|
+
'TC-11: the SAME fixture without the flag still renders the bordered UI (default-off)',
|
|
115
|
+
async () => {
|
|
116
|
+
session = spawnTui({
|
|
117
|
+
projectDir,
|
|
118
|
+
homeDir: join(projectDir, 'home'),
|
|
119
|
+
args: ['--session-log', REPLAY_FIXTURE],
|
|
120
|
+
env: { ROBOTA_TURN_MARKS: '1' },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await session.waitFor(/Type a message or \/help/, WAIT_MS);
|
|
124
|
+
await session.sendKeys('hello');
|
|
125
|
+
await session.pressEnter();
|
|
126
|
+
await session.waitFor(/REPLAYED_ANSWER_42/, WAIT_MS);
|
|
127
|
+
|
|
128
|
+
const snapshot = session.snapshot();
|
|
129
|
+
|
|
130
|
+
expect(snapshot).toMatch(BOX_DRAWING);
|
|
131
|
+
expect(snapshot).toContain('You:');
|
|
132
|
+
expect(snapshot).not.toContain('[Screen reader mode:');
|
|
133
|
+
expect(session.raw()).not.toContain(OSC_133_PROMPT_START);
|
|
134
|
+
},
|
|
135
|
+
CASE_TIMEOUT_MS,
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('Robota turn-mark override through the real binary', () => {
|
|
140
|
+
let projectDir: string;
|
|
141
|
+
let session: IPtySession | undefined;
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-turn-marks-pty-')));
|
|
145
|
+
writeTuiProviderSettings(projectDir);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
afterEach(async () => {
|
|
149
|
+
await session?.disposeAsync();
|
|
150
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
for (const [label, env, expected] of [
|
|
154
|
+
['WezTerm default', { TERM_PROGRAM: 'WezTerm' }, false],
|
|
155
|
+
['WezTerm opt-in', { TERM_PROGRAM: 'WezTerm', ROBOTA_TURN_MARKS: '1' }, true],
|
|
156
|
+
['TTY kill switch', { ROBOTA_TURN_MARKS: '0' }, false],
|
|
157
|
+
] as const) {
|
|
158
|
+
it(
|
|
159
|
+
`${label} ${expected ? 'emits' : 'withholds'} OSC 133`,
|
|
160
|
+
async () => {
|
|
161
|
+
session = spawnTui({
|
|
162
|
+
projectDir,
|
|
163
|
+
homeDir: join(projectDir, 'home'),
|
|
164
|
+
args: ['--screen-reader'],
|
|
165
|
+
env: { ...env, ROBOTA_SCREEN_READER_STARTUP_QUIET_MS: '0' },
|
|
166
|
+
});
|
|
167
|
+
await session.waitFor(/Type a message or \/help/, WAIT_MS);
|
|
168
|
+
expect(session.raw().includes(OSC_133_PROMPT_START)).toBe(expected);
|
|
169
|
+
},
|
|
170
|
+
CASE_TIMEOUT_MS,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('CLI-2004 the channel the mode was enabled through', () => {
|
|
176
|
+
let projectDir: string;
|
|
177
|
+
let session: IPtySession | undefined;
|
|
178
|
+
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-screenreader-pty-')));
|
|
181
|
+
writeTuiProviderSettings(projectDir);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
afterEach(async () => {
|
|
185
|
+
await session?.disposeAsync();
|
|
186
|
+
session = undefined;
|
|
187
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it(
|
|
191
|
+
'TC-12: ROBOTA_SCREEN_READER=1 with no flag announces the env channel first',
|
|
192
|
+
async () => {
|
|
193
|
+
markOnboarded(join(projectDir, 'home'));
|
|
194
|
+
session = spawnTui({
|
|
195
|
+
projectDir,
|
|
196
|
+
homeDir: join(projectDir, 'home'),
|
|
197
|
+
env: { ROBOTA_SCREEN_READER: '1', ROBOTA_SCREEN_READER_STARTUP_QUIET_MS: '0' },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await session.waitFor(/Type a message or \/help/, WAIT_MS);
|
|
201
|
+
|
|
202
|
+
expect(session.snapshot().trimStart().startsWith('[Screen reader mode: on via env]')).toBe(
|
|
203
|
+
true,
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
CASE_TIMEOUT_MS,
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
it(
|
|
210
|
+
'TC-12: --screen-reader beats ROBOTA_SCREEN_READER=0 and says so',
|
|
211
|
+
async () => {
|
|
212
|
+
markOnboarded(join(projectDir, 'home'));
|
|
213
|
+
session = spawnTui({
|
|
214
|
+
projectDir,
|
|
215
|
+
homeDir: join(projectDir, 'home'),
|
|
216
|
+
args: ['--screen-reader'],
|
|
217
|
+
env: { ROBOTA_SCREEN_READER: '0', ROBOTA_SCREEN_READER_STARTUP_QUIET_MS: '0' },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await session.waitFor(/Type a message or \/help/, WAIT_MS);
|
|
221
|
+
|
|
222
|
+
expect(session.snapshot().trimStart().startsWith('[Screen reader mode: on via flag]')).toBe(
|
|
223
|
+
true,
|
|
224
|
+
);
|
|
225
|
+
},
|
|
226
|
+
CASE_TIMEOUT_MS,
|
|
227
|
+
);
|
|
228
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CMD-004 Phase 2 Stage C (TC-04): `/settings` still opens the settings screen — now rendered from
|
|
3
|
+
* the requester-routed `ui_intent` session event (the legacy `settings-tui-requested` effect
|
|
4
|
+
* branch is deleted from the TUI). Observed through a real PTY against the BUILT robota binary
|
|
5
|
+
* (`pnpm --filter @robota-sdk/agent-cli build`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import { spawnTui, writeTuiProviderSettings } from './pty-driver.js';
|
|
15
|
+
|
|
16
|
+
import type { IPtySession } from './pty-driver.js';
|
|
17
|
+
|
|
18
|
+
describe('/settings opens the settings screen via ui_intent (CMD-004 Stage C)', () => {
|
|
19
|
+
let projectDir: string;
|
|
20
|
+
let session: IPtySession | undefined;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
projectDir = realpathSync(mkdtempSync(join(tmpdir(), 'robota-settings-pty-')));
|
|
24
|
+
writeTuiProviderSettings(projectDir);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await session?.disposeAsync();
|
|
29
|
+
session = undefined;
|
|
30
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('renders the transports settings screen and Esc returns to the prompt', async () => {
|
|
34
|
+
session = spawnTui({ projectDir, homeDir: join(projectDir, 'home') });
|
|
35
|
+
await session.waitFor(/Type a message or \/help/);
|
|
36
|
+
|
|
37
|
+
await session.sendKeys('/settings');
|
|
38
|
+
await session.pressEnter();
|
|
39
|
+
|
|
40
|
+
// The settings screen opened — from the ui_intent event, not a legacy effect branch.
|
|
41
|
+
await session.waitFor(/Settings › Transports/, 20_000);
|
|
42
|
+
expect(session.snapshot()).toContain('Settings › Transports');
|
|
43
|
+
|
|
44
|
+
// Esc closes the screen and returns to the idle prompt.
|
|
45
|
+
const mark = session.outputOffset();
|
|
46
|
+
session.pressEscape();
|
|
47
|
+
await session.waitForSince(mark, /Type a message or \/help/, 20_000);
|
|
48
|
+
}, 60_000);
|
|
49
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TEST-007: self-test for the TUI-owned PTY harness (relocated internally by BOUNDARY-2655).
|
|
3
|
+
*
|
|
4
|
+
* Drives a trivial process (no fixture needed) through `spawnPty` to pin the harness contract:
|
|
5
|
+
* `waitFor` resolves on a printed marker, `expectExit` returns the real exit code, and
|
|
6
|
+
* `snapshot()`/`raw()` capture output. The terminal-handoff suites (in agent-ui-terminal) are the
|
|
7
|
+
* harness's real-world consumers; this guards the harness itself.
|
|
8
|
+
*
|
|
9
|
+
* HARNESS-025 adds the HOME-isolation contract: a PTY child must never receive the developer's real
|
|
10
|
+
* `HOME`, or the suite silently depends on whatever is in that home directory.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
|
|
16
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import { createIsolatedHome, createPtyEnv } from './isolated-home.js';
|
|
19
|
+
import { spawnPty } from './spawn-pty.js';
|
|
20
|
+
|
|
21
|
+
import type { IPtyRunSession } from './spawn-pty.js';
|
|
22
|
+
|
|
23
|
+
const PACKAGE_DIR = fileURLToPath(new URL('../../..', import.meta.url));
|
|
24
|
+
const sessions: IPtyRunSession[] = [];
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
for (const session of sessions.splice(0)) session.dispose();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('spawnPty harness self-test', () => {
|
|
31
|
+
it('waits for a marker and reports the real exit code', async () => {
|
|
32
|
+
const session = spawnPty({
|
|
33
|
+
command: process.execPath,
|
|
34
|
+
args: ['-e', 'process.stdout.write("MARKER_OK\\n"); setTimeout(() => process.exit(3), 50);'],
|
|
35
|
+
cwd: PACKAGE_DIR,
|
|
36
|
+
env: createPtyEnv(),
|
|
37
|
+
});
|
|
38
|
+
sessions.push(session);
|
|
39
|
+
|
|
40
|
+
await session.waitFor('MARKER_OK', 5_000);
|
|
41
|
+
expect(session.snapshot()).toContain('MARKER_OK');
|
|
42
|
+
expect(await session.expectExit(5_000)).toBe(3);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('paces sent keys and reads them back from the child', async () => {
|
|
46
|
+
const session = spawnPty({
|
|
47
|
+
command: 'sh',
|
|
48
|
+
args: ['-c', 'IFS= read -r line; printf "ECHO:[%s]\\n" "$line"'],
|
|
49
|
+
cwd: PACKAGE_DIR,
|
|
50
|
+
env: createPtyEnv(),
|
|
51
|
+
});
|
|
52
|
+
sessions.push(session);
|
|
53
|
+
|
|
54
|
+
await session.sendKeys('abc', 10);
|
|
55
|
+
session.write('\r');
|
|
56
|
+
await session.waitFor('ECHO:[abc]', 5_000);
|
|
57
|
+
expect(await session.expectExit(5_000)).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('PTY HOME isolation', () => {
|
|
62
|
+
/** Ask the child to report the HOME it actually sees, so the assertion is on the child's view. */
|
|
63
|
+
function spawnHomeReporter(env?: NodeJS.ProcessEnv): IPtyRunSession {
|
|
64
|
+
const session = spawnPty({
|
|
65
|
+
command: 'sh',
|
|
66
|
+
args: ['-c', 'printf "CHILD_HOME:[%s]\\n" "$HOME"'],
|
|
67
|
+
cwd: PACKAGE_DIR,
|
|
68
|
+
...(env ? { env } : {}),
|
|
69
|
+
});
|
|
70
|
+
sessions.push(session);
|
|
71
|
+
return session;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readReportedHome(snapshot: string): string {
|
|
75
|
+
const match = /CHILD_HOME:\[([^\]]*)\]/.exec(snapshot);
|
|
76
|
+
if (match?.[1] === undefined) throw new Error(`child never reported HOME:\n${snapshot}`);
|
|
77
|
+
return match[1];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it('does not hand the real HOME to a child when no env is supplied', async () => {
|
|
81
|
+
const realHome = process.env['HOME'];
|
|
82
|
+
expect(
|
|
83
|
+
realHome,
|
|
84
|
+
'the test host must have a HOME for this assertion to mean anything',
|
|
85
|
+
).toBeTruthy();
|
|
86
|
+
|
|
87
|
+
const session = spawnHomeReporter();
|
|
88
|
+
await session.waitFor('CHILD_HOME:[', 5_000);
|
|
89
|
+
await session.expectExit(5_000);
|
|
90
|
+
|
|
91
|
+
const childHome = readReportedHome(session.snapshot());
|
|
92
|
+
expect(childHome).not.toBe(realHome);
|
|
93
|
+
expect(childHome.startsWith(tmpdir())).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('hands the child the isolated HOME built by createPtyEnv', async () => {
|
|
97
|
+
const env = createPtyEnv();
|
|
98
|
+
const session = spawnHomeReporter(env);
|
|
99
|
+
await session.waitFor('CHILD_HOME:[', 5_000);
|
|
100
|
+
await session.expectExit(5_000);
|
|
101
|
+
|
|
102
|
+
expect(readReportedHome(session.snapshot())).toBe(env['HOME']);
|
|
103
|
+
expect(env['HOME']).not.toBe(process.env['HOME']);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('creates an EMPTY home directory, so no user config can leak in', () => {
|
|
107
|
+
const home = createIsolatedHome();
|
|
108
|
+
expect(existsSync(home)).toBe(true);
|
|
109
|
+
expect(readdirSync(home)).toEqual([]);
|
|
110
|
+
expect(home).not.toBe(process.env['HOME']);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('lets an override customize the env without reintroducing the real HOME', () => {
|
|
114
|
+
const env = createPtyEnv({ EDITOR: 'sh /tmp/fake-editor.sh' });
|
|
115
|
+
expect(env['EDITOR']).toBe('sh /tmp/fake-editor.sh');
|
|
116
|
+
expect(env['TERM']).toBe('xterm-256color');
|
|
117
|
+
expect(env['PATH']).toBe(process.env['PATH']);
|
|
118
|
+
expect(env['HOME']).not.toBe(process.env['HOME']);
|
|
119
|
+
});
|
|
120
|
+
});
|