{"slug": "from-i-can-t-click-to-a-full-testing-harness-how-we-built-playwright-for-the", "title": "From \"I Can't Click\" to a Full Testing Harness: How We Built Playwright for the Terminal", "summary": "A developer building TTT, a terminal text editor and IDE in Go, created a built-in scripted interaction system that allows AI agents to drive the editor like Playwright drives a browser. The system includes a Lua plugin API with click, screenshot, and debug commands, plus a CLI --exec flag for simple command sequences, enabling automated testing and AI-driven interaction.", "body_md": "I'm building [TTT](https://tttedit.dev) -- a terminal text editor and IDE written in Go. Single binary, zero config, runs anywhere. Think VS Code but in your terminal. It has syntax highlighting, LSP integration, a plugin system, an integrated terminal, git integration, etc...\n\nThe [source is on GitHub](https://github.com/eugenioenko/ttt) and I develop it with Claude Code as my pair programmer. This is the story of how a frustrating limitation turned into something genuinely useful: a built-in scripted interaction system that lets AI agents (or anyone) drive the editor like Playwright drives a browser.\n\nI was deep in revamping the widget system and building out a Lua plugin API. Phases of work stacking up -- widget rendering, panel support, tree views, input fields, command registration, keybinding hooks. The kind of work where you need to *see* what's happening. Click a tree node, check if it expands. Open a panel, verify focus moves correctly. Run a plugin, confirm the dialog appears.\n\nHere's the thing: Claude Code can run shell commands and read files. It cannot interact with a live TUI session. The editor launches, takes over the terminal, and that's it -- Claude is blind.\n\nThe project already had functional tests using [tui-use](https://github.com/meienberger/tui-use), a JavaScript library that drives a real terminal binary. It can type, press keys, wait for text to appear, and take snapshots:\n\n``` js\nconst tui = await start(\"bin/ttt\", [\"test-file.go\"]);\nawait tui.waitFor(\"test-file.go\");\nawait tui.exec(\"editor.joinLines\");\nconst screen = await tui.snapshot();\nexpect(screen).toContain(\"joined line\");\n```\n\nThis works. But it's slow -- each test spawns the binary, waits for screen renders, polls with timeouts, and parses terminal escape codes. And critically, **it can't click**. Mouse events aren't supported. For a widget system with tree views, buttons, and split panels, that's a dealbreaker.\n\nSo we added a `Debug: Simulate Click`\n\ncommand to the editor itself. Open the command palette, type coordinates, it fires a synthetic mouse event. Problem solved? Kind of. Claude still can't drive a live session to use it.\n\nBut it planted a seed: the editor can simulate its own input.\n\nTTT has a Lua plugin system. Plugins can register panels, commands, keybindings, and interact with the editor through a `ttt`\n\nmodule. We added `ttt.click(x, y)`\n\ndirectly to the Lua API:\n\n``` js\nlocal ttt = require(\"ttt\")\nttt.click(10, 5)\n```\n\nThen `ttt.screenshot(path)`\n\nto dump the screen to a file, and `ttt.debug(path)`\n\nto dump the full internal state -- widget tree, focus, selection, panels, cursor position -- as JSON.\n\nNow a Lua script could interact with the editor and capture results to files that Claude can read:\n\n``` js\nlocal ttt = require(\"ttt\")\nttt.click(10, 5)\nttt.screenshot(\"/tmp/after-click.txt\")\nttt.debug(\"/tmp/state.json\")\nttt.quit()\n```\n\nWe added a `--plugin`\n\nCLI flag to load a Lua file on startup, and `--size WxH`\n\nto force deterministic screen dimensions:\n\n```\nbin/ttt --plugin test.lua --size 120x40\ncat /tmp/after-click.txt\ncat /tmp/state.json\n```\n\nThis was a breakthrough. Claude could now write a Lua script, run the editor, and inspect the results. But writing a Lua file for every quick check felt heavy.\n\nThe insight: most debugging interactions are simple sequences. Click here, press that key, take a screenshot. Why write a file?\n\nWe added `--exec`\n\n, which takes a semicolon-separated string of commands:\n\n```\nbin/ttt --size 120x40 --exec \"wait 200; screenshot /tmp/s1.txt; click 10 5; wait 100; screenshot /tmp/s2.txt; quit\"\n```\n\nThe supported commands:\n\n| Command | What it does |\n|---|---|\n`click X Y` |\nSimulate a mouse click |\n`key COMBO` |\nSimulate a key press (`ctrl+p` , `enter` , etc.) |\n`type TEXT` |\nType a string character by character |\n`exec \"Command\"` |\nRun a command by title |\n`screenshot PATH` |\nDump screen text to a file |\n`debug PATH` |\nDump full state JSON (widget tree, focus, etc.) |\n`wait MS` |\nWait milliseconds |\n`quit` |\nExit |\n\nNow Claude can do this in a single bash command:\n\n```\nbin/ttt --size 120x40 file.go --exec \"wait 200; screenshot /tmp/screen.txt; debug /tmp/state.json; quit\" && cat /tmp/screen.txt\n```\n\nAnd instantly see what's on screen. No Lua file, no polling, no escape codes. Build, run, inspect -- milliseconds, not seconds.\n\nThe `debug`\n\ncommand captures everything you'd want to assert on:\n\n```\n{\n  \"screen\": { \"width\": 120, \"height\": 40 },\n  \"cursor\": { \"line\": 5, \"col\": 10 },\n  \"buffer\": { \"path\": \"file.go\", \"lines\": 42, \"modified\": false },\n  \"focus\": \"editor\",\n  \"sidebar\": { \"visible\": true, \"active\": \"explorer\", \"panels\": [\"explorer\", \"search\", \"changes\"] },\n  \"bottom_panel\": { \"visible\": false, \"active\": \"output\" },\n  \"tabs\": [{ \"path\": \"file.go\", \"modified\": false }],\n  \"selection\": { \"active\": false },\n  \"output\": [],\n  \"widget_tree\": {\n    \"type\": \"VStack\",\n    \"rect\": { \"x\": 0, \"y\": 0, \"w\": 120, \"h\": 40 },\n    \"children\": [\n      {\n        \"type\": \"MenuBar\",\n        \"rect\": { \"x\": 0, \"y\": 0, \"w\": 120, \"h\": 1 }\n      },\n      {\n        \"type\": \"SplitPanel\",\n        \"props\": { \"show_left\": true, \"divider_pos\": 30 },\n        \"children\": [\n          {\n            \"type\": \"Sidebar\",\n            \"props\": { \"visible\": true, \"active\": \"explorer\" },\n            \"children\": [\n              {\n                \"type\": \"Tree\",\n                \"props\": { \"items\": 12, \"selected\": 3 },\n                \"focused\": true\n              }\n            ]\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\nThe screenshot gives you the text. The debug dump gives you the state. Between the two, you can verify anything -- layout, focus, widget hierarchy, selection, which panel is active, how many items are in a tree.\n\nLooking back, each step was small but unlocked something the previous couldn't:\n\nWhat started as \"I can't click in tests\" ended up as a general-purpose testing and debugging harness that's faster than Playwright, gives deeper insight (you get the widget tree, not just pixels), and works for both AI agents and human developers.\n\nThe PR that adds all of this: [feat/debug-commands #274](https://github.com/eugenioenko/ttt/pull/274)\n\nThe immediate use case: I'm working through an audit of the plugin and widget system. Dozens of items to fix -- tree expand ordering, box padding, focus management, API consistency. For each fix, I can now:\n\n`bin/ttt --size 120x40 --exec \"wait 200; debug /tmp/state.json; quit\"`\n\nNo test file to maintain. No polling. No flaky waits. Just build, run, check.\n\nTTT is open source and available at [tttedit.dev](https://tttedit.dev). Install it, open a file, run `Debug: Dump State`\n\nfrom the command palette, and look at the JSON. The full widget tree is right there.\n\nIf you're building a TUI application and struggling with testing, consider this pattern: expose your internal state through a debug dump, add a scripted command interface, and let your tools (AI or otherwise) drive it programmatically. It's surprisingly little code for a lot of capability.", "url": "https://wpnews.pro/news/from-i-can-t-click-to-a-full-testing-harness-how-we-built-playwright-for-the", "canonical_source": "https://dev.to/eugenioenko/from-i-cant-click-to-a-full-testing-harness-how-we-built-playwright-for-the-terminal-1bf6", "published_at": "2026-06-27 21:48:30+00:00", "updated_at": "2026-06-27 22:03:42.758362+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "artificial-intelligence"], "entities": ["TTT", "Claude Code", "GitHub", "Lua", "Playwright"], "alternates": {"html": "https://wpnews.pro/news/from-i-can-t-click-to-a-full-testing-harness-how-we-built-playwright-for-the", "markdown": "https://wpnews.pro/news/from-i-can-t-click-to-a-full-testing-harness-how-we-built-playwright-for-the.md", "text": "https://wpnews.pro/news/from-i-can-t-click-to-a-full-testing-harness-how-we-built-playwright-for-the.txt", "jsonld": "https://wpnews.pro/news/from-i-can-t-click-to-a-full-testing-harness-how-we-built-playwright-for-the.jsonld"}}