Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions test/e2e/live/issue-6194-tui-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,41 @@ export function readIssue6194Capture(path: string): Issue6194Capture {
}
}

export function buildIssue6194PairExpectProcedure(): string {
return `proc expect_pair_or_exit {firstPattern firstMark secondPattern secondMark firstTimeoutExit firstEofExit secondTimeoutExit secondEofExit} {
set firstSeen 0
set secondSeen 0
expect {
-nocase -re $firstPattern {
if {!$firstSeen} {
mark $firstMark
set firstSeen 1
}
if {$firstSeen && $secondSeen} { return }
exp_continue -continue_timer
}
-nocase -re $secondPattern {
if {!$secondSeen} {
mark $secondMark
set secondSeen 1
}
if {$firstSeen && $secondSeen} { return }
exp_continue -continue_timer
}
timeout {
send "\\003"
if {!$firstSeen} { exit $firstTimeoutExit }
exit $secondTimeoutExit
}
eof {
if {!$firstSeen} { exit $firstEofExit }
exit $secondEofExit
}
}
}
`;
}

export function buildIssue6194TuiExpectScript(): string {
return `set timeout $env(NEMOCLAW_ISSUE_6194_TUI_TIMEOUT)
set sandbox $env(NEMOCLAW_ISSUE_6194_SANDBOX)
Expand All @@ -52,14 +87,13 @@ proc expect_or_exit {pattern markName timeoutExit eofExit} {
eof { exit $eofExit }
}
}
${buildIssue6194PairExpectProcedure()}\
spawn openshell sandbox exec --name $sandbox --tty -- sh -lc "export TERM=xterm-256color; cd /sandbox; openclaw tui --session $session"
expect_or_exit {connected[^\\r\\n]*idle} connected_idle_initial 10 11
send -- "Reply with the three fragments joined by underscores: NEMOCLAW6194, CHAT, OK. Put only that joined token on its own line. Do not use tools.\\r"
expect_or_exit {NEMOCLAW6194_CHAT_OK} chat_reply 20 21
expect_or_exit {connected[^\\r\\n]*idle} connected_idle_after_chat 22 23
expect_pair_or_exit {NEMOCLAW6194_CHAT_OK} chat_reply {connected[^\\r\\n]*idle} connected_idle_after_chat 20 21 22 23
send -- "/nemoclaw status\\r"
expect_or_exit {NemoClaw Status} slash_status_output 30 31
expect_or_exit {connected[^\\r\\n]*idle} connected_idle_after_status 32 33
expect_pair_or_exit {NemoClaw Status} slash_status_output {connected[^\\r\\n]*idle} connected_idle_after_status 30 31 32 33
# Network-rule approvals belong to the separate OpenShell terminal UI. Keep
# this OpenClaw TUI regression scoped to inputs it can perform directly so a
# tool-less hosted model cannot turn assistant prose into a test oracle.
Expand Down
70 changes: 66 additions & 4 deletions test/e2e/support/issue-6194-tui-post-idle-contract.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { SecretStore } from "../fixtures/secrets.ts";
import {
buildIssue6194OpenShellApprovalExpectScript,
buildIssue6194PairExpectProcedure,
buildIssue6194TuiExpectScript,
ISSUE6194_NETWORK_APPROVAL_ENDPOINT,
ISSUE6194_NETWORK_APPROVAL_HOST,
Expand Down Expand Up @@ -63,18 +65,21 @@ describe("live TUI post-idle coverage contract (#6194)", () => {
expect(script).toContain('puts "ISSUE6194_MARK $name"');
expect(script).toContain('send_log "ISSUE6194_MARK $name\\n"');
expect(script).toContain("proc expect_or_exit");
expect(script).toContain("proc expect_pair_or_exit");
expect(script.match(/exp_continue -continue_timer/gu)).toHaveLength(2);
expect(script).not.toContain("while {!$firstSeen || !$secondSeen}");
expect(script).toContain(
"expect_or_exit {connected[^\\r\\n]*idle} connected_idle_initial 10 11",
);
expect(script).toContain("expect_or_exit {NEMOCLAW6194_CHAT_OK} chat_reply 20 21");
expect(script).toContain(
"expect_or_exit {connected[^\\r\\n]*idle} connected_idle_after_chat 22 23",
"expect_pair_or_exit {NEMOCLAW6194_CHAT_OK} chat_reply {connected[^\\r\\n]*idle} connected_idle_after_chat 20 21 22 23",
);
expect(script).toContain("/nemoclaw status");
expect(script).toContain("expect_or_exit {NemoClaw Status} slash_status_output 30 31");
expect(script).toContain(
"expect_or_exit {connected[^\\r\\n]*idle} connected_idle_after_status 32 33",
"expect_pair_or_exit {NemoClaw Status} slash_status_output {connected[^\\r\\n]*idle} connected_idle_after_status 30 31 32 33",
);
expect(script).toContain("if {!$firstSeen} { exit $firstTimeoutExit }");
expect(script).toContain("if {!$firstSeen} { exit $firstEofExit }");
expect(script).toContain("mark clean_exit");

const markers = [
Expand All @@ -93,6 +98,63 @@ describe("live TUI post-idle coverage contract (#6194)", () => {
expect([...order].sort((a, b) => a - b)).toEqual(order);
});

describe.runIf(
spawnSync("expect", ["-v"], {
encoding: "utf8",
timeout: 5000,
killSignal: "SIGKILL",
}).status === 0,
)("paired Expect behavior", () => {
function runPair(command: string, timeoutSeconds = 1) {
const script = `set timeout ${timeoutSeconds}
proc mark {name} { puts "ISSUE6194_MARK $name" }
${buildIssue6194PairExpectProcedure()}spawn sh -c {${command}}
expect_pair_or_exit {FIRST_SIGNAL} first {SECOND_SIGNAL} second 20 21 22 23
exit 0
`;
const startedAt = Date.now();
const result = spawnSync("expect", ["-c", script], {
encoding: "utf8",
timeout: 2500,
killSignal: "SIGKILL",
});
return { ...result, elapsedMs: Date.now() - startedAt };
}

it.each([
["first then second", "printf 'FIRST_SIGNAL\\n'; sleep 0.05; printf 'SECOND_SIGNAL\\n'"],
["second then first", "printf 'SECOND_SIGNAL\\n'; sleep 0.05; printf 'FIRST_SIGNAL\\n'"],
])("accepts %s", (_name, command) => {
const result = runPair(command);

expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0);
expect(result.stdout).toContain("ISSUE6194_MARK first");
expect(result.stdout).toContain("ISSUE6194_MARK second");
});

it.each([
["first signal timeout", "printf 'SECOND_SIGNAL\\n'; sleep 2", 20],
["first signal EOF", "printf 'SECOND_SIGNAL\\n'", 21],
["second signal timeout", "printf 'FIRST_SIGNAL\\n'; sleep 2", 22],
["second signal EOF", "printf 'FIRST_SIGNAL\\n'", 23],
])("preserves the %s exit", (_name, command, expectedExit) => {
const result = runPair(command);

expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedExit);
});

it("keeps repeated redraws inside one timeout", () => {
const repeatedSecondSignals = Array.from(
{ length: 30 },
() => "printf 'SECOND_SIGNAL\\n'; sleep 0.1",
).join("; ");
const result = runPair(`${repeatedSecondSignals}; sleep 2`);

expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(20);
expect(result.elapsedMs).toBeLessThan(2200);
});
});

it("confirms the two-step Ctrl+C exit without waiting for the global timeout", () => {
const script = buildIssue6194TuiExpectScript();
const exitFlow = script.slice(script.indexOf("# Network-rule approvals belong"));
Expand Down
Loading