diff --git a/README.md b/README.md index 7a53063..ebc4d9f 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ Waits and assertions fall into five timeout classes: | Class | Applies to | Default | | --- | --- | --- | -| `text` | `expect text`, `wait text` | 5000 ms | +| `text` | `expect text`, `wait text`, `wait bell`, `expect bell` | 5000 ms | | `idle` | `wait idle` | 5000 ms | | `command` | `wait command`, `expect exit-code` | 30000 ms | | `exit` | `wait exit` | 30000 ms | @@ -179,11 +179,11 @@ way. | Command | Description | | --------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `state` | cwd, size, cursor, last command + exit code, effective timeouts, text snapshot. | +| `state` | cwd, size, cursor, last command + exit code, bell count, effective timeouts, text snapshot. | | `text [--full]` | Plain text of the viewport (or scrollback). | | `screenshot [-o file.svg] [--full]` | Terminal text to stdout, or a crisp full-color SVG image (svg-term-style window) to a file. | | `cells X Y [W H]` | Per-cell attributes (char, fg, bg, flags). | -| `get command\|output\|exit-code\|cwd\|cursor\|size` | Structured getters. | +| `get command\|output\|exit-code\|cwd\|cursor\|size\|bells` | Structured getters. | `state` prints `key: value` lines then the screen; `text` and `screenshot` print the screen bare. @@ -216,6 +216,7 @@ print the screen bare. | `wait command` | Until the current command finishes. | | `wait exit` | Until the session exits. | | `wait ready` | Until the shell reports a prompt. | +| `wait bell` | Until the next terminal bell event. | ### Expect (exit 0 = pass, 1 = fail) @@ -224,6 +225,7 @@ print the screen bare. | `expect text "T" [--regex --full --no-strict --not --fg C --bg C --timeout MS]` | Visibility + optional color. | | `expect exit-code N [--timeout MS]` | Last command's exit code. | | `expect output "T" [--regex]` | Last command's captured output. | +| `expect bell N [--timeout MS]` | Cumulative bell count reaches at least N. | | `expect snapshot NAME [-u] [--include-colors]` | Compare against `__snapshots__/NAME.snap`. | Colors accept ANSI-256 (`9`), hex (`#ff0000`), or rgb (`255,0,0`). diff --git a/SKILL.md b/SKILL.md index d673019..f3413be 100644 --- a/SKILL.md +++ b/SKILL.md @@ -72,11 +72,11 @@ without parsing text: | Command | Description | | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | -| `state` | cwd, size, cursor, last command + exit code, timeouts, and a text snapshot. | +| `state` | cwd, size, cursor, last command + exit code, bell count, timeouts, and a text snapshot. | | `text [--full]` | Rendered viewport text, or full scrollback with `--full`. | | `screenshot [PATH] [-o FILE] [--full]` | Terminal text to stdout, or a full-color SVG image (crisp at any zoom, svg-term-style window) when a path is given. | | `cells X Y [W H]` | Per-cell attributes (char, fg, bg, flags) for a region. | -| `get command\|output\|exit-code\|cwd\|cursor\|size` | One structured field. | +| `get command\|output\|exit-code\|cwd\|cursor\|size\|bells` | One structured field. | ### Input @@ -107,6 +107,7 @@ without parsing text: | `wait command [--timeout MS]` | Until the current foreground command finishes (needs shell integration). | | `wait exit [--timeout MS]` | Until the session's program/shell itself exits. | | `wait ready [--timeout MS]` | Until the shell reports a ready prompt (needs shell integration). `open` waits by default. | +| `wait bell [--timeout MS]` | Until the next terminal bell event. | ### Expect (exit 0 = pass, 1 = fail) @@ -115,6 +116,7 @@ without parsing text: | `expect text "T" [--regex --full --no-strict --not --fg C --bg C --timeout MS]` | Visibility plus optional color. `--no-strict` relaxes a strict single-match. | | `expect exit-code N [--timeout MS]` | The last command's exit code. Waits for the command to finish first. | | `expect output "T" [--regex]` | The last command's captured output. | +| `expect bell N [--timeout MS]` | The cumulative bell count reaches at least N. | | `expect snapshot NAME [-u] [--include-colors]` | Compare the screen against `__snapshots__/NAME.snap`; `-u` writes/updates it. | Colors accept ansi-256 (`9`), hex (`#ff0000`), or rgb (`255,0,0`). @@ -284,10 +286,12 @@ await su.close(); Methods mirror the cli commands: `open` / `run`, `submit` / `type` / `write`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, the dedicated `get_command` / `get_output` / -`get_exit_code` / `get_cwd` / `get_cursor` / `get_size` methods, +`get_exit_code` / `get_cwd` / `get_cursor` / `get_size` / `get_bell_count` +methods, `screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit` / -`wait_ready`, `expect_text` / `expect_exit_code` / `expect_output` / -`expect_snapshot`, and `close`. Python module-level helpers are `sessions`, +`wait_ready` / `wait_bell`, `expect_text` / `expect_exit_code` / +`expect_output` / `expect_bell_count` / `expect_snapshot`, and `close`. +Python module-level helpers are `sessions`, `close_all`, and `get_recording`; JavaScript exports `sessions`, `closeAll`, and `getRecording`. The JavaScript client otherwise uses the same names in camelCase (`waitCommand`, `expectText`, `getExitCode`, etc.). diff --git a/bindings/js/README.md b/bindings/js/README.md index 3471a81..a429b35 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -49,7 +49,7 @@ All derive from `ShellUseError` and carry `kind` and `exitCode`. `waitX` and `ex ## API -`new ShellUse(session?, { timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize`, `screenshot`, `waitText` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady`, `expectText` / `expectExitCode` / `expectOutput` / `expectSnapshot`, `close`, and `closeQuiet`. +`new ShellUse(session?, { timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize` / `getBellCount`, `screenshot`, `waitText` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady` / `waitBell`, `expectText` / `expectExitCode` / `expectOutput` / `expectBellCount` / `expectSnapshot`, `close`, and `closeQuiet`. Module-level helpers: `sessions()`, `closeAll()`, `getRecording()`, `uniqueSession()`. diff --git a/bindings/js/native/index.d.ts b/bindings/js/native/index.d.ts index 89f98e1..e7597d8 100644 --- a/bindings/js/native/index.d.ts +++ b/bindings/js/native/index.d.ts @@ -16,6 +16,7 @@ export declare class NativeSession { getCwd(): Promise getCursor(): Promise getSize(): Promise + getBellCount(): Promise write(data: string): Promise type(text: string): Promise submit(data?: string | undefined | null): Promise @@ -33,9 +34,11 @@ export declare class NativeSession { waitCommand(timeoutMs?: number | undefined | null): Promise waitExit(timeoutMs?: number | undefined | null): Promise waitReady(timeoutMs?: number | undefined | null): Promise + waitBell(timeoutMs?: number | undefined | null): Promise expectText(text: string, options?: ExpectTextOptions | undefined | null): Promise expectExitCode(code: number, timeoutMs?: number | undefined | null): Promise expectOutput(text: string, regex?: boolean | undefined | null): Promise + expectBellCount(count: number, timeoutMs?: number | undefined | null): Promise snapshot(name: string, options?: SnapshotOptions | undefined | null): Promise screenshot(options?: ScreenshotOptions | undefined | null): Promise panicProbe(): Promise @@ -189,6 +192,7 @@ export interface State { last_exit: number | null exited: number | null ready: boolean + bell_count: number timeouts: EffectiveTimeouts text: string } diff --git a/bindings/js/native/lib.rs b/bindings/js/native/lib.rs index beabd81..670db54 100644 --- a/bindings/js/native/lib.rs +++ b/bindings/js/native/lib.rs @@ -165,6 +165,8 @@ pub struct State { pub last_exit: Option, pub exited: Option, pub ready: bool, + #[napi(js_name = "bell_count")] + pub bell_count: f64, pub timeouts: EffectiveTimeouts, pub text: String, } @@ -181,6 +183,7 @@ impl From for State { last_exit: value.last_exit, exited: value.exited, ready: value.ready, + bell_count: value.bell_count as f64, timeouts: value.timeouts.into(), text: value.text, } @@ -713,6 +716,20 @@ impl NativeSession { .await } + #[napi] + pub async fn get_bell_count(&self) -> Result { + execute( + self.handle.clone(), + "getBellCount", + Operation::GetBellCount, + |result| match result { + OperationResult::BellCount(value) => Ok(value as f64), + _ => Err(unexpected("getBellCount")), + }, + ) + .await + } + #[napi] pub async fn write(&self, data: String) -> Result<()> { self.unit("write", Operation::Write { data }).await @@ -929,6 +946,14 @@ impl NativeSession { .await } + #[napi] + pub async fn wait_bell(&self, timeout_ms: Option) -> Result<()> { + self.timeout_unit("waitBell", timeout_ms, |timeout_ms| Operation::WaitBell { + timeout_ms, + }) + .await + } + #[napi] pub async fn expect_text( &self, @@ -992,6 +1017,22 @@ impl NativeSession { .await } + #[napi] + pub async fn expect_bell_count(&self, count: f64, timeout_ms: Option) -> Result<()> { + let handle = self.handle.clone(); + blocking("expectBellCount", move || { + let operation = Operation::ExpectBellCount { + count: integer(count, "count", u64::MAX)?, + timeout_ms: timeout(timeout_ms, "timeoutMs")?, + }; + match handle.execute(operation)? { + OperationResult::Unit => Ok(()), + _ => Err(unexpected("expectBellCount")), + } + }) + .await + } + #[napi] pub async fn snapshot( &self, diff --git a/bindings/js/src/client.ts b/bindings/js/src/client.ts index 8abfc05..125370a 100644 --- a/bindings/js/src/client.ts +++ b/bindings/js/src/client.ts @@ -313,6 +313,10 @@ export class ShellUse { return this.#runtime.getSize(); } + async getBellCount(): Promise { + return this.#runtime.getBellCount(); + } + async screenshot(path: string | null = null, opts: { full?: boolean } = {}): Promise { return this.#runtime.screenshot({ full: opts.full ?? false, @@ -355,6 +359,12 @@ export class ShellUse { ); } + async waitBell(opts: { timeout?: number } = {}): Promise { + await this.#guard("waitBell", () => + this.#runtime.waitBell(this.#timeout("text", opts.timeout)), + ); + } + async expectText(text: string, opts: ExpectTextOptions = {}): Promise { await this.#guard("expectText", () => this.#runtime.expectText(text, { @@ -381,6 +391,12 @@ export class ShellUse { ); } + async expectBellCount(count: number, opts: { timeout?: number } = {}): Promise { + await this.#guard("expectBellCount", () => + this.#runtime.expectBellCount(count, this.#timeout("text", opts.timeout)), + ); + } + async expectSnapshot( name: string, opts: { update?: boolean; includeColors?: boolean } = {}, diff --git a/bindings/js/src/native.ts b/bindings/js/src/native.ts index f8863c1..6948b7c 100644 --- a/bindings/js/src/native.ts +++ b/bindings/js/src/native.ts @@ -199,6 +199,10 @@ export class NativeRuntime { return this.#call((session) => session.getSize()); } + getBellCount(): Promise { + return this.#call((session) => session.getBellCount()); + } + write(data: string): Promise { return this.#call((session) => session.write(data)); } @@ -273,6 +277,10 @@ export class NativeRuntime { return this.#call((session) => session.waitReady(timeoutMs)); } + waitBell(timeoutMs?: number): Promise { + return this.#call((session) => session.waitBell(timeoutMs)); + } + expectText(text: string, options?: ExpectTextOptions): Promise { return this.#call((session) => session.expectText(text, options)); } @@ -285,6 +293,10 @@ export class NativeRuntime { return this.#call((session) => session.expectOutput(text, regex)); } + expectBellCount(count: number, timeoutMs?: number): Promise { + return this.#call((session) => session.expectBellCount(count, timeoutMs)); + } + async snapshot(name: string, options?: SnapshotOptions): Promise { return this.#call((session) => session.snapshot(name, options)); } diff --git a/bindings/js/test/integration.test.mjs b/bindings/js/test/integration.test.mjs index 8b9de15..c1c42e4 100644 --- a/bindings/js/test/integration.test.mjs +++ b/bindings/js/test/integration.test.mjs @@ -23,6 +23,14 @@ const evalArgs = typeof globalThis.Deno === "undefined" ? ["-e", "console.log('ready'); setInterval(() => {}, 1000)"] : ["eval", "console.log('ready'); setInterval(() => {}, 1000)"]; +const twoBellsCommand = + process.platform === "win32" + ? "[Console]::Out.Write([char]7); [Console]::Out.Write([char]7)" + : "printf '\\a\\a'"; +const delayedBellCommand = + process.platform === "win32" + ? "Start-Sleep -Seconds 1; [Console]::Out.Write([char]7)" + : "sleep 1; printf '\\a'"; test("echo roundtrip drives a real session", async () => { await withTerminal({ shell }, async (su) => { @@ -56,6 +64,28 @@ test("echo roundtrip drives a real session", async () => { }); }); +test("bell state, waits, and expectations stay consistent", async () => { + const su = ShellUse.ephemeral("bell-events"); + + try { + await su.open({ shell }); + await su.submit(twoBellsCommand); + await su.expectBellCount(2, { timeout: 5000 }); + await su.waitCommand(); + + assert.equal((await su.state()).bell_count, 2); + assert.equal(await su.getBellCount(), 2); + assert.equal(await su.getBellCount(), 2); + + await su.submit(delayedBellCommand); + await su.waitBell({ timeout: 5000 }); + await su.expectBellCount(3); + assert.equal(await su.getBellCount(), 3); + } finally { + await su.closeQuiet(); + } +}); + test( "assertion errors include the current terminal", async () => { diff --git a/bindings/python/README.md b/bindings/python/README.md index 9d649df..234a40a 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -54,7 +54,7 @@ All derive from `ShellUseError`. `wait_*` and `expect_*` raise `ExpectationError ## API -`ShellUse(session="default", *, timeouts=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size`, `screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`. +`ShellUse(session="default", *, timeouts=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size` / `get_bell_count`, `screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready` / `wait_bell`, `expect_text` / `expect_exit_code` / `expect_output` / `expect_bell_count` / `expect_snapshot`, `close`, and `close_quiet`. Module-level helpers: `sessions()`, `close_all()`, `get_recording()`, `unique_session()`. diff --git a/bindings/python/native/src/lib.rs b/bindings/python/native/src/lib.rs index 4e756e7..a807801 100644 --- a/bindings/python/native/src/lib.rs +++ b/bindings/python/native/src/lib.rs @@ -299,6 +299,15 @@ impl NativeSession { ) } + fn get_bell_count<'py>(&self, py: Python<'py>) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_bell_count(&name, Operation::GetBellCount), + u64_to_py, + ) + } + fn write<'py>(&self, py: Python<'py>, data: String) -> PyResult> { let name = self.name.clone(); future_blocking( @@ -692,6 +701,28 @@ impl NativeSession { ) } + #[pyo3(signature = (timeout_ms))] + fn wait_bell<'py>( + &self, + py: Python<'py>, + timeout_ms: Option>, + ) -> PyResult> { + let timeout_ms = capture_optional_integer(timeout_ms); + let name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::WaitBell { + timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, + }, + ) + }, + unit_to_py, + ) + } + #[pyo3(signature = (text, regex, full, strict, not_, fg, bg, timeout_ms))] #[allow(clippy::too_many_arguments)] fn expect_text<'py>( @@ -768,6 +799,31 @@ impl NativeSession { ) } + #[pyo3(signature = (count, timeout_ms))] + fn expect_bell_count<'py>( + &self, + py: Python<'py>, + count: Bound<'py, PyAny>, + timeout_ms: Option>, + ) -> PyResult> { + let count = capture_integer(&count); + let timeout_ms = capture_optional_integer(timeout_ms); + let name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::ExpectBellCount { + count: integer_u64(&count, "count")?, + timeout_ms: optional_u64(timeout_ms.as_ref(), "timeout")?, + }, + ) + }, + unit_to_py, + ) + } + #[pyo3(signature = (name, update, include_colors, cwd))] fn snapshot<'py>( &self, @@ -1132,6 +1188,13 @@ fn execute_size(name: &str, operation: Operation) -> Result } } +fn execute_bell_count(name: &str, operation: Operation) -> Result { + match global_registry().execute(name, operation)? { + OperationResult::BellCount(value) => Ok(value), + _ => Err(unexpected_result("the terminal bell count")), + } +} + fn execute_snapshot(name: &str, operation: Operation) -> Result { match global_registry().execute(name, operation)? { OperationResult::Snapshot(value) => Ok(value), @@ -1162,6 +1225,10 @@ fn optional_i32_to_py(py: Python<'_>, value: Option) -> PyResult> Ok(value.into_pyobject(py)?.into_any().unbind()) } +fn u64_to_py(py: Python<'_>, value: u64) -> PyResult> { + Ok(value.into_pyobject(py)?.into_any().unbind()) +} + fn string_list_to_py(py: Python<'_>, value: Vec) -> PyResult> { Ok(PyList::new(py, value)?.into_any().unbind()) } @@ -1200,6 +1267,7 @@ fn state_to_py(py: Python<'_>, value: State) -> PyResult> { result.set_item("last_exit", value.last_exit)?; result.set_item("exited", value.exited)?; result.set_item("ready", value.ready)?; + result.set_item("bell_count", value.bell_count)?; let timeouts = PyDict::new(py); timeouts.set_item("text", value.timeouts.text)?; timeouts.set_item("idle", value.timeouts.idle)?; diff --git a/bindings/python/src/shell_use/_native.pyi b/bindings/python/src/shell_use/_native.pyi index 19f3e81..f8a0c24 100644 --- a/bindings/python/src/shell_use/_native.pyi +++ b/bindings/python/src/shell_use/_native.pyi @@ -54,6 +54,7 @@ class NativeSession: def get_cwd(self) -> typing.Awaitable[typing.Optional[str]]: ... def get_cursor(self) -> typing.Awaitable[typing.Dict[str, int]]: ... def get_size(self) -> typing.Awaitable[typing.Dict[str, int]]: ... + def get_bell_count(self) -> typing.Awaitable[int]: ... def write(self, data: str) -> typing.Awaitable[None]: ... def type(self, text: str) -> typing.Awaitable[None]: ... def submit(self, data: typing.Optional[str]) -> typing.Awaitable[None]: ... @@ -73,9 +74,11 @@ class NativeSession: def wait_command(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def wait_exit(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def wait_ready(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... + def wait_bell(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_text(self, text: str, regex: bool, full: bool, strict: bool, not_: bool, fg: typing.Optional[str], bg: typing.Optional[str], timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_exit_code(self, code: int, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_output(self, text: str, regex: bool) -> typing.Awaitable[None]: ... + def expect_bell_count(self, count: int, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def snapshot(self, name: str, update: bool, include_colors: bool, cwd: typing.Optional[str]) -> typing.Awaitable[str]: ... def screenshot(self, path: typing.Optional[str], full: bool) -> typing.Awaitable[str]: ... def recording(self) -> typing.Awaitable[str]: ... diff --git a/bindings/python/src/shell_use/client.py b/bindings/python/src/shell_use/client.py index 456e4be..bd38f31 100644 --- a/bindings/python/src/shell_use/client.py +++ b/bindings/python/src/shell_use/client.py @@ -334,6 +334,9 @@ async def get_cursor(self) -> Dict[str, int]: async def get_size(self) -> Dict[str, int]: return await self._await(self._native.get_size()) + async def get_bell_count(self) -> int: + return await self._await(self._native.get_bell_count()) + async def screenshot( self, path: Optional[str] = None, *, full: bool = False ) -> str: @@ -379,6 +382,12 @@ async def wait_ready(self, *, timeout: Optional[int] = None) -> None: self._native.wait_ready(self._timeout("ready", timeout)), ) + async def wait_bell(self, *, timeout: Optional[int] = None) -> None: + await self._guarded( + "wait_bell", + self._native.wait_bell(self._timeout("text", timeout)), + ) + async def expect_text( self, text: str, @@ -420,6 +429,16 @@ async def expect_output(self, text: str, *, regex: bool = False) -> None: "expect_output", self._native.expect_output(text, regex) ) + async def expect_bell_count( + self, count: int, *, timeout: Optional[int] = None + ) -> None: + await self._guarded( + "expect_bell_count", + self._native.expect_bell_count( + count, self._timeout("text", timeout) + ), + ) + async def expect_snapshot( self, name: str, diff --git a/bindings/python/src/shell_use/types.py b/bindings/python/src/shell_use/types.py index 86c25f5..7d6461b 100644 --- a/bindings/python/src/shell_use/types.py +++ b/bindings/python/src/shell_use/types.py @@ -56,6 +56,7 @@ class State: timeouts: Timeouts text: str session_shell: Optional[str] + bell_count: int = 0 @classmethod def from_dict(cls, d: Dict[str, Any]) -> "State": @@ -68,6 +69,7 @@ def from_dict(cls, d: Dict[str, Any]) -> "State": last_exit=d.get("last_exit"), exited=d.get("exited"), ready=d.get("ready", False), + bell_count=d.get("bell_count", 0), timeouts=Timeouts(**d["timeouts"]), text=d.get("text", ""), session_shell=d.get("session_shell"), diff --git a/bindings/python/stub-gen/src/main.rs b/bindings/python/stub-gen/src/main.rs index 5fe0cd1..d693f7f 100644 --- a/bindings/python/stub-gen/src/main.rs +++ b/bindings/python/stub-gen/src/main.rs @@ -134,6 +134,7 @@ mod stubs { def get_cwd(self) -> typing.Awaitable[typing.Optional[str]]: ... def get_cursor(self) -> typing.Awaitable[typing.Dict[str, int]]: ... def get_size(self) -> typing.Awaitable[typing.Dict[str, int]]: ... + def get_bell_count(self) -> typing.Awaitable[int]: ... def write(self, data: str) -> typing.Awaitable[None]: ... def type(self, text: str) -> typing.Awaitable[None]: ... def submit(self, data: typing.Optional[str]) -> typing.Awaitable[None]: ... @@ -167,6 +168,7 @@ mod stubs { def wait_command(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def wait_exit(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def wait_ready(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... + def wait_bell(self, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_text( self, text: str, @@ -180,6 +182,7 @@ mod stubs { ) -> typing.Awaitable[None]: ... def expect_exit_code(self, code: int, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def expect_output(self, text: str, regex: bool) -> typing.Awaitable[None]: ... + def expect_bell_count(self, count: int, timeout_ms: typing.Optional[int]) -> typing.Awaitable[None]: ... def snapshot( self, name: str, diff --git a/bindings/python/tests/test_integration.py b/bindings/python/tests/test_integration.py index de60f02..7d02ee3 100644 --- a/bindings/python/tests/test_integration.py +++ b/bindings/python/tests/test_integration.py @@ -22,6 +22,16 @@ from shell_use.client import _panic_probe SHELL = "pwsh" if sys.platform == "win32" else None +TWO_BELLS_COMMAND = ( + "[Console]::Out.Write([char]7); [Console]::Out.Write([char]7)" + if sys.platform == "win32" + else "printf '\\a\\a'" +) +DELAYED_BELL_COMMAND = ( + "Start-Sleep -Seconds 1; [Console]::Out.Write([char]7)" + if sys.platform == "win32" + else "sleep 1; printf '\\a'" +) def run(coro): @@ -56,6 +66,26 @@ async def scenario(): run(scenario()) + def test_bell_state_waits_and_expectations(self): + async def scenario(): + async with self._client() as su: + await su.open(shell=SHELL) + + await su.submit(TWO_BELLS_COMMAND) + await su.expect_bell_count(2, timeout=5000) + await su.wait_command() + + self.assertEqual((await su.state()).bell_count, 2) + self.assertEqual(await su.get_bell_count(), 2) + self.assertEqual(await su.get_bell_count(), 2) + + await su.submit(DELAYED_BELL_COMMAND) + await su.wait_bell(timeout=5000) + await su.expect_bell_count(3) + self.assertEqual(await su.get_bell_count(), 3) + + run(scenario()) + def test_effective_timeouts_are_exposed_in_typed_state(self): async def scenario(): expected = Timeouts( diff --git a/crates/shell-use-cli/src/cli.rs b/crates/shell-use-cli/src/cli.rs index 194c1aa..c4aae86 100644 --- a/crates/shell-use-cli/src/cli.rs +++ b/crates/shell-use-cli/src/cli.rs @@ -474,6 +474,34 @@ mod tests { )); } + #[test] + fn bell_commands_parse_with_counts_and_timeouts() { + let cli = Cli::try_parse_from(["shell-use", "wait", "bell", "--timeout", "1234"]) + .expect("parse wait bell"); + assert!(matches!( + cli.command, + Some(Command::Wait { + what: WaitCmd::Bell { + timeout: Some(1234) + } + }) + )); + + let cli = Cli::try_parse_from(["shell-use", "expect", "bell", "3", "--timeout", "4321"]) + .expect("parse expect bell"); + assert!(matches!( + cli.command, + Some(Command::Expect { + what: ExpectCmd::Bell { + count: 3, + timeout: Some(4321) + } + }) + )); + + assert!(Cli::try_parse_from(["shell-use", "expect", "bell"]).is_err()); + } + #[test] fn daemon_stop_accepts_all() { let cli = @@ -548,6 +576,8 @@ pub enum GetArg { Cursor, /// Terminal size. Size, + /// Cumulative terminal bell count. + Bells, } #[derive(Subcommand)] @@ -691,6 +721,12 @@ pub enum WaitCmd { #[arg(long, value_name = "MS")] timeout: Option, }, + /// Wait for the next terminal bell event. + Bell { + /// Timeout in milliseconds. + #[arg(long, value_name = "MS")] + timeout: Option, + }, } #[derive(Subcommand)] @@ -740,6 +776,14 @@ pub enum ExpectCmd { #[arg(long)] regex: bool, }, + /// Wait until the cumulative terminal bell count reaches this value. + Bell { + /// Minimum cumulative bell count. + count: u64, + /// Timeout in milliseconds. + #[arg(long, value_name = "MS")] + timeout: Option, + }, /// Assert the screen matches a saved snapshot. Snapshot { /// Snapshot name. diff --git a/crates/shell-use-cli/src/main.rs b/crates/shell-use-cli/src/main.rs index a027da8..f24fecf 100644 --- a/crates/shell-use-cli/src/main.rs +++ b/crates/shell-use-cli/src/main.rs @@ -215,6 +215,7 @@ fn map_field(f: GetArg) -> GetField { GetArg::Cwd => GetField::Cwd, GetArg::Cursor => GetField::Cursor, GetArg::Size => GetField::Size, + GetArg::Bells => GetField::BellCount, } } @@ -283,6 +284,9 @@ fn map_wait(what: WaitCmd) -> Request { WaitCmd::Ready { timeout } => Request::WaitReady { timeout_ms: timeout, }, + WaitCmd::Bell { timeout } => Request::WaitBell { + timeout_ms: timeout, + }, } } @@ -312,6 +316,10 @@ fn map_expect(what: ExpectCmd) -> Request { timeout_ms: timeout, }, ExpectCmd::Output { text, regex } => Request::ExpectOutput { text, regex }, + ExpectCmd::Bell { count, timeout } => Request::ExpectBellCount { + count, + timeout_ms: timeout, + }, ExpectCmd::Snapshot { name, update, @@ -648,14 +656,14 @@ SESSION open [--shell S] [--cols N --rows N] [--cwd D] [--env K=V]\n\ run [args...]\n\ sessions | close [--all] | daemon start|status | daemon stop --session N|--all\n\ INSPECT state | text [--full] | screenshot [-o file.svg] [--full]\n\ - cells X Y [W H] | get command|output|exit-code|cwd|cursor|size\n\ + cells X Y [W H] | get command|output|exit-code|cwd|cursor|size|bells\n\ INPUT type \"text\" | submit [\"text\"] | press | keys \"Ctrl+a\"\n\ mouse click X Y | mouse click --on-text \"OK\" | mouse move|down|up|drag|scroll\n\ PTY resize COLS ROWS | write | signal INT|TERM|KILL|QUIT | kill\n\ WAIT wait text \"T\" [--regex --full --not --timeout MS]\n\ - wait idle | wait command | wait exit | wait ready\n\ + wait idle | wait command | wait exit | wait ready | wait bell\n\ EXPECT expect text \"T\" [--regex --full --not --fg C --bg C --timeout MS]\n\ - expect exit-code N | expect output \"T\" [--regex]\n\ + expect exit-code N | expect output \"T\" [--regex] | expect bell N\n\ expect snapshot NAME [-u] [--include-colors]\n\ RECORD sessions auto-record; get-recording [session] > out.cast (asciinema v2)\n\ play with `asciinema play out.cast`, render GIF with `agg out.cast out.gif`\n\ diff --git a/crates/shell-use-cli/src/protocol.rs b/crates/shell-use-cli/src/protocol.rs index b86ccd6..e7cc3e9 100644 --- a/crates/shell-use-cli/src/protocol.rs +++ b/crates/shell-use-cli/src/protocol.rs @@ -81,6 +81,10 @@ pub enum Request { #[serde(default)] timeout_ms: Option, }, + WaitBell { + #[serde(default)] + timeout_ms: Option, + }, ExpectText { text: String, regex: bool, @@ -101,6 +105,11 @@ pub enum Request { text: String, regex: bool, }, + ExpectBellCount { + count: u64, + #[serde(default)] + timeout_ms: Option, + }, Snapshot { name: String, update: bool, @@ -177,6 +186,7 @@ impl Request { GetField::Cwd => Operation::GetCwd, GetField::Cursor => Operation::GetCursor, GetField::Size => Operation::GetSize, + GetField::BellCount => Operation::GetBellCount, }), Request::Write { data } => Ok(Operation::Write { data }), Request::Submit { data } => Ok(Operation::Submit { data }), @@ -201,6 +211,7 @@ impl Request { Request::WaitCommand { timeout_ms } => Ok(Operation::WaitCommand { timeout_ms }), Request::WaitExit { timeout_ms } => Ok(Operation::WaitExit { timeout_ms }), Request::WaitReady { timeout_ms } => Ok(Operation::WaitReady { timeout_ms }), + Request::WaitBell { timeout_ms } => Ok(Operation::WaitBell { timeout_ms }), Request::ExpectText { text, regex, @@ -224,6 +235,9 @@ impl Request { Ok(Operation::ExpectExitCode { code, timeout_ms }) } Request::ExpectOutput { text, regex } => Ok(Operation::ExpectOutput { text, regex }), + Request::ExpectBellCount { count, timeout_ms } => { + Ok(Operation::ExpectBellCount { count, timeout_ms }) + } Request::Snapshot { name, update, @@ -254,6 +268,7 @@ pub enum GetField { Cwd, Cursor, Size, + BellCount, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -325,6 +340,7 @@ fn operation_data(result: OperationResult) -> Result, OperationResult::Cwd(value) => Ok(json!({ "value": value })), OperationResult::Cursor(value) => Ok(json!({ "value": value })), OperationResult::Size(value) => Ok(json!({ "value": value })), + OperationResult::BellCount(value) => Ok(json!({ "value": value })), OperationResult::Snapshot(status) => Ok(json!({ "status": status })), OperationResult::Screenshot(ScreenshotResult::Path(path)) => Ok(json!({ "path": path })), OperationResult::Screenshot(ScreenshotResult::Text(text)) => Ok(json!({ "text": text })), @@ -386,19 +402,33 @@ mod tests { r#"{"kind":"wait_command"}"#, r#"{"kind":"wait_exit"}"#, r#"{"kind":"wait_ready"}"#, + r#"{"kind":"wait_bell"}"#, ] { let request: Request = serde_json::from_str(raw).expect("deserialize wait"); let timeout = match request { Request::WaitIdle { timeout_ms } | Request::WaitCommand { timeout_ms } | Request::WaitExit { timeout_ms } - | Request::WaitReady { timeout_ms } => timeout_ms, + | Request::WaitReady { timeout_ms } + | Request::WaitBell { timeout_ms } => timeout_ms, other => panic!("expected a wait, got {other:?}"), }; assert_eq!(timeout, None); } } + #[test] + fn bell_expectation_timeout_is_optional() { + let raw = r#"{"kind":"expect_bell_count","count":2}"#; + match serde_json::from_str::(raw).expect("deserialize expect_bell_count") { + Request::ExpectBellCount { count, timeout_ms } => { + assert_eq!(count, 2); + assert_eq!(timeout_ms, None); + } + other => panic!("expected ExpectBellCount, got {other:?}"), + } + } + #[test] fn expect_exit_code_timeout_is_optional() { let raw = r#"{"kind":"expect_exit_code","code":0}"#; diff --git a/crates/shell-use-cli/tests/session_lifecycle.rs b/crates/shell-use-cli/tests/session_lifecycle.rs index 7e2e6f0..3bda17e 100644 --- a/crates/shell-use-cli/tests/session_lifecycle.rs +++ b/crates/shell-use-cli/tests/session_lifecycle.rs @@ -220,6 +220,34 @@ fn wait_ready_without_a_session_reports_no_session() { ); } +#[test] +fn bell_count_wait_and_expect_are_exposed_over_the_cli() { + let sandbox = Sandbox::new("bells"); + sandbox.ok(&["open"]); + + sandbox.ok(&["submit", &two_bells_command()]); + sandbox.ok(&["expect", "bell", "2", "--timeout", "5000"]); + sandbox.ok(&["wait", "command"]); + + let state = sandbox.ok(&["state"]); + assert!(state.contains("bell_count: 2"), "{state}"); + + for _ in 0..2 { + let response: serde_json::Value = + serde_json::from_str(&sandbox.ok(&["--json", "get", "bells"])) + .expect("parse bell count response"); + assert_eq!(response["data"]["value"], 2); + } + + sandbox.ok(&["submit", &delayed_bell_command()]); + sandbox.ok(&["wait", "bell", "--timeout", "5000"]); + sandbox.ok(&["expect", "bell", "3", "--timeout", "5000"]); + let response: serde_json::Value = + serde_json::from_str(&sandbox.ok(&["--json", "get", "bells"])) + .expect("parse final bell count response"); + assert_eq!(response["data"]["value"], 3); +} + /// A session timeout default must apply to later commands without `--timeout`. #[test] fn a_session_timeout_default_applies_to_later_commands() { @@ -395,6 +423,22 @@ fn sleeper() -> Vec<&'static str> { } } +fn two_bells_command() -> String { + if cfg!(windows) { + "[Console]::Out.Write([char]7); [Console]::Out.Write([char]7)".to_string() + } else { + "printf '\\a\\a'".to_string() + } +} + +fn delayed_bell_command() -> String { + if cfg!(windows) { + "Start-Sleep -Seconds 1; [Console]::Out.Write([char]7)".to_string() + } else { + "sleep 1; printf '\\a'".to_string() + } +} + fn interactive_reader() -> &'static str { if cfg!(windows) { "Write-Output ('reader-'+'ready'); $null = Read-Host" diff --git a/crates/shell-use/src/api.rs b/crates/shell-use/src/api.rs index 5a593c7..b27209b 100644 --- a/crates/shell-use/src/api.rs +++ b/crates/shell-use/src/api.rs @@ -88,6 +88,7 @@ pub enum Operation { GetCwd, GetCursor, GetSize, + GetBellCount, Write { data: String, }, @@ -126,6 +127,9 @@ pub enum Operation { WaitReady { timeout_ms: Option, }, + WaitBell { + timeout_ms: Option, + }, ExpectText { text: String, regex: bool, @@ -144,6 +148,10 @@ pub enum Operation { text: String, regex: bool, }, + ExpectBellCount { + count: u64, + timeout_ms: Option, + }, Snapshot { name: String, update: bool, @@ -170,6 +178,7 @@ pub enum OperationResult { Cwd(Option), Cursor(Cursor), Size(Size), + BellCount(u64), Snapshot(SnapshotResult), Screenshot(ScreenshotResult), } @@ -285,6 +294,7 @@ pub struct State { pub last_exit: Option, pub exited: Option, pub ready: bool, + pub bell_count: u64, pub timeouts: EffectiveTimeouts, pub text: String, } diff --git a/crates/shell-use/src/engine.rs b/crates/shell-use/src/engine.rs index a3d3c3c..b998cf7 100644 --- a/crates/shell-use/src/engine.rs +++ b/crates/shell-use/src/engine.rs @@ -483,6 +483,7 @@ fn dispatch( .size(); Ok(OperationResult::Size(Size { cols, rows })) } + Operation::GetBellCount => Ok(OperationResult::BellCount(session.bells.count())), Operation::Write { data } => { act(session.write(data.as_bytes()))?; Ok(OperationResult::Unit) @@ -556,6 +557,13 @@ fn dispatch( )?; Ok(OperationResult::Unit) } + Operation::WaitBell { timeout_ms } => { + wait_bell( + session, + timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)), + )?; + Ok(OperationResult::Unit) + } Operation::ExpectText { text, regex, @@ -591,6 +599,14 @@ fn dispatch( expect_output(session, &text, regex)?; Ok(OperationResult::Unit) } + Operation::ExpectBellCount { count, timeout_ms } => { + expect_bell_count( + session, + count, + timeout_ms.unwrap_or_else(|| session.timeout_for(config::TimeoutClass::Text)), + )?; + Ok(OperationResult::Unit) + } Operation::Snapshot { name, update, @@ -633,6 +649,7 @@ fn state(session: &TerminalSession) -> crate::api::State { last_exit: state.tracker.last_exit(), exited: state.exited, ready: state.tracker.is_ready(), + bell_count: session.bells.count(), timeouts: effective_timeouts(session), text: text_of(&state.emu.viewable_rows()), } @@ -949,6 +966,29 @@ fn wait_ready(session: &TerminalSession, timeout_ms: u64) -> Result<(), ShellUse } } +fn wait_bell(session: &TerminalSession, timeout_ms: u64) -> Result<(), ShellUseError> { + let baseline = session.bells.sequence(); + let mut rang = false; + poll_until( + || { + rang = session.bells.sequence() != baseline; + rang || session_stopped(session) + }, + timeout_ms, + ); + if rang { + Ok(()) + } else if session_stopped(session) { + Err(ShellUseError::assertion( + "session exited before a bell was received", + )) + } else { + Err(ShellUseError::assertion(format!( + "wait bell: timed out after {timeout_ms}ms without receiving a bell" + ))) + } +} + #[allow(clippy::too_many_arguments)] fn expect_text( session: &TerminalSession, @@ -1134,6 +1174,32 @@ fn expect_output(session: &TerminalSession, text: &str, regex: bool) -> Result<( } } +fn expect_bell_count( + session: &TerminalSession, + expected: u64, + timeout_ms: u64, +) -> Result<(), ShellUseError> { + let mut actual = session.bells.count(); + poll_until( + || { + actual = session.bells.count(); + actual >= expected || session_stopped(session) + }, + timeout_ms, + ); + if actual >= expected { + Ok(()) + } else if session_stopped(session) { + Err(ShellUseError::assertion(format!( + "session exited at bell count {actual} before reaching {expected}" + ))) + } else { + Err(ShellUseError::assertion(format!( + "expected bell count {expected}: timed out after {timeout_ms}ms; current count is {actual}" + ))) + } +} + fn do_snapshot( session: &TerminalSession, name: &str, diff --git a/crates/shell-use/src/event.rs b/crates/shell-use/src/event.rs new file mode 100644 index 0000000..eda1967 --- /dev/null +++ b/crates/shell-use/src/event.rs @@ -0,0 +1,27 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +#[derive(Clone, Default)] +pub(crate) struct BellTracker { + count: Arc, + sequence: Arc, +} + +impl BellTracker { + pub fn count(&self) -> u64 { + self.count.load(Ordering::Relaxed) + } + + pub fn sequence(&self) -> u64 { + self.sequence.load(Ordering::Relaxed) + } + + pub fn ring(&self) { + let _ = self + .count + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |count| { + Some(count.saturating_add(1)) + }); + self.sequence.fetch_add(1, Ordering::Relaxed); + } +} diff --git a/crates/shell-use/src/lib.rs b/crates/shell-use/src/lib.rs index ffa3784..5b7fdc6 100644 --- a/crates/shell-use/src/lib.rs +++ b/crates/shell-use/src/lib.rs @@ -10,6 +10,7 @@ pub mod shell; pub mod terminal; pub mod trace; +mod event; mod session; pub use api::*; diff --git a/crates/shell-use/src/session.rs b/crates/shell-use/src/session.rs index 4be0396..79ecfe7 100644 --- a/crates/shell-use/src/session.rs +++ b/crates/shell-use/src/session.rs @@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::Instant; +use crate::event::BellTracker; use crate::logger::Logger; use crate::shell::{self, Shell}; use crate::terminal::alacritty::AlacrittyEmu; @@ -34,6 +35,7 @@ pub struct Session { pub pty: Arc>, pub state: Arc>, pub cancelled: Arc, + pub(crate) bells: BellTracker, recorder: Arc>, logger: Arc, _reader: JoinHandle<()>, @@ -77,8 +79,14 @@ impl Session { Pty::spawn_launch(&launch, cols, rows, cwd)? }; + let bells = BellTracker::default(); let state = Arc::new(Mutex::new(TermState { - emu: Box::new(AlacrittyEmu::new(cols, rows, 5_000)), + emu: Box::new(AlacrittyEmu::with_bell_tracker( + cols, + rows, + 5_000, + bells.clone(), + )), tracker: CommandTracker::new(), last_change: Instant::now(), awaiting_start: None, @@ -155,6 +163,7 @@ impl Session { pty, state, cancelled, + bells, recorder, logger, _reader: handle, diff --git a/crates/shell-use/src/terminal/alacritty.rs b/crates/shell-use/src/terminal/alacritty.rs index 47c41a6..d67b0c1 100644 --- a/crates/shell-use/src/terminal/alacritty.rs +++ b/crates/shell-use/src/terminal/alacritty.rs @@ -15,6 +15,7 @@ use alacritty_terminal::vte::ansi; use compact_str::{CompactString, ToCompactString}; +use crate::event::BellTracker; use crate::terminal::cell::{Attrs, Color, EmuCell, UnderlineStyle, CONTINUATION}; use crate::terminal::emu::Emulator; @@ -115,14 +116,19 @@ fn cell_from_alac(c: &alacritty_terminal::term::cell::Cell) -> EmuCell { #[derive(Default, Clone)] struct CaptureProxy { pending: Arc>>, + bells: BellTracker, } impl EventListener for CaptureProxy { fn send_event(&self, ev: Event) { - if let Event::PtyWrite(bytes) = ev { - if let Ok(mut buf) = self.pending.lock() { - buf.extend_from_slice(bytes.as_bytes()); + match ev { + Event::PtyWrite(bytes) => { + if let Ok(mut buf) = self.pending.lock() { + buf.extend_from_slice(bytes.as_bytes()); + } } + Event::Bell => self.bells.ring(), + _ => {} } } } @@ -137,6 +143,15 @@ pub struct AlacrittyEmu { impl AlacrittyEmu { pub fn new(cols: u16, rows: u16, scrollback: usize) -> Self { + Self::with_bell_tracker(cols, rows, scrollback, BellTracker::default()) + } + + pub(crate) fn with_bell_tracker( + cols: u16, + rows: u16, + scrollback: usize, + bells: BellTracker, + ) -> Self { let size = TermSize::new(cols as usize, rows as usize); let config = AlacConfig { scrolling_history: scrollback, @@ -145,6 +160,7 @@ impl AlacrittyEmu { let pending: Arc>> = Arc::default(); let proxy = CaptureProxy { pending: pending.clone(), + bells, }; AlacrittyEmu { term: Term::new(config, &size, proxy), @@ -218,4 +234,25 @@ mod tests { use super::*; crate::emulator_conformance_tests!(|c, r, s| Box::new(AlacrittyEmu::new(c, r, s))); + + #[test] + fn multiple_bells_in_one_chunk_are_counted_individually() { + let bells = BellTracker::default(); + let mut emulator = AlacrittyEmu::with_bell_tracker(80, 24, 100, bells.clone()); + + emulator.process(b"\x07\x07"); + + assert_eq!(bells.count(), 2); + assert_eq!(bells.sequence(), 2); + } + + #[test] + fn an_osc_bell_terminator_does_not_ring_the_terminal_bell() { + let bells = BellTracker::default(); + let mut emulator = AlacrittyEmu::with_bell_tracker(80, 24, 100, bells.clone()); + + emulator.process(b"\x1b]0;window title\x07"); + + assert_eq!(bells.count(), 0); + } } diff --git a/crates/shell-use/tests/runtime.rs b/crates/shell-use/tests/runtime.rs index 89a8bfc..74b3add 100644 --- a/crates/shell-use/tests/runtime.rs +++ b/crates/shell-use/tests/runtime.rs @@ -147,3 +147,75 @@ fn close_all_interrupts_in_flight_waits() { assert!(start.elapsed() < Duration::from_secs(2)); assert_eq!(wait.join().unwrap().unwrap_err().kind, ErrorKind::Assertion); } + +#[test] +fn bell_counts_waits_and_expectations_are_cumulative() { + let registry = SessionRegistry::default(); + let session = registry.session("bells"); + session.open(OpenOptions::default()).expect("open terminal"); + session + .execute(Operation::Submit { + data: Some(two_bells_command()), + }) + .expect("submit bell command"); + session + .execute(Operation::ExpectBellCount { + count: 2, + timeout_ms: Some(5_000), + }) + .expect("wait for two bells"); + session + .execute(Operation::WaitCommand { + timeout_ms: Some(30_000), + }) + .expect("wait for bell command"); + + let OperationResult::State(state) = session.execute(Operation::State).expect("read state") + else { + panic!("unexpected state result"); + }; + assert_eq!(state.bell_count, 2); + for _ in 0..2 { + assert!(matches!( + session + .execute(Operation::GetBellCount) + .expect("read bell count"), + OperationResult::BellCount(2) + )); + } + + session + .execute(Operation::Submit { + data: Some(delayed_bell_command()), + }) + .expect("submit delayed bell"); + session + .execute(Operation::WaitBell { + timeout_ms: Some(5_000), + }) + .expect("wait for the next bell"); + assert!(matches!( + session + .execute(Operation::GetBellCount) + .expect("read final bell count"), + OperationResult::BellCount(3) + )); + + session.close().expect("close terminal"); +} + +fn two_bells_command() -> String { + if cfg!(windows) { + "[Console]::Out.Write([char]7); [Console]::Out.Write([char]7)".to_string() + } else { + "printf '\\a\\a'".to_string() + } +} + +fn delayed_bell_command() -> String { + if cfg!(windows) { + "Start-Sleep -Milliseconds 300; [Console]::Out.Write([char]7)".to_string() + } else { + "sleep 0.3; printf '\\a'".to_string() + } +}