Skip to content
Open
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
2 changes: 2 additions & 0 deletions bindings/js/native/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export interface RecordingOptions {
fps?: number
speed?: number
idleTimeLimit?: number
zoom?: number
}

export interface RunOptions {
Expand All @@ -170,6 +171,7 @@ export interface RunOptions {
export interface ScreenshotOptions {
full?: boolean
path?: string
zoom?: number
}

export declare function sessions(): Promise<Array<string>>
Expand Down
5 changes: 5 additions & 0 deletions bindings/js/native/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ pub struct SnapshotOptions {
pub struct ScreenshotOptions {
pub full: Option<bool>,
pub path: Option<String>,
pub zoom: Option<f64>,
}

#[napi(object)]
Expand All @@ -368,6 +369,7 @@ pub struct RecordingOptions {
pub fps: Option<f64>,
pub speed: Option<f64>,
pub idle_time_limit: Option<f64>,
pub zoom: Option<f64>,
}

#[napi(string_enum = "lowercase")]
Expand Down Expand Up @@ -1161,13 +1163,15 @@ impl NativeSession {
let options = options.unwrap_or(ScreenshotOptions {
full: None,
path: None,
zoom: None,
});
execute(
self.handle.clone(),
"screenshot",
Operation::Screenshot {
full: options.full.unwrap_or(false),
path: options.path,
zoom: options.zoom,
},
|result| match result {
OperationResult::Screenshot(CoreScreenshotResult::Path(value))
Expand All @@ -1193,6 +1197,7 @@ impl NativeSession {
fps,
speed: options.speed,
idle_time_limit: options.idle_time_limit,
zoom: options.zoom,
},
)
.await
Expand Down
13 changes: 12 additions & 1 deletion bindings/js/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export interface RecordingOptions {
fps?: number;
speed?: number;
idleTimeLimit?: number;
zoom?: number;
}

export interface ScreenshotOptions {
full?: boolean;
zoom?: number;
}

const TERMINAL_MARKER = "Terminal content:\n";
Expand Down Expand Up @@ -340,10 +346,14 @@ export class TuiTest {
return this.#runtime.getSize();
}

async screenshot(path: string | null = null, opts: { full?: boolean } = {}): Promise<string> {
async screenshot(path: string | null = null, opts: ScreenshotOptions = {}): Promise<string> {
if (opts.zoom !== undefined && path === null) {
throw new TypeError("screenshot zoom requires a path");
}
return this.#runtime.screenshot({
full: opts.full ?? false,
path: optional(path),
zoom: opts.zoom,
});
}

Expand All @@ -354,6 +364,7 @@ export class TuiTest {
fps: opts.fps,
speed: opts.speed,
idleTimeLimit: opts.idleTimeLimit,
zoom: opts.zoom,
});
}

Expand Down
1 change: 1 addition & 0 deletions bindings/js/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type {
TitleOptions,
RecordingFormat,
RecordingOptions,
ScreenshotOptions,
WaitTextOptions,
} from "./client.js";
export { uniqueSession } from "./ephemeral.js";
Expand Down
15 changes: 14 additions & 1 deletion bindings/js/test/integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ test("echo roundtrip drives a real session", async () => {
assert.deepEqual(await su.getSize(), { cols: 92, rows: 26 });
assert.ok((await su.cells(0, 0, 92, 26)).length > 0);
assert.match(await su.screenshot(), /hello-sdk/);
await assert.rejects(() => su.screenshot(null, { zoom: 0.5 }), /requires a path/);

await su.write("echo typed-write");
await su.keys("Enter");
Expand Down Expand Up @@ -95,7 +96,15 @@ test("recording API exports styled Unicode to APNG and GIF", async () => {
]) {
const path = join(root, `styled.${extension}`);
await withTerminal({ shell, cols: 20, rows: 4 }, async (su) => {
await su.startRecording(path, { format, fps: 30 });
if (format === "apng") {
const screenshotPath = join(root, "zoomed.svg");
await su.screenshot(screenshotPath, { zoom: 0.5 });
assert.match(
await readFile(screenshotPath, "utf8"),
/width="139" height="92" viewBox="0 0 278 184"/,
);
}
await su.startRecording(path, { format, fps: 30, zoom: 0.5 });
await su.submit(command);
await su.waitCommand();
assert.equal(await su.stopRecording(), path);
Expand All @@ -104,8 +113,12 @@ test("recording API exports styled Unicode to APNG and GIF", async () => {
if (format === "apng") {
assert.deepEqual(bytes.subarray(0, 8), Buffer.from("\x89PNG\r\n\x1a\n", "latin1"));
assert.ok(bytes.includes(Buffer.from("acTL")));
assert.equal(bytes.readUInt32BE(16), 278);
assert.equal(bytes.readUInt32BE(20), 184);
} else {
assert.equal(bytes.subarray(0, 6).toString("ascii"), "GIF89a");
assert.equal(bytes.readUInt16LE(6), 278);
assert.equal(bytes.readUInt16LE(8), 184);
}
}
} finally {
Expand Down
10 changes: 7 additions & 3 deletions bindings/python/native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -875,22 +875,24 @@ impl NativeSession {
)
}

#[pyo3(signature = (path, full))]
#[pyo3(signature = (path, full, zoom=None))]
fn screenshot<'py>(
&self,
py: Python<'py>,
path: Option<String>,
full: bool,
zoom: Option<f64>,
) -> PyResult<Bound<'py, PyAny>> {
let name = self.name.clone();
future_blocking(
py,
move || execute_screenshot(&name, Operation::Screenshot { full, path }),
move || execute_screenshot(&name, Operation::Screenshot { full, path, zoom }),
screenshot_to_py,
)
}

#[pyo3(signature = (path, format, fps, speed, idle_time_limit))]
#[allow(clippy::too_many_arguments)]
#[pyo3(signature = (path, format, fps, speed, idle_time_limit, zoom=None))]
fn start_recording<'py>(
&self,
py: Python<'py>,
Expand All @@ -899,6 +901,7 @@ impl NativeSession {
fps: Option<Bound<'py, PyAny>>,
speed: Option<f64>,
idle_time_limit: Option<f64>,
zoom: Option<f64>,
) -> PyResult<Bound<'py, PyAny>> {
let fps = capture_optional_integer(fps);
let name = self.name.clone();
Expand All @@ -916,6 +919,7 @@ impl NativeSession {
.transpose()?,
speed,
idle_time_limit,
zoom,
},
)
},
Expand Down
4 changes: 2 additions & 2 deletions bindings/python/src/tui_test/_native.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ class NativeSession:
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 snapshot(self, name: str, update: bool, include_colors: bool, include_title: bool, cwd: typing.Optional[str]) -> typing.Awaitable[str]: ...
def screenshot(self, path: typing.Optional[str], full: bool) -> typing.Awaitable[str]: ...
def start_recording(self, path: str, format: typing.Optional[str], fps: typing.Optional[int], speed: typing.Optional[float], idle_time_limit: typing.Optional[float]) -> typing.Awaitable[None]: ...
def screenshot(self, path: typing.Optional[str], full: bool, zoom: typing.Optional[float]) -> typing.Awaitable[str]: ...
def start_recording(self, path: str, format: typing.Optional[str], fps: typing.Optional[int], speed: typing.Optional[float], idle_time_limit: typing.Optional[float], zoom: typing.Optional[float]) -> typing.Awaitable[None]: ...
def stop_recording(self) -> typing.Awaitable[str]: ...
def recording(self) -> typing.Awaitable[str]: ...

Expand Down
13 changes: 10 additions & 3 deletions bindings/python/src/tui_test/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,15 @@ async def get_size(self) -> Dict[str, int]:
return await self._await(self._native.get_size())

async def screenshot(
self, path: Optional[str] = None, *, full: bool = False
self,
path: Optional[str] = None,
*,
full: bool = False,
zoom: Optional[float] = None,
) -> str:
return await self._await(self._native.screenshot(path, full))
if zoom is not None and path is None:
raise ValueError("screenshot zoom requires a path")
return await self._await(self._native.screenshot(path, full, zoom))

async def start_recording(
self,
Expand All @@ -370,10 +376,11 @@ async def start_recording(
fps: Optional[int] = None,
speed: Optional[float] = None,
idle_time_limit: Optional[float] = None,
zoom: Optional[float] = None,
) -> None:
await self._await(
self._native.start_recording(
path, format, fps, speed, idle_time_limit
path, format, fps, speed, idle_time_limit, zoom
)
)

Expand Down
8 changes: 7 additions & 1 deletion bindings/python/stub-gen/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,14 +207,20 @@ mod stubs {
include_title: bool,
cwd: typing.Optional[str],
) -> typing.Awaitable[str]: ...
def screenshot(self, path: typing.Optional[str], full: bool) -> typing.Awaitable[str]: ...
def screenshot(
self,
path: typing.Optional[str],
full: bool,
zoom: typing.Optional[float],
) -> typing.Awaitable[str]: ...
def start_recording(
self,
path: str,
format: typing.Optional[str],
fps: typing.Optional[int],
speed: typing.Optional[float],
idle_time_limit: typing.Optional[float],
zoom: typing.Optional[float],
) -> typing.Awaitable[None]: ...
def stop_recording(self) -> typing.Awaitable[str]: ...
def recording(self) -> typing.Awaitable[str]: ...
Expand Down
24 changes: 23 additions & 1 deletion bindings/python/tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,18 @@ async def scenario():
path = Path(root) / f"styled.{extension}"
async with self._client() as su:
await su.open(shell=SHELL, cols=20, rows=4)
if format == "apng":
screenshot = Path(root) / "zoomed.svg"
await su.screenshot(
str(screenshot), zoom=0.5
)
self.assertIn(
'width="139" height="92" '
'viewBox="0 0 278 184"',
screenshot.read_text(encoding="utf-8"),
)
await su.start_recording(
str(path), format=format, fps=30
str(path), format=format, fps=30, zoom=0.5
)
await su.submit(command)
await su.wait_command()
Expand All @@ -94,8 +104,20 @@ async def scenario():
if format == "apng":
self.assertEqual(data[:8], b"\x89PNG\r\n\x1a\n")
self.assertIn(b"acTL", data)
self.assertEqual(
int.from_bytes(data[16:20], "big"), 278
)
self.assertEqual(
int.from_bytes(data[20:24], "big"), 184
)
else:
self.assertEqual(data[:6], b"GIF89a")
self.assertEqual(
int.from_bytes(data[6:8], "little"), 278
)
self.assertEqual(
int.from_bytes(data[8:10], "little"), 184
)

run(scenario())

Expand Down
20 changes: 17 additions & 3 deletions bindings/python/tests/test_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,12 @@ def test_recording_helpers_use_typed_methods(self):
terminal = _CapturingClient("s")
run(
terminal.start_recording(
"demo.cast",
format="cast",
"demo.png",
format="apng",
fps=24,
speed=2.0,
idle_time_limit=3.0,
zoom=0.5,
)
)
run(terminal.stop_recording())
Expand All @@ -220,12 +221,25 @@ def test_recording_helpers_use_typed_methods(self):
[
(
"start_recording",
("demo.cast", "cast", 24, 2.0, 3.0),
("demo.png", "apng", 24, 2.0, 3.0, 0.5),
),
("stop_recording", ()),
],
)

def test_screenshot_forwards_zoom(self):
terminal = _CapturingClient("s")
run(terminal.screenshot("screen.svg", full=True, zoom=0.5))
self.assertEqual(
terminal.fake.calls,
[("screenshot", ("screen.svg", True, 0.5))],
)

def test_screenshot_rejects_zoom_without_path(self):
terminal = _CapturingClient("s")
with self.assertRaisesRegex(ValueError, "requires a path"):
run(terminal.screenshot(zoom=0.5))


class ClientTimeoutTests(unittest.TestCase):
def test_unconfigured_waits_pass_none(self):
Expand Down
29 changes: 29 additions & 0 deletions crates/tui-test-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ pub enum Command {
/// Include scrollback, not just the visible viewport.
#[arg(long)]
full: bool,
/// Scale the SVG dimensions while keeping the same terminal cells.
#[arg(long)]
zoom: Option<f64>,
},
/// Start or stop an animated terminal recording.
Record {
Expand Down Expand Up @@ -354,6 +357,9 @@ pub enum RecordCmd {
/// Clamp idle gaps to this many seconds.
#[arg(long)]
idle_time_limit: Option<f64>,
/// Scale image/video dimensions while keeping the same terminal cells.
#[arg(long)]
zoom: Option<f64>,
},
/// Stop the active recording and finish its output file.
Stop,
Expand Down Expand Up @@ -515,6 +521,8 @@ mod tests {
"2",
"--idle-time-limit",
"3",
"--zoom",
"0.5",
])
.expect("parse recording start");
assert!(matches!(
Expand All @@ -525,12 +533,33 @@ mod tests {
fps: Some(24),
speed: Some(2.0),
idle_time_limit: Some(3.0),
zoom: Some(0.5),
..
}
})
));
}

#[test]
fn screenshot_accepts_zoom() {
let cli = Cli::try_parse_from([
"tui-test",
"screenshot",
"--out",
"screen.svg",
"--zoom",
"0.5",
])
.expect("parse screenshot zoom");
assert!(matches!(
cli.command,
Some(Command::Screenshot {
zoom: Some(0.5),
..
})
));
}

#[test]
fn open_has_no_catch_all_timeout_flag() {
assert!(Cli::try_parse_from(["tui-test", "open", "--timeout", "1000"]).is_err());
Expand Down
Loading
Loading