diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml index 4bd234fa..78fa2723 100644 --- a/.github/workflows/terminal-compatibility.yml +++ b/.github/workflows/terminal-compatibility.yml @@ -98,7 +98,7 @@ jobs: defaults write com.googlecode.iterm2 ApplePersistenceIgnoreState -bool true - run: bun install --frozen-lockfile - name: Verify native automation boundary and disposable profiles - run: bun run test tests/testing/nativeTerminal.test.ts + run: bun run test tests/testing/nativeTerminal.test.ts tests/testing/resizeWriteMetadata.test.ts tests/testing/resizeStreamGate.test.ts - run: bun run build - name: Run Tuistory with native XCTest window and accessibility checks run: bun run test:tuistory tests/tuistory/native-terminal.tuistory.test.ts @@ -120,6 +120,10 @@ jobs: .artifacts/native-terminal/**/*.xcresult .artifacts/native-terminal/**/screenshots .artifacts/native-terminal/**/xcodebuild.log + .artifacts/native-terminal/**/write-metadata-*.jsonl + .artifacts/native-terminal/**/write-metadata-*.jsonl.pending + .artifacts/native-terminal/**/copy-samples-*.jsonl + .artifacts/native-terminal/**/stream-control-*.json* .artifacts/native-terminal/**/vscode-profile/**/settings.json .artifacts/native-terminal/**/vscode-profile/**/keybindings.json if-no-files-found: error diff --git a/docs/extension-authoring.md b/docs/extension-authoring.md index d0bb80fc..7735ce36 100644 --- a/docs/extension-authoring.md +++ b/docs/extension-authoring.md @@ -187,6 +187,8 @@ api.commands.register({ Every view receives `close(value?)`, `workspaceRoot`, and the command `args`, plus the properties supplied to `context.ui.open`. Autohand owns modal pause/resume, alternate-screen cleanup, Escape, and Ctrl+C behavior. Components may build menus, dialogs, custom renderers, or editor-like interfaces with normal Ink composition. +Closing a view restores the composer. Finishing, cancelling, or failing the earlier command preserves existing drafts, later typing, and command prefills. + ## Status and help lines Append, hide, or replace line segments through the existing line-extension contract: diff --git a/docs/maintenance/cli-pr-integration-2026-11-10.md b/docs/maintenance/cli-pr-integration-2026-11-10.md index b45ddf82..3c62ca5f 100644 --- a/docs/maintenance/cli-pr-integration-2026-11-10.md +++ b/docs/maintenance/cli-pr-integration-2026-11-10.md @@ -2,7 +2,7 @@ This candidate combines the remaining Igor-authored work from [#745](https://github.com/autohandai/code-cli/pull/745), [#743](https://github.com/autohandai/code-cli/pull/743), [#742](https://github.com/autohandai/code-cli/pull/742), [#727](https://github.com/autohandai/code-cli/pull/727), [#690](https://github.com/autohandai/code-cli/pull/690), [#669](https://github.com/autohandai/code-cli/pull/669), and [#747](https://github.com/autohandai/code-cli/pull/747), together with the saved-session input fix from `df44f764`. -The integration starts from main at `150be8938f3bacd4264f0622e88dcffc465bfc8f`. It preserves the intervening explicit Git-initialization change, atomic configuration writes, partial cloud-history protection, extension reload coordination, and permission enforcement. The first validation revision was paused when #747 was added to the requested scope; the expanded revision receives fresh combined checks. +The integration starts from main at `150be8938f3bacd4264f0622e88dcffc465bfc8f`. It preserves the intervening explicit Git-initialization change, atomic configuration writes, partial cloud-history protection, extension reload coordination, and permission enforcement. The combined revision receives fresh tests, lint, full proof, and terminal compatibility checks before merge. | Area | Resulting behavior | Reference | | --- | --- | --- | @@ -13,6 +13,7 @@ The integration starts from main at `150be8938f3bacd4264f0622e88dcffc465bfc8f`. | Command output | Cap model observations while preserving complete oversized output in private session artifacts; streamed and nested command paths use the same cap. | [Configuration reference](../config-reference.md) | | Computer Use | Temporarily enter AUTO for the computer task and restore the preceding mode, preserving subsequent manual selections, explicit denials, hooks, and restricted mode. | [Computer control](../computer-control.md) | | Saved sessions | Accept composer edits while restoring history and defer submitted work until restoration completes. | [Resume terminal tests](../../tests/tuistory/resume.tuistory.test.ts) | +| Composer ownership | Keep an existing or newly typed draft when an older slash command or extension view completes, cancels, or fails. Input submission is consumed at dispatch, so delayed completion cannot clear a later draft. | [Extension authoring](../extension-authoring.md) | | CLI contract | Bootstrap the current 199 option definitions across 52 command paths, and compare future changes with the committed main snapshot. | [Option contract](../compatibility/claude-code/README.md) | | macOS distribution | Check transported CLI and trace binaries for notarization in addition to existing signature checks. | [Release workflow](../../.github/workflows/release.yml) | | Providers | Keep the active provider when removing an inactive custom entry; preserve it if replacement is cancelled. Cloud restoration clears previous local transport settings. Bound OpenAI-family native tool requests, including delegated agents, and use compatible Azure reasoning parameters. | [Provider reference](../providers.md) | diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 8ae1b4b1..d16e30c6 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -39,11 +39,17 @@ The tmux suite launches a compiled CLI through a real tmux server on a unique te First-run dialogs are handled before fixture input. The offline fixture denies only the macOS request to find devices on local networks through XCTest's interruption handler. The handler checks accessibility values, titles, and labels: macOS permission text can have an empty label, while its visible text is in the value or title. The iTerm2 scenario opens a fresh window after handling interruptions, so a restored session cannot consume fixture input. All terminal text and resize assertions refer to the same window. Application termination and interruption-monitor removal use XCTest teardown blocks so failed assertions also release them. -VS Code uses separate disposable profiles for idle and streaming cases under a short `/private/tmp/ah-vsc-*` directory. Its [main IPC socket](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/ipc/node/ipc.net.ts) lives under the user-data directory, so putting profiles below a long checkout path can exceed macOS's Unix socket limit and make Code exit before creating a window. Settings evidence stays under the checkout's `.artifacts/native-terminal/` directory, and bounded main-log tails are attached to XCTest results. The profiles enable [`chat.disableAIFeatures`](https://code.visualstudio.com/docs/agents/reference/ai-settings) to suppress built-in Copilot onboarding. VS Code also launches with [`--force-renderer-accessibility`](https://www.chromium.org/developers/design-documents/accessibility/) to expose Chromium's native accessibility tree; the editor's screen-reader setting alone does not ensure that tree is available to XCTest. The scenario opens the terminal through its native Terminal > New Terminal menu. If the specific Copilot sign-in dialog still appears, the scenario closes that dialog and opens the integrated terminal. It does not sign in or grant network access. Window, workbench, and terminal-view failures preserve bounded accessibility diagnostics and screenshots; the workflow also retains the Xcode output and generated VS Code settings. +VS Code uses separate disposable profiles for idle and streaming cases under a short `/private/tmp/ah-vsc-*` directory. Its [main IPC socket](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/ipc/node/ipc.net.ts) lives under the user-data directory, so putting profiles below a long checkout path can exceed macOS's Unix socket limit and make Code exit before creating a window. Settings evidence stays under the checkout's `.artifacts/native-terminal/` directory, and bounded main-log tails are attached to XCTest results. The profiles enable [`chat.disableAIFeatures`](https://code.visualstudio.com/docs/agents/reference/ai-settings) to suppress built-in Copilot onboarding. The disposable profiles set `editor.accessibilitySupport: "off"` and omit `--force-renderer-accessibility` for ordinary graphical terminal coverage. [VS Code maps this setting to xterm screen-reader mode](https://github.com/microsoft/vscode/blob/1.144.0/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts#L2047); [automatic detection can enable that mode when native accessibility is active](https://github.com/microsoft/vscode/blob/1.144.0/src/vs/platform/accessibility/browser/accessibilityService.ts#L145). Explicit `off` prevents the test observer from enabling the parallel screen-reader row mirror. Native AppKit window/menu checks and the authoritative raw buffer copy remain required. This graphical scenario does not cover screen-reader mode, and it changes only disposable fixture profiles. The scenario opens the terminal through its native Terminal > New Terminal menu. If the specific Copilot sign-in dialog still appears, the scenario closes that dialog and opens the integrated terminal. It does not sign in or grant network access. Window, workbench, and terminal-view failures preserve bounded accessibility diagnostics and screenshots; the workflow also retains the Xcode output and generated VS Code settings. Native scenarios first size and position their fixture window within the reported display's visible bounds, leaving room for all four physical resize gestures. Setup shrinks oversized dimensions before moving the window, then grows it at the target position, so a full-width window's resize handle never moves off-screen. VS Code setup starts after terminal readiness and verifies the actual width and height accepted by each gesture; a partially accepted shrink cannot authorize an off-screen move. VS Code's input readiness uses the native Split Terminal command becoming enabled after New Terminal, which establishes terminal focus; it does not require xterm's input to expose the same TextView role as standalone terminals. The fixture's rendered output remains the mandatory proof that paste, Enter, and terminal rendering worked. All scenarios retain the exact transcript, streaming, draft, cursor, resize, and exit assertions. -VS Code's readout preflight captures one immutable app snapshot, scopes it to the fixture's current native window frame, and searches at most 2,048 nodes. Its native process ID, Core Graphics window ID, and single-window count remain fixed through all gestures and reads, while accessibility window references are refreshed after resize. [xterm's accessibility tree](https://github.com/xtermjs/xterm.js/blob/master/src/browser/AccessibilityManager.ts) exposes a screen-row list separately from its live announcement region. In the recorded macOS tree this is an Other node containing consecutive, equal-height Group rows, each with one StaticText value. The adapter requires one unambiguous, complete row list in the native window; it preserves row order, blank rows, and repeated values. xterm scales each accessibility row to its text and cell width, so rows can be wider than the list container after resize; the adapter validates their vertical grid without requiring horizontal containment. It never selects rows based on fixture content or concatenates announcement siblings. Native TextView buffers remain supported. Ambiguous, oversized, malformed, or stale snapshots fail closed to the buffer-copy preflight. If accessibility lacks it, a disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to select the full terminal buffer and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The preflight uses that raw copied buffer only if it contains the actual fixture marker and a second accessibility read still lacks it. Each subsequent copy clears the pasteboard and requires a new pasteboard generation; old frames cannot satisfy later resize checks. A temporarily unavailable copy produces no frame; the original startup, resize, cursor, or exit predicate retains its deadline. Once resize readiness accepts a fresh raw frame, all exact counts and streaming assertions inspect that same frame without issuing a second copy. Resize readiness requires the header, history, draft, and the stream marker for streaming scenarios, without examining their counts. This avoids accepting a partial accessibility update before the header returns; duplicated accepted frames still fail immediately against the exact-count assertions. Permanent marker loss fails the original settling deadline. The command clears terminal selection while preserving focus and the PTY cursor, which the existing cursor-edit assertion verifies. No terminal output is filtered or deduplicated. Readout samples, selected backend, settings, and keybindings are retained with native results. +VS Code's authoritative readout uses its native terminal buffer copy. Hosted recordings showed the canvas progressing from stream 377 to 382 while correctly shaped accessibility rows remained on stream 313 with missing transcript text throughout the settling deadline. Accessibility content is therefore retained only as bounded failure diagnostics. A disposable-profile shortcut uses [`runCommands`](https://code.visualstudio.com/docs/configure/keybindings#_running-multiple-commands) to focus the existing terminal, select its full buffer, and [copy and clear its selection](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminalContrib/clipboard/browser/terminal.clipboard.contribution.ts). The binding is scoped to `terminalIsOpen`, so it can restore terminal focus after a resize. Each observation clears the pasteboard and requires a new pasteboard generation with text; a stale value cannot pass even if all expected markers are present. A fresh generation and string already available after the key event return immediately; otherwise the existing waiter allows up to two seconds and revalidates freshness. The copied bytes are preserved without filtering, trimming, or deduplication. A temporarily unavailable copy produces no frame within the original readiness deadlines. Resize readiness requires the header, history, draft, and stream marker for streaming scenarios, without examining their counts; all exact assertions inspect that same accepted raw frame. Duplicate markers fail the unchanged count assertions, and permanent marker loss fails the original settling deadline. The native process ID, Core Graphics window ID, single-window count, current geometry, cursor-edit and exit checks remain mandatory. The command clears selection while preserving the PTY cursor. Backend, bounded preflight samples, pasteboard generation metadata, settings, and keybindings are retained with native results. + +Native-copy diagnostics retain each attempt's phase, timestamps, pasteboard generations and types, raw text, availability, and native window identity and geometry. A bounded tail of 128 records and 2 MiB is persisted after each attempt, with explicit eviction counts, so late resize failures remain inspectable even if the application exits. The fixture also writes asynchronous, bounded write metadata: sequence, viewport, stream counter and bytes before stdout delegation, then separate return/backpressure and callback-completion timestamps. Metadata-only observation forwards the original string or binary chunk and its encoding unchanged. That metadata contains no terminal output, never waits in the producer, and retains the latest records within 1 MiB. The workflow retains both the committed metadata file and any in-flight `.pending` file, alongside copy samples and XCTest evidence. Diagnostic instrumentation does not establish that an intermittent failure is repaired. + +VS Code's streaming scenario keeps response chunks flowing through each physical resize. After the gesture, a disposable control file (512 bytes maximum, command IDs 1–8) requests observation. Only after a newer post-command chunk at changed/current TTY dimensions does the fixture gate new response chunks. A late TTY resize reopens the producer until it emits at the new dimensions and refreshes the same observation command. The request and working spinner stay active; no renderer operation is triggered. A separate atomic current-state snapshot (512 bytes maximum) records command, emitted-command, revision, counter, dimensions and pause state. Native checks that state before and after each fresh copy, independently of diagnostic-history eviction. The copy must contain the exact paused counter and all original markers before unchanged exact-count assertions run. Resuming and proving a newer emitted chunk at the same viewport share the original single 10-second settling budget with acknowledgement and copying, including after the final resize. Screenshots follow resumed-progress proof. Asynchronous control reads are single-flight and closed on every path; disposal suppresses late work and drains pending state writes. Unused Code predicate objects are omitted to avoid implicit accessibility debug snapshots during polling; explicit failure evidence remains. + +Code baseline preparation resizes width from the right-edge midpoint, then height from the bottom-edge midpoint. Each accepted size, native identity and display bounds is verified before moving. This avoids the rounded-corner hit target selecting only the bottom edge on an initially screen-width window. The four scenario corner drags remain physical and retain their original deltas and assertions. The strict visible-history assertions require room for both transcript exchanges and the multiline composer. At a compact 57×19 terminal, the full-screen transcript correctly follows its newest exchange and clips older rows; a separate PTY case checks that the history reappears on growth, the draft and cursor survive, and Ctrl+C exits cleanly. This distinguishes viewport clipping from lost transcript data. Standalone terminal references bind to their accessibility identities. VS Code refreshes its accessibility window before setup steps and after every setup or scenario gesture, guarded by the same native process ID, window ID, single-window count, and current frame. diff --git a/src/core/agent/AgentCommandRuntime.ts b/src/core/agent/AgentCommandRuntime.ts index 6364e6a4..966ba91b 100644 --- a/src/core/agent/AgentCommandRuntime.ts +++ b/src/core/agent/AgentCommandRuntime.ts @@ -114,10 +114,6 @@ const INTERACTIVE_SLASH_COMMANDS = new Set([ const FIXED_COMPOSER_RESULT_COMMANDS = new Set(['/tasks', '/team', '/teams', '/squad']); const MARKDOWN_REPORT_COMMANDS = new Set(['/pr-review', '/deslop', '/tester']); -export function shouldPreserveComposerDraftAfterCommand(command: string): boolean { - return command === '/whatityped' || command === '/peers' || command === '/feedback' || command === '/status' || command === '/sync'; -} - export function shouldRenderSlashCommandResultInComposer(command: string): boolean { return FIXED_COMPOSER_RESULT_COMMANDS.has(command); } @@ -274,9 +270,6 @@ export async function runAgentSlashCommandWithInput(host: AgentCommandRuntimeHos host.persistentInputActiveTurn = false; } cleanupConsoleBridge(); - if (isInteractive && !shouldPreserveComposerDraftAfterCommand(command) && host.inkRenderer?.isRunning()) { - host.inkRenderer.clearInput(); - } } } diff --git a/src/core/agent/AgentLifecycleRunner.ts b/src/core/agent/AgentLifecycleRunner.ts index 96e3d1c2..08d61515 100644 --- a/src/core/agent/AgentLifecycleRunner.ts +++ b/src/core/agent/AgentLifecycleRunner.ts @@ -50,7 +50,7 @@ import { shouldForceAgentIdleLogout } from './AgentSessionAccounting.js'; import { consumeAgentInkSubmittedInstructionEcho, } from './AgentUIRuntime.js'; -import { renderAgentSlashCommandResult, shouldPreserveComposerDraftAfterCommand } from './AgentCommandRuntime.js'; +import { renderAgentSlashCommandResult } from './AgentCommandRuntime.js'; import { createQueuedAgentInstruction, resolveActiveGoalContinuation, @@ -1960,10 +1960,9 @@ export async function runAgentInteractiveLoop(host: AgentLifecycleHost): Promise host.writeDebugLine?.bind(host) ); if (host.ui || host.inkRenderer) { + // Submission already consumed its text; current input belongs + // to a later draft or a command prefill. host.setComposerIdle(); - if (!shouldPreserveComposerDraftAfterCommand(command)) { - host.clearComposerInput(); - } // Return to the top of the loop so the idle-wait path can await // the next Composer submission without falling through to // instruction.startsWith('/') which would throw on null. diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index 0e30519a..5f4ce257 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -20,12 +20,12 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res const fixture = path.join(output, 'Autohand Terminal Fixture.command'); const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; const bun = execFileSync('/bin/sh', ['-c', 'command -v bun'], { encoding: 'utf8' }).trim(); - await writeFile(fixture, `#!/bin/sh\nset -eu\ncd ${quote(root)}\nexport AUTOHAND_HOME=${quote(path.join(output, 'home'))}\nexport CI=false\nexec ${quote(bun)} src/testing/scenarios/resizeFlickerScenario.tsx --full-screen --multiline "$@"\n`, { mode: 0o755 }); + await writeFile(fixture, `#!/bin/sh\nset -eu\ncd ${quote(root)}\nexport AUTOHAND_HOME=${quote(path.join(output, 'home'))}\nexport CI=false\nif [ "\${1-}" = --streaming ]; then fixture_mode=streaming; else fixture_mode=idle; fi\nexport AUTOHAND_RESIZE_WRITE_METADATA=${quote(output)}/write-metadata-\${fixture_mode}.jsonl\n${terminal === 'vscode' ? `export AUTOHAND_RESIZE_STREAM_CONTROL=${quote(output)}/stream-control-\${fixture_mode}.json\n` : ''}exec ${quote(bun)} src/testing/scenarios/resizeFlickerScenario.tsx --full-screen --multiline "$@"\n`, { mode: 0o755 }); const profile = path.join(output, 'vscode-profile'); const runtimeProfile = terminal === 'vscode' ? await mkdtemp('/private/tmp/ah-vsc-') : undefined; const settings = { 'chat.disableAIFeatures': true, - 'editor.accessibilitySupport': 'on', 'workbench.startupEditor': 'none', 'window.restoreWindows': 'none', + 'editor.accessibilitySupport': 'off', 'workbench.startupEditor': 'none', 'window.restoreWindows': 'none', 'workbench.panel.opensMaximized': 'always', 'update.mode': 'none', 'security.workspace.trust.enabled': false, 'terminal.integrated.enablePersistentSessions': false, 'terminal.integrated.scrollback': 5000, @@ -39,8 +39,12 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res const keybindingsFile = path.join(evidenceProfile, 'User', 'keybindings.json'); if (runtimeProfile) { await writeFile(keybindingsFile, JSON.stringify([{ - key: 'ctrl+shift+y', command: 'runCommands', when: 'terminalFocus', args: { - commands: ['workbench.action.terminal.selectAll', 'workbench.action.terminal.copyAndClearSelection'], + key: 'ctrl+shift+y', command: 'runCommands', when: 'terminalIsOpen', args: { + commands: [ + 'workbench.action.terminal.focus', + 'workbench.action.terminal.selectAll', + 'workbench.action.terminal.copyAndClearSelection', + ], }, }])); } @@ -53,7 +57,7 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res launchArguments[mode] = terminal === 'vscode' ? ['--user-data-dir', isolatedProfile, '--extensions-dir', path.join(isolatedProfile, 'extensions'), '--disable-extensions', '--disable-workspace-trust', '--skip-welcome', '--skip-release-notes', - '--force-renderer-accessibility', '--new-window'] + '--new-window'] : terminal === 'ghostty' ? ['--config-default-files=false', '--confirm-close-surface=false'] : []; } const project = path.resolve('src/testing/drivers/native-terminal-lab'); diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 5cfc0909..2a32bde4 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -7,39 +7,6 @@ private struct NativeAccessibilityText { var value = "" var strings: [String] { [label, title, value] } - var content: String { [value, label, title].first { !$0.isEmpty } ?? "" } -} - -private struct NativeReadoutNode { - var isTextView = false - var elementType: XCUIElement.ElementType = .other - var frame = CGRect.zero - var text = NativeAccessibilityText() - var children: [NativeReadoutNode] = [] -} - -private func nativeCodeTerminalBuffer(_ capture: () throws -> Snapshot, - children: (Snapshot) -> [Snapshot], - textViewValue: (Snapshot) -> String?, - screenBuffer: (Snapshot) -> String? = { _ in nil }, - maximumNodes: Int = 2_048) rethrows -> String { - var pending = [try capture()] - var visited = 0 - var values: [String] = [] - var screens: [String] = [] - while let node = pending.popLast() { - guard visited < maximumNodes else { return "" } - visited += 1 - if let value = textViewValue(node) { values.append(value) } - if let screen = screenBuffer(node) { screens.append(screen) } - guard screens.count <= 1 else { return "" } - let descendants = children(node) - guard descendants.count <= maximumNodes - visited - pending.count else { return "" } - pending.append(contentsOf: descendants.reversed()) - } - if let screen = screens.first { return screen } - return values.filter { $0.contains("RESIZE_HISTORY retained text") }.max { $0.count < $1.count } - ?? values.max { $0.count < $1.count } ?? "" } private struct NativeWindowFrame: Equatable { @@ -64,42 +31,105 @@ private func nativeFixtureMoveFrame(current: CGRect, target: CGRect, bounds: CGR return bounds.contains(proposed) ? proposed : nil } -private func nativeCodeScreenRows(_ capture: () throws -> Snapshot, windowFrame: CGRect, - children: (Snapshot) -> [Snapshot], - elementType: (Snapshot) -> XCUIElement.ElementType, - frame: (Snapshot) -> CGRect, value: (Snapshot) -> String?, - maximumNodes: Int = 2_048) rethrows -> String { - let snapshot = try capture() - let windows = children(snapshot).filter { elementType($0) == .window && frame($0) == windowFrame } - guard windows.count == 1 else { return "" } - return nativeCodeTerminalBuffer({ windows[0] }, children: children, - textViewValue: { elementType($0) == .textView ? value($0) : nil }, - screenBuffer: { node in - guard elementType(node) == .other else { return nil } - let rows = children(node) - let viewport = frame(node) - guard rows.count >= 2, rows.count <= maximumNodes / 2, - viewport.width > 0, viewport.height > 0, windowFrame.contains(viewport) else { return nil } - var previous: CGRect? - var values: [String] = [] - for row in rows { - let position = frame(row) - let contents = children(row) - guard elementType(row) == .group, contents.count == 1, - elementType(contents[0]) == .staticText, let text = value(contents[0]), - position.width > 0, position.height > 0, - abs(position.minX - viewport.minX) <= 1, - position.maxY <= viewport.maxY + 1 else { return nil } - if let previous { - guard abs(previous.maxY - position.minY) <= 1, - abs(previous.height - position.height) <= 1 else { return nil } - } else if abs(position.minY - viewport.minY) > 1 { return nil } - values.append(text) - previous = position - } - guard let last = previous, viewport.maxY - last.maxY <= last.height + 1 else { return nil } - return values.joined(separator: "\n") - }, maximumNodes: maximumNodes) +private func nativeCopiedTerminalBuffer(baseline: Int, observed: Int, value: String?) -> String? { + guard observed > baseline else { return nil } + return value +} + +private func nativeCopiedTerminalObservation(baseline: Int, read: () -> (generation: Int, value: String?), + wait: () -> Bool) -> String? { + let immediate = read() + if let value = nativeCopiedTerminalBuffer(baseline: baseline, observed: immediate.generation, value: immediate.value) { return value } + guard wait() else { return nil } + let sample = read() + return nativeCopiedTerminalBuffer(baseline: baseline, observed: sample.generation, value: sample.value) +} + +private struct NativeCopyEvidence { + private(set) var records: [Data] = [] + private(set) var dropped = 0 + private var bytes = 0 + + mutating func append(_ record: Data) { + let size = record.count + 1 + guard size <= 2 * 1024 * 1024 - 64 else { + dropped += 1 + return + } + while records.count >= 128 || size > 2 * 1024 * 1024 - 64 - bytes { + bytes -= records.removeFirst().count + 1 + dropped += 1 + } + records.append(record) + bytes += size + } +} + +private struct NativeCopySample: Encodable { + let phase: String + let started: TimeInterval + let finished: TimeInterval + let baseline: Int + let previousGeneration: Int + let previousRaw: String? + let previousTypes: [String] + let keyFinished: TimeInterval + let observed: Int + let types: [String] + let raw: String? + let available: Bool + let processIdentifier: Int32 + let windowIdentifier: UInt32 + let frame: CGRect +} + +private struct NativeStreamPosition: Codable, Equatable { + let kind: String + var revision: Int? = nil + var command: Int? = nil + var emittedCommand: Int? = nil + var counter: Int? = nil + var columns: Int? = nil + var rows: Int? = nil + var paused: Bool? = nil +} + +private struct NativeStreamCommand: Encodable { + let id: Int + let action: String + let counter: Int + let columns: Int + let rows: Int +} + +private func nativeStreamPositions(_ data: Data) -> [NativeStreamPosition] { + guard data.count <= 512, let position = try? JSONDecoder().decode(NativeStreamPosition.self, from: data), + position.kind == "stream-state", let revision = position.revision, revision > 0, + let command = position.command, (0...8).contains(command), position.paused != nil, + let emittedCommand = position.emittedCommand, (0...8).contains(emittedCommand), + let counter = position.counter, counter > 0, + let columns = position.columns, columns > 0, columns <= 10000, + let rows = position.rows, rows > 0, rows <= 10000 else { return [] } + return [position] +} + +private func nativeStreamPaused(_ positions: [NativeStreamPosition], command: Int, before: NativeStreamPosition) -> NativeStreamPosition? { + guard let position = positions.last, position.kind == "stream-state", position.command == command, + position.emittedCommand == command, position.paused == true, + (position.counter ?? 0) > (before.counter ?? 0), + position.columns != before.columns || position.rows != before.rows else { return nil } + return position +} + +private func nativeStreamResumed(_ positions: [NativeStreamPosition], command: Int, after: NativeStreamPosition) -> Bool { + guard let position = positions.last else { return false } + return position.kind == "stream-state" && position.command == command && position.emittedCommand == command && position.paused == false + && (position.counter ?? 0) > (after.counter ?? 0) + && position.columns == after.columns && position.rows == after.rows +} + +private func nativeSettlingTime(_ deadline: Date, now: Date = Date()) -> TimeInterval { + max(0, deadline.timeIntervalSince(now)) } private struct NativeTerminalReadout { @@ -112,9 +142,12 @@ private struct NativeTerminalReadout { self.requiresStreaming = requiresStreaming } - mutating func capture() -> Bool { + mutating func capture(streamCounter: Int? = nil, checkpoint: NativeStreamPosition? = nil, + currentState: (() -> NativeStreamPosition?)? = nil) -> Bool { frame = read() + if let checkpoint, currentState?() != checkpoint { return false } guard let value = frame else { return false } + if let counter = checkpoint?.counter ?? streamCounter, !value.contains("RESIZE_STREAM \(counter) retained streamed text") { return false } return value.contains("Autohand Code") && value.contains("RESIZE_HISTORY retained text") && value.contains("preserved draft") && (!requiresStreaming || value.contains("RESIZE_STREAM ")) } @@ -151,88 +184,71 @@ private func nativeFixturePreparationFrames(current: CGRect, available: CGRect) return [reduced, CGRect(origin: target.origin, size: reduced.size), target] } -private func nativeTerminalInputReady(terminal: String, textViewExists: Bool, splitTerminalEnabled: Bool) -> Bool { - terminal == "vscode" ? splitTerminalEnabled : textViewExists +private struct NativeFixtureSetupDrag { + let anchor: CGVector + let offset: CGVector + let delta: CGVector + let size: CGSize +} + +private func nativeFixtureSetupDrags(current: CGRect, target: CGRect) -> [NativeFixtureSetupDrag] { + var size = current.size + var drags: [NativeFixtureSetupDrag] = [] + if abs(target.width - size.width) > 0.5 { + let delta = target.width - size.width + size.width = target.width + drags.append(NativeFixtureSetupDrag(anchor: CGVector(dx: 1, dy: 0.5), offset: CGVector(dx: -2, dy: 0), + delta: CGVector(dx: delta, dy: 0), size: size)) + } + if abs(target.height - size.height) > 0.5 { + let delta = target.height - size.height + size.height = target.height + drags.append(NativeFixtureSetupDrag(anchor: CGVector(dx: 0.5, dy: 1), offset: CGVector(dx: 0, dy: -2), + delta: CGVector(dx: 0, dy: delta), size: size)) + } + return drags } -private func nativeReadoutUsesCopiedBuffer(terminal: String, accessibilityHasFixture: Bool, copiedBufferHasFixture: Bool) -> Bool { - terminal == "vscode" && !accessibilityHasFixture && copiedBufferHasFixture +private func nativeFixtureSetupSizeAccepted(_ size: CGSize, expected: CGSize) -> Bool { + abs(size.width - expected.width) <= 1 && abs(size.height - expected.height) <= 1 +} + +private func nativeTerminalInputReady(terminal: String, textViewExists: Bool, splitTerminalEnabled: Bool) -> Bool { + terminal == "vscode" ? splitTerminalEnabled : textViewExists } final class TerminalCompatibilityUITests: XCTestCase { private var environment: [String: String] { ProcessInfo.processInfo.environment } + private var copyEvidence = NativeCopyEvidence() + private var copyPhase = "startup" + private var copyEvidencePath: URL? @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } - private func screenRows(_ values: [String], origin: CGPoint = CGPoint(x: 73, y: 101)) -> NativeReadoutNode { - NativeReadoutNode(elementType: .other, - frame: CGRect(origin: origin, size: CGSize(width: 731, height: CGFloat(values.count) * 14 + 6)), - children: values.enumerated().map { index, value in - let row = CGRect(x: origin.x, y: origin.y + CGFloat(index) * 14, width: 731, height: 14) - return NativeReadoutNode(elementType: .group, frame: row, children: [ - NativeReadoutNode(elementType: .staticText, frame: row.offsetBy(dx: 0, dy: 2), - text: NativeAccessibilityText(value: value)) - ]) - }) - } - - private func screenReadout(_ snapshot: NativeReadoutNode, maximumNodes: Int = 2_048) -> String { - nativeCodeScreenRows({ snapshot }, windowFrame: CGRect(x: 20, y: 33, width: 784, height: 550), - children: { $0.children }, elementType: { $0.elementType }, frame: { $0.frame }, - value: { $0.text.value }, maximumNodes: maximumNodes) - } - - func testNativeScreenRowsReadTheFreshAppWindowAndExcludeAnnouncementSiblingsByStructure() { - let values = ["Autohand Code", "\u{00a0}", "RESIZE_HISTORY retained text", "preserved draft", "RESIZE_STREAM 365"] - let current = NativeReadoutNode(elementType: .window, frame: CGRect(x: 20, y: 33, width: 784, height: 550), children: [ - NativeReadoutNode(elementType: .staticText, text: NativeAccessibilityText(value: values.joined() + values.joined())), - screenRows(values) - ]) - var captures = 0 - let value = nativeCodeScreenRows({ captures += 1; return NativeReadoutNode(children: [current]) }, windowFrame: current.frame, - children: { $0.children }, elementType: { $0.elementType }, frame: { $0.frame }, value: { $0.text.value }) - XCTAssertEqual(value, values.joined(separator: "\n")) - XCTAssertEqual(captures, 1) - let sparseOldWindow = NativeReadoutNode(elementType: .window, frame: current.frame, - children: [NativeReadoutNode(elementType: .button)]) - XCTAssertEqual(screenReadout(NativeReadoutNode(children: [sparseOldWindow])), "") - } - - func testNativeScreenRowsPreserveDuplicateAndBlankRowsWithoutChoosingACleanCandidate() { - let values = ["RESIZE_HISTORY retained text", "", "RESIZE_HISTORY retained text", "preserved draft"] - var window = NativeReadoutNode(elementType: .window, frame: CGRect(x: 20, y: 33, width: 784, height: 550), children: [screenRows(values)]) - XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), values.joined(separator: "\n")) - window.children.append(screenRows(["RESIZE_HISTORY retained text", "preserved draft"])) - XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), "") - } - - func testNativeScreenRowsPreserveTextScaledBeyondTheCapturedListWidth() { - let values = ["Autohand Code", "RESIZE_HISTORY retained text", "", "RESIZE_HISTORY retained text", "preserved draft"] - + Array(repeating: "\u{00a0}", count: 22) - var rows = screenRows(values) - rows.frame.size = CGSize(width: 651, height: 378) - for index in rows.children.indices { - rows.children[index].frame.size.width = index == 0 ? 182 : 686 - rows.children[index].children[0].frame = rows.children[index].frame.offsetBy(dx: 0, dy: 2) - } - let nativeFrame = CGRect(x: 20, y: 33, width: 744, height: 510) - let window = NativeReadoutNode(elementType: .window, frame: nativeFrame, children: [rows]) - let content = nativeCodeScreenRows({ NativeReadoutNode(children: [window]) }, windowFrame: nativeFrame, - children: { $0.children }, elementType: { $0.elementType }, frame: { $0.frame }, value: { $0.text.value }) - XCTAssertEqual(content, values.joined(separator: "\n")) - XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 2) - } - - func testNativeScreenRowsRejectStaleGeometryBudgetsAndNonConsecutiveRows() { - var window = NativeReadoutNode(elementType: .window, frame: CGRect(x: 20, y: 33, width: 784, height: 550), - children: [screenRows(["RESIZE_HISTORY retained text", "preserved draft"])]) - XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window]), maximumNodes: 3), "") - window.frame.size.width += 1 - XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), "") - window.frame.size.width -= 1 - window.children[0].children.reverse() - XCTAssertEqual(screenReadout(NativeReadoutNode(children: [window])), "") + func testNativeViewportPreparesCodeWithSeparateAxisEdgesBeforeMoving() { + let current = CGRect(x: 0, y: 31, width: 1024, height: 677) + let target = CGRect(x: 20, y: 33, width: 904, height: 610) + let drags = nativeFixtureSetupDrags(current: current, target: target) + XCTAssertEqual(drags.count, 2) + guard drags.count == 2 else { return } + XCTAssertEqual(drags[0].anchor.dx, 1) + XCTAssertEqual(drags[0].anchor.dy, 0.5) + XCTAssertEqual(drags[0].offset.dx, -2) + XCTAssertEqual(drags[0].offset.dy, 0) + XCTAssertEqual(drags[0].delta.dx, -120) + XCTAssertEqual(drags[0].delta.dy, 0) + XCTAssertEqual(drags[0].size, CGSize(width: 904, height: 677)) + XCTAssertEqual(drags[1].anchor.dx, 0.5) + XCTAssertEqual(drags[1].anchor.dy, 1) + XCTAssertEqual(drags[1].offset.dx, 0) + XCTAssertEqual(drags[1].offset.dy, -2) + XCTAssertEqual(drags[1].delta.dx, 0) + XCTAssertEqual(drags[1].delta.dy, -67) + XCTAssertEqual(drags[1].size, target.size) + XCTAssertFalse(nativeFixtureSetupSizeAccepted(CGSize(width: 1024, height: 610), expected: drags[0].size)) + XCTAssertTrue(nativeFixtureSetupSizeAccepted(drags[0].size, expected: drags[0].size)) + XCTAssertTrue(nativeFixtureSetupDrags(current: target, target: target).isEmpty) } func testNativeViewportDoesNotMoveAWindowWhoseWidthIgnoredTheShrink() { @@ -261,6 +277,157 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertNil(nativeFixtureWindow([NativeWindowFrame(processIdentifier: 6737, windowIdentifier: 101, frame: resized.frame)], identity: identity)) } + func testNativeReadoutRejectsStaleClipboardValuesEvenWhenAllMarkersArePresent() { + let stale = "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 313" + XCTAssertNil(nativeCopiedTerminalBuffer(baseline: 18, observed: 18, value: stale)) + XCTAssertNil(nativeCopiedTerminalBuffer(baseline: 18, observed: 17, value: stale)) + XCTAssertNil(nativeCopiedTerminalBuffer(baseline: 18, observed: 19, value: nil)) + } + + func testNativeReadoutAlreadyFreshCopyReturnsRawDataWithoutEnteringTheWaiter() { + var waits = 0 + var reads = 0 + let raw = "Autohand Code\r\nRESIZE_HISTORY retained text\nRESIZE_HISTORY retained text\n preserved draft " + let value = nativeCopiedTerminalObservation(baseline: 18, read: { + reads += 1 + return (19, raw) + }, wait: { waits += 1; return true }) + XCTAssertEqual(value, raw) + XCTAssertEqual(reads, 1) + XCTAssertEqual(waits, 0) + } + + func testNativeReadoutStaleAndMissingCopiesUseTheExistingWaiter() { + for initial in ["stale", nil] { + var generation = 18 + var value = initial + var waits = 0 + var reads = 0 + let result = nativeCopiedTerminalObservation(baseline: 18, read: { + reads += 1 + return (generation, value) + }, wait: { + waits += 1 + generation = 19 + value = "fresh\n\nraw" + return true + }) + XCTAssertEqual(result, "fresh\n\nraw") + XCTAssertEqual(waits, 1) + XCTAssertEqual(reads, 2) + } + } + + func testNativeReadoutMissingOrStillStaleCopiesFailClosed() { + var waits = 0 + XCTAssertNil(nativeCopiedTerminalObservation(baseline: 18, read: { (18, "stale") }, wait: { waits += 1; return true })) + XCTAssertEqual(waits, 1) + XCTAssertNil(nativeCopiedTerminalObservation(baseline: 18, read: { (19, nil) }, wait: { false })) + } + + func testNativeReadoutGateRequiresViewportProgressAndANewerResumedChunk() { + let before = NativeStreamPosition(kind: "stream-state", command: 0, emittedCommand: 0, counter: 10, columns: 100, rows: 30, paused: false) + let unchanged = NativeStreamPosition(kind: "stream-state", command: 1, emittedCommand: 1, counter: 11, columns: 100, rows: 30, paused: true) + let stale = NativeStreamPosition(kind: "stream-state", command: 1, emittedCommand: 1, counter: 10, columns: 90, rows: 28, paused: true) + let paused = NativeStreamPosition(kind: "stream-state", command: 1, emittedCommand: 1, counter: 12, columns: 90, rows: 28, paused: true) + XCTAssertNil(nativeStreamPaused([unchanged, stale], command: 1, before: before)) + XCTAssertNotNil(nativeStreamPaused([paused], command: 1, before: before)) + XCTAssertFalse(nativeStreamResumed([paused], command: 2, after: paused)) + let resumed = NativeStreamPosition(kind: "stream-state", command: 2, emittedCommand: 2, counter: 13, columns: 90, rows: 28, paused: false) + XCTAssertTrue(nativeStreamResumed([resumed], command: 2, after: paused)) + } + + func testNativeReadoutGateNeverFallsBackToAnOlderAckAfterANewerInvalidViewport() { + let before = NativeStreamPosition(kind: "stream-state", command: 2, emittedCommand: 2, counter: 166, columns: 93, rows: 27, paused: false) + let old = NativeStreamPosition(kind: "stream-state", command: 3, emittedCommand: 3, counter: 180, columns: 97, rows: 28, paused: true) + let newer = NativeStreamPosition(kind: "stream-state", command: 3, emittedCommand: 3, counter: 181, columns: 93, rows: 27, paused: true) + XCTAssertNil(nativeStreamPaused([old, newer], command: 3, before: before)) + XCTAssertNil(nativeStreamPaused([], command: 3, before: before)) + } + + func testNativeReadoutGateRejectsStateChangesDuringFreshCopyAndRequiresPostResumeEmission() { + let old = NativeStreamPosition(kind: "stream-state", revision: 5, command: 3, emittedCommand: 3, + counter: 180, columns: 97, rows: 28, paused: true) + let updated = NativeStreamPosition(kind: "stream-state", revision: 6, command: 3, emittedCommand: 3, + counter: 181, columns: 128, rows: 39, paused: true) + var state: NativeStreamPosition? = old + var readout = NativeTerminalReadout(read: { + state = updated + return "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 180 retained streamed text" + }, requiresStreaming: true) + XCTAssertFalse(readout.capture(checkpoint: old, currentState: { state })) + XCTAssertFalse(readout.capture(checkpoint: old, currentState: { nil })) + let admitted = NativeStreamPosition(kind: "stream-state", revision: 7, command: 4, emittedCommand: 3, + counter: 182, columns: 128, rows: 39, paused: false) + XCTAssertFalse(nativeStreamResumed([admitted], command: 4, after: updated)) + let emitted = NativeStreamPosition(kind: "stream-state", revision: 8, command: 4, emittedCommand: 4, + counter: 183, columns: 128, rows: 39, paused: false) + XCTAssertTrue(nativeStreamResumed([emitted], command: 4, after: updated)) + } + + func testNativeReadoutGateCountsTheExactPausedFrameWithoutSelectingCleanCounts() { + var value = "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 11 retained streamed text" + var readout = NativeTerminalReadout(read: { value }, requiresStreaming: true) + XCTAssertFalse(readout.capture(streamCounter: 12)) + value = "Autohand Code\n" + value.replacingOccurrences(of: "STREAM 11", with: "STREAM 12") + XCTAssertTrue(readout.capture(streamCounter: 12)) + XCTAssertEqual(readout.frame?.components(separatedBy: "Autohand Code").count, 3) + let deadline = Date(timeIntervalSince1970: 100) + XCTAssertEqual(nativeSettlingTime(deadline, now: Date(timeIntervalSince1970: 97)), 3) + XCTAssertEqual(nativeSettlingTime(deadline, now: Date(timeIntervalSince1970: 101)), 0) + } + + func testNativeReadoutGateParsesOnlyCompleteBoundedAuthoritativeSnapshots() { + let tail = Data("{\"kind\":\"stream-state\",\"revision\":5,\"command\":2,\"emittedCommand\":2,\"counter\":13,\"columns\":90,\"rows\":28,\"paused\":false}".utf8) + let positions = nativeStreamPositions(tail) + XCTAssertEqual(positions.count, 1) + XCTAssertEqual(positions.first?.counter, 13) + XCTAssertTrue(nativeStreamPositions(Data(repeating: 65, count: 513)).isEmpty) + XCTAssertTrue(nativeStreamPositions(Data("partial row".utf8)).isEmpty) + } + + func testNativeReadoutEvidenceRetainsPartialUnavailableAndCompleteSamplesUnchanged() throws { + var evidence = NativeCopyEvidence() + let partial = "RESIZE_STREAM 400\r\n" + let complete = "Autohand Code\nRESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 449\nRESIZE_STREAM 449" + for raw in [partial, nil, complete] { + evidence.append(Data((raw ?? "unavailable").utf8)) + } + XCTAssertEqual(evidence.records, [Data(partial.utf8), Data("unavailable".utf8), Data(complete.utf8)]) + XCTAssertEqual(evidence.dropped, 0) + XCTAssertEqual(evidence.records.last, Data(complete.utf8), "Diagnostic capture must preserve duplicate raw rows") + } + + func testNativeReadoutEvidenceBoundsTheRecordCountAndBytesWithoutChangingSamples() { + var countBounded = NativeCopyEvidence() + for index in 0..<129 { countBounded.append(Data("raw-\(index)".utf8)) } + XCTAssertEqual(countBounded.records.count, 128) + XCTAssertEqual(countBounded.dropped, 1) + XCTAssertEqual(countBounded.records.first, Data("raw-1".utf8)) + XCTAssertEqual(countBounded.records.last, Data("raw-128".utf8)) + var bytesBounded = NativeCopyEvidence() + let oversized = Data(repeating: 65, count: 2 * 1024 * 1024 + 1) + bytesBounded.append(oversized) + XCTAssertTrue(bytesBounded.records.isEmpty) + XCTAssertEqual(bytesBounded.dropped, 1) + bytesBounded.append(Data("still raw".utf8)) + XCTAssertEqual(bytesBounded.records, [Data("still raw".utf8)]) + bytesBounded.append(Data(repeating: 66, count: 2 * 1024 * 1024 - 65)) + XCTAssertEqual(bytesBounded.records.count, 1) + XCTAssertEqual(bytesBounded.dropped, 2) + } + + func testNativeReadoutPreservesEveryByteAndDuplicateInAFreshNativeCopy() { + let raw = "Autohand Code\r\nRESIZE_HISTORY retained text\n\nRESIZE_HISTORY retained text\n preserved draft \nRESIZE_STREAM 382\n" + let fresh = nativeCopiedTerminalBuffer(baseline: 18, observed: 19, value: raw) + XCTAssertEqual(fresh, raw) + XCTAssertEqual(fresh.map { Array($0.utf8) }, Array(raw.utf8)) + XCTAssertEqual(fresh?.components(separatedBy: "RESIZE_HISTORY retained text").count, 3) + var readout = NativeTerminalReadout(read: { fresh }, requiresStreaming: true) + XCTAssertTrue(readout.capture()) + XCTAssertEqual(readout.frame, raw) + } + func testNativeReadoutWaitsForTheHeaderBeforeAcceptingACompleteFrame() { let partial = "RESIZE_HISTORY retained text\npreserved draft\nRESIZE_STREAM 293" let complete = "Autohand Code\n" + partial @@ -332,53 +499,6 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertNil(readout.frame) } - func testNativeReadoutIgnoresAnnouncementsAndRowMirrorsWhenNoCompleteTextViewExists() { - let announcement = "Autohand Code RESIZE_HISTORY retained text preserved draft" - let root = NativeReadoutNode(children: [ - NativeReadoutNode(text: NativeAccessibilityText(value: announcement + announcement)), - NativeReadoutNode(text: NativeAccessibilityText(value: "RESIZE_HISTORY retained text")), - NativeReadoutNode(text: NativeAccessibilityText(value: "preserved draft")) - ]) - let accessibility = nativeCodeTerminalBuffer({ root }, children: { $0.children }, - textViewValue: { $0.isTextView ? $0.text.value : nil }) - XCTAssertEqual(accessibility, "", "Announcements and individual rows are not a native terminal buffer") - let copied = announcement + "\n" + announcement - let useCopied = nativeReadoutUsesCopiedBuffer(terminal: "vscode", - accessibilityHasFixture: accessibility.contains("RESIZE_HISTORY retained text"), - copiedBufferHasFixture: copied.contains("RESIZE_HISTORY retained text")) - XCTAssertTrue(useCopied) - let content = useCopied ? copied : accessibility - XCTAssertEqual(content, copied) - XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 2, - "A duplicated copied frame must still fail the native count assertion") - } - - func testNativeReadoutCapturesOneSnapshotAndPreservesRawBufferDuplicates() { - let buffer = "Autohand Code\nRESIZE_HISTORY retained text\nRESIZE_HISTORY retained text\npreserved draft\ntaXil" - let root = NativeReadoutNode(children: [ - NativeReadoutNode(text: NativeAccessibilityText(value: buffer + buffer)), - NativeReadoutNode(isTextView: true, text: NativeAccessibilityText(label: buffer, value: buffer)), - NativeReadoutNode(text: NativeAccessibilityText(value: "RESIZE_HISTORY retained text")) - ]) - var captures = 0 - let value = nativeCodeTerminalBuffer({ captures += 1; return root }, children: { $0.children }, - textViewValue: { $0.isTextView ? $0.text.value : nil }) - XCTAssertEqual(captures, 1) - XCTAssertEqual(value, buffer) - XCTAssertEqual(value.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 2) - } - - func testNativeReadoutFailsClosedWhenTheSnapshotExceedsItsNodeBudget() { - let root = NativeReadoutNode(children: [ - NativeReadoutNode(isTextView: true, text: NativeAccessibilityText(value: "Autohand Code RESIZE_HISTORY retained text")), - NativeReadoutNode() - ]) - XCTAssertEqual(nativeCodeTerminalBuffer({ root }, children: { $0.children }, - textViewValue: { $0.isTextView ? $0.text.value : nil }, maximumNodes: 2), "") - XCTAssertEqual(nativeCodeTerminalBuffer({ root }, children: { $0.children }, - textViewValue: { $0.isTextView ? $0.text.value : nil }, maximumNodes: 3), "Autohand Code RESIZE_HISTORY retained text") - } - func testNativeViewportShrinksBeforeMovingAFullWidthWindow() { let display = CGRect(x: 0, y: 0, width: 1024, height: 768) let available = CGRect(x: 0, y: 31, width: 1024, height: 674) @@ -397,13 +517,6 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertTrue(nativeTerminalInputReady(terminal: "iterm2", textViewExists: true, splitTerminalEnabled: false)) } - func testNativeInputReadoutRequiresTheActualFixtureBeforeUsingTheCopiedBuffer() { - XCTAssertTrue(nativeReadoutUsesCopiedBuffer(terminal: "vscode", accessibilityHasFixture: false, copiedBufferHasFixture: true)) - XCTAssertFalse(nativeReadoutUsesCopiedBuffer(terminal: "vscode", accessibilityHasFixture: false, copiedBufferHasFixture: false)) - XCTAssertFalse(nativeReadoutUsesCopiedBuffer(terminal: "vscode", accessibilityHasFixture: true, copiedBufferHasFixture: true)) - XCTAssertFalse(nativeReadoutUsesCopiedBuffer(terminal: "iterm2", accessibilityHasFixture: false, copiedBufferHasFixture: true)) - } - func testNativeViewportKeepsAllFourResizesWithinTheDisplayAndLargeEnoughForHistory() { let available = CGRect(x: 0, y: 31, width: 1024, height: 677) var frame = nativeFixtureBaseline(current: CGRect(x: 400, y: 246, width: 587, height: 462), available: available) @@ -443,11 +556,6 @@ final class TerminalCompatibilityUITests: XCTestCase { buttons: [NativeAccessibilityText(title: "Allow")])) } - func testAccessibilityContentDoesNotDuplicateMirroredAttributes() { - XCTAssertEqual(NativeAccessibilityText(label: "RESIZE_HISTORY retained text", title: "RESIZE_HISTORY retained text", - value: "RESIZE_HISTORY retained text").content, "RESIZE_HISTORY retained text") - } - @MainActor private func resizeScenario(streaming: Bool) throws { guard environment["GITHUB_ACTIONS"] == "true", @@ -459,6 +567,19 @@ final class TerminalCompatibilityUITests: XCTestCase { return } continueAfterFailure = false + if terminal == "vscode" { + copyEvidence = NativeCopyEvidence() + copyPhase = "startup" + copyEvidencePath = URL(fileURLWithPath: fixture).deletingLastPathComponent() + .appendingPathComponent("copy-samples-\(streaming ? "streaming" : "idle").jsonl") + addTeardownBlock { + let records = self.copyEvidence.records.map { String(decoding: $0, as: UTF8.self) }.joined(separator: "\n") + let attachment = XCTAttachment(string: records + "\n{\"dropped\":\(self.copyEvidence.dropped)}\n") + attachment.name = "vscode-\(streaming ? "streaming" : "idle")-copy-samples" + attachment.lifetime = .keepAlways + self.add(attachment) + } + } let interruption = addUIInterruptionMonitor(withDescription: "Deny local network access for the offline terminal fixture") { alert in let buttons = alert.buttons.allElementsBoundByIndex let text = ([alert] + alert.staticTexts.allElementsBoundByIndex).map(self.accessibilityText) @@ -471,6 +592,10 @@ final class TerminalCompatibilityUITests: XCTestCase { let app = XCUIApplication(url: URL(fileURLWithPath: application)) addTeardownBlock { if app.state != .notRunning { app.terminate() } } let command = "'" + fixture.replacingOccurrences(of: "'", with: "'\\''") + "'" + (streaming ? " --streaming" : "") + let gated = terminal == "vscode" && streaming + let fixtureDirectory = URL(fileURLWithPath: fixture).deletingLastPathComponent() + let streamControl = fixtureDirectory.appendingPathComponent("stream-control-streaming.json") + let streamState = streamControl.appendingPathExtension("state.json") let modes = try JSONDecoder().decode([String: [String]].self, from: arguments) guard let launchArguments = modes[streaming ? "streaming" : "idle"] else { XCTFail("Missing isolated launch arguments for the native scenario") @@ -569,8 +694,8 @@ final class TerminalCompatibilityUITests: XCTestCase { guard let identity = nativeIdentity else { return nil } return self.currentCodeWindow(app, identity: identity) }) - let readTerminal: (Bool) -> String? = { copied in - if let identity = nativeIdentity { return self.codeTerminalValue(app, identity: identity, useCopiedBuffer: copied) } + let readTerminal: () -> String? = { + if let identity = nativeIdentity { return self.codeTerminalValue(app, identity: identity) } return self.terminalValue(window) } if terminal != "vscode" { terminalView.click() } @@ -579,23 +704,12 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey("v", modifierFlags: .command) app.typeKey(.return, modifierFlags: []) var startupValue = "" - var accessibilityStartup = "" - var copiedStartup = "" - var useCopiedBuffer = false let ready = NSPredicate { _, _ in - accessibilityStartup = readTerminal(false) ?? "" - startupValue = accessibilityStartup - if !startupValue.contains("RESIZE_HISTORY retained text"), terminal == "vscode" { - copiedStartup = readTerminal(true) ?? "" - accessibilityStartup = readTerminal(false) ?? "" - useCopiedBuffer = nativeReadoutUsesCopiedBuffer(terminal: terminal, - accessibilityHasFixture: accessibilityStartup.contains("RESIZE_HISTORY retained text"), - copiedBufferHasFixture: copiedStartup.contains("RESIZE_HISTORY retained text")) - startupValue = useCopiedBuffer ? copiedStartup : accessibilityStartup - } + startupValue = readTerminal() ?? "" return startupValue.contains("RESIZE_HISTORY retained text") + && (!gated || !self.streamPositions(streamState).isEmpty) } - guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: app)], timeout: 30) == .completed else { + guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: terminal == "vscode" ? nil : app)], timeout: 30) == .completed else { let text = XCTAttachment(string: String(startupValue.suffix(16_000))) text.name = "\(terminal)-startup-terminal" text.lifetime = .keepAlways @@ -608,20 +722,25 @@ final class TerminalCompatibilityUITests: XCTestCase { return } if terminal == "vscode" { - print("NATIVE_TERMINAL_READOUT vscode \(useCopiedBuffer ? "native-copy" : "accessibility")") - let evidence = XCTAttachment(string: "Readout: \(useCopiedBuffer ? "native-copy" : "accessibility")\nAccessibility:\n" - + String(accessibilityStartup.prefix(7_900)) + "\nCopied buffer:\n" + String(copiedStartup.prefix(7_900))) + print("NATIVE_TERMINAL_READOUT vscode native-copy") + let evidence = XCTAttachment(string: "Readout: native-copy\nCopied buffer:\n" + String(startupValue.prefix(16_000))) evidence.name = "vscode-\(streaming ? "streaming" : "idle")-readout-preflight" evidence.lifetime = .keepAlways add(evidence) } for (index, delta) in nativeResizeDeltas.enumerated() { + copyPhase = "resize-\(index)" if let identity = nativeIdentity { guard let current = currentCodeWindow(app, identity: identity) else { return } window = current } let frame = window.frame let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) + let streamBefore = gated ? streamPositions(streamState).last : nil + guard !gated || streamBefore != nil else { + XCTFail("The streaming fixture did not publish its pre-gesture position") + return + } corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(delta)) if let identity = nativeIdentity { guard let current = currentCodeWindow(app, identity: identity) else { return } @@ -629,9 +748,23 @@ final class TerminalCompatibilityUITests: XCTestCase { } XCTAssertNotEqual(window.frame.size, frame.size, "The native window did not resize") XCTAssertTrue(displayBounds.contains(window.frame), "The resized fixture window left the display bounds") - var readout = NativeTerminalReadout(read: { readTerminal(useCopiedBuffer) }, requiresStreaming: streaming) - let settled = NSPredicate { _, _ in readout.capture() } - guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: window)], timeout: 10) == .completed, + let settlingDeadline = Date().addingTimeInterval(10) + if let before = streamBefore, !writeStreamCommand(streamControl, id: index * 2 + 1, action: "observe", position: before) { return } + var paused: NativeStreamPosition? + var readout = NativeTerminalReadout(read: { readTerminal() }, requiresStreaming: streaming) + let settled = NSPredicate { _, _ in + if let before = streamBefore { + guard let position = nativeStreamPaused(self.streamPositions(streamState), command: index * 2 + 1, before: before) else { + paused = nil + return false + } + paused = position + return readout.capture(checkpoint: position, currentState: { self.streamPositions(streamState).last }) + } + return readout.capture() + } + guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: terminal == "vscode" ? nil : window)], + timeout: gated ? nativeSettlingTime(settlingDeadline) : 10) == .completed, let content = readout.frame else { let content = XCTAttachment(string: String((readout.frame ?? "No fresh terminal buffer was available").suffix(16_000))) content.name = "\(terminal)-resize-\(index)-terminal" @@ -645,6 +778,22 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertEqual(content.components(separatedBy: "RESIZE_HISTORY retained text").count - 1, 1, content) XCTAssertEqual(content.components(separatedBy: "preserved draft").count - 1, 1, content) if streaming { XCTAssertTrue(content.contains("RESIZE_STREAM "), content) } + if let paused { + guard writeStreamCommand(streamControl, id: index * 2 + 2, action: "resume", position: paused) else { return } + let resumed = NSPredicate { _, _ in + nativeStreamResumed(self.streamPositions(streamState), command: index * 2 + 2, after: paused) + } + let remaining = nativeSettlingTime(settlingDeadline) + guard remaining > 0, + XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: resumed, object: nil)], timeout: remaining) == .completed else { + XCTFail("The streaming fixture did not resume newer chunks within the original settling deadline") + return + } + } + if let identity = nativeIdentity { + guard let current = currentCodeWindow(app, identity: identity) else { return } + window = current + } let image = XCTAttachment(screenshot: window.screenshot()) image.name = "\(terminal)-\(streaming ? "streaming" : "idle")-\(index)" image.lifetime = .keepAlways @@ -653,15 +802,17 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) app.typeKey("x", modifierFlags: .shift) - let edited = NSPredicate { _, _ in readTerminal(useCopiedBuffer)?.contains("taXil") == true } - expectation(for: edited, evaluatedWith: app) + copyPhase = "cursor" + let edited = NSPredicate { _, _ in readTerminal()?.contains("taXil") == true } + expectation(for: edited, evaluatedWith: terminal == "vscode" ? nil : app) waitForExpectations(timeout: 10) app.typeKey(.escape, modifierFlags: []) app.typeKey("c", modifierFlags: .control) + copyPhase = "exit" app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) - let exit = NSPredicate { _, _ in app.state == .notRunning || readTerminal(useCopiedBuffer)?.contains("RESIZE_PTY_EXIT") == true } - expectation(for: exit, evaluatedWith: app) + let exit = NSPredicate { _, _ in app.state == .notRunning || readTerminal()?.contains("RESIZE_PTY_EXIT") == true } + expectation(for: exit, evaluatedWith: terminal == "vscode" ? nil : app) waitForExpectations(timeout: 15) print(streaming ? "NATIVE_TERMINAL_STREAMING_PASS" : "NATIVE_TERMINAL_IDLE_PASS") } @@ -696,10 +847,28 @@ final class TerminalCompatibilityUITests: XCTestCase { } let moved = window.frame if abs(step.width - moved.width) > 0.5 || abs(step.height - moved.height) > 0.5 { - let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) - corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(CGVector(dx: step.width - moved.width, dy: step.height - moved.height))) - guard let current = nativeFixtureWindowReference(window, refresh: refreshWindow) else { return bounds } - window = current + if terminal == "vscode" { + for drag in nativeFixtureSetupDrags(current: moved, target: step) { + guard verifyWindow(), bounds.contains(window.frame) else { + XCTFail("The Code baseline window identity or bounds changed before an axis drag") + return bounds + } + let edge = window.coordinate(withNormalizedOffset: drag.anchor).withOffset(drag.offset) + edge.press(forDuration: 0.1, thenDragTo: edge.withOffset(drag.delta)) + guard let current = nativeFixtureWindowReference(window, refresh: refreshWindow) else { return bounds } + window = current + print("NATIVE_TERMINAL_PREPARE_AXIS vscode \(window.frame) expected=\(drag.size)") + guard verifyWindow(), bounds.contains(window.frame), nativeFixtureSetupSizeAccepted(window.frame.size, expected: drag.size) else { + XCTFail("The Code baseline axis drag did not accept its size on the same bounded native window") + return bounds + } + } + } else { + let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) + corner.press(forDuration: 0.1, thenDragTo: corner.withOffset(CGVector(dx: step.width - moved.width, dy: step.height - moved.height))) + guard let current = nativeFixtureWindowReference(window, refresh: refreshWindow) else { return bounds } + window = current + } } print("NATIVE_TERMINAL_PREPARE \(terminal) \(window.frame) target=\(step) display=\(bounds)") XCTAssertTrue(verifyWindow(), "The native fixture window identity changed during setup") @@ -794,37 +963,87 @@ final class TerminalCompatibilityUITests: XCTestCase { return windows[0] } - private func codeTerminalValue(_ app: XCUIApplication, identity: NativeWindowFrame, useCopiedBuffer: Bool) -> String? { + private func codeTerminalValue(_ app: XCUIApplication, identity: NativeWindowFrame) -> String? { guard let native = nativeFixtureWindow(visibleNativeWindows(processIdentifier: identity.processIdentifier), identity: identity) else { XCTFail("VS Code changed the fixture's native window ID, process, or window count") return nil } - let value: String? - if useCopiedBuffer { value = copiedTerminalValue(app) } - else { - value = try? nativeCodeScreenRows({ try app.snapshot() }, windowFrame: native.frame, - children: { $0.children }, elementType: { $0.elementType }, frame: { $0.frame }, value: { $0.value as? String }) - } + let value = copiedTerminalValue(app, native: native) guard nativeFixtureWindow(visibleNativeWindows(processIdentifier: identity.processIdentifier), identity: identity) == native else { return nil } return value } - private func copiedTerminalValue(_ window: XCUIElement) -> String? { + private func streamPositions(_ file: URL) -> [NativeStreamPosition] { + guard let handle = try? FileHandle(forReadingFrom: file) else { return [] } + defer { try? handle.close() } + guard let data = try? handle.read(upToCount: 513), data.count <= 512 else { return [] } + return nativeStreamPositions(data) + } + + private func writeStreamCommand(_ file: URL, id: Int, action: String, position: NativeStreamPosition) -> Bool { + guard let counter = position.counter, let columns = position.columns, let rows = position.rows else { + XCTFail("The streaming fixture position is incomplete") + return false + } + do { + let data = try JSONEncoder().encode(NativeStreamCommand(id: id, action: action, counter: counter, columns: columns, rows: rows)) + guard data.count <= 512 else { XCTFail("The stream observation command exceeded its bound"); return false } + try data.write(to: file, options: .atomic) + return true + } catch { + XCTFail("The streaming fixture control command could not be saved: \(error)") + return false + } + } + + private func copiedTerminalValue(_ window: XCUIElement, native: NativeWindowFrame) -> String? { let pasteboard = NSPasteboard.general + let started = Date().timeIntervalSince1970 + let previousGeneration = pasteboard.changeCount + let previousRaw = pasteboard.string(forType: .string) + let previousTypes = pasteboard.types?.map(\.rawValue) ?? [] pasteboard.clearContents() let generation = pasteboard.changeCount + var keyFinished = started + var available = false + var copied: String? + defer { + let sample = NativeCopySample(phase: copyPhase, started: started, finished: Date().timeIntervalSince1970, + baseline: generation, previousGeneration: previousGeneration, previousRaw: previousRaw, + previousTypes: previousTypes, keyFinished: keyFinished, observed: pasteboard.changeCount, + types: pasteboard.types?.map(\.rawValue) ?? [], raw: copied ?? pasteboard.string(forType: .string), + available: available, processIdentifier: native.processIdentifier, + windowIdentifier: native.windowIdentifier, frame: native.frame) + if let record = try? JSONEncoder().encode(sample) { + copyEvidence.append(record) + if let path = copyEvidencePath { + let records = copyEvidence.records.map { String(decoding: $0, as: UTF8.self) }.joined(separator: "\n") + try? (records + "\n{\"dropped\":\(copyEvidence.dropped)}\n").write(to: path, atomically: true, encoding: .utf8) + } + } + } window.typeKey("y", modifierFlags: [.control, .shift]) - let written = NSPredicate { _, _ in pasteboard.changeCount > generation && pasteboard.string(forType: .string) != nil } - guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: written, object: pasteboard)], timeout: 2) == .completed, - pasteboard.changeCount > generation, let value = pasteboard.string(forType: .string) else { + keyFinished = Date().timeIntervalSince1970 + let fresh = nativeCopiedTerminalObservation(baseline: generation, read: { + (pasteboard.changeCount, pasteboard.string(forType: .string)) + }, wait: { + let written = NSPredicate { _, _ in + nativeCopiedTerminalBuffer(baseline: generation, observed: pasteboard.changeCount, + value: pasteboard.string(forType: .string)) != nil + } + return XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: written, object: pasteboard)], timeout: 2) == .completed + }) + guard let value = fresh else { print("NATIVE_TERMINAL_COPY_UNAVAILABLE baseline=\(generation) observed=\(pasteboard.changeCount)") return nil } + print("NATIVE_TERMINAL_COPY_FRESH baseline=\(generation) observed=\(pasteboard.changeCount) bytes=\(value.utf8.count)") + copied = value + available = true return value } - private func terminalValue(_ window: XCUIElement, useCopiedBuffer: Bool = false) -> String? { - if useCopiedBuffer { return copiedTerminalValue(window) } + private func terminalValue(_ window: XCUIElement) -> String? { let values = window.textViews.allElementsBoundByIndex.compactMap { $0.value as? String } if let terminal = values.filter({ $0.contains("RESIZE_HISTORY retained text") }).max(by: { $0.count < $1.count }) { return terminal diff --git a/src/testing/scenarios/resizeFlickerScenario.tsx b/src/testing/scenarios/resizeFlickerScenario.tsx index ee240a02..c7b8c17f 100644 --- a/src/testing/scenarios/resizeFlickerScenario.tsx +++ b/src/testing/scenarios/resizeFlickerScenario.tsx @@ -1,24 +1,40 @@ /** @license Apache-2.0 */ import { appendFileSync } from 'node:fs'; +import { createResizeWriteMetadata, observeResizeWrite, resizeWritePayload } from './resizeWriteMetadata.js'; +import { ResizeStreamGate } from './resizeStreamGate.js'; if (!process.env.AUTOHAND_HOME) throw new Error('The resize fixture requires an isolated AUTOHAND_HOME'); const terminal = process.env.AUTOHAND_RESIZE_TERMINAL; if (terminal) process.env.TERM = terminal; const tracePath = process.env.AUTOHAND_RESIZE_WRITE_TRACE; +const metadataPath = process.env.AUTOHAND_RESIZE_WRITE_METADATA; +const writeMetadata = metadataPath ? createResizeWriteMetadata(metadataPath) : undefined; +const streamControl = process.env.AUTOHAND_RESIZE_STREAM_CONTROL; +if (streamControl && !writeMetadata) throw new Error('The stream observation gate requires bounded write metadata'); +const streamGate = streamControl && process.argv.includes('--streaming') + ? new ResizeStreamGate(streamControl, record => writeMetadata?.record({ timestamp: Date.now() / 1000, ...record })) : undefined; const viewportMarkers = process.argv.includes('--viewport-markers'); -if (tracePath || viewportMarkers) { +let streamed = 0; +if (tracePath || metadataPath || viewportMarkers) { const originalWrite = process.stdout.write.bind(process.stdout); let tracedBytes = 0; + let sequence = 0; const write: typeof process.stdout.write = (chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void): boolean => { - const value = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(); - const output = process.argv.includes('--unsupported-sync') ? value.replaceAll(/\x1b\[\?2026[hl]/g, '') : value; const encoding = typeof encodingOrCallback === 'string' ? encodingOrCallback : undefined; const complete = typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; const marker = viewportMarkers ? `\x1b]777;resize-fixture;${process.stdout.columns};${process.stdout.rows}\x07` : ''; - const result = originalWrite(marker + output, encoding, complete); + const payload = resizeWritePayload(chunk, encoding, { marker, unsupportedSync: process.argv.includes('--unsupported-sync') }); + const current = ++sequence; + const result = writeMetadata ? observeResizeWrite({ + sequence: current, bytes: payload.bytes, record: writeMetadata.record, complete, + snapshot: () => ({ timestamp: Date.now() / 1000, columns: process.stdout.columns, rows: process.stdout.rows, streamed }), + write: callback => originalWrite(payload.chunk, encoding, callback), + }) : originalWrite(payload.chunk, encoding, complete); if (tracePath) { + const value = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(); + const output = process.argv.includes('--unsupported-sync') ? value.replaceAll(/\x1b\[\?2026[hl]/g, '') : value; const record = JSON.stringify({ columns: process.stdout.columns, rows: process.stdout.rows, terminal: process.env.TERM, output }) + '\n'; tracedBytes += Buffer.byteLength(record); if (tracedBytes > 4 * 1024 * 1024) throw new Error('Resize fixture exceeded its bounded write trace'); @@ -32,19 +48,22 @@ if (tracePath || viewportMarkers) { const { InkRenderer } = await import('../../ui/ink/InkRenderer.js'); let streamTimer: ReturnType | undefined; -let streamed = 0; const renderer = new InkRenderer({ fullScreenMode: process.argv.includes('--full-screen'), onInstruction: () => {}, onEscape: () => { clearInterval(streamTimer); + void streamGate?.dispose(); renderer.setWorking(false); renderer.setFinalResponse(`RESIZE_STREAM_COMPLETE ${streamed}`); }, onCtrlC: () => { clearInterval(streamTimer); - void renderer.stop().then(() => { - process.stdout.write('\nRESIZE_PTY_EXIT\n', () => process.exit(0)); + void renderer.stop().then(async () => { + await streamGate?.dispose(); + process.stdout.write('\nRESIZE_PTY_EXIT\n', () => { + void (writeMetadata?.flush() ?? Promise.resolve()).then(() => process.exit(0)); + }); }); }, }); @@ -58,7 +77,10 @@ if (process.argv.includes('--streaming')) { renderer.start(); if (process.argv.includes('--streaming')) { streamTimer = setInterval(() => { + const viewport = { columns: process.stdout.columns, rows: process.stdout.rows }; + if (streamGate && !streamGate.permits(viewport)) return; streamed += 1; renderer.setStreamingResponse(`RESIZE_STREAM ${streamed} retained streamed text`); + streamGate?.emitted({ counter: streamed, ...viewport }); }, 40); } diff --git a/src/testing/scenarios/resizeStreamGate.ts b/src/testing/scenarios/resizeStreamGate.ts new file mode 100644 index 00000000..426ae8ab --- /dev/null +++ b/src/testing/scenarios/resizeStreamGate.ts @@ -0,0 +1,107 @@ +/** @license Apache-2.0 */ +import { open, rename, writeFile } from 'node:fs/promises'; +import { createResizeWriteMetadata } from './resizeWriteMetadata.js'; + +interface StreamPosition { counter: number; columns: number; rows: number } +interface StreamCommand extends StreamPosition { id: number; action: 'observe' | 'resume' } + +async function readCommand(file: string): Promise { + const handle = await open(file, 'r'); + try { + const buffer = Buffer.alloc(513); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + if (bytesRead > 512) throw new Error('Resize stream command exceeded 512 bytes'); + return JSON.parse(buffer.subarray(0, bytesRead).toString('utf8')); + } finally { await handle.close(); } +} + +function parseCommand(value: unknown): StreamCommand { + if (!value || typeof value !== 'object' || !('id' in value) || !('action' in value) || !('counter' in value) + || !('columns' in value) || !('rows' in value) || (value.action !== 'observe' && value.action !== 'resume') + || typeof value.id !== 'number' || !Number.isSafeInteger(value.id) || value.id < 1 || value.id > 8 + || typeof value.counter !== 'number' || !Number.isSafeInteger(value.counter) || value.counter < 0 + || typeof value.columns !== 'number' || !Number.isSafeInteger(value.columns) || value.columns < 1 || value.columns > 10000 + || typeof value.rows !== 'number' || !Number.isSafeInteger(value.rows) || value.rows < 1 || value.rows > 10000) { + throw new Error('Invalid resize stream command'); + } + return { id: value.id, action: value.action, counter: value.counter, columns: value.columns, rows: value.rows }; +} + +export class ResizeStreamGate { + private command: StreamCommand | undefined; + private last: StreamPosition | undefined; + private emittedCommand = 0; + private paused = false; + private disposed = false; + private pending: Promise | undefined; + private failure: unknown; + private revision = 0; + private readonly state: ReturnType; + + constructor(file: string, private readonly record: (value: object) => void, + private readonly read: () => Promise = () => readCommand(file), persist?: (content: string) => Promise) { + const stateFile = `${file}.state.json`; + this.state = createResizeWriteMetadata(stateFile, { maxRecords: 1, maxBytes: 512, + persist: persist ?? (async content => { + await writeFile(`${stateFile}.pending`, content.split('\n', 1)[0] ?? ''); + await rename(`${stateFile}.pending`, stateFile); + }), + }); + } + + private publishState(): void { + if (!this.last) return; + this.state.record({ kind: 'stream-state', command: this.command?.id ?? 0, revision: ++this.revision, + emittedCommand: this.emittedCommand, ...this.last, paused: this.paused }); + } + + refresh(): Promise { + if (this.disposed || this.failure) return Promise.resolve(); + if (this.pending) return this.pending; + this.pending = this.read().then(value => { + if (this.disposed) return; + const command = parseCommand(value); + if (command.id <= (this.command?.id ?? 0)) return; + this.command = command; + this.paused = false; + this.publishState(); + }).catch(error => { + if (!this.disposed && !(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')) this.failure = error; + }).finally(() => { this.pending = undefined; }); + return this.pending; + } + + permits(viewport: Omit): boolean { + if (this.disposed) return false; + if (this.failure) throw this.failure; + void this.refresh(); + const command = this.command; + const last = this.last; + if (this.paused && last && (last.columns !== viewport.columns || last.rows !== viewport.rows)) { + this.paused = false; + this.publishState(); + } + if (!this.paused && command?.action === 'observe' && last && this.emittedCommand === command.id && last.counter > command.counter + && (last.columns !== command.columns || last.rows !== command.rows) + && last.columns === viewport.columns && last.rows === viewport.rows) { + this.paused = true; + this.record({ kind: 'stream-gate', command: command.id, ...last, paused: true }); + this.publishState(); + } + return !this.paused; + } + + emitted(position: StreamPosition): void { + if (this.disposed) return; + this.last = position; + this.emittedCommand = this.command?.id ?? 0; + this.record({ kind: 'stream-chunk', command: this.command?.id ?? 0, ...position, paused: false }); + this.publishState(); + } + + async dispose(): Promise { + this.disposed = true; + if (this.pending) await this.pending; + await this.state.flush(); + } +} diff --git a/src/testing/scenarios/resizeWriteMetadata.ts b/src/testing/scenarios/resizeWriteMetadata.ts new file mode 100644 index 00000000..2f805871 --- /dev/null +++ b/src/testing/scenarios/resizeWriteMetadata.ts @@ -0,0 +1,98 @@ +/** @license Apache-2.0 */ +import { rename, writeFile } from 'node:fs/promises'; + +interface ResizeMetadataOptions { + persist?: (content: string) => Promise; + maxRecords?: number; + maxBytes?: number; +} + +export function resizeWritePayload(chunk: string | Uint8Array, encoding: BufferEncoding | undefined, + options: { marker?: string; unsupportedSync?: boolean } = {}): { chunk: string | Uint8Array; bytes: number } { + let payload = chunk; + if (options.marker || options.unsupportedSync) { + const value = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(); + const output = options.unsupportedSync ? value.replaceAll(/\x1b\[\?2026[hl]/g, '') : value; + payload = (options.marker ?? '') + output; + } + return { chunk: payload, bytes: typeof payload === 'string' ? Buffer.byteLength(payload, encoding) : payload.byteLength }; +} + +interface ResizeWriteObservation { + sequence: number; + bytes: number; + snapshot(): { timestamp: number; columns: number | undefined; rows: number | undefined; streamed: number }; + record(value: object): void; + write(complete: (error?: Error | null) => void): boolean; + complete?: (error?: Error | null) => void; +} + +export function observeResizeWrite(observation: ResizeWriteObservation): boolean { + const { sequence, bytes, snapshot, record, write, complete } = observation; + record({ ...snapshot(), kind: 'write-start', sequence, bytes }); + const result = write(error => { + record({ ...snapshot(), kind: 'complete', sequence, error: error != null }); + complete?.(error); + }); + record({ ...snapshot(), kind: 'write-return', sequence, backpressured: !result }); + return result; +} + +export function createResizeWriteMetadata(file: string, options: ResizeMetadataOptions = {}): { + record(value: object): void; + flush(): Promise; +} { + const maxRecords = options.maxRecords ?? 4096; + const maxBytes = options.maxBytes ?? 1024 * 1024; + if (!Number.isSafeInteger(maxRecords) || maxRecords < 1 || !Number.isSafeInteger(maxBytes) || maxBytes < 128) { + throw new Error('Resize metadata bounds must allow at least one record and 128 bytes'); + } + const persist = options.persist ?? (async (content: string) => { + await writeFile(`${file}.pending`, content); + await rename(`${file}.pending`, file); + }); + const records: { line: string; bytes: number }[] = []; + let bytes = 0; + let dropped = 0; + let dirty = false; + let pending: Promise | undefined; + let failure: unknown; + let failed = false; + + const schedule = () => { + if (pending || failed) return; + pending = (async () => { + do { + dirty = false; + const overflow = dropped ? JSON.stringify({ kind: 'metadata-overflow', dropped }) + '\n' : ''; + await persist(records.map(record => record.line).join('') + overflow); + } while (dirty); + })().catch(error => { failure = error; failed = true; }).finally(() => { + pending = undefined; + if (dirty && !failed) schedule(); + }); + }; + + return { + record(value) { + const line = JSON.stringify(value) + '\n'; + const size = Buffer.byteLength(line); + if (size > maxBytes - 64) { + dropped += 1; + } else { + while (records.length >= maxRecords || bytes + size > maxBytes - 64) { + bytes -= records.shift()!.bytes; + dropped += 1; + } + records.push({ line, bytes: size }); + bytes += size; + } + dirty = true; + schedule(); + }, + async flush() { + while (pending) await pending; + if (failed) throw failure; + }, + }; +} diff --git a/tests/core/agent/AgentCommandRuntime.slashParsing.test.ts b/tests/core/agent/AgentCommandRuntime.slashParsing.test.ts index a661b39d..db441473 100644 --- a/tests/core/agent/AgentCommandRuntime.slashParsing.test.ts +++ b/tests/core/agent/AgentCommandRuntime.slashParsing.test.ts @@ -138,6 +138,23 @@ describe('renderAgentSlashCommandResult', () => { }); describe('runAgentSlashCommandWithInput', () => { + it.each(['cancel', 'prefill', 'error'] as const)('preserves input owned by the next turn after a modal %s', async outcome => { + let draft = ''; + const host = { + runtime: { options: {}, config: {} }, + inkRenderer: { isRunning: () => true, clearInput: () => { draft = ''; } }, + handleSlashCommand: async () => { + draft = outcome === 'prefill' ? '/model next-provider' : 'next instruction'; + if (outcome === 'error') throw new Error('modal failed'); + return null; + }, + }; + const command = runAgentSlashCommandWithInput(host, '/model', []); + if (outcome === 'error') await expect(command).rejects.toThrow('modal failed'); + else await command; + expect(draft).toBe(outcome === 'prefill' ? '/model next-provider' : 'next instruction'); + }); + it('preserves text entered as the composer returns from status', async () => { let draft = '/status'; const host = { diff --git a/tests/core/agent/FeedbackSurveyLifecycle.test.ts b/tests/core/agent/FeedbackSurveyLifecycle.test.ts index 787d1fec..e25f0cdc 100644 --- a/tests/core/agent/FeedbackSurveyLifecycle.test.ts +++ b/tests/core/agent/FeedbackSurveyLifecycle.test.ts @@ -96,9 +96,9 @@ describe('feedback survey in the interactive loop', () => { expect(host.closeSession).toHaveBeenCalledOnce(); }); - it('preserves a draft typed after the feedback survey becomes visible', async () => { + it.each(['/feedback', '/deploy', '/help'])('preserves a draft typed while %s finishes', async command => { let draft = ''; - const { host } = createHost(['/feedback'], { + const { host } = createHost([command], { ui: {}, setComposerIdle: vi.fn(), clearComposerInput: vi.fn(() => { draft = ''; }), @@ -114,6 +114,33 @@ describe('feedback survey in the interactive loop', () => { expect(draft).toBe('still typing'); }); + it.each(['/deploy', '/help'])('preserves a newer same-text draft when %s finishes', async command => { + let draft = ''; + const { host } = createHost([command], { + ui: {}, + setComposerIdle: vi.fn(), + clearComposerInput: vi.fn(() => { draft = ''; }), + runSlashCommandWithInput: vi.fn(async () => { + await Promise.resolve(); + draft = command; + host.shouldExit = true; + return null; + }), + }); + await runAgentInteractiveLoop(host); + expect(draft).toBe(command); + }); + + it('preserves a pre-existing draft when a keybinding dispatches a command', async () => { + let draft = 'draft before Ctrl+K'; + const { host } = createHost(['/deploy'], { + ui: {}, setComposerIdle: vi.fn(), clearComposerInput: vi.fn(() => { draft = ''; }), + runSlashCommandWithInput: vi.fn(async () => { host.shouldExit = true; return null; }), + }); + await runAgentInteractiveLoop(host); + expect(draft).toBe('draft before Ctrl+K'); + }); + it('clears the previous survey as soon as the next prompt is submitted', async () => { const { host, events } = createHost(['first prompt', 'second prompt']); diff --git a/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts index 19bba4d9..efd0ff1f 100644 --- a/tests/testing/nativeTerminal.test.ts +++ b/tests/testing/nativeTerminal.test.ts @@ -41,7 +41,21 @@ function preparedLaunchArguments(): Record { } describe('native terminal automation boundary', () => { - it.each(['idle', 'streaming'])('installs a single native buffer-copy binding in the isolated %s VS Code profile', async mode => { + it('retains separate fixture write timing for each native scenario', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + vi.stubEnv('GITHUB_ACTIONS', 'true'); + vi.stubEnv('CI', 'true'); + vi.stubEnv('AUTOHAND_NATIVE_TERMINAL', 'vscode'); + await prepareNativeTerminalTest(); + const fixture = vi.mocked(writeFile).mock.calls.find(([file]) => String(file).endsWith('Fixture.command')); + expect(fixture?.[1]).toContain('AUTOHAND_RESIZE_WRITE_METADATA='); + expect(fixture?.[1]).toContain('write-metadata-${fixture_mode}.jsonl'); + expect(fixture?.[1]).toContain('fixture_mode=streaming'); + expect(fixture?.[1]).toContain('fixture_mode=idle'); + expect(fixture?.[1]).toContain('AUTOHAND_RESIZE_STREAM_CONTROL='); + }); + + it.each(['idle', 'streaming'])('focuses the existing terminal before each native copy in the isolated %s VS Code profile', async mode => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); vi.stubEnv('GITHUB_ACTIONS', 'true'); vi.stubEnv('CI', 'true'); @@ -52,8 +66,12 @@ describe('native terminal automation boundary', () => { expect(bindingWrite).toBeDefined(); const bindings: unknown = JSON.parse(String(bindingWrite?.[1])); expect(bindings).toEqual([{ - key: 'ctrl+shift+y', command: 'runCommands', when: 'terminalFocus', args: { - commands: ['workbench.action.terminal.selectAll', 'workbench.action.terminal.copyAndClearSelection'], + key: 'ctrl+shift+y', command: 'runCommands', when: 'terminalIsOpen', args: { + commands: [ + 'workbench.action.terminal.focus', + 'workbench.action.terminal.selectAll', + 'workbench.action.terminal.copyAndClearSelection', + ], }, }]); expect(copyFile).toHaveBeenCalledWith(evidence, path.join('/private/tmp/ah-vsc-123456', mode, 'User/keybindings.json')); @@ -85,7 +103,7 @@ describe('native terminal automation boundary', () => { await expect(prepareNativeTerminalTest()).rejects.toThrow('Choose iterm2, ghostty, cmux, or vscode'); }); - it.each(['idle', 'streaming'])('prepares an isolated %s VS Code profile with native renderer accessibility', async mode => { + it.each(['idle', 'streaming'])('prepares an isolated %s VS Code profile for graphical terminal use', async mode => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); vi.stubEnv('GITHUB_ACTIONS', 'true'); vi.stubEnv('CI', 'true'); @@ -98,9 +116,9 @@ describe('native terminal automation boundary', () => { const settingsWrite = vi.mocked(writeFile).mock.calls.find(([file]) => file === path.join(profile, 'User/settings.json')); expect(settingsWrite).toBeDefined(); const settings: unknown = JSON.parse(String(settingsWrite?.[1])); - expect(settings).toMatchObject({ + expect.soft(settings).toMatchObject({ 'chat.disableAIFeatures': true, - 'editor.accessibilitySupport': 'on', + 'editor.accessibilitySupport': 'off', 'window.restoreWindows': 'none', 'terminal.integrated.enablePersistentSessions': false, 'terminal.integrated.scrollback': 5000, @@ -108,10 +126,11 @@ describe('native terminal automation boundary', () => { const launchArguments = preparedLaunchArguments(); expect(launchArguments).toMatchObject({ [mode]: expect.arrayContaining([ - '--force-renderer-accessibility', '--user-data-dir', runtimeProfile, + '--user-data-dir', runtimeProfile, '--extensions-dir', path.join(runtimeProfile, 'extensions'), '--disable-extensions', ]), }); + expect.soft(launchArguments[mode]?.some(argument => argument.startsWith('--force-renderer-accessibility'))).toBe(false); expect(launchArguments).toMatchObject({ idle: expect.not.arrayContaining([path.join('/private/tmp/ah-vsc-123456', 'streaming')]), streaming: expect.not.arrayContaining([path.join('/private/tmp/ah-vsc-123456', 'idle')]), diff --git a/tests/testing/resizeStreamGate.test.ts b/tests/testing/resizeStreamGate.test.ts new file mode 100644 index 00000000..7be4c1e7 --- /dev/null +++ b/tests/testing/resizeStreamGate.test.ts @@ -0,0 +1,93 @@ +/** @license Apache-2.0 */ +import { describe, expect, it, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { ResizeStreamGate } from '../../src/testing/scenarios/resizeStreamGate.js'; + +describe('resize stream observation gate', () => { + it('reopens on a late viewport change and refreshes the same observation acknowledgement', async () => { + const record = vi.fn(); + const persist = vi.fn(async (_content: string) => {}); + const gate = new ResizeStreamGate('unused', record, async () => ({ id: 3, action: 'observe', counter: 166, columns: 93, rows: 27 }), persist); + await gate.refresh(); + gate.emitted({ counter: 180, columns: 97, rows: 28 }); + expect(gate.permits({ columns: 97, rows: 28 })).toBe(false); + expect(gate.permits({ columns: 128, rows: 39 })).toBe(true); + gate.emitted({ counter: 181, columns: 128, rows: 39 }); + expect(gate.permits({ columns: 128, rows: 39 })).toBe(false); + expect(record).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'stream-gate', command: 3, counter: 181, + columns: 128, rows: 39, paused: true })); + await gate.dispose(); + const snapshot: unknown = JSON.parse(persist.mock.calls.at(-1)?.[0].split('\n')[0] ?? '{}'); + expect(snapshot).toMatchObject({ kind: 'stream-state', command: 3, counter: 181, columns: 128, rows: 39, + paused: true, revision: expect.any(Number) }); + }); + + it('requires a post-command chunk rather than pausing on intermediate drag output', async () => { + const gate = new ResizeStreamGate('unused', vi.fn(), async () => ({ id: 1, action: 'observe', counter: 10, columns: 100, rows: 30 }), async () => {}); + gate.emitted({ counter: 11, columns: 90, rows: 28 }); + await gate.refresh(); + expect(gate.permits({ columns: 90, rows: 28 })).toBe(true); + gate.emitted({ counter: 12, columns: 90, rows: 28 }); + expect(gate.permits({ columns: 90, rows: 28 })).toBe(false); + await gate.dispose(); + }); + + it('bounds control reads before parsing and disposes after a read failure', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'resize-stream-gate-')); + const file = path.join(directory, 'command.json'); + const gate = new ResizeStreamGate(file, vi.fn()); + try { + await writeFile(file, 'x'.repeat(513)); + await gate.refresh(); + expect(() => gate.permits({ columns: 100, rows: 30 })).toThrow('exceeded 512 bytes'); + } finally { await gate.dispose(); await rm(directory, { recursive: true, force: true }); } + }); + + it('pauses only after a newer chunk was emitted at the resulting viewport and resumes', async () => { + const record = vi.fn(); + let command = { id: 1, action: 'observe', counter: 10, columns: 100, rows: 30 }; + const gate = new ResizeStreamGate('unused', record, async () => command, async () => {}); + await gate.refresh(); + gate.emitted({ counter: 11, columns: 100, rows: 30 }); + expect(gate.permits({ columns: 100, rows: 30 })).toBe(true); + expect(gate.permits({ columns: 90, rows: 28 })).toBe(true); + gate.emitted({ counter: 12, columns: 90, rows: 28 }); + expect(gate.permits({ columns: 90, rows: 28 })).toBe(false); + expect(record).toHaveBeenCalledWith(expect.objectContaining({ kind: 'stream-gate', command: 1, counter: 12, + columns: 90, rows: 28, paused: true })); + await gate.refresh(); + command = { id: 2, action: 'resume', counter: 12, columns: 90, rows: 28 }; + await gate.refresh(); + expect(gate.permits({ columns: 90, rows: 28 })).toBe(true); + gate.emitted({ counter: 13, columns: 90, rows: 28 }); + expect(record).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'stream-chunk', command: 2, counter: 13 })); + await gate.dispose(); + }); + + it('owns one pending read and suppresses late work after disposal', async () => { + let release: ((command: unknown) => void) | undefined; + const read = vi.fn(() => new Promise(resolve => { release = resolve; })); + const record = vi.fn(); + const gate = new ResizeStreamGate('unused', record, read, async () => {}); + const pending = gate.refresh(); + void gate.refresh(); + expect(read).toHaveBeenCalledTimes(1); + const disposed = gate.dispose(); + release?.({ id: 1, action: 'resume', counter: 0, columns: 100, rows: 30 }); + await pending; + await disposed; + expect(record).not.toHaveBeenCalled(); + expect(gate.permits({ columns: 100, rows: 30 })).toBe(false); + await gate.refresh(); + expect(read).toHaveBeenCalledTimes(1); + }); + + it.each([{}, { id: 40, action: 'resume', counter: 0, columns: 100, rows: 30 }])('fails closed on invalid bounded control %j', async command => { + const gate = new ResizeStreamGate('unused', vi.fn(), async () => command, async () => {}); + await gate.refresh(); + expect(() => gate.permits({ columns: 100, rows: 30 })).toThrow('Invalid resize stream command'); + await gate.dispose(); + }); +}); diff --git a/tests/testing/resizeWriteMetadata.test.ts b/tests/testing/resizeWriteMetadata.test.ts new file mode 100644 index 00000000..efcb0263 --- /dev/null +++ b/tests/testing/resizeWriteMetadata.test.ts @@ -0,0 +1,130 @@ +/** @license Apache-2.0 */ +import { describe, expect, it, vi } from 'vitest'; +import { createResizeWriteMetadata, observeResizeWrite, resizeWritePayload } from '../../src/testing/scenarios/resizeWriteMetadata.js'; + +describe('resize fixture write metadata', () => { + it('forwards split multibyte and non-UTF8 chunks unchanged when only observing metadata', () => { + const chunks = [Uint8Array.from([0xe2]), Uint8Array.from([0x82, 0xac]), Buffer.from([0xff, 0xfe])]; + const forwarded = chunks.map(chunk => resizeWritePayload(chunk, undefined)); + for (let index = 0; index < chunks.length; index += 1) { + expect(forwarded[index]?.chunk).toBe(chunks[index]); + expect(forwarded[index]?.bytes).toBe(chunks[index]?.byteLength); + } + const actual = Buffer.concat(forwarded.map(payload => typeof payload.chunk === 'string' ? Buffer.from(payload.chunk) : payload.chunk)); + expect(actual).toEqual(Buffer.from([0xe2, 0x82, 0xac, 0xff, 0xfe])); + }); + + it.each<{ chunk: string | Uint8Array; encoding?: BufferEncoding; bytes: number }>([ + { chunk: 'é', encoding: 'latin1', bytes: 1 }, + { chunk: 'abcd', encoding: 'utf16le', bytes: 8 }, + { chunk: 'w6k=', encoding: 'base64', bytes: 2 }, + { chunk: Uint8Array.from([1, 2, 3, 4]).subarray(1, 3), encoding: 'utf16le', bytes: 2 }, + ])('measures the actual payload type and encoding $encoding', ({ chunk, encoding, bytes }) => { + const payload = resizeWritePayload(chunk, encoding); + expect(payload.chunk).toBe(chunk); + expect(payload.bytes).toBe(bytes); + }); + + it('limits payload transformations to explicit viewport-marker or unsupported-sync modes', () => { + const chunk = Buffer.from('\x1b[?2026htext\x1b[?2026l'); + expect(resizeWritePayload(chunk, undefined, { unsupportedSync: true }).chunk).toBe('text'); + const marked = resizeWritePayload('text', undefined, { marker: '\x1b]777;resize-fixture;100;30\x07' }); + expect(marked.chunk).toBe('\x1b]777;resize-fixture;100;30\x07text'); + expect(marked.bytes).toBe(Buffer.byteLength(marked.chunk)); + }); + + it('flushes a record appended between the last write continuation and pending cleanup', async () => { + const persist = vi.fn(async (_content: string) => {}); + const metadata = createResizeWriteMetadata('unused', { persist }); + metadata.record({ sequence: 1 }); + await Promise.resolve(); + metadata.record({ sequence: 2 }); + await metadata.flush(); + expect(persist.mock.calls.at(-1)?.[0]).toContain('"sequence":2'); + expect(persist).toHaveBeenCalledTimes(2); + }); + + it('waits for a new write chain scheduled while flush is awaiting pending cleanup', async () => { + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + const persist = vi.fn(async (_content: string) => { if (persist.mock.calls.length === 2) await gate; }); + const metadata = createResizeWriteMetadata('unused', { persist }); + metadata.record({ sequence: 1 }); + await Promise.resolve(); + metadata.record({ sequence: 2 }); + let finished = false; + const flush = metadata.flush().then(() => { finished = true; }); + await vi.waitFor(() => { expect(persist).toHaveBeenCalledTimes(2); }); + expect(finished).toBe(false); + release?.(); + await flush; + expect(finished).toBe(true); + }); + + it.each([undefined, null, new Error('write failed')])('records write-start before delegation and preserves completion %s', error => { + let timestamp = 10; + const record = vi.fn(); + const complete = vi.fn(); + const result = observeResizeWrite({ + sequence: 1, bytes: 250, snapshot: () => ({ timestamp, columns: 100, rows: 30, streamed: 40 }), record, complete, + write: callback => { + expect(record.mock.calls[0]?.[0]).toEqual({ kind: 'write-start', sequence: 1, bytes: 250, + timestamp: 10, columns: 100, rows: 30, streamed: 40 }); + timestamp = 12; + callback(error); + timestamp = 13; + return false; + }, + }); + expect(result).toBe(false); + expect(complete).toHaveBeenCalledExactlyOnceWith(error); + expect(record.mock.calls[1]?.[0]).toMatchObject({ kind: 'complete', sequence: 1, timestamp: 12, error: error != null }); + expect(record.mock.calls[2]?.[0]).toMatchObject({ kind: 'write-return', sequence: 1, timestamp: 13, backpressured: true }); + }); + + it('rejects limits that cannot retain a bounded tail', () => { + expect(() => createResizeWriteMetadata('unused', { maxRecords: 0 })).toThrow('Resize metadata bounds'); + expect(() => createResizeWriteMetadata('unused', { maxBytes: 64 })).toThrow('Resize metadata bounds'); + }); + + it('persists a bounded tail while one slow writer is in flight', async () => { + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + const persist = vi.fn(async (_content: string) => { await gate; }); + const metadata = createResizeWriteMetadata('unused', { persist, maxRecords: 2, maxBytes: 512 }); + for (let sequence = 0; sequence < 100; sequence += 1) metadata.record({ sequence }); + expect(persist).toHaveBeenCalledTimes(1); + release?.(); + await metadata.flush(); + expect(persist).toHaveBeenCalledTimes(2); + const latest = persist.mock.calls.at(-1)?.[0] ?? ''; + expect(latest.trim().split('\n').map((line: string) => JSON.parse(line))).toEqual([ + { sequence: 98 }, { sequence: 99 }, { kind: 'metadata-overflow', dropped: 98 }, + ]); + expect(Buffer.byteLength(latest)).toBeLessThanOrEqual(512); + }); + + it('caps bytes, reports oversized records, and retains late events', async () => { + const persist = vi.fn(async (_content: string) => {}); + const metadata = createResizeWriteMetadata('unused', { persist, maxRecords: 100, maxBytes: 256 }); + metadata.record({ oversized: 'x'.repeat(512) }); + for (let sequence = 0; sequence < 30; sequence += 1) metadata.record({ sequence, value: 'x'.repeat(40) }); + await metadata.flush(); + const latest = persist.mock.calls.at(-1)?.[0] ?? ''; + expect(Buffer.byteLength(latest)).toBeLessThanOrEqual(256); + expect(latest).toContain('"sequence":29'); + expect(latest).not.toContain('"sequence":0,'); + expect(latest).toContain('"kind":"metadata-overflow"'); + }); + + it('surfaces persistence failure on flush without rejecting the producer', async () => { + const error = new Error('write failed'); + const persist = vi.fn(async () => { throw error; }); + const metadata = createResizeWriteMetadata('unused', { persist }); + expect(() => metadata.record({ sequence: 1 })).not.toThrow(); + await expect(metadata.flush()).rejects.toBe(error); + metadata.record({ sequence: 2 }); + await expect(metadata.flush()).rejects.toBe(error); + expect(persist).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/tuistory/extensions.tuistory.test.ts b/tests/tuistory/extensions.tuistory.test.ts index 1480e4f1..c1ea0b68 100644 --- a/tests/tuistory/extensions.tuistory.test.ts +++ b/tests/tuistory/extensions.tuistory.test.ts @@ -5,7 +5,7 @@ */ import fs from 'fs-extra'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Session } from 'tuistory'; import { createTempAutohandHome, @@ -24,6 +24,7 @@ import { driveExtensionBuilderAuthoring, } from '../../src/testing/scenarios/extensionBuilderAuthoringDemo.js'; import { showDocumentFactoryInstruction } from '../../src/testing/scenarios/documentFactoryScenario.js'; +import { waitForTerminalScreen } from '../../src/testing/drivers/tuistory-driver.js'; const EXAMPLE_IDS = [ 'autohand.code-health', @@ -270,6 +271,64 @@ describe('built extensions CLI Tuistory E2E', () => { await exitInteractive(session); }, 90_000); + it.each([false, true].flatMap(fullScreenMode => (['existing', 'different', 'same'] as const) + .map(draftKind => ({ fullScreenMode, draftKind }))))('preserves $draftKind composer input through controlled slash completion (full screen: $fullScreenMode)', async ({ fullScreenMode, draftKind }) => { + const state = await createTempAutohandHome({ config: { features: { fullScreenMode } } }); + tempStates.push(state); + const source = path.join(state.workspaceRoot, 'completion-gate'); + const started = path.join(state.workspaceRoot, 'command-started'); + const release = path.join(state.workspaceRoot, 'command-release'); + await fs.outputJson(path.join(source, 'autohand.extension.json'), { + schemaVersion: 1, extensionApi: 1, id: 'autohand.completion-gate', name: 'Completion gate', + version: '1.0.0', description: 'Controlled slash completion fixture.', contributes: { runtime: ['dist/extension.mjs'] }, + }); + await fs.outputFile(path.join(source, 'dist/extension.mjs'), ` +import fs from 'node:fs'; +export function activate(api) { + api.commands.register({command:'/draft-gate', description:'Controlled completion', execute:async () => { + await new Promise(resolve => { + const check=()=>{if(fs.existsSync(${JSON.stringify(release)})){watcher.close();resolve();}}; + const watcher=fs.watch(${JSON.stringify(state.workspaceRoot)},check); + fs.writeFileSync(${JSON.stringify(started)},'started');check(); + }); + return 'GATED_COMMAND_FINISHED'; + }}); + api.keybindings.register({key:'ctrl+k', command:'/draft-gate', when:'always'}); + api.ui.setStatusLine({segments:[{id:'gate-ready',text:'gate:ready'}]}); +} +`); + const installation = await runBuiltCommand(state, ['extensions', 'install', source, '--trust']); + expect(installation.exitCode, installation.output).toBe(0); + const session = await launchBuiltAutohand(['--path', state.workspaceRoot, '--config', state.configPath], { + autohandHome: state.autohandHome, cwd: state.workspaceRoot, + }); + sessions.push(session); + const showsDraft = (screen: string, draft: string) => screen.split('\n') + .map(line => line.trimStart()).filter(line => line.startsWith('❯ ')).at(-1) === `❯ ${draft}`; + try { + await session.waitForText('gate:ready', { timeout: 20_000 }); + await session.type('draft before keybinding'); + await waitForTerminalScreen(session, { timeout: 10_000, waitFor: screen => showsDraft(screen, 'draft before keybinding') }); + await session.press(['ctrl', 'k']); + await vi.waitFor(async () => expect(await fs.readFile(started, 'utf8')).toBe('started'), { timeout: 10_000 }); + await waitForTerminalScreen(session, { timeout: 10_000, waitFor: screen => showsDraft(screen, 'draft before keybinding') }); + const draft = draftKind === 'existing' ? 'draft before keybinding' : draftKind === 'same' ? '/draft-gate' : 'new draft during command'; + if (draftKind !== 'existing') { + await session.press(['ctrl', 'c']); + await session.type(draft); + await waitForTerminalScreen(session, { timeout: 10_000, waitFor: screen => showsDraft(screen, draft) }); + } + expect(session.getRawOutput()).not.toContain('GATED_COMMAND_FINISHED'); + await fs.writeFile(release, 'ready'); + await session.waitForText('GATED_COMMAND_FINISHED', { timeout: 10_000 }); + await waitForTerminalScreen(session, { timeout: 10_000, waitFor: screen => showsDraft(screen, draft) }); + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + } finally { + await fs.writeFile(release, 'ready'); + } + }); + it.each([false, true])('runs trusted slash commands, Ink views, line segments, flags, and keybindings (full screen: %s)', async (fullScreenMode) => { const state = await createTempAutohandHome({ config: { features: { fullScreenMode } } }); tempStates.push(state); @@ -311,8 +370,15 @@ describe('built extensions CLI Tuistory E2E', () => { await session.press(['ctrl', 'k']); await session.waitForText('Target: quality-assurance', { timeout: 10_000 }); await session.press('escape'); + await waitForTerminalScreen(session, { + timeout: 10_000, + waitFor: screen => screen.includes('❯ Plan, search, build anything') + && !screen.includes('Target: quality-assurance'), + }); - await session.type('/extensions disable autohand.runtime-showcase'); + const disableCommand = '/extensions disable autohand.runtime-showcase'; + await session.type(disableCommand); + await waitForTerminalScreen(session, { timeout: 10_000, waitFor: screen => screen.includes(`❯ ${disableCommand}`) }); await session.press('enter'); await session.waitForText('Disabled autohand.runtime-showcase', { timeout: 10_000 }); await session.type('/deploy'); diff --git a/tests/tuistory/resize-flicker.tuistory.test.ts b/tests/tuistory/resize-flicker.tuistory.test.ts index caefb8c2..abb0a07a 100644 --- a/tests/tuistory/resize-flicker.tuistory.test.ts +++ b/tests/tuistory/resize-flicker.tuistory.test.ts @@ -1,5 +1,6 @@ /** @license Apache-2.0 */ import path from 'node:path'; +import { readFile, rename, writeFile } from 'node:fs/promises'; import { stripVTControlCharacters } from 'node:util'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Session } from 'tuistory'; @@ -14,6 +15,115 @@ let state: TuistoryTempState | undefined; afterEach(async () => { session?.close(); await state?.cleanup(); session = undefined; state = undefined; }); describe('terminal resize frames', () => { + it('gates only new stream chunks after viewport progress and resumes without changing the active request', async () => { + state = await createTempAutohandHome(); + const metadata = path.join(state.autohandHome, 'gate-metadata.jsonl'); + const control = path.join(state.autohandHome, 'stream-control.json'); + const command = async (value: object) => { await writeFile(`${control}.pending`, JSON.stringify(value)); await rename(`${control}.pending`, control); }; + const records = async (): Promise[]> => (await readFile(metadata, 'utf8')).trim().split('\n').map(line => JSON.parse(line)); + session = await launchTuistorySession({ command: 'bun', + args: [path.resolve('src/testing/scenarios/resizeFlickerScenario.tsx'), '--full-screen', '--streaming', '--multiline'], + cwd: process.cwd(), cols: 100, rows: 30, waitForDataTimeout: 15_000, + env: { ...process.env, AUTOHAND_HOME: state.autohandHome, CI: 'false', FORCE_COLOR: '0', + AUTOHAND_RESIZE_WRITE_METADATA: metadata, AUTOHAND_RESIZE_STREAM_CONTROL: control }, + }); + await session.waitForText('RESIZE_STREAM'); + await vi.waitFor(async () => { expect((await records()).some(record => record.kind === 'stream-chunk')).toBe(true); }); + const before = (await records()).filter(record => record.kind === 'stream-chunk').at(-1)!; + await command({ id: 1, action: 'observe', counter: before.counter, columns: 100, rows: 30 }); + await vi.waitFor(async () => { + expect((await records()).some(record => record.kind === 'stream-chunk' && Number(record.counter) > Number(before.counter))).toBe(true); + expect((await records()).some(record => record.kind === 'stream-gate' && record.paused === true)).toBe(false); + }); + session.resize({ cols: 90, rows: 28 }); + let paused = 0; + await vi.waitFor(async () => { + const ack = (await records()).filter(record => record.kind === 'stream-gate' && record.command === 1 && record.paused === true).at(-1); + expect(ack).toMatchObject({ columns: 90, rows: 28 }); + paused = Number(ack?.counter); + expect(paused).toBeGreaterThan(Number(before.counter)); + }); + await session.press('left'); + await session.press('left'); + await session.type('X'); + const frame = await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => screen.includes('taXil') && screen.includes(`RESIZE_STREAM ${paused} retained streamed text`) }); + expect(frame).toContain('Resize streaming fixture'); + expect(frame.match(/Autohand Code/gu)).toHaveLength(1); + expect(frame.match(/RESIZE_HISTORY retained text/gu)).toHaveLength(1); + expect(frame.match(/preserved draft/gu)).toHaveLength(1); + expect((await records()).filter(record => record.kind === 'stream-chunk').at(-1)?.counter).toBe(paused); + session.resize({ cols: 110, rows: 32 }); + const intermediate = paused; + await vi.waitFor(async () => { + const ack = (await records()).filter(record => record.kind === 'stream-gate' && record.command === 1 && record.paused === true).at(-1); + expect(ack).toMatchObject({ columns: 110, rows: 32 }); + paused = Number(ack?.counter); + expect(paused).toBeGreaterThan(intermediate); + }); + await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => screen.includes('taXil') && screen.includes(`RESIZE_STREAM ${paused} retained streamed text`) }); + await vi.waitFor(async () => { + const snapshot = await readFile(`${control}.state.json`, 'utf8'); + expect(Buffer.byteLength(snapshot)).toBeLessThanOrEqual(512); + expect(JSON.parse(snapshot)).toMatchObject({ kind: 'stream-state', command: 1, emittedCommand: 1, + counter: paused, columns: 110, rows: 32, paused: true }); + }); + await command({ id: 2, action: 'resume', counter: paused, columns: 110, rows: 32 }); + await vi.waitFor(async () => { + expect((await records()).some(record => record.kind === 'stream-chunk' && record.command === 2 && Number(record.counter) > paused)).toBe(true); + const snapshot: Record = JSON.parse(await readFile(`${control}.state.json`, 'utf8')); + expect(snapshot).toMatchObject({ command: 2, emittedCommand: 2, columns: 110, rows: 32, paused: false }); + expect(Number(snapshot.counter)).toBeGreaterThan(paused); + }); + await session.press('escape'); + await session.waitForText('RESIZE_STREAM_COMPLETE'); + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + }); + + it('records bounded write timing and viewport metadata without storing terminal content', async () => { + state = await createTempAutohandHome(); + const metadata = path.join(state.autohandHome, 'write-metadata.jsonl'); + session = await launchTuistorySession({ + command: 'bun', + args: [path.resolve('src/testing/scenarios/resizeFlickerScenario.tsx'), '--full-screen', '--streaming', '--multiline'], + cwd: process.cwd(), cols: 100, rows: 30, waitForDataTimeout: 15_000, + env: { ...process.env, AUTOHAND_HOME: state.autohandHome, CI: 'false', FORCE_COLOR: '0', + AUTOHAND_RESIZE_WRITE_METADATA: metadata }, + }); + const initial = await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => /RESIZE_STREAM [1-9]\d*/u.test(screen) }); + const initialCounter = Number(initial.match(/RESIZE_STREAM (\d+)/u)?.[1]); + session.resize({ cols: 90, rows: 28 }); + await vi.waitFor(async () => { + const records: unknown[] = (await readFile(metadata, 'utf8')).trim().split('\n').map(line => JSON.parse(line)); + expect(records).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'write-start', columns: 90, rows: 28, sequence: expect.any(Number), + timestamp: expect.any(Number), streamed: expect.any(Number), bytes: expect.any(Number) }), + expect.objectContaining({ kind: 'write-return', sequence: expect.any(Number), + timestamp: expect.any(Number), backpressured: expect.any(Boolean) }), + expect.objectContaining({ kind: 'complete', sequence: expect.any(Number), timestamp: expect.any(Number) }), + ])); + expect(records.every(record => typeof record === 'object' && record !== null && !('output' in record))).toBe(true); + expect(Buffer.byteLength(await readFile(metadata, 'utf8'))).toBeLessThanOrEqual(1024 * 1024); + }, { timeout: 5_000 }); + await waitForTerminalScreen(session, { timeout: 5_000, + waitFor: screen => Number(screen.match(/RESIZE_STREAM (\d+)/u)?.[1]) > initialCounter }); + await session.press('left'); + await session.press('left'); + await session.type('X'); + const edited = await waitForTerminalScreen(session, { timeout: 5_000, waitFor: screen => screen.includes('taXil') }); + expect(edited.match(/Autohand Code/gu)).toHaveLength(1); + expect(edited.match(/RESIZE_HISTORY retained text/gu)).toHaveLength(1); + expect(edited.match(/preserved draft/gu)).toHaveLength(1); + await session.press('escape'); + await session.waitForText('RESIZE_STREAM_COMPLETE'); + await exitInteractive(session); + expect(session.exitInfo?.exitCode).toBe(0); + expect(session.getRawOutput()).toContain('RESIZE_PTY_EXIT'); + }); + it('clips older history at the native 57x19 viewport and restores it when the streaming multiline viewport grows', async () => { state = await createTempAutohandHome(); session = await launchTuistorySession({ diff --git a/tests/tuistory/settings-sync.tuistory.test.ts b/tests/tuistory/settings-sync.tuistory.test.ts index 5e3a458b..5c32c767 100644 --- a/tests/tuistory/settings-sync.tuistory.test.ts +++ b/tests/tuistory/settings-sync.tuistory.test.ts @@ -96,7 +96,16 @@ describe('built settings sync health', () => { timeout: 5000, waitFor: screen => screen.includes('Completed in') && screen.includes('Plan, search, build anything'), }); - await session.type(command); await session.press('enter'); + await session.type(command); + // Keep Enter separate from command text when the CLI reader is descheduled. + await waitForTerminalScreen(session, { + timeout: 5000, + waitFor: screen => screen.split('\n') + .map(line => line.trim()) + .filter(line => line.startsWith('❯ ')) + .at(-1) === `❯ ${command}`, + }); + await session.press('enter'); await session.waitForText(command === '/status' ? 'Settings:' : 'Settings Sync:'); await session.waitForText('Download failed: 404'); await delay(3000);