/** * Python driver that runs a Textual app headless in the app's own process and * drives it with an auto_pilot coroutine. * * The driver cannot be observed from the Node.js process, so it prints one * JSON document per line on stdout: an optional "update" line per action, then * the final "result" line. */ export interface PressAction { press: string | string[]; } export interface ClickTarget { /** Mouse button: 1=left, 2=middle, 3=right. */ button?: number; /** Hold control while clicking. */ control?: boolean; /** Hold meta while clicking. */ meta?: boolean; /** Click offset relative to the widget origin, default [0, 0]. */ offset?: [number, number]; /** Widget selector such as "#button" or "Button". */ selector: string; /** Hold shift while clicking. */ shift?: boolean; /** Number of clicks, default 1. */ times?: number; } export type ClickValue = string | ClickTarget; export type ScrollDirection = "down" | "up" | "left" | "right" | "home" | "end"; export interface ScrollTarget { /** Number of cells to scroll for down/up/left/right; default is one page. */ amount?: number; /** Scroll direction; home/end jump to the start/end of the widget. */ direction: ScrollDirection; /** Widget selector such as "#list" or "OptionList". */ selector: string; } export type DriverAction = | { press: string | string[] } | { click: ClickValue } | { double_click: ClickValue } | { hover: ClickValue } | { type: string } | { scroll: ScrollTarget } | { pause: number } | { resize: [number, number] } | { screenshot: boolean }; export interface DriverConfig { /** Actions executed in order by the auto_pilot coroutine. */ actions: DriverAction[]; /** Absolute path of the Python file that defines the Textual app. */ appPath: string; /** Screen coordinates [x, y] of the widget to report under "widgetAt". */ at?: [number, number]; /** Capture a summary of the messages the app processed. */ messages?: boolean; /** Selectors to inspect after the actions; the result carries "widgets". */ query?: string[]; /** Whether to export an SVG screenshot of the final screen. */ screenshot: boolean; /** Absolute path of the SVG file to write when a screenshot is requested. */ screenshotPath: string; /** Initial terminal size as [width, height]. */ size: [number, number]; /** Include the widget tree in the result. */ tree?: boolean; /** Emit one "update" line per action with the screen text at that point. */ updates?: boolean; } /** * Shared Python helpers used by both the one-shot driver and the session * driver. Written next to each generated driver so a plain * `import pi_textual_lib` works. */ export const PI_TEXTUAL_LIB = String.raw`import io import importlib.util import os import sys from collections import deque def load_app(path): sys.path.insert(0, os.path.dirname(os.path.abspath(path))) spec = importlib.util.spec_from_file_location("pi_textual_user_app", path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) candidate = getattr(module, "app", None) if candidate is not None: return candidate from textual.app import App subclasses = [ value for value in vars(module).values() if isinstance(value, type) and issubclass(value, App) and value is not App ] if len(subclasses) == 1: return subclasses[0]() if len(subclasses) > 1: raise ValueError( "The module defines several App subclasses. Define a module-level 'app' instance instead." ) raise ValueError( "The module defines no Textual App. Define a module-level 'app' instance or one App subclass." ) def click_args(target): """Normalizes a click target into Pilot.click keyword arguments.""" if isinstance(target, str): return {"widget": target} if not isinstance(target, dict): raise ValueError( f"click target must be a selector string or object, got {type(target).__name__}" ) return { "widget": target.get("selector"), "offset": tuple(target.get("offset") or (0, 0)), "times": target.get("times", 1), "button": target.get("button", 1), **{ modifier: target[modifier] for modifier in ("shift", "meta", "control") if modifier in target }, } def type_chars(text): """Splits typed text into one key press per character.""" mapping = {"\n": "enter", "\t": "tab"} return [mapping.get(char, char) for char in text] async def apply_action(pilot, app, action, screenshot_path=None): """Applies one action. Returns the SVG byte count when the action exported a screenshot.""" kind = next(iter(action)) if kind == "press": keys = action["press"] if isinstance(keys, str): keys = [keys] await pilot.press(*keys) elif kind == "click": await pilot.click(**click_args(action["click"])) elif kind == "double_click": await pilot.double_click(**click_args(action["double_click"])) elif kind == "hover": args = click_args(action["hover"]) for modifier in ("shift", "meta", "control"): args.pop(modifier, None) await pilot.hover(**args) elif kind == "type": await pilot.press(*type_chars(action["type"])) elif kind == "scroll": target = action["scroll"] widget = app.query_one(target["selector"]) # Let layout settle first: scroll targets computed before the widget # knows its scrollable size are clamped to zero and go nowhere. await pilot.pause(0.05) direction = target["direction"] amount = target.get("amount") if direction == "home": widget.scroll_home() elif direction == "end": widget.scroll_end() elif amount is None: getattr(widget, f"scroll_page_{direction}")() else: axis = "x" if direction in ("left", "right") else "y" delta = amount if direction in ("down", "right") else -amount widget.scroll_relative(**{axis: delta}) await pilot.pause(0.05) elif kind == "pause": await pilot.pause(action["pause"]) elif kind == "resize": width, height = action["resize"] await pilot.resize_terminal(width, height) elif kind == "screenshot": if screenshot_path is None: raise ValueError("a 'screenshot' action needs 'screenshot': true on the run") svg = app.export_screenshot() with open(screenshot_path, "w", encoding="utf-8") as file: file.write(svg) return len(svg.encode("utf-8")) else: raise ValueError(f"Unknown action: {kind!r}") return None def capture_text(app): strips = app.screen._compositor.render_strips() return "\n".join(strip.text.rstrip() for strip in strips).rstrip("\n") def check_render_strips(app): from textual import __version__ if not hasattr(app.screen._compositor, "render_strips"): raise RuntimeError( f"Textual {__version__} lacks the internal render_strips API; install textual 8.2.x" ) def simplify(value): if value is None or isinstance(value, (bool, int, float, str)): return value try: text = str(value) except Exception: return None return text if len(text) <= 500 else text[:500] + "..." def _option_prompt(option): if option is None: return None return simplify(getattr(option, "prompt", option)) def _tree_node_label(node): if node is None: return None return simplify(getattr(node, "label", node)) def _column_count(widget): return len(getattr(widget, "columns", ())) CURATED_STATE = { "Input": ( ("value", "value"), ("cursor_position", "cursor_position"), ("password", "password"), ("placeholder", "placeholder"), ), "TextArea": ( ("text", "text"), ("selection", "selection"), ("language", "language"), ("read_only", "read_only"), ), "Button": (("label", "label"), ("variant", "variant")), "Checkbox": (("value", "value"), ("label", "label")), "Switch": (("value", "value"),), "RadioSet": (("pressed_index", "pressed_index"),), "Select": (("value", "value"), ("prompt", "prompt")), "OptionList": ( ("option_count", "option_count"), ("highlighted_option", "highlighted_option", _option_prompt), ), "SelectionList": (("value", "value"),), "ListView": (("index", "index"),), "DataTable": ( ("row_count", "row_count"), ("column_count", "column_count", _column_count), ("cursor_row", "cursor_row"), ("cursor_column", "cursor_column"), ("hover_row", "hover_row"), ("hover_column", "hover_column"), ), "Tree": (("cursor_node", "cursor_node", _tree_node_label),), "TabbedContent": (("active", "active"), ("tab_count", "tab_count")), "ProgressBar": (("progress", "progress"), ("total", "total")), } def renderable_text(widget): # Static (and subclasses like Label) expose 'content' and a rendered # 'visual' in textual >= 8.2; older textual versions expose 'renderable'. content = getattr(widget, "content", None) if content is None: content = getattr(widget, "renderable", None) if content is None: return None if isinstance(content, str): return content try: visual = widget.visual except Exception: visual = None if visual is not None: try: text = str(visual).strip() except Exception: text = "" if text: return text try: from rich.console import Console console = Console( force_terminal=False, width=120, file=io.StringIO(), highlight=False, legacy_windows=False, ) console.print(content) text = console.file.getvalue().rstrip("\n") except Exception: return None return text if text else None def widget_state(widget): state = { "type": type(widget).__name__, "id": widget.id, "classes": sorted(widget.classes), "name": getattr(widget, "name", None), "visible": widget.visible, "disabled": getattr(widget, "disabled", False), "focus": widget.has_focus, } for entry in CURATED_STATE.get(type(widget).__name__, ()): attribute, key = entry[0], entry[1] formatter = entry[2] if len(entry) > 2 else simplify try: state[key] = formatter(getattr(widget, attribute)) except Exception: pass if "text" not in state: text = renderable_text(widget) if text is not None: state["text"] = text return state def widget_states(app, selectors): results = [] for selector in selectors: try: widgets = list(app.query(selector)) except Exception as error: results.append( {"selector": selector, "error": f"{type(error).__name__}: {error}"} ) continue results.append( { "selector": selector, "count": len(widgets), "widgets": [widget_state(widget) for widget in widgets], } ) return results class MessageCollector: """Counts and records the messages the app processes via message_hook.""" def __init__(self, max_recent=50): self.counts = {} self.recent = deque(maxlen=max_recent) def __call__(self, message): name = type(message).__name__ self.counts[name] = self.counts.get(name, 0) + 1 entry = {"type": name} for attribute in ("key", "button", "character"): value = getattr(message, attribute, None) if value is not None: entry[attribute] = simplify(value) break self.recent.append(entry) def summary(self): return { "total": sum(self.counts.values()), "counts": dict(self.counts), "recent": list(self.recent), } def reset(self): self.counts.clear() def install_message_hook(collector): """Routes every message the app processes to the collector. Must run before the app's message pumps are created: the pumps capture the context at task creation, so setting the variable later is invisible to them. """ from textual._context import message_hook as message_hook_var message_hook_var.set(collector) def focus_chain(app, max_depth=20): """The focused widget and its ancestors, innermost first.""" chain = [] widget = getattr(app, "focused", None) while widget is not None and len(chain) < max_depth: chain.append({"type": type(widget).__name__, "id": widget.id}) widget = widget.parent return chain def widget_at(app, x, y): """State snapshot of the widget under the given screen coordinates.""" widget, region = app.get_widget_at(x, y) state = widget_state(widget) state["region"] = { "x": region.x, "y": region.y, "width": region.width, "height": region.height, } return state def widget_tree(app, max_depth=10, max_nodes=400): counter = {"nodes": 0, "truncated": False} def walk(widget, depth): if counter["truncated"] or depth > max_depth: return None counter["nodes"] += 1 if counter["nodes"] > max_nodes: counter["truncated"] = True return None node = { "type": type(widget).__name__, "id": widget.id, "classes": sorted(widget.classes), "name": getattr(widget, "name", None), "visible": widget.visible, "disabled": getattr(widget, "disabled", False), "focus": widget.has_focus, "children": [], } for child in widget.children: child_node = walk(child, depth + 1) if child_node is not None: node["children"].append(child_node) return node root = walk(app.screen, 0) or {"type": "Screen", "id": None, "children": []} if counter["truncated"]: root["truncated"] = True return root `; const DRIVER_TEMPLATE = String.raw`import asyncio import base64 import json import os import sys import traceback sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import pi_textual_lib as lib CONFIG = json.loads(base64.b64decode("{{CONFIG}}").decode("utf-8")) def emit(document): # Textual swaps sys.stdout for a _PrintCapture while the app runs, and # that capture buffers into sys.__stdout__ without flushing it. Write # straight to the original stream so updates arrive while the app runs. line = json.dumps(document) + "\n" if sys.__stdout__ is not None: sys.__stdout__.write(line) sys.__stdout__.flush() else: os.write(1, line.encode("utf-8")) async def drive(app, pilot): result = {} screenshot_bytes = None for index, action in enumerate(CONFIG["actions"]): if app._exit or app._exception is not None: break try: action_bytes = await lib.apply_action( pilot, app, action, CONFIG.get("screenshotPath") ) if action_bytes is not None: screenshot_bytes = action_bytes except Exception as error: result["error"] = ( f"action {index} ({action}): {type(error).__name__}: {error}" ) app.exit(result) return if app._exit or app._exception is not None: break await pilot.wait_for_animation() if CONFIG.get("updates"): await pilot.pause(0.02) emit({"kind": "update", "index": index, "text": lib.capture_text(app)}) if app._exception is not None: # run_async re-raises it in main(); nothing more to do here. return if app._exit is True: result["appExitValue"] = app.return_value app.exit(result) return lib.check_render_strips(app) await pilot.wait_for_animation() await pilot.wait_for_scheduled_animations() await pilot.pause(0.05) result["text"] = lib.capture_text(app) result["size"] = [app.size.width, app.size.height] if CONFIG.get("tree"): result["tree"] = lib.widget_tree(app) result["focused"] = lib.focus_chain(app) if CONFIG.get("query"): result["widgets"] = lib.widget_states(app, CONFIG["query"]) if CONFIG.get("at"): result["widgetAt"] = lib.widget_at(app, *CONFIG["at"]) if CONFIG.get("screenshot") or screenshot_bytes is not None: svg = app.export_screenshot() result["svgPath"] = CONFIG["screenshotPath"] result["svgBytes"] = len(svg.encode("utf-8")) with open(CONFIG["screenshotPath"], "w", encoding="utf-8") as file: file.write(svg) app.exit(result) async def main(): collector = None if CONFIG.get("messages"): collector = lib.MessageCollector() lib.install_message_hook(collector) try: app = lib.load_app(CONFIG["appPath"]) await app.run_async( headless=True, size=tuple(CONFIG["size"]), auto_pilot=lambda pilot: drive(app, pilot), ) except Exception as error: emit( { "kind": "result", "error": f"{type(error).__name__}: {error}\n{traceback.format_exc()[-4000:]}", } ) return if app._exception is not None: error = app._exception trace = "".join( traceback.format_exception(type(error), error, error.__traceback__) ) emit( { "kind": "result", "error": f"{type(error).__name__}: {error}\n{trace[-4000:]}", } ) return value = app.return_value if isinstance(value, dict): if collector is not None: value["messages"] = collector.summary() emit({"kind": "result", **value}) else: emit({"kind": "result", "appExitValue": value}) asyncio.run(main()) `; /** * Builds the Python driver source for the given configuration. * The configuration is embedded as base64 so it survives any quote or * newline in the JSON and cannot break out of the Python literal. */ export function buildDriver(config: DriverConfig): string { const encoded = Buffer.from(JSON.stringify(config)).toString("base64"); return DRIVER_TEMPLATE.replace("{{CONFIG}}", encoded); }