From c2c4a1c6d457deb3b7896a5789afceac3d0ed286 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 20:56:04 +1300 Subject: [PATCH 1/9] Read VS Code terminal content through focused native clipboard copies Require a fresh pasteboard generation after focusing the existing terminal and preserve the copied text unchanged. Remove the stale accessibility content adapter while retaining bounded diagnostics and every terminal behavior assertion. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 2 +- src/testing/drivers/native-terminal-driver.ts | 8 +- .../TerminalCompatibilityUITests.swift | 273 +++--------------- tests/testing/nativeTerminal.test.ts | 10 +- 4 files changed, 53 insertions(+), 240 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 8ae1b4b1..828d7ece 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -43,7 +43,7 @@ VS Code uses separate disposable profiles for idle and streaming cases under a s 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. 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. 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/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index 0e30519a..d8340452 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -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', + ], }, }])); } diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 5cfc0909..7741e099 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,9 @@ 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 struct NativeTerminalReadout { @@ -155,86 +89,12 @@ private func nativeTerminalInputReady(terminal: String, textViewExists: Bool, sp terminal == "vscode" ? splitTerminalEnabled : textViewExists } -private func nativeReadoutUsesCopiedBuffer(terminal: String, accessibilityHasFixture: Bool, copiedBufferHasFixture: Bool) -> Bool { - terminal == "vscode" && !accessibilityHasFixture && copiedBufferHasFixture -} - final class TerminalCompatibilityUITests: XCTestCase { private var environment: [String: String] { ProcessInfo.processInfo.environment } @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 testNativeViewportDoesNotMoveAWindowWhoseWidthIgnoredTheShrink() { let bounds = CGRect(x: 0, y: 0, width: 1024, height: 768) let target = CGRect(x: 20, y: 33, width: 904, height: 610) @@ -261,6 +121,24 @@ 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 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 +210,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 +228,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 +267,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", @@ -569,8 +388,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,20 +398,8 @@ 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") } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: ready, object: app)], timeout: 30) == .completed else { @@ -608,9 +415,8 @@ 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) @@ -629,7 +435,7 @@ 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) + var readout = NativeTerminalReadout(read: { readTerminal() }, requiresStreaming: streaming) let settled = NSPredicate { _, _ in readout.capture() } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: window)], timeout: 10) == .completed, let content = readout.frame else { @@ -653,14 +459,14 @@ 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 } + let edited = NSPredicate { _, _ in readTerminal()?.contains("taXil") == true } expectation(for: edited, evaluatedWith: app) waitForExpectations(timeout: 10) app.typeKey(.escape, modifierFlags: []) app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) - let exit = NSPredicate { _, _ in app.state == .notRunning || readTerminal(useCopiedBuffer)?.contains("RESIZE_PTY_EXIT") == true } + let exit = NSPredicate { _, _ in app.state == .notRunning || readTerminal()?.contains("RESIZE_PTY_EXIT") == true } expectation(for: exit, evaluatedWith: app) waitForExpectations(timeout: 15) print(streaming ? "NATIVE_TERMINAL_STREAMING_PASS" : "NATIVE_TERMINAL_IDLE_PASS") @@ -794,17 +600,12 @@ 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) guard nativeFixtureWindow(visibleNativeWindows(processIdentifier: identity.processIdentifier), identity: identity) == native else { return nil } return value } @@ -814,17 +615,21 @@ final class TerminalCompatibilityUITests: XCTestCase { pasteboard.clearContents() let generation = pasteboard.changeCount window.typeKey("y", modifierFlags: [.control, .shift]) - let written = NSPredicate { _, _ in pasteboard.changeCount > generation && pasteboard.string(forType: .string) != nil } + let written = NSPredicate { _, _ in + nativeCopiedTerminalBuffer(baseline: generation, observed: pasteboard.changeCount, + value: 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 { + let value = nativeCopiedTerminalBuffer(baseline: generation, observed: pasteboard.changeCount, + value: pasteboard.string(forType: .string)) 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)") 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/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts index 19bba4d9..89cbf816 100644 --- a/tests/testing/nativeTerminal.test.ts +++ b/tests/testing/nativeTerminal.test.ts @@ -41,7 +41,7 @@ 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.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 +52,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')); From c42e2161b201b17d137e7a91eda158c4c6926b2b Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 21:48:32 +1300 Subject: [PATCH 2/9] Capture bounded native terminal copy and write timing diagnostics Persist capped copy observations and asynchronous fixture metadata with faithful payload, encoding, callback, and backpressure behavior. Retain late events and overflow counts without changing native commands, predicates, cadence, or deadlines. Co-authored-by: Autohand Evolve --- .github/workflows/terminal-compatibility.yml | 5 +- docs/terminal-compatibility.md | 2 + src/testing/drivers/native-terminal-driver.ts | 2 +- .../TerminalCompatibilityUITests.swift | 117 +++++++++++++++- .../scenarios/resizeFlickerScenario.tsx | 24 +++- src/testing/scenarios/resizeWriteMetadata.ts | 98 +++++++++++++ tests/testing/nativeTerminal.test.ts | 13 ++ tests/testing/resizeWriteMetadata.test.ts | 130 ++++++++++++++++++ .../tuistory/resize-flicker.tuistory.test.ts | 43 ++++++ 9 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 src/testing/scenarios/resizeWriteMetadata.ts create mode 100644 tests/testing/resizeWriteMetadata.test.ts diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml index 4bd234fa..86de111f 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 - 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,9 @@ 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/**/vscode-profile/**/settings.json .artifacts/native-terminal/**/vscode-profile/**/keybindings.json if-no-files-found: error diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 828d7ece..c43ef11a 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -45,6 +45,8 @@ Native scenarios first size and position their fixture window within the reporte 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. 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. + 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. Native CI availability and per-application results must be assessed from that workflow's completed jobs. A passing PTY suite or a successful XCTest build alone does not prove a native application passed. Failures in app launch, accessibility access, or window resizing fail the native job and preserve its XCTest evidence; they are not silently converted to headless passes. diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index d8340452..488d47a6 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -20,7 +20,7 @@ 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\nexec ${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 = { diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 7741e099..1ed400f7 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -36,6 +36,44 @@ private func nativeCopiedTerminalBuffer(baseline: Int, observed: Int, value: Str return 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 NativeTerminalReadout { let read: () -> String? let requiresStreaming: Bool @@ -91,6 +129,9 @@ private func nativeTerminalInputReady(terminal: String, textViewExists: Bool, sp 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) } @@ -128,6 +169,37 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertNil(nativeCopiedTerminalBuffer(baseline: 18, observed: 19, value: nil)) } + 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) @@ -278,6 +350,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) @@ -422,6 +507,7 @@ final class TerminalCompatibilityUITests: XCTestCase { 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 @@ -459,11 +545,13 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) app.typeKey("x", modifierFlags: .shift) + copyPhase = "cursor" let edited = NSPredicate { _, _ in readTerminal()?.contains("taXil") == true } expectation(for: edited, evaluatedWith: 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()?.contains("RESIZE_PTY_EXIT") == true } @@ -605,16 +693,39 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTFail("VS Code changed the fixture's native window ID, process, or window count") return nil } - let value = copiedTerminalValue(app) + 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 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]) + keyFinished = Date().timeIntervalSince1970 let written = NSPredicate { _, _ in nativeCopiedTerminalBuffer(baseline: generation, observed: pasteboard.changeCount, value: pasteboard.string(forType: .string)) != nil @@ -626,6 +737,8 @@ final class TerminalCompatibilityUITests: XCTestCase { return nil } print("NATIVE_TERMINAL_COPY_FRESH baseline=\(generation) observed=\(pasteboard.changeCount) bytes=\(value.utf8.count)") + copied = value + available = true return value } diff --git a/src/testing/scenarios/resizeFlickerScenario.tsx b/src/testing/scenarios/resizeFlickerScenario.tsx index ee240a02..608a8e90 100644 --- a/src/testing/scenarios/resizeFlickerScenario.tsx +++ b/src/testing/scenarios/resizeFlickerScenario.tsx @@ -1,24 +1,35 @@ /** @license Apache-2.0 */ import { appendFileSync } from 'node:fs'; +import { createResizeWriteMetadata, observeResizeWrite, resizeWritePayload } from './resizeWriteMetadata.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 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,7 +43,6 @@ 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: () => {}, @@ -44,7 +54,9 @@ const renderer = new InkRenderer({ onCtrlC: () => { clearInterval(streamTimer); void renderer.stop().then(() => { - process.stdout.write('\nRESIZE_PTY_EXIT\n', () => process.exit(0)); + process.stdout.write('\nRESIZE_PTY_EXIT\n', () => { + void (writeMetadata?.flush() ?? Promise.resolve()).then(() => process.exit(0)); + }); }); }, }); 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/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts index 89cbf816..215eeac1 100644 --- a/tests/testing/nativeTerminal.test.ts +++ b/tests/testing/nativeTerminal.test.ts @@ -41,6 +41,19 @@ function preparedLaunchArguments(): Record { } describe('native terminal automation boundary', () => { + 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'); + }); + 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'); 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/resize-flicker.tuistory.test.ts b/tests/tuistory/resize-flicker.tuistory.test.ts index caefb8c2..1fff41cc 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 } 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,48 @@ let state: TuistoryTempState | undefined; afterEach(async () => { session?.close(); await state?.cleanup(); session = undefined; state = undefined; }); describe('terminal resize frames', () => { + 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({ From 69c88df546f204a9878f7f2d318ce144be924fc3 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 22:27:37 +1300 Subject: [PATCH 3/9] Coordinate streamed resize observation within the native settling deadline Keep response chunks active through the physical resize, then gate only new fixture updates for a fresh raw terminal copy. Require post-resize progress and resumed output within the original ten-second budget while preserving transcript, draft, cursor, window, and exit assertions. Co-authored-by: Autohand Evolve --- .github/workflows/terminal-compatibility.yml | 3 +- docs/terminal-compatibility.md | 2 + src/testing/drivers/native-terminal-driver.ts | 2 +- .../TerminalCompatibilityUITests.swift | 148 +++++++++++++++++- .../scenarios/resizeFlickerScenario.tsx | 12 +- src/testing/scenarios/resizeStreamGate.ts | 82 ++++++++++ tests/testing/nativeTerminal.test.ts | 1 + tests/testing/resizeStreamGate.test.ts | 75 +++++++++ .../tuistory/resize-flicker.tuistory.test.ts | 50 +++++- 9 files changed, 365 insertions(+), 10 deletions(-) create mode 100644 src/testing/scenarios/resizeStreamGate.ts create mode 100644 tests/testing/resizeStreamGate.test.ts diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml index 86de111f..eaf92f5b 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 tests/testing/resizeWriteMetadata.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 @@ -123,6 +123,7 @@ jobs: .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/terminal-compatibility.md b/docs/terminal-compatibility.md index c43ef11a..9884d382 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -47,6 +47,8 @@ VS Code's authoritative readout uses its native terminal buffer copy. Hosted rec 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. The request and working spinner stay active; no renderer operation is triggered. A fresh native copy must contain that exact paused counter and all original markers before unchanged exact-count assertions run. Resuming and proving a newer chunk at the same viewport share the original single 10-second settling budget with acknowledgement and copying, including after the final resize. Asynchronous control reads are single-flight and closed on every path; disposal suppresses late work. Native metadata reads are capped at 16 KiB. Unused Code predicate objects are omitted to avoid implicit accessibility debug snapshots during polling; explicit failure evidence remains. + 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. Native CI availability and per-application results must be assessed from that workflow's completed jobs. A passing PTY suite or a successful XCTest build alone does not prove a native application passed. Failures in app launch, accessibility access, or window resizing fail the native job and preserve its XCTest evidence; they are not silently converted to headless passes. diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index 488d47a6..2dbda2d4 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -20,7 +20,7 @@ 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\nif [ "\${1-}" = --streaming ]; then fixture_mode=streaming; else fixture_mode=idle; fi\nexport AUTOHAND_RESIZE_WRITE_METADATA=${quote(output)}/write-metadata-\${fixture_mode}.jsonl\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 = { diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 1ed400f7..ca3f32ff 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -74,6 +74,54 @@ private struct NativeCopySample: Encodable { let frame: CGRect } +private struct NativeStreamPosition: Codable { + let kind: String + var command: 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] { + String(decoding: data, as: UTF8.self).split(separator: "\n").compactMap { line in + guard let position = try? JSONDecoder().decode(NativeStreamPosition.self, from: Data(line.utf8)), + position.kind == "stream-chunk" || position.kind == "stream-gate", + let counter = position.counter, counter > 0, + let columns = position.columns, columns > 0, columns <= 10000, + let rows = position.rows, rows > 0, rows <= 10000 else { return nil } + return position + } +} + +private func nativeStreamPaused(_ positions: [NativeStreamPosition], command: Int, before: NativeStreamPosition) -> NativeStreamPosition? { + positions.last { position in + position.kind == "stream-gate" && position.command == command && position.paused == true + && (position.counter ?? 0) > (before.counter ?? 0) + && (position.columns != before.columns || position.rows != before.rows) + } +} + +private func nativeStreamResumed(_ positions: [NativeStreamPosition], command: Int, after: NativeStreamPosition) -> Bool { + positions.contains { position in + position.kind == "stream-chunk" && position.command == 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 { let read: () -> String? let requiresStreaming: Bool @@ -84,9 +132,10 @@ private struct NativeTerminalReadout { self.requiresStreaming = requiresStreaming } - mutating func capture() -> Bool { + mutating func capture(streamCounter: Int? = nil) -> Bool { frame = read() guard let value = frame else { return false } + if let 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 ")) } @@ -169,6 +218,37 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertNil(nativeCopiedTerminalBuffer(baseline: 18, observed: 19, value: nil)) } + func testNativeReadoutGateRequiresViewportProgressAndANewerResumedChunk() { + let before = NativeStreamPosition(kind: "stream-chunk", command: 0, counter: 10, columns: 100, rows: 30, paused: false) + let unchanged = NativeStreamPosition(kind: "stream-gate", command: 1, counter: 11, columns: 100, rows: 30, paused: true) + let stale = NativeStreamPosition(kind: "stream-gate", command: 1, counter: 10, columns: 90, rows: 28, paused: true) + let paused = NativeStreamPosition(kind: "stream-gate", command: 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-chunk", command: 2, counter: 13, columns: 90, rows: 28, paused: false) + XCTAssertTrue(nativeStreamResumed([resumed], command: 2, after: paused)) + } + + 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 testNativeReadoutGateParsesOnlyCompleteValidMetadataTailRecords() { + let tail = Data("partial row\n{\"kind\":\"write-start\",\"columns\":90,\"rows\":28}\n{\"kind\":\"stream-chunk\",\"command\":2,\"counter\":13,\"columns\":90,\"rows\":28,\"paused\":false}\n".utf8) + let positions = nativeStreamPositions(tail) + XCTAssertEqual(positions.count, 1) + XCTAssertEqual(positions.first?.counter, 13) + } + func testNativeReadoutEvidenceRetainsPartialUnavailableAndCompleteSamplesUnchanged() throws { var evidence = NativeCopyEvidence() let partial = "RESIZE_STREAM 400\r\n" @@ -375,6 +455,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 streamMetadata = fixtureDirectory.appendingPathComponent("write-metadata-streaming.jsonl") + let streamControl = fixtureDirectory.appendingPathComponent("stream-control-streaming.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") @@ -486,8 +570,9 @@ final class TerminalCompatibilityUITests: XCTestCase { let ready = NSPredicate { _, _ in startupValue = readTerminal() ?? "" return startupValue.contains("RESIZE_HISTORY retained text") + && (!gated || self.streamPositions(streamMetadata).contains { $0.kind == "stream-chunk" }) } - 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 @@ -514,6 +599,11 @@ final class TerminalCompatibilityUITests: XCTestCase { } let frame = window.frame let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) + let streamBefore = gated ? streamPositions(streamMetadata).last { $0.kind == "stream-chunk" } : 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 } @@ -521,9 +611,19 @@ 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") + 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 readout.capture() } - guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: window)], timeout: 10) == .completed, + let settled = NSPredicate { _, _ in + if paused == nil, let before = streamBefore { + guard let position = nativeStreamPaused(self.streamPositions(streamMetadata), command: index * 2 + 1, before: before) else { return false } + paused = position + } + return readout.capture(streamCounter: paused?.counter) + } + 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" @@ -541,13 +641,25 @@ final class TerminalCompatibilityUITests: XCTestCase { image.name = "\(terminal)-\(streaming ? "streaming" : "idle")-\(index)" image.lifetime = .keepAlways add(image) + if let paused { + guard writeStreamCommand(streamControl, id: index * 2 + 2, action: "resume", position: paused) else { return } + let resumed = NSPredicate { _, _ in + nativeStreamResumed(self.streamPositions(streamMetadata), 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 + } + } } app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) app.typeKey("x", modifierFlags: .shift) copyPhase = "cursor" let edited = NSPredicate { _, _ in readTerminal()?.contains("taXil") == true } - expectation(for: edited, evaluatedWith: app) + expectation(for: edited, evaluatedWith: terminal == "vscode" ? nil : app) waitForExpectations(timeout: 10) app.typeKey(.escape, modifierFlags: []) app.typeKey("c", modifierFlags: .control) @@ -555,7 +667,7 @@ final class TerminalCompatibilityUITests: XCTestCase { app.typeKey("c", modifierFlags: .control) app.typeKey("c", modifierFlags: .control) let exit = NSPredicate { _, _ in app.state == .notRunning || readTerminal()?.contains("RESIZE_PTY_EXIT") == true } - expectation(for: exit, evaluatedWith: app) + expectation(for: exit, evaluatedWith: terminal == "vscode" ? nil : app) waitForExpectations(timeout: 15) print(streaming ? "NATIVE_TERMINAL_STREAMING_PASS" : "NATIVE_TERMINAL_IDLE_PASS") } @@ -698,6 +810,30 @@ final class TerminalCompatibilityUITests: XCTestCase { return value } + private func streamPositions(_ file: URL) -> [NativeStreamPosition] { + guard let handle = try? FileHandle(forReadingFrom: file) else { return [] } + defer { try? handle.close() } + guard let end = try? handle.seekToEnd(), (try? handle.seek(toOffset: end > 16_384 ? end - 16_384 : 0)) != nil, + let data = try? handle.read(upToCount: 16_384) 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 diff --git a/src/testing/scenarios/resizeFlickerScenario.tsx b/src/testing/scenarios/resizeFlickerScenario.tsx index 608a8e90..c7b8c17f 100644 --- a/src/testing/scenarios/resizeFlickerScenario.tsx +++ b/src/testing/scenarios/resizeFlickerScenario.tsx @@ -1,6 +1,7 @@ /** @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'); @@ -9,6 +10,10 @@ 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'); let streamed = 0; if (tracePath || metadataPath || viewportMarkers) { @@ -48,12 +53,14 @@ const renderer = new InkRenderer({ onInstruction: () => {}, onEscape: () => { clearInterval(streamTimer); + void streamGate?.dispose(); renderer.setWorking(false); renderer.setFinalResponse(`RESIZE_STREAM_COMPLETE ${streamed}`); }, onCtrlC: () => { clearInterval(streamTimer); - void renderer.stop().then(() => { + void renderer.stop().then(async () => { + await streamGate?.dispose(); process.stdout.write('\nRESIZE_PTY_EXIT\n', () => { void (writeMetadata?.flush() ?? Promise.resolve()).then(() => process.exit(0)); }); @@ -70,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..d5681545 --- /dev/null +++ b/src/testing/scenarios/resizeStreamGate.ts @@ -0,0 +1,82 @@ +/** @license Apache-2.0 */ +import { open } from 'node:fs/promises'; + +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; + + constructor(file: string, private readonly record: (value: object) => void, + private readonly read: () => Promise = () => readCommand(file)) {} + + 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; + }).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 && 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 }); + } + 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 }); + } + + async dispose(): Promise { + this.disposed = true; + if (this.pending) await this.pending; + } +} diff --git a/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts index 215eeac1..546485b5 100644 --- a/tests/testing/nativeTerminal.test.ts +++ b/tests/testing/nativeTerminal.test.ts @@ -52,6 +52,7 @@ describe('native terminal automation boundary', () => { 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 => { diff --git a/tests/testing/resizeStreamGate.test.ts b/tests/testing/resizeStreamGate.test.ts new file mode 100644 index 00000000..906cc397 --- /dev/null +++ b/tests/testing/resizeStreamGate.test.ts @@ -0,0 +1,75 @@ +/** @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('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 })); + 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); + 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); + 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); + await gate.refresh(); + expect(() => gate.permits({ columns: 100, rows: 30 })).toThrow('Invalid resize stream command'); + await gate.dispose(); + }); +}); diff --git a/tests/tuistory/resize-flicker.tuistory.test.ts b/tests/tuistory/resize-flicker.tuistory.test.ts index 1fff41cc..1b50c1e7 100644 --- a/tests/tuistory/resize-flicker.tuistory.test.ts +++ b/tests/tuistory/resize-flicker.tuistory.test.ts @@ -1,6 +1,6 @@ /** @license Apache-2.0 */ import path from 'node:path'; -import { readFile } from 'node:fs/promises'; +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'; @@ -15,6 +15,54 @@ 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); + await command({ id: 2, action: 'resume', counter: paused, columns: 90, rows: 28 }); + await vi.waitFor(async () => { + expect((await records()).some(record => record.kind === 'stream-chunk' && record.command === 2 && Number(record.counter) > paused)).toBe(true); + }); + 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'); From 81c1812bebe800aa6e6339a8e20bb287e80c1692 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 21:52:17 +1300 Subject: [PATCH 4/9] Preserve composer input across asynchronous command completion Consume submitted input at its existing synchronous boundary and remove obsolete late clears that erased newer drafts or prefills. Verify controlled completion in both TUI layouts and separate fully rendered command drafts from Enter in terminal tests. Co-authored-by: Autohand Evolve --- docs/extension-authoring.md | 2 + src/core/agent/AgentCommandRuntime.ts | 7 -- src/core/agent/AgentLifecycleRunner.ts | 7 +- .../AgentCommandRuntime.slashParsing.test.ts | 17 +++++ .../agent/FeedbackSurveyLifecycle.test.ts | 31 +++++++- tests/tuistory/extensions.tuistory.test.ts | 70 ++++++++++++++++++- tests/tuistory/settings-sync.tuistory.test.ts | 11 ++- 7 files changed, 129 insertions(+), 16 deletions(-) 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/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/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/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/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); From 31c38ad2476ec96a885707aa82e3f73f621c5ba2 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 22:28:41 +1300 Subject: [PATCH 5/9] Document composer ownership in the combined CLI integration Record how asynchronous command completion preserves newer composer drafts alongside the existing terminal, provider, sync, and session improvements. Co-authored-by: Autohand Evolve --- docs/maintenance/cli-pr-integration-2026-11-10.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) | From cc24d92bda6f523e469eb3efe07ff881cdb48380 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 22:55:37 +1300 Subject: [PATCH 6/9] Track late terminal reflow with authoritative observation state Refresh the paused producer checkpoint when a later TTY size arrives. Verify a bounded atomic state snapshot around native copying and require an emitted post-resume chunk, keeping the original settling deadline and raw screen assertions. Co-authored-by: Autohand Evolve --- .github/workflows/terminal-compatibility.yml | 2 +- docs/terminal-compatibility.md | 2 +- .../TerminalCompatibilityUITests.swift | 122 ++++++++++++------ src/testing/scenarios/resizeStreamGate.ts | 29 ++++- tests/testing/resizeStreamGate.test.ts | 26 +++- .../tuistory/resize-flicker.tuistory.test.ts | 21 ++- 6 files changed, 152 insertions(+), 50 deletions(-) diff --git a/.github/workflows/terminal-compatibility.yml b/.github/workflows/terminal-compatibility.yml index eaf92f5b..78fa2723 100644 --- a/.github/workflows/terminal-compatibility.yml +++ b/.github/workflows/terminal-compatibility.yml @@ -123,7 +123,7 @@ jobs: .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/**/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/terminal-compatibility.md b/docs/terminal-compatibility.md index 9884d382..a18d1052 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -47,7 +47,7 @@ VS Code's authoritative readout uses its native terminal buffer copy. Hosted rec 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. The request and working spinner stay active; no renderer operation is triggered. A fresh native copy must contain that exact paused counter and all original markers before unchanged exact-count assertions run. Resuming and proving a newer chunk at the same viewport share the original single 10-second settling budget with acknowledgement and copying, including after the final resize. Asynchronous control reads are single-flight and closed on every path; disposal suppresses late work. Native metadata reads are capped at 16 KiB. Unused Code predicate objects are omitted to avoid implicit accessibility debug snapshots during polling; explicit failure evidence remains. +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. 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/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index ca3f32ff..1d93859d 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -74,9 +74,11 @@ private struct NativeCopySample: Encodable { let frame: CGRect } -private struct NativeStreamPosition: Codable { +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 @@ -92,30 +94,29 @@ private struct NativeStreamCommand: Encodable { } private func nativeStreamPositions(_ data: Data) -> [NativeStreamPosition] { - String(decoding: data, as: UTF8.self).split(separator: "\n").compactMap { line in - guard let position = try? JSONDecoder().decode(NativeStreamPosition.self, from: Data(line.utf8)), - position.kind == "stream-chunk" || position.kind == "stream-gate", - let counter = position.counter, counter > 0, - let columns = position.columns, columns > 0, columns <= 10000, - let rows = position.rows, rows > 0, rows <= 10000 else { return nil } - return position - } + 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? { - positions.last { position in - position.kind == "stream-gate" && position.command == command && position.paused == true - && (position.counter ?? 0) > (before.counter ?? 0) - && (position.columns != before.columns || position.rows != before.rows) - } + 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 { - positions.contains { position in - position.kind == "stream-chunk" && position.command == command && position.paused == false - && (position.counter ?? 0) > (after.counter ?? 0) - && position.columns == after.columns && position.rows == after.rows - } + 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 { @@ -132,10 +133,12 @@ private struct NativeTerminalReadout { self.requiresStreaming = requiresStreaming } - mutating func capture(streamCounter: Int? = nil) -> 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 = streamCounter, !value.contains("RESIZE_STREAM \(counter) retained streamed text") { 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 ")) } @@ -219,17 +222,45 @@ final class TerminalCompatibilityUITests: XCTestCase { } func testNativeReadoutGateRequiresViewportProgressAndANewerResumedChunk() { - let before = NativeStreamPosition(kind: "stream-chunk", command: 0, counter: 10, columns: 100, rows: 30, paused: false) - let unchanged = NativeStreamPosition(kind: "stream-gate", command: 1, counter: 11, columns: 100, rows: 30, paused: true) - let stale = NativeStreamPosition(kind: "stream-gate", command: 1, counter: 10, columns: 90, rows: 28, paused: true) - let paused = NativeStreamPosition(kind: "stream-gate", command: 1, counter: 12, columns: 90, rows: 28, paused: true) + 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-chunk", command: 2, counter: 13, columns: 90, rows: 28, paused: false) + 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) @@ -242,11 +273,13 @@ final class TerminalCompatibilityUITests: XCTestCase { XCTAssertEqual(nativeSettlingTime(deadline, now: Date(timeIntervalSince1970: 101)), 0) } - func testNativeReadoutGateParsesOnlyCompleteValidMetadataTailRecords() { - let tail = Data("partial row\n{\"kind\":\"write-start\",\"columns\":90,\"rows\":28}\n{\"kind\":\"stream-chunk\",\"command\":2,\"counter\":13,\"columns\":90,\"rows\":28,\"paused\":false}\n".utf8) + 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 { @@ -457,8 +490,8 @@ final class TerminalCompatibilityUITests: XCTestCase { let command = "'" + fixture.replacingOccurrences(of: "'", with: "'\\''") + "'" + (streaming ? " --streaming" : "") let gated = terminal == "vscode" && streaming let fixtureDirectory = URL(fileURLWithPath: fixture).deletingLastPathComponent() - let streamMetadata = fixtureDirectory.appendingPathComponent("write-metadata-streaming.jsonl") 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") @@ -570,7 +603,7 @@ final class TerminalCompatibilityUITests: XCTestCase { let ready = NSPredicate { _, _ in startupValue = readTerminal() ?? "" return startupValue.contains("RESIZE_HISTORY retained text") - && (!gated || self.streamPositions(streamMetadata).contains { $0.kind == "stream-chunk" }) + && (!gated || !self.streamPositions(streamState).isEmpty) } 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))) @@ -599,7 +632,7 @@ final class TerminalCompatibilityUITests: XCTestCase { } let frame = window.frame let corner = window.coordinate(withNormalizedOffset: CGVector(dx: 1, dy: 1)).withOffset(CGVector(dx: -2, dy: -2)) - let streamBefore = gated ? streamPositions(streamMetadata).last { $0.kind == "stream-chunk" } : nil + let streamBefore = gated ? streamPositions(streamState).last : nil guard !gated || streamBefore != nil else { XCTFail("The streaming fixture did not publish its pre-gesture position") return @@ -616,11 +649,15 @@ final class TerminalCompatibilityUITests: XCTestCase { var paused: NativeStreamPosition? var readout = NativeTerminalReadout(read: { readTerminal() }, requiresStreaming: streaming) let settled = NSPredicate { _, _ in - if paused == nil, let before = streamBefore { - guard let position = nativeStreamPaused(self.streamPositions(streamMetadata), command: index * 2 + 1, before: before) else { return false } + 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(streamCounter: paused?.counter) + return readout.capture() } guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: settled, object: terminal == "vscode" ? nil : window)], timeout: gated ? nativeSettlingTime(settlingDeadline) : 10) == .completed, @@ -637,14 +674,10 @@ 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) } - let image = XCTAttachment(screenshot: window.screenshot()) - image.name = "\(terminal)-\(streaming ? "streaming" : "idle")-\(index)" - image.lifetime = .keepAlways - add(image) if let paused { guard writeStreamCommand(streamControl, id: index * 2 + 2, action: "resume", position: paused) else { return } let resumed = NSPredicate { _, _ in - nativeStreamResumed(self.streamPositions(streamMetadata), command: index * 2 + 2, after: paused) + nativeStreamResumed(self.streamPositions(streamState), command: index * 2 + 2, after: paused) } let remaining = nativeSettlingTime(settlingDeadline) guard remaining > 0, @@ -653,6 +686,14 @@ final class TerminalCompatibilityUITests: XCTestCase { 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 + add(image) } app.typeKey(.leftArrow, modifierFlags: []) app.typeKey(.leftArrow, modifierFlags: []) @@ -813,8 +854,7 @@ final class TerminalCompatibilityUITests: XCTestCase { private func streamPositions(_ file: URL) -> [NativeStreamPosition] { guard let handle = try? FileHandle(forReadingFrom: file) else { return [] } defer { try? handle.close() } - guard let end = try? handle.seekToEnd(), (try? handle.seek(toOffset: end > 16_384 ? end - 16_384 : 0)) != nil, - let data = try? handle.read(upToCount: 16_384) else { return [] } + guard let data = try? handle.read(upToCount: 513), data.count <= 512 else { return [] } return nativeStreamPositions(data) } diff --git a/src/testing/scenarios/resizeStreamGate.ts b/src/testing/scenarios/resizeStreamGate.ts index d5681545..426ae8ab 100644 --- a/src/testing/scenarios/resizeStreamGate.ts +++ b/src/testing/scenarios/resizeStreamGate.ts @@ -1,5 +1,6 @@ /** @license Apache-2.0 */ -import { open } from 'node:fs/promises'; +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' } @@ -34,9 +35,25 @@ export class ResizeStreamGate { 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)) {} + 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(); @@ -47,6 +64,7 @@ export class ResizeStreamGate { 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; }); @@ -59,11 +77,16 @@ export class ResizeStreamGate { 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; } @@ -73,10 +96,12 @@ export class ResizeStreamGate { 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/tests/testing/resizeStreamGate.test.ts b/tests/testing/resizeStreamGate.test.ts index 906cc397..7be4c1e7 100644 --- a/tests/testing/resizeStreamGate.test.ts +++ b/tests/testing/resizeStreamGate.test.ts @@ -6,8 +6,26 @@ 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 })); + 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); @@ -30,7 +48,7 @@ describe('resize stream observation gate', () => { 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); + 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); @@ -52,7 +70,7 @@ describe('resize stream observation gate', () => { 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); + const gate = new ResizeStreamGate('unused', record, read, async () => {}); const pending = gate.refresh(); void gate.refresh(); expect(read).toHaveBeenCalledTimes(1); @@ -67,7 +85,7 @@ describe('resize stream observation gate', () => { }); 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); + 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/tuistory/resize-flicker.tuistory.test.ts b/tests/tuistory/resize-flicker.tuistory.test.ts index 1b50c1e7..abb0a07a 100644 --- a/tests/tuistory/resize-flicker.tuistory.test.ts +++ b/tests/tuistory/resize-flicker.tuistory.test.ts @@ -53,9 +53,28 @@ describe('terminal resize frames', () => { 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); - await command({ id: 2, action: 'resume', counter: paused, columns: 90, rows: 28 }); + 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'); From e7010af52236f743e196c32384d67bb14cba862a Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 23:12:37 +1300 Subject: [PATCH 7/9] Prepare native Code windows with independent edge resize targets Avoid an ambiguous bottom corner hit on full-width baseline windows. Verify each accepted width and height before moving the same native window, preserving all four scenario resize gestures and existing assertions. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 2 + .../TerminalCompatibilityUITests.swift | 80 ++++++++++++++++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index a18d1052..d5134dbf 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -49,6 +49,8 @@ Native-copy diagnostics retain each attempt's phase, timestamps, pasteboard gene 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. Native CI availability and per-application results must be assessed from that workflow's completed jobs. A passing PTY suite or a successful XCTest build alone does not prove a native application passed. Failures in app launch, accessibility access, or window resizing fail the native job and preserve its XCTest evidence; they are not silently converted to headless passes. diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 1d93859d..3fa473a7 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -175,6 +175,35 @@ private func nativeFixturePreparationFrames(current: CGRect, available: CGRect) return [reduced, CGRect(origin: target.origin, size: reduced.size), target] } +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 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 } @@ -188,6 +217,31 @@ final class TerminalCompatibilityUITests: XCTestCase { @MainActor func testIdleResize() throws { try resizeScenario(streaming: false) } @MainActor func testStreamingResize() throws { try resizeScenario(streaming: true) } + 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() { let bounds = CGRect(x: 0, y: 0, width: 1024, height: 768) let target = CGRect(x: 20, y: 33, width: 904, height: 610) @@ -743,10 +797,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") From 1e4b7136a51dabd740157503e9e2d59cc3082f32 Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 23:26:19 +1300 Subject: [PATCH 8/9] Read fresh native terminal copies before entering the fallback waiter Validate clipboard generation and raw text immediately after the copy command. Preserve the existing two-second fallback and final freshness check for stale or missing data, removing avoidable observation latency without extending the settling deadline. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 2 +- .../TerminalCompatibilityUITests.swift | 67 +++++++++++++++++-- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index d5134dbf..1d29b7c9 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -43,7 +43,7 @@ VS Code uses separate disposable profiles for idle and streaming cases under a s 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 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. 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. +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. diff --git a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift index 3fa473a7..2a32bde4 100644 --- a/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift +++ b/src/testing/drivers/native-terminal-lab/TerminalCompatibilityUITests.swift @@ -36,6 +36,15 @@ private func nativeCopiedTerminalBuffer(baseline: Int, observed: Int, value: Str 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 @@ -275,6 +284,47 @@ final class TerminalCompatibilityUITests: XCTestCase { 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) @@ -974,13 +1024,16 @@ final class TerminalCompatibilityUITests: XCTestCase { } window.typeKey("y", modifierFlags: [.control, .shift]) keyFinished = Date().timeIntervalSince1970 - let written = NSPredicate { _, _ in - nativeCopiedTerminalBuffer(baseline: generation, observed: pasteboard.changeCount, - value: pasteboard.string(forType: .string)) != nil - } - guard XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: written, object: pasteboard)], timeout: 2) == .completed, - let value = nativeCopiedTerminalBuffer(baseline: generation, observed: pasteboard.changeCount, - value: pasteboard.string(forType: .string)) else { + 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 } From 0afefffd7d3dce5133ae258b2e7a47be307065ae Mon Sep 17 00:00:00 2001 From: Igor Costa Date: Tue, 10 Nov 2026 23:40:06 +1300 Subject: [PATCH 9/9] Use a graphical Code profile for native buffer-copy qualification Remove renderer accessibility forcing left over from the replaced AX content reader. Keep the screen-reader row mirror off only in disposable graphical fixtures, retaining native identity, raw-copy assertions and deadlines while documenting the accessibility coverage boundary. Co-authored-by: Autohand Evolve --- docs/terminal-compatibility.md | 2 +- src/testing/drivers/native-terminal-driver.ts | 4 ++-- tests/testing/nativeTerminal.test.ts | 9 +++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/terminal-compatibility.md b/docs/terminal-compatibility.md index 1d29b7c9..d16e30c6 100644 --- a/docs/terminal-compatibility.md +++ b/docs/terminal-compatibility.md @@ -39,7 +39,7 @@ 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. diff --git a/src/testing/drivers/native-terminal-driver.ts b/src/testing/drivers/native-terminal-driver.ts index 2dbda2d4..5f4ce257 100644 --- a/src/testing/drivers/native-terminal-driver.ts +++ b/src/testing/drivers/native-terminal-driver.ts @@ -25,7 +25,7 @@ export async function prepareNativeTerminalTest(): Promise<{ args: string[]; res 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, @@ -57,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/tests/testing/nativeTerminal.test.ts b/tests/testing/nativeTerminal.test.ts index 546485b5..efd0ff1f 100644 --- a/tests/testing/nativeTerminal.test.ts +++ b/tests/testing/nativeTerminal.test.ts @@ -103,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'); @@ -116,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, @@ -126,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')]),