Personal outliner built with Rust.
3

Configure Feed

Select the types of activity you want to include in your feed.

implement add-dev-automation: fixture graphs, gpui ui tests, devtools automation server, dev-loop skill

graham.systems (Jul 14, 2026, 10:53 AM -0700) 0b9dae0f 1e5631f6

+2249 -46
+90
.claude/skills/dev-loop/SKILL.md
··· 1 + --- 2 + name: dev-loop 3 + description: Run trawler with the devtools automation server to see and drive the app while developing — seed a fixture graph, launch, inject keystrokes, dump UI state, take screenshots. Use when asked to run the app, verify a change visually, screenshot the UI, or exercise a flow in the real app. 4 + --- 5 + 6 + Drive a real trawler instance over its devtools socket. Everything here is 7 + cross-platform Rust — no OS-specific automation tools. 8 + 9 + ## The loop 10 + 11 + 1. **Kill any running instance first** — a running `trawler.exe` holds a 12 + file lock that makes `cargo build` fail: 13 + 14 + ```sh 15 + # Windows: taskkill //IM trawler.exe //F (ignore "not found" errors) 16 + # POSIX: pkill -x trawler 17 + ``` 18 + 19 + 2. **Build with devtools** (release builds never include it): 20 + 21 + ```sh 22 + cargo build -p trawler --features devtools 23 + ``` 24 + 25 + 3. **Seed a scratch fixture graph** (deterministic content — same pages, 26 + blocks, tags, properties, and one query block every time; refuses to 27 + overwrite an existing graph, so remove the dir to reseed): 28 + 29 + ```sh 30 + ./target/debug/trawler --seed-fixtures <scratch-dir>/fixture-graph 31 + ``` 32 + 33 + Never point a dev session at the user's real graph 34 + (`%APPDATA%\trawler\graph`) — always set `TRAWLER_GRAPH_DIR`. 35 + 36 + 4. **Launch with the server enabled**, in the background: 37 + 38 + ```sh 39 + TRAWLER_GRAPH_DIR=<scratch-dir>/fixture-graph TRAWLER_DEVTOOLS=1 \ 40 + ./target/debug/trawler 41 + ``` 42 + 43 + The port is printed to stdout and written to 44 + `<graph-dir>/devtools.port`. 45 + 46 + 5. **Drive it**: connect to `127.0.0.1:<port>`, send one JSON object per 47 + line, read one JSON response per line (in order): 48 + 49 + ```json 50 + {"cmd":"keys","keys":"ctrl-k"} // keystrokes through the keymap 51 + {"cmd":"type","text":"trawler-design"} // text into the focused input 52 + {"cmd":"dump"} // structured UI state (v1) 53 + {"cmd":"bounds"} // window x/y/w/h/scale 54 + {"cmd":"screenshot","path":"C:/abs/path/shot.png"} 55 + ``` 56 + 57 + PowerShell client sketch: 58 + 59 + ```powershell 60 + $c = New-Object Net.Sockets.TcpClient("127.0.0.1", $port) 61 + $w = New-Object IO.StreamWriter($c.GetStream()); $w.AutoFlush = $true 62 + $r = New-Object IO.StreamReader($c.GetStream()) 63 + $w.WriteLine('{"cmd":"dump"}'); $r.ReadLine() 64 + ``` 65 + 66 + 6. **Look at the result**: `Read` the screenshot PNG (multimodal), or parse 67 + the `dump` JSON for assertions. Prefer `dump` for "is the state right", 68 + the screenshot for "does it look right". 69 + 70 + 7. **Clean up**: kill the instance when done (and before any rebuild). 71 + 72 + ## Facts that save time 73 + 74 + - Keystroke syntax is gpui's `Keystroke::parse`: `"ctrl-k"`, `"shift-tab"`, 75 + `"alt-up"`, `"enter"`, `"escape"`. Multiple in one command: 76 + `"ctrl-k enter"`. 77 + - Replies arrive after the action ran, but debounced work (query 78 + re-evaluation, ~400ms) settles later — poll `dump` until it does. 79 + - The journal view only renders journal pages. To reach fixture pages 80 + (`trawler-design`, `reading-list`), navigate first: 81 + `keys ctrl-k` → `type <page name>` → `keys enter`. Quick-open's top match 82 + for a partial name may be a tag, not the page — type the full page name. 83 + - The fixture's journal page is `2026-07-10`; the app also creates an 84 + empty journal page for the real today and focuses it at launch. 85 + - Malformed/unknown commands return `{"ok":false,...}` and are harmless. 86 + - Screenshot failing (`ok:false`) does not break the session — it's 87 + best-effort per platform (Wayland especially); `dump` always works. 88 + - For behavior questions, prefer the headless UI tests 89 + (`cargo test -p trawler`, see `crates/trawler/src/ui_tests.rs`) over 90 + driving the live app — they're deterministic and CI-runnable.
+713 -9
Cargo.lock
··· 3 3 version = 4 4 4 5 5 [[package]] 6 + name = "addr2line" 7 + version = "0.25.1" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" 10 + dependencies = [ 11 + "gimli", 12 + ] 13 + 14 + [[package]] 6 15 name = "adler2" 7 16 version = "2.0.1" 8 17 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 75 84 dependencies = [ 76 85 "libc", 77 86 ] 87 + 88 + [[package]] 89 + name = "annotate-snippets" 90 + version = "0.11.5" 91 + source = "registry+https://github.com/rust-lang/crates.io-index" 92 + checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" 93 + dependencies = [ 94 + "anstyle", 95 + "unicode-width 0.2.2", 96 + ] 97 + 98 + [[package]] 99 + name = "anstyle" 100 + version = "1.0.14" 101 + source = "registry+https://github.com/rust-lang/crates.io-index" 102 + checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" 78 103 79 104 [[package]] 80 105 name = "anyhow" ··· 532 557 ] 533 558 534 559 [[package]] 560 + name = "backtrace" 561 + version = "0.3.76" 562 + source = "registry+https://github.com/rust-lang/crates.io-index" 563 + checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" 564 + dependencies = [ 565 + "addr2line", 566 + "cfg-if", 567 + "libc", 568 + "miniz_oxide", 569 + "object", 570 + "rustc-demangle", 571 + "windows-link 0.2.1", 572 + ] 573 + 574 + [[package]] 535 575 name = "base64" 536 576 version = "0.22.1" 537 577 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 580 620 ] 581 621 582 622 [[package]] 623 + name = "bindgen" 624 + version = "0.72.1" 625 + source = "registry+https://github.com/rust-lang/crates.io-index" 626 + checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" 627 + dependencies = [ 628 + "annotate-snippets", 629 + "bitflags 2.13.0", 630 + "cexpr", 631 + "clang-sys", 632 + "itertools 0.13.0", 633 + "proc-macro2", 634 + "quote", 635 + "regex", 636 + "rustc-hash 2.1.3", 637 + "shlex 1.3.0", 638 + "syn 2.0.118", 639 + ] 640 + 641 + [[package]] 583 642 name = "bit-set" 584 643 version = "0.8.0" 585 644 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 915 974 checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" 916 975 dependencies = [ 917 976 "nom 7.1.3", 977 + ] 978 + 979 + [[package]] 980 + name = "cfg-expr" 981 + version = "0.20.8" 982 + source = "registry+https://github.com/rust-lang/crates.io-index" 983 + checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" 984 + dependencies = [ 985 + "smallvec", 986 + "target-lexicon", 918 987 ] 919 988 920 989 [[package]] ··· 1169 1238 checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" 1170 1239 1171 1240 [[package]] 1241 + name = "convert_case" 1242 + version = "0.8.0" 1243 + source = "registry+https://github.com/rust-lang/crates.io-index" 1244 + checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" 1245 + dependencies = [ 1246 + "unicode-segmentation", 1247 + ] 1248 + 1249 + [[package]] 1250 + name = "cookie-factory" 1251 + version = "0.3.3" 1252 + source = "registry+https://github.com/rust-lang/crates.io-index" 1253 + checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" 1254 + 1255 + [[package]] 1172 1256 name = "core-foundation" 1173 1257 version = "0.9.4" 1174 1258 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1528 1612 source = "registry+https://github.com/rust-lang/crates.io-index" 1529 1613 checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" 1530 1614 dependencies = [ 1531 - "convert_case", 1615 + "convert_case 0.4.0", 1532 1616 "proc-macro2", 1533 1617 "quote", 1534 1618 "rustc_version", ··· 1606 1690 checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" 1607 1691 dependencies = [ 1608 1692 "bitflags 2.13.0", 1693 + "block2", 1694 + "libc", 1609 1695 "objc2", 1610 1696 ] 1611 1697 ··· 1642 1728 checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" 1643 1729 1644 1730 [[package]] 1731 + name = "drm" 1732 + version = "0.14.1" 1733 + source = "registry+https://github.com/rust-lang/crates.io-index" 1734 + checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" 1735 + dependencies = [ 1736 + "bitflags 2.13.0", 1737 + "bytemuck", 1738 + "drm-ffi", 1739 + "drm-fourcc", 1740 + "libc", 1741 + "rustix 0.38.44", 1742 + ] 1743 + 1744 + [[package]] 1745 + name = "drm-ffi" 1746 + version = "0.9.1" 1747 + source = "registry+https://github.com/rust-lang/crates.io-index" 1748 + checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" 1749 + dependencies = [ 1750 + "drm-sys", 1751 + "rustix 1.1.4", 1752 + ] 1753 + 1754 + [[package]] 1755 + name = "drm-fourcc" 1756 + version = "2.2.0" 1757 + source = "registry+https://github.com/rust-lang/crates.io-index" 1758 + checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" 1759 + 1760 + [[package]] 1761 + name = "drm-sys" 1762 + version = "0.8.1" 1763 + source = "registry+https://github.com/rust-lang/crates.io-index" 1764 + checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" 1765 + dependencies = [ 1766 + "libc", 1767 + "linux-raw-sys 0.9.4", 1768 + ] 1769 + 1770 + [[package]] 1645 1771 name = "dtor" 1646 1772 version = "0.0.6" 1647 1773 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2255 2381 ] 2256 2382 2257 2383 [[package]] 2384 + name = "gbm" 2385 + version = "0.18.0" 2386 + source = "registry+https://github.com/rust-lang/crates.io-index" 2387 + checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" 2388 + dependencies = [ 2389 + "bitflags 2.13.0", 2390 + "drm", 2391 + "drm-fourcc", 2392 + "gbm-sys", 2393 + "libc", 2394 + "wayland-backend", 2395 + "wayland-server", 2396 + ] 2397 + 2398 + [[package]] 2399 + name = "gbm-sys" 2400 + version = "0.4.0" 2401 + source = "registry+https://github.com/rust-lang/crates.io-index" 2402 + checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" 2403 + dependencies = [ 2404 + "libc", 2405 + ] 2406 + 2407 + [[package]] 2258 2408 name = "generator" 2259 2409 version = "0.8.9" 2260 2410 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2363 2513 ] 2364 2514 2365 2515 [[package]] 2516 + name = "gimli" 2517 + version = "0.32.3" 2518 + source = "registry+https://github.com/rust-lang/crates.io-index" 2519 + checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" 2520 + 2521 + [[package]] 2522 + name = "git2" 2523 + version = "0.20.4" 2524 + source = "registry+https://github.com/rust-lang/crates.io-index" 2525 + checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" 2526 + dependencies = [ 2527 + "bitflags 2.13.0", 2528 + "libc", 2529 + "libgit2-sys", 2530 + "log", 2531 + "url", 2532 + ] 2533 + 2534 + [[package]] 2535 + name = "gl" 2536 + version = "0.14.0" 2537 + source = "registry+https://github.com/rust-lang/crates.io-index" 2538 + checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" 2539 + dependencies = [ 2540 + "gl_generator", 2541 + ] 2542 + 2543 + [[package]] 2544 + name = "gl_generator" 2545 + version = "0.14.0" 2546 + source = "registry+https://github.com/rust-lang/crates.io-index" 2547 + checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" 2548 + dependencies = [ 2549 + "khronos_api", 2550 + "log", 2551 + "xml-rs", 2552 + ] 2553 + 2554 + [[package]] 2366 2555 name = "glob" 2367 2556 version = "0.3.3" 2368 2557 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2445 2634 "as-raw-xcb-connection", 2446 2635 "ashpd 0.11.1", 2447 2636 "async-task", 2448 - "bindgen", 2637 + "backtrace", 2638 + "bindgen 0.71.1", 2449 2639 "blade-graphics", 2450 2640 "blade-macros", 2451 2641 "blade-util", ··· 2521 2711 "wayland-protocols-plasma", 2522 2712 "windows 0.61.3", 2523 2713 "windows-core 0.61.2", 2524 - "windows-numerics", 2714 + "windows-numerics 0.2.0", 2525 2715 "windows-registry 0.5.3", 2526 2716 "x11-clipboard", 2527 2717 "x11rb", ··· 2597 2787 checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b" 2598 2788 dependencies = [ 2599 2789 "anyhow", 2600 - "bindgen", 2790 + "bindgen 0.71.1", 2601 2791 "core-foundation 0.10.0", 2602 2792 "core-video", 2603 2793 "ctor", ··· 2661 2851 "dunce", 2662 2852 "futures", 2663 2853 "futures-lite 1.13.0", 2854 + "git2", 2664 2855 "globset", 2665 2856 "gpui_collections", 2857 + "gpui_util_macros", 2666 2858 "itertools 0.14.0", 2667 2859 "libc", 2668 2860 "log", 2669 2861 "nix 0.29.0", 2862 + "rand 0.9.4", 2670 2863 "regex", 2671 2864 "rust-embed", 2672 2865 "schemars", ··· 3410 3603 dependencies = [ 3411 3604 "libc", 3412 3605 "libloading", 3606 + "pkg-config", 3413 3607 ] 3608 + 3609 + [[package]] 3610 + name = "khronos_api" 3611 + version = "3.1.0" 3612 + source = "registry+https://github.com/rust-lang/crates.io-index" 3613 + checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" 3414 3614 3415 3615 [[package]] 3416 3616 name = "kurbo" ··· 3503 3703 ] 3504 3704 3505 3705 [[package]] 3706 + name = "libgit2-sys" 3707 + version = "0.18.5+1.9.4" 3708 + source = "registry+https://github.com/rust-lang/crates.io-index" 3709 + checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" 3710 + dependencies = [ 3711 + "cc", 3712 + "libc", 3713 + "libz-sys", 3714 + "pkg-config", 3715 + ] 3716 + 3717 + [[package]] 3506 3718 name = "libloading" 3507 3719 version = "0.8.9" 3508 3720 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3528 3740 ] 3529 3741 3530 3742 [[package]] 3743 + name = "libspa" 3744 + version = "0.9.2" 3745 + source = "registry+https://github.com/rust-lang/crates.io-index" 3746 + checksum = "b6b8cfa2a7656627b4c92c6b9ef929433acd673d5ab3708cda1b18478ac00df4" 3747 + dependencies = [ 3748 + "bitflags 2.13.0", 3749 + "cc", 3750 + "convert_case 0.8.0", 3751 + "cookie-factory", 3752 + "libc", 3753 + "libspa-sys", 3754 + "nix 0.30.1", 3755 + "nom 8.0.0", 3756 + "system-deps", 3757 + ] 3758 + 3759 + [[package]] 3760 + name = "libspa-sys" 3761 + version = "0.9.2" 3762 + source = "registry+https://github.com/rust-lang/crates.io-index" 3763 + checksum = "901049455d2eb6decf9058235d745237952f4804bc584c5fcb41412e6adcc6e0" 3764 + dependencies = [ 3765 + "bindgen 0.72.1", 3766 + "cc", 3767 + "system-deps", 3768 + ] 3769 + 3770 + [[package]] 3771 + name = "libwayshot-xcap" 3772 + version = "0.3.2" 3773 + source = "registry+https://github.com/rust-lang/crates.io-index" 3774 + checksum = "558a3a7ca16a17a14adf8f051b3adcd7766d397532f5f6d6a48034db11e54c22" 3775 + dependencies = [ 3776 + "drm", 3777 + "gbm", 3778 + "gl", 3779 + "image", 3780 + "khronos-egl", 3781 + "memmap2", 3782 + "rustix 1.1.4", 3783 + "thiserror 2.0.18", 3784 + "tracing", 3785 + "wayland-backend", 3786 + "wayland-client", 3787 + "wayland-protocols 0.32.13", 3788 + "wayland-protocols-wlr", 3789 + ] 3790 + 3791 + [[package]] 3792 + name = "libz-sys" 3793 + version = "1.1.25" 3794 + source = "registry+https://github.com/rust-lang/crates.io-index" 3795 + checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1" 3796 + dependencies = [ 3797 + "cc", 3798 + "libc", 3799 + "pkg-config", 3800 + "vcpkg", 3801 + ] 3802 + 3803 + [[package]] 3531 3804 name = "linux-raw-sys" 3532 3805 version = "0.4.15" 3533 3806 source = "registry+https://github.com/rust-lang/crates.io-index" 3534 3807 checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 3808 + 3809 + [[package]] 3810 + name = "linux-raw-sys" 3811 + version = "0.9.4" 3812 + source = "registry+https://github.com/rust-lang/crates.io-index" 3813 + checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" 3535 3814 3536 3815 [[package]] 3537 3816 name = "linux-raw-sys" ··· 4025 4304 4026 4305 [[package]] 4027 4306 name = "nix" 4307 + version = "0.30.1" 4308 + source = "registry+https://github.com/rust-lang/crates.io-index" 4309 + checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" 4310 + dependencies = [ 4311 + "bitflags 2.13.0", 4312 + "cfg-if", 4313 + "cfg_aliases", 4314 + "libc", 4315 + ] 4316 + 4317 + [[package]] 4318 + name = "nix" 4028 4319 version = "0.31.3" 4029 4320 source = "registry+https://github.com/rust-lang/crates.io-index" 4030 4321 checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" ··· 4249 4540 checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" 4250 4541 dependencies = [ 4251 4542 "bitflags 2.13.0", 4543 + "block2", 4544 + "libc", 4252 4545 "objc2", 4546 + "objc2-cloud-kit", 4547 + "objc2-core-data", 4253 4548 "objc2-core-foundation", 4549 + "objc2-core-graphics", 4550 + "objc2-core-image", 4551 + "objc2-core-text", 4552 + "objc2-core-video", 4254 4553 "objc2-foundation", 4255 4554 "objc2-quartz-core", 4256 4555 ] 4257 4556 4258 4557 [[package]] 4558 + name = "objc2-av-foundation" 4559 + version = "0.3.2" 4560 + source = "registry+https://github.com/rust-lang/crates.io-index" 4561 + checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" 4562 + dependencies = [ 4563 + "bitflags 2.13.0", 4564 + "block2", 4565 + "dispatch2", 4566 + "objc2", 4567 + "objc2-avf-audio", 4568 + "objc2-core-audio-types", 4569 + "objc2-core-foundation", 4570 + "objc2-core-graphics", 4571 + "objc2-core-image", 4572 + "objc2-core-video", 4573 + "objc2-foundation", 4574 + "objc2-image-io", 4575 + "objc2-media-toolbox", 4576 + "objc2-quartz-core", 4577 + ] 4578 + 4579 + [[package]] 4580 + name = "objc2-avf-audio" 4581 + version = "0.3.2" 4582 + source = "registry+https://github.com/rust-lang/crates.io-index" 4583 + checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" 4584 + dependencies = [ 4585 + "objc2", 4586 + "objc2-foundation", 4587 + ] 4588 + 4589 + [[package]] 4590 + name = "objc2-cloud-kit" 4591 + version = "0.3.2" 4592 + source = "registry+https://github.com/rust-lang/crates.io-index" 4593 + checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" 4594 + dependencies = [ 4595 + "bitflags 2.13.0", 4596 + "objc2", 4597 + "objc2-foundation", 4598 + ] 4599 + 4600 + [[package]] 4601 + name = "objc2-core-audio" 4602 + version = "0.3.2" 4603 + source = "registry+https://github.com/rust-lang/crates.io-index" 4604 + checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" 4605 + dependencies = [ 4606 + "dispatch2", 4607 + "objc2", 4608 + "objc2-core-audio-types", 4609 + "objc2-core-foundation", 4610 + ] 4611 + 4612 + [[package]] 4613 + name = "objc2-core-audio-types" 4614 + version = "0.3.2" 4615 + source = "registry+https://github.com/rust-lang/crates.io-index" 4616 + checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" 4617 + dependencies = [ 4618 + "bitflags 2.13.0", 4619 + "objc2", 4620 + ] 4621 + 4622 + [[package]] 4623 + name = "objc2-core-data" 4624 + version = "0.3.2" 4625 + source = "registry+https://github.com/rust-lang/crates.io-index" 4626 + checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" 4627 + dependencies = [ 4628 + "bitflags 2.13.0", 4629 + "objc2", 4630 + "objc2-foundation", 4631 + ] 4632 + 4633 + [[package]] 4259 4634 name = "objc2-core-foundation" 4260 4635 version = "0.3.2" 4261 4636 source = "registry+https://github.com/rust-lang/crates.io-index" 4262 4637 checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" 4263 4638 dependencies = [ 4264 4639 "bitflags 2.13.0", 4640 + "block2", 4265 4641 "dispatch2", 4642 + "libc", 4266 4643 "objc2", 4267 4644 ] 4268 4645 4269 4646 [[package]] 4647 + name = "objc2-core-graphics" 4648 + version = "0.3.2" 4649 + source = "registry+https://github.com/rust-lang/crates.io-index" 4650 + checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" 4651 + dependencies = [ 4652 + "bitflags 2.13.0", 4653 + "block2", 4654 + "dispatch2", 4655 + "libc", 4656 + "objc2", 4657 + "objc2-core-foundation", 4658 + "objc2-io-surface", 4659 + "objc2-metal", 4660 + ] 4661 + 4662 + [[package]] 4663 + name = "objc2-core-image" 4664 + version = "0.3.2" 4665 + source = "registry+https://github.com/rust-lang/crates.io-index" 4666 + checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" 4667 + dependencies = [ 4668 + "objc2", 4669 + "objc2-foundation", 4670 + ] 4671 + 4672 + [[package]] 4673 + name = "objc2-core-media" 4674 + version = "0.3.2" 4675 + source = "registry+https://github.com/rust-lang/crates.io-index" 4676 + checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" 4677 + dependencies = [ 4678 + "bitflags 2.13.0", 4679 + "block2", 4680 + "dispatch2", 4681 + "objc2", 4682 + "objc2-core-audio", 4683 + "objc2-core-audio-types", 4684 + "objc2-core-foundation", 4685 + "objc2-core-video", 4686 + ] 4687 + 4688 + [[package]] 4689 + name = "objc2-core-text" 4690 + version = "0.3.2" 4691 + source = "registry+https://github.com/rust-lang/crates.io-index" 4692 + checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" 4693 + dependencies = [ 4694 + "bitflags 2.13.0", 4695 + "objc2", 4696 + "objc2-core-foundation", 4697 + "objc2-core-graphics", 4698 + ] 4699 + 4700 + [[package]] 4701 + name = "objc2-core-video" 4702 + version = "0.3.2" 4703 + source = "registry+https://github.com/rust-lang/crates.io-index" 4704 + checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" 4705 + dependencies = [ 4706 + "bitflags 2.13.0", 4707 + "block2", 4708 + "objc2", 4709 + "objc2-core-foundation", 4710 + "objc2-core-graphics", 4711 + "objc2-io-surface", 4712 + "objc2-metal", 4713 + ] 4714 + 4715 + [[package]] 4270 4716 name = "objc2-encode" 4271 4717 version = "4.1.0" 4272 4718 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4279 4725 checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" 4280 4726 dependencies = [ 4281 4727 "bitflags 2.13.0", 4728 + "block2", 4729 + "libc", 4282 4730 "objc2", 4283 4731 "objc2-core-foundation", 4732 + ] 4733 + 4734 + [[package]] 4735 + name = "objc2-image-io" 4736 + version = "0.3.2" 4737 + source = "registry+https://github.com/rust-lang/crates.io-index" 4738 + checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" 4739 + dependencies = [ 4740 + "objc2", 4741 + "objc2-core-foundation", 4742 + "objc2-core-graphics", 4743 + ] 4744 + 4745 + [[package]] 4746 + name = "objc2-io-surface" 4747 + version = "0.3.2" 4748 + source = "registry+https://github.com/rust-lang/crates.io-index" 4749 + checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" 4750 + dependencies = [ 4751 + "bitflags 2.13.0", 4752 + "objc2", 4753 + "objc2-core-foundation", 4754 + ] 4755 + 4756 + [[package]] 4757 + name = "objc2-media-toolbox" 4758 + version = "0.3.2" 4759 + source = "registry+https://github.com/rust-lang/crates.io-index" 4760 + checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" 4761 + dependencies = [ 4762 + "objc2", 4763 + "objc2-core-audio-types", 4764 + "objc2-core-foundation", 4765 + "objc2-core-media", 4284 4766 ] 4285 4767 4286 4768 [[package]] ··· 4616 5098 ] 4617 5099 4618 5100 [[package]] 5101 + name = "pipewire" 5102 + version = "0.9.2" 5103 + source = "registry+https://github.com/rust-lang/crates.io-index" 5104 + checksum = "9688b89abf11d756499f7c6190711d6dbe5a3acdb30c8fbf001d6596d06a8d44" 5105 + dependencies = [ 5106 + "anyhow", 5107 + "bitflags 2.13.0", 5108 + "libc", 5109 + "libspa", 5110 + "libspa-sys", 5111 + "nix 0.30.1", 5112 + "once_cell", 5113 + "pipewire-sys", 5114 + "thiserror 2.0.18", 5115 + ] 5116 + 5117 + [[package]] 5118 + name = "pipewire-sys" 5119 + version = "0.9.2" 5120 + source = "registry+https://github.com/rust-lang/crates.io-index" 5121 + checksum = "cb028afee0d6ca17020b090e3b8fa2d7de23305aef975c7e5192a5050246ea36" 5122 + dependencies = [ 5123 + "bindgen 0.72.1", 5124 + "libspa-sys", 5125 + "system-deps", 5126 + ] 5127 + 5128 + [[package]] 4619 5129 name = "pkg-config" 4620 5130 version = "0.3.33" 4621 5131 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 5317 5827 "serde", 5318 5828 "serde_derive", 5319 5829 ] 5830 + 5831 + [[package]] 5832 + name = "rustc-demangle" 5833 + version = "0.1.27" 5834 + source = "registry+https://github.com/rust-lang/crates.io-index" 5835 + checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" 5320 5836 5321 5837 [[package]] 5322 5838 name = "rustc-hash" ··· 6330 6846 ] 6331 6847 6332 6848 [[package]] 6849 + name = "system-deps" 6850 + version = "7.0.8" 6851 + source = "registry+https://github.com/rust-lang/crates.io-index" 6852 + checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" 6853 + dependencies = [ 6854 + "cfg-expr", 6855 + "heck 0.5.0", 6856 + "pkg-config", 6857 + "toml 1.1.2+spec-1.1.0", 6858 + "version-compare", 6859 + ] 6860 + 6861 + [[package]] 6333 6862 name = "taffy" 6334 6863 version = "0.9.0" 6335 6864 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6508 7037 ] 6509 7038 6510 7039 [[package]] 7040 + name = "target-lexicon" 7041 + version = "0.13.5" 7042 + source = "registry+https://github.com/rust-lang/crates.io-index" 7043 + checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" 7044 + 7045 + [[package]] 6511 7046 name = "tempfile" 6512 7047 version = "3.27.0" 6513 7048 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6941 7476 "gpui", 6942 7477 "loro", 6943 7478 "pulldown-cmark", 7479 + "serde", 7480 + "serde_json", 6944 7481 "trawler-core", 6945 7482 "unicode-segmentation", 7483 + "xcap", 6946 7484 ] 6947 7485 6948 7486 [[package]] ··· 6955 7493 "serde", 6956 7494 "steel-core", 6957 7495 "tantivy", 7496 + "trawler-core", 6958 7497 ] 6959 7498 6960 7499 [[package]] ··· 7264 7803 ] 7265 7804 7266 7805 [[package]] 7806 + name = "vcpkg" 7807 + version = "0.2.15" 7808 + source = "registry+https://github.com/rust-lang/crates.io-index" 7809 + checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 7810 + 7811 + [[package]] 7812 + name = "version-compare" 7813 + version = "0.2.1" 7814 + source = "registry+https://github.com/rust-lang/crates.io-index" 7815 + checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" 7816 + 7817 + [[package]] 7267 7818 name = "version_check" 7268 7819 version = "0.9.5" 7269 7820 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7472 8023 ] 7473 8024 7474 8025 [[package]] 8026 + name = "wayland-protocols-wlr" 8027 + version = "0.3.12" 8028 + source = "registry+https://github.com/rust-lang/crates.io-index" 8029 + checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" 8030 + dependencies = [ 8031 + "bitflags 2.13.0", 8032 + "wayland-backend", 8033 + "wayland-client", 8034 + "wayland-protocols 0.32.13", 8035 + "wayland-scanner", 8036 + ] 8037 + 8038 + [[package]] 7475 8039 name = "wayland-scanner" 7476 8040 version = "0.31.10" 7477 8041 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7483 8047 ] 7484 8048 7485 8049 [[package]] 8050 + name = "wayland-server" 8051 + version = "0.31.13" 8052 + source = "registry+https://github.com/rust-lang/crates.io-index" 8053 + checksum = "cc1846eb04c49182e04f4a099e2a830a2b745610bbc1d61246e206f29c7000a0" 8054 + dependencies = [ 8055 + "bitflags 2.13.0", 8056 + "downcast-rs 1.2.1", 8057 + "rustix 1.1.4", 8058 + "wayland-backend", 8059 + "wayland-scanner", 8060 + ] 8061 + 8062 + [[package]] 7486 8063 name = "wayland-sys" 7487 8064 version = "0.31.11" 7488 8065 source = "registry+https://github.com/rust-lang/crates.io-index" 7489 8066 checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" 7490 8067 dependencies = [ 7491 8068 "dlib", 8069 + "libc", 7492 8070 "log", 8071 + "memoffset", 7493 8072 "once_cell", 7494 8073 "pkg-config", 7495 8074 ] ··· 7548 8127 ] 7549 8128 7550 8129 [[package]] 8130 + name = "widestring" 8131 + version = "1.2.1" 8132 + source = "registry+https://github.com/rust-lang/crates.io-index" 8133 + checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" 8134 + 8135 + [[package]] 7551 8136 name = "winapi" 7552 8137 version = "0.3.9" 7553 8138 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7594 8179 source = "registry+https://github.com/rust-lang/crates.io-index" 7595 8180 checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" 7596 8181 dependencies = [ 7597 - "windows-collections", 8182 + "windows-collections 0.2.0", 7598 8183 "windows-core 0.61.2", 7599 - "windows-future", 8184 + "windows-future 0.2.1", 7600 8185 "windows-link 0.1.3", 7601 - "windows-numerics", 8186 + "windows-numerics 0.2.0", 8187 + ] 8188 + 8189 + [[package]] 8190 + name = "windows" 8191 + version = "0.62.2" 8192 + source = "registry+https://github.com/rust-lang/crates.io-index" 8193 + checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" 8194 + dependencies = [ 8195 + "windows-collections 0.3.2", 8196 + "windows-core 0.62.2", 8197 + "windows-future 0.3.2", 8198 + "windows-numerics 0.3.1", 7602 8199 ] 7603 8200 7604 8201 [[package]] ··· 7611 8208 "rayon", 7612 8209 "thiserror 2.0.18", 7613 8210 "windows 0.61.3", 7614 - "windows-future", 8211 + "windows-future 0.2.1", 7615 8212 ] 7616 8213 7617 8214 [[package]] ··· 7624 8221 ] 7625 8222 7626 8223 [[package]] 8224 + name = "windows-collections" 8225 + version = "0.3.2" 8226 + source = "registry+https://github.com/rust-lang/crates.io-index" 8227 + checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" 8228 + dependencies = [ 8229 + "windows-core 0.62.2", 8230 + ] 8231 + 8232 + [[package]] 7627 8233 name = "windows-core" 7628 8234 version = "0.57.0" 7629 8235 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7649 8255 ] 7650 8256 7651 8257 [[package]] 8258 + name = "windows-core" 8259 + version = "0.62.2" 8260 + source = "registry+https://github.com/rust-lang/crates.io-index" 8261 + checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 8262 + dependencies = [ 8263 + "windows-implement 0.60.2", 8264 + "windows-interface 0.59.3", 8265 + "windows-link 0.2.1", 8266 + "windows-result 0.4.1", 8267 + "windows-strings 0.5.1", 8268 + ] 8269 + 8270 + [[package]] 7652 8271 name = "windows-future" 7653 8272 version = "0.2.1" 7654 8273 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7656 8275 dependencies = [ 7657 8276 "windows-core 0.61.2", 7658 8277 "windows-link 0.1.3", 7659 - "windows-threading", 8278 + "windows-threading 0.1.0", 8279 + ] 8280 + 8281 + [[package]] 8282 + name = "windows-future" 8283 + version = "0.3.2" 8284 + source = "registry+https://github.com/rust-lang/crates.io-index" 8285 + checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" 8286 + dependencies = [ 8287 + "windows-core 0.62.2", 8288 + "windows-link 0.2.1", 8289 + "windows-threading 0.2.1", 7660 8290 ] 7661 8291 7662 8292 [[package]] ··· 7726 8356 ] 7727 8357 7728 8358 [[package]] 8359 + name = "windows-numerics" 8360 + version = "0.3.1" 8361 + source = "registry+https://github.com/rust-lang/crates.io-index" 8362 + checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" 8363 + dependencies = [ 8364 + "windows-core 0.62.2", 8365 + "windows-link 0.2.1", 8366 + ] 8367 + 8368 + [[package]] 7729 8369 name = "windows-registry" 7730 8370 version = "0.4.0" 7731 8371 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7766 8406 ] 7767 8407 7768 8408 [[package]] 8409 + name = "windows-result" 8410 + version = "0.4.1" 8411 + source = "registry+https://github.com/rust-lang/crates.io-index" 8412 + checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 8413 + dependencies = [ 8414 + "windows-link 0.2.1", 8415 + ] 8416 + 8417 + [[package]] 7769 8418 name = "windows-strings" 7770 8419 version = "0.3.1" 7771 8420 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 7781 8430 checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 7782 8431 dependencies = [ 7783 8432 "windows-link 0.1.3", 8433 + ] 8434 + 8435 + [[package]] 8436 + name = "windows-strings" 8437 + version = "0.5.1" 8438 + source = "registry+https://github.com/rust-lang/crates.io-index" 8439 + checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 8440 + dependencies = [ 8441 + "windows-link 0.2.1", 7784 8442 ] 7785 8443 7786 8444 [[package]] ··· 7886 8544 ] 7887 8545 7888 8546 [[package]] 8547 + name = "windows-threading" 8548 + version = "0.2.1" 8549 + source = "registry+https://github.com/rust-lang/crates.io-index" 8550 + checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" 8551 + dependencies = [ 8552 + "windows-link 0.2.1", 8553 + ] 8554 + 8555 + [[package]] 7889 8556 name = "windows_aarch64_gnullvm" 7890 8557 version = "0.48.5" 7891 8558 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 8128 8795 ] 8129 8796 8130 8797 [[package]] 8798 + name = "xcap" 8799 + version = "0.9.6" 8800 + source = "registry+https://github.com/rust-lang/crates.io-index" 8801 + checksum = "b6ad471d5ba232bc276382d26a9d3b837d6853b7df389058b5bb1e94dcdd248c" 8802 + dependencies = [ 8803 + "dispatch2", 8804 + "image", 8805 + "libwayshot-xcap", 8806 + "log", 8807 + "objc2", 8808 + "objc2-app-kit", 8809 + "objc2-av-foundation", 8810 + "objc2-core-foundation", 8811 + "objc2-core-graphics", 8812 + "objc2-core-media", 8813 + "objc2-core-video", 8814 + "objc2-foundation", 8815 + "percent-encoding", 8816 + "pipewire", 8817 + "rand 0.9.4", 8818 + "scopeguard", 8819 + "serde", 8820 + "thiserror 2.0.18", 8821 + "url", 8822 + "widestring", 8823 + "windows 0.62.2", 8824 + "xcb", 8825 + "zbus", 8826 + ] 8827 + 8828 + [[package]] 8131 8829 name = "xcb" 8132 8830 version = "1.7.0" 8133 8831 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 8186 8884 version = "0.2.1" 8187 8885 source = "registry+https://github.com/rust-lang/crates.io-index" 8188 8886 checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" 8887 + 8888 + [[package]] 8889 + name = "xml-rs" 8890 + version = "0.8.28" 8891 + source = "registry+https://github.com/rust-lang/crates.io-index" 8892 + checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" 8189 8893 8190 8894 [[package]] 8191 8895 name = "xmlwriter"
+58
README.md
··· 59 59 TRAWLER_GRAPH_DIR=/tmp/my-test-graph cargo run -p trawler 60 60 ``` 61 61 62 + ## Dev automation (devtools) 63 + 64 + For development — especially agent-assisted development — trawler has an 65 + optional automation mode: headless UI tests, a deterministic fixture graph, 66 + and a local socket for driving and observing a running instance. None of it 67 + exists in a default build. 68 + 69 + ### UI tests 70 + 71 + `cargo test --workspace` includes gpui integration tests 72 + (`crates/trawler/src/ui_tests.rs`) that open the real `TrawlerApp` over a 73 + seeded fixture graph on gpui's fake test platform — no window, no GPU — and 74 + drive it through the real keymap with simulated keystrokes. They run 75 + identically on every OS and in CI. 76 + 77 + ### Fixture graphs 78 + 79 + `trawler-core`'s `fixtures` feature provides a deterministic seeded graph 80 + (fixed dates, fixed content, even stable block ids) shared by the UI tests 81 + and interactive dev sessions, so state dumps and screenshots are comparable 82 + across machines. Seed one from the CLI (devtools build): 83 + 84 + ```sh 85 + cargo run -p trawler --features devtools -- --seed-fixtures /tmp/scratch-graph 86 + ``` 87 + 88 + ### Automation server 89 + 90 + Build with `--features devtools` and launch with `TRAWLER_DEVTOOLS=1` (both 91 + required — a devtools binary run normally opens no sockets): 92 + 93 + ```sh 94 + TRAWLER_DEVTOOLS=1 TRAWLER_GRAPH_DIR=/tmp/scratch-graph \ 95 + cargo run -p trawler --features devtools 96 + ``` 97 + 98 + The app binds a TCP listener on `127.0.0.1` (OS-assigned port, printed to 99 + stdout and written to `<graph-dir>/devtools.port`) speaking one JSON object 100 + per line, one response per line: 101 + 102 + | Request | Effect | 103 + |---|---| 104 + | `{"cmd":"keys","keys":"ctrl-k"}` | Dispatch keystrokes through the real keymap (`Keystroke::parse` syntax, whitespace-separated). No OS focus needed. | 105 + | `{"cmd":"type","text":"hello [["}` | Insert text through the focused editor's input path (triggers completion, query re-eval, etc.). | 106 + | `{"cmd":"dump"}` | Versioned JSON of semantic UI state: view, visible rows, focused block/cursor/selection, open popups, window bounds. | 107 + | `{"cmd":"bounds"}` | Window position/size/scale only. | 108 + | `{"cmd":"screenshot","path":"shot.png"}` | Capture the app window to a PNG. | 109 + 110 + Failures answer `{"ok":false,"error":...}` and never affect the app. Replies 111 + are sent after the dispatched action has run; poll `dump` for state that 112 + settles asynchronously (e.g. query re-evaluation after its debounce). 113 + 114 + Screenshots are best-effort per platform: solid on Windows and X11, macOS 115 + needs a one-time Screen Recording permission, and Linux Wayland depends on 116 + the compositor — everything else keeps working where capture doesn't. 117 + (Implementation note: capture runs in a short-lived helper process because 118 + xcap won't enumerate the calling process's own windows on Windows.) 119 + 62 120 ## Graph directory format 63 121 64 122 Everything under the graph directory is derived from, or is, a single Loro
+8
crates/trawler-core/Cargo.toml
··· 7 7 [lints] 8 8 workspace = true 9 9 10 + [features] 11 + # Deterministic fixture-graph builder shared by tests and dev-server 12 + # sessions (openspec change add-dev-automation, capability fixture-graphs). 13 + fixtures = [] 14 + 10 15 [dependencies] 11 16 chrono = { version = "0.4.45", features = ["serde"] } 12 17 loro = "1.13.6" ··· 16 21 17 22 [dev-dependencies] 18 23 rand = "0.10.2" 24 + # Self-dependency so `cargo test --workspace` (no explicit feature flags) 25 + # builds and runs the fixture module's tests. 26 + trawler-core = { path = ".", features = ["fixtures"] }
+194
crates/trawler-core/src/fixtures.rs
··· 1 + //! Deterministic fixture graph shared by UI tests and dev-server sessions. 2 + //! 3 + //! See openspec/changes/add-dev-automation specs/fixture-graphs/spec.md. 4 + //! Everything here goes through the real [`GraphStorage`]/[`Outline`] 5 + //! mutation APIs — never raw file writes — so a seeded directory is a 6 + //! normal graph the app and indexes treat like user-entered content. 7 + //! 8 + //! Determinism: the journal date is fixed (no clock reads), block creation 9 + //! order is a straight-line program (no randomness, no map iteration), and 10 + //! the Loro peer id is pinned, so even the `peer@counter` block ids are 11 + //! identical across machines. Later edits from an app session get a fresh 12 + //! random peer id on reopen, so pinning here cannot collide with real use. 13 + 14 + use std::io; 15 + use std::path::Path; 16 + 17 + use chrono::NaiveDate; 18 + use loro::TreeID; 19 + 20 + use crate::graph::PropertyValue; 21 + use crate::outline::{Outline, Position}; 22 + use crate::storage::GraphStorage; 23 + 24 + /// The fixed peer id every fixture graph is authored under. 25 + pub const FIXTURE_PEER_ID: u64 = 1; 26 + 27 + /// The fixture's "today": the date of its journal page and the date its 28 + /// date-reference block points at. Fixed so screenshots and dumps of a 29 + /// fixture graph never depend on when it was seeded. 30 + pub fn fixture_journal_date() -> NaiveDate { 31 + NaiveDate::from_ymd_opt(2026, 7, 10).expect("valid fixed fixture date") 32 + } 33 + 34 + /// The Steel expression on the fixture's query block. Projects the blocks 35 + /// tagged `#project` into a due/priority table — guaranteed non-empty 36 + /// against the fixture content. 37 + pub const FIXTURE_QUERY_EXPR: &str = r#"(table (tag 'project) '("due" "priority"))"#; 38 + 39 + /// Names of the fixture's non-journal pages. 40 + pub const DESIGN_PAGE_NAME: &str = "trawler-design"; 41 + pub const READING_PAGE_NAME: &str = "reading-list"; 42 + 43 + /// Handles into a freshly seeded fixture graph, for tests that need to 44 + /// address specific nodes without searching for them by content. 45 + pub struct FixtureGraph { 46 + pub storage: GraphStorage, 47 + /// The journal page for [`fixture_journal_date`]. 48 + pub journal_page: TreeID, 49 + /// The `trawler-design` page. 50 + pub design_page: TreeID, 51 + /// The `reading-list` page. 52 + pub reading_page: TreeID, 53 + /// The query block (on the `trawler-design` page) whose content is 54 + /// [`FIXTURE_QUERY_EXPR`] and whose `query` property is `true`. 55 + pub query_block: TreeID, 56 + } 57 + 58 + /// Seed the standard fixture into `dir`, which must not already contain a 59 + /// graph. Returns the open storage plus handles to the interesting nodes. 60 + /// 61 + /// The content is persisted (snapshot + update log) before returning, so 62 + /// the directory can be handed straight to an app launch via 63 + /// `TRAWLER_GRAPH_DIR`. 64 + pub fn seed(dir: impl AsRef<Path>) -> io::Result<FixtureGraph> { 65 + let dir = dir.as_ref(); 66 + if GraphStorage::exists(dir) { 67 + return Err(io::Error::new( 68 + io::ErrorKind::AlreadyExists, 69 + format!("refusing to seed fixtures into existing graph at {dir:?}"), 70 + )); 71 + } 72 + 73 + let storage = GraphStorage::create(dir)?; 74 + storage.doc().set_peer_id(FIXTURE_PEER_ID).map_err(io_err)?; 75 + 76 + let (journal_page, design_page, reading_page, query_block) = { 77 + let outline = Outline::new(storage.doc()); 78 + build(&outline).map_err(io_err)? 79 + }; 80 + storage.persist_update()?; 81 + 82 + Ok(FixtureGraph { 83 + storage, 84 + journal_page, 85 + design_page, 86 + reading_page, 87 + query_block, 88 + }) 89 + } 90 + 91 + /// The straight-line fixture program: one journal page, two named pages, 92 + /// a three-level outline, tags, page/date references, typed properties, 93 + /// and one query block — 19 blocks total, small enough for one screenshot. 94 + fn build(outline: &Outline) -> loro::LoroResult<(TreeID, TreeID, TreeID, TreeID)> { 95 + let date = fixture_journal_date(); 96 + 97 + // Journal page: "2026-07-10" (journal pages are pages whose content is 98 + // an ISO date — see `journal_page_date` in the app crate). 99 + let journal = outline.create_block( 100 + None, 101 + Position::Index(0), 102 + &date.format("%Y-%m-%d").to_string(), 103 + )?; 104 + outline.create_block( 105 + Some(journal), 106 + Position::Index(0), 107 + "Started the trawler dogfood log #trawler", 108 + )?; 109 + outline.create_block( 110 + Some(journal), 111 + Position::Index(1), 112 + &format!("Reviewed [[{DESIGN_PAGE_NAME}]] and updated the goals"), 113 + )?; 114 + let deep_work = outline.create_block(Some(journal), Position::Index(2), "Deep work")?; 115 + outline.create_block( 116 + Some(deep_work), 117 + Position::Index(0), 118 + "Outlined the fixture graph #project", 119 + )?; 120 + let sketch = outline.create_block( 121 + Some(deep_work), 122 + Position::Index(1), 123 + "Sketched the automation server #project", 124 + )?; 125 + outline.create_block( 126 + Some(sketch), 127 + Position::Index(0), 128 + &format!("Follow up in [[{READING_PAGE_NAME}]]"), 129 + )?; 130 + 131 + // trawler-design page: goals, tasks with typed properties, query block. 132 + let design = outline.create_block(None, Position::Index(1), DESIGN_PAGE_NAME)?; 133 + let goals = outline.create_block(Some(design), Position::Index(0), "Goals")?; 134 + outline.create_block( 135 + Some(goals), 136 + Position::Index(0), 137 + "Keyboard-first outlining #project", 138 + )?; 139 + outline.create_block( 140 + Some(goals), 141 + Position::Index(1), 142 + "Live Steel queries #project", 143 + )?; 144 + let tasks = outline.create_block(Some(design), Position::Index(1), "Tasks")?; 145 + let ship = outline.create_block( 146 + Some(tasks), 147 + Position::Index(0), 148 + "Ship fixture graphs #project", 149 + )?; 150 + outline.set_property( 151 + ship, 152 + "due", 153 + &PropertyValue::Date(NaiveDate::from_ymd_opt(2026, 7, 15).expect("valid date")), 154 + )?; 155 + outline.set_property(ship, "priority", &PropertyValue::Text("high".into()))?; 156 + let wire = outline.create_block( 157 + Some(tasks), 158 + Position::Index(1), 159 + "Wire dev automation #project", 160 + )?; 161 + outline.set_property( 162 + wire, 163 + "due", 164 + &PropertyValue::Date(NaiveDate::from_ymd_opt(2026, 7, 20).expect("valid date")), 165 + )?; 166 + outline.set_property(wire, "priority", &PropertyValue::Text("medium".into()))?; 167 + outline.set_property(wire, "done", &PropertyValue::Bool(false))?; 168 + let query_block = outline.create_block(Some(design), Position::Index(2), FIXTURE_QUERY_EXPR)?; 169 + outline.set_property(query_block, "query", &PropertyValue::Bool(true))?; 170 + 171 + // reading-list page: tagged books with text/number properties and a 172 + // date reference back to the journal date. 173 + let reading = outline.create_block(None, Position::Index(2), READING_PAGE_NAME)?; 174 + let sea = outline.create_block(Some(reading), Position::Index(0), "The Sea Around Us #book")?; 175 + outline.set_property(sea, "author", &PropertyValue::Text("Rachel Carson".into()))?; 176 + outline.set_property(sea, "rating", &PropertyValue::Number(5.0))?; 177 + let wind = outline.create_block( 178 + Some(reading), 179 + Position::Index(1), 180 + "Under the Sea-Wind #book", 181 + )?; 182 + outline.set_property(wind, "author", &PropertyValue::Text("Rachel Carson".into()))?; 183 + outline.create_block( 184 + Some(reading), 185 + Position::Index(2), 186 + &format!("Reading notes from [[{}]]", date.format("%Y-%m-%d")), 187 + )?; 188 + 189 + Ok((journal, design, reading, query_block)) 190 + } 191 + 192 + fn io_err(err: loro::LoroError) -> io::Error { 193 + io::Error::other(err.to_string()) 194 + }
+2
crates/trawler-core/src/lib.rs
··· 1 1 //! Core block-graph engine: storage, indexes, queries, search. No UI dependencies. 2 2 3 + #[cfg(feature = "fixtures")] 4 + pub mod fixtures; 3 5 pub mod graph; 4 6 pub mod index; 5 7 pub mod outline;
+143
crates/trawler-core/tests/fixture_graphs.rs
··· 1 + //! Integration tests for the deterministic fixture graph (openspec change 2 + //! add-dev-automation, capability fixture-graphs). The `fixtures` feature 3 + //! is enabled for test builds via the crate's self-dev-dependency, so these 4 + //! run under a plain `cargo test --workspace`. 5 + 6 + use std::path::PathBuf; 7 + use std::sync::Arc; 8 + use std::time::Duration; 9 + 10 + use trawler_core::fixtures::{ 11 + self, fixture_journal_date, DESIGN_PAGE_NAME, FIXTURE_QUERY_EXPR, READING_PAGE_NAME, 12 + }; 13 + use trawler_core::graph::{NodeId, PropertyValue}; 14 + use trawler_core::index::GraphIndex; 15 + use trawler_core::outline::Outline; 16 + use trawler_core::query::{eval_query, QueryOutcome, QueryValue}; 17 + use trawler_core::search::SearchIndex; 18 + use trawler_core::storage::GraphStorage; 19 + 20 + fn temp_dir(name: &str) -> PathBuf { 21 + let dir = std::env::temp_dir().join(format!( 22 + "trawler-fixture-test-{name}-{:?}", 23 + std::thread::current().id() 24 + )); 25 + let _ = std::fs::remove_dir_all(&dir); 26 + dir 27 + } 28 + 29 + /// Every block id in the outline paired with its content, in depth-first 30 + /// document order — a full projection of tree shape + text for equivalence 31 + /// assertions. 32 + fn content_projection(storage: &GraphStorage) -> Vec<(String, String)> { 33 + let outline = Outline::new(storage.doc()); 34 + let mut out = Vec::new(); 35 + let mut stack: Vec<loro::TreeID> = outline.children(None).into_iter().rev().collect(); 36 + while let Some(id) = stack.pop() { 37 + out.push((id.to_string(), outline.content(id).unwrap())); 38 + for child in outline.children(Some(id)).into_iter().rev() { 39 + stack.push(child); 40 + } 41 + } 42 + out 43 + } 44 + 45 + #[test] 46 + fn seeding_twice_produces_equivalent_graphs() { 47 + let dir_a = temp_dir("determinism-a"); 48 + let dir_b = temp_dir("determinism-b"); 49 + fixtures::seed(&dir_a).unwrap(); 50 + fixtures::seed(&dir_b).unwrap(); 51 + 52 + // Reopen from disk so we compare what was actually persisted. 53 + let a = GraphStorage::open(&dir_a).unwrap(); 54 + let b = GraphStorage::open(&dir_b).unwrap(); 55 + 56 + // Same tree shape and content, block by block — ids included, thanks to 57 + // the pinned peer id. 58 + let proj_a = content_projection(&a); 59 + let proj_b = content_projection(&b); 60 + assert!(!proj_a.is_empty()); 61 + assert_eq!(proj_a, proj_b); 62 + 63 + // Same derived indexes: pages, tags, dates, backlinks, properties. 64 + assert_eq!(GraphIndex::rebuild(a.doc()), GraphIndex::rebuild(b.doc())); 65 + 66 + let _ = std::fs::remove_dir_all(&dir_a); 67 + let _ = std::fs::remove_dir_all(&dir_b); 68 + } 69 + 70 + #[test] 71 + fn seed_refuses_existing_graph_dir() { 72 + let dir = temp_dir("refuse-existing"); 73 + fixtures::seed(&dir).unwrap(); 74 + let err = match fixtures::seed(&dir) { 75 + Err(err) => err, 76 + Ok(_) => panic!("seeding over an existing graph should fail"), 77 + }; 78 + assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists); 79 + let _ = std::fs::remove_dir_all(&dir); 80 + } 81 + 82 + #[test] 83 + fn seeded_fixture_is_a_valid_indexed_queryable_graph() { 84 + let dir = temp_dir("validity"); 85 + let seeded = fixtures::seed(&dir).unwrap(); 86 + let journal_page = seeded.journal_page; 87 + let design_page = seeded.design_page; 88 + let query_block = seeded.query_block; 89 + drop(seeded); 90 + 91 + // Reopen through the normal storage path, as the app would. 92 + let storage = GraphStorage::open(&dir).unwrap(); 93 + let index = GraphIndex::rebuild(storage.doc()); 94 + 95 + // All three pages resolve by name. 96 + let date_name = fixture_journal_date().format("%Y-%m-%d").to_string(); 97 + assert_eq!(index.pages_by_name.get(&date_name), Some(&journal_page)); 98 + assert_eq!( 99 + index.pages_by_name.get(DESIGN_PAGE_NAME), 100 + Some(&design_page) 101 + ); 102 + assert!(index.pages_by_name.contains_key(READING_PAGE_NAME)); 103 + 104 + // Tags landed in the tag index. 105 + assert_eq!(index.tags["project"].len(), 6); 106 + assert_eq!(index.tags["book"].len(), 2); 107 + assert_eq!(index.tags["trawler"].len(), 1); 108 + 109 + // The journal block referencing [[trawler-design]] shows up as a 110 + // backlink of the design page. 111 + let design_node = NodeId::tree(design_page); 112 + assert!(!index.backlinks_of(&design_node).is_empty()); 113 + 114 + // The [[2026-07-10]] reference landed in the date index. 115 + assert!(!index.dates[&fixture_journal_date()].is_empty()); 116 + 117 + // Typed properties are indexed, and the query block carries the flag. 118 + assert_eq!(index.properties["due"].len(), 2); 119 + assert_eq!( 120 + index.properties["query"].get(&query_block), 121 + Some(&PropertyValue::Bool(true)) 122 + ); 123 + 124 + // The query block's expression evaluates to a non-empty table. 125 + let outline = Outline::new(storage.doc()); 126 + assert_eq!(outline.content(query_block).unwrap(), FIXTURE_QUERY_EXPR); 127 + let search = SearchIndex::open_or_create(dir.join("search-index"), storage.doc()).unwrap(); 128 + let outcome = eval_query( 129 + FIXTURE_QUERY_EXPR, 130 + Arc::new(index), 131 + Arc::new(search), 132 + Duration::from_secs(10), 133 + ); 134 + match outcome { 135 + QueryOutcome::Completed(QueryValue::Table { columns, rows }) => { 136 + assert_eq!(columns, vec!["due".to_string(), "priority".to_string()]); 137 + assert!(!rows.is_empty()); 138 + } 139 + other => panic!("expected non-empty table result, got {other:?}"), 140 + } 141 + 142 + let _ = std::fs::remove_dir_all(&dir); 143 + }
+15
crates/trawler/Cargo.toml
··· 7 7 [lints] 8 8 workspace = true 9 9 10 + [features] 11 + # Local automation server + fixture seeding for development sessions 12 + # (openspec change add-dev-automation, capability dev-automation-server). 13 + # Off by default and never part of a release build; even a devtools build 14 + # only starts the server when TRAWLER_DEVTOOLS=1 is set at launch. 15 + devtools = ["dep:serde", "dep:serde_json", "dep:xcap", "trawler-core/fixtures"] 16 + 10 17 [dependencies] 11 18 chrono = "0.4.45" 12 19 futures = "0.3" 13 20 gpui = "0.2.2" 14 21 loro = "1.13.6" 15 22 pulldown-cmark = { version = "0.13.4", default-features = false } 23 + serde = { version = "1.0.228", features = ["derive"], optional = true } 24 + serde_json = { version = "1.0.150", optional = true } 16 25 trawler-core = { path = "../trawler-core" } 17 26 unicode-segmentation = "1.13.3" 27 + xcap = { version = "0.9.6", optional = true } 18 28 19 29 [dev-dependencies] 30 + # Headless UI tests (openspec change add-dev-automation, capability 31 + # ui-test-harness): gpui's fake test platform + the deterministic fixture 32 + # graph. Test builds only — the shipped binary is unaffected. 33 + gpui = { version = "0.2.2", features = ["test-support"] } 34 + trawler-core = { path = "../trawler-core", features = ["fixtures"] }
+567
crates/trawler/src/devtools.rs
··· 1 + //! Local dev automation server (openspec change add-dev-automation, 2 + //! capability dev-automation-server): a `127.0.0.1`-only TCP endpoint that 3 + //! lets an agent or contributor drive and observe the running app during 4 + //! development — inject keystrokes/text through GPUI's own dispatch, read 5 + //! a structured dump of the UI state, and capture a window screenshot. 6 + //! 7 + //! Doubly gated: this module only compiles under the `devtools` cargo 8 + //! feature, and even then [`maybe_start`] is a no-op unless 9 + //! `TRAWLER_DEVTOOLS=1` is set at launch. 10 + //! 11 + //! Protocol: one JSON request object per line in, one JSON response object 12 + //! per line out, in request order. Keystroke syntax is gpui's 13 + //! `Keystroke::parse` (e.g. `"ctrl-k"`, `"shift-tab"`). Failures answer 14 + //! `{"ok":false,"error":...}` and never affect the app. 15 + //! 16 + //! Threading: a plain listener thread does blocking line I/O and forwards 17 + //! `(request, reply-sender)` pairs over an async channel to a single 18 + //! foreground task spawned on the GPUI main thread, which executes each 19 + //! command against the real window. Input therefore needs no OS focus and 20 + //! replies are sent only after the dispatched action has run. Screenshots 21 + //! are the exception: capture is OS-level and needs no GPUI state, so it 22 + //! runs on its own thread and replies when the PNG is written, keeping a 23 + //! slow capture from hitching the UI. 24 + 25 + use std::io::{BufRead as _, BufReader, Write as _}; 26 + use std::net::{TcpListener, TcpStream}; 27 + use std::path::{Path, PathBuf}; 28 + use std::sync::mpsc as sync_mpsc; 29 + 30 + use futures::channel::mpsc as async_mpsc; 31 + use futures::StreamExt as _; 32 + use gpui::{App, AsyncApp, Keystroke, Modifiers, WindowHandle}; 33 + use serde::{Deserialize, Serialize}; 34 + use serde_json::{json, Value}; 35 + use trawler_core::outline::Outline; 36 + 37 + use crate::{TrawlerApp, View}; 38 + 39 + /// Version stamped on every `dump` response; bump on breaking schema 40 + /// changes so clients can detect drift. 41 + const DUMP_VERSION: u64 = 1; 42 + 43 + /// Written into the graph directory so clients can discover the port. 44 + const PORT_FILE: &str = "devtools.port"; 45 + 46 + // Unknown fields are tolerated (serde can't deny them on an internally 47 + // tagged enum) — deliberate: newer clients degrade gracefully. 48 + #[derive(Debug, PartialEq, Deserialize)] 49 + #[serde(tag = "cmd", rename_all = "snake_case")] 50 + enum Request { 51 + /// Dispatch whitespace-separated keystrokes through the window keymap. 52 + Keys { keys: String }, 53 + /// Insert literal text through the focused editor's input path. 54 + Type { text: String }, 55 + /// Structured UI state (see [`Dump`]). 56 + Dump, 57 + /// Window position/size/scale only. 58 + Bounds, 59 + /// Capture the app window to a PNG at `path`. 60 + Screenshot { path: PathBuf }, 61 + } 62 + 63 + /// Devtools CLI flags that run instead of the app. Returns `true` if one 64 + /// was handled (successfully or not) and `main` should return immediately. 65 + pub fn handle_cli() -> bool { 66 + let args: Vec<String> = std::env::args().skip(1).collect(); 67 + match args.first().map(String::as_str) { 68 + // Seed the standard fixture graph and exit. 69 + Some("--seed-fixtures") => { 70 + match args.get(1) { 71 + Some(dir) => match trawler_core::fixtures::seed(dir) { 72 + Ok(_) => println!("seeded fixture graph at {dir}"), 73 + Err(err) => eprintln!("failed to seed fixture graph at {dir}: {err}"), 74 + }, 75 + None => eprintln!("usage: trawler --seed-fixtures <dir>"), 76 + } 77 + true 78 + } 79 + // Hidden helper mode used by the `screenshot` command: capture the 80 + // window of another trawler process. Runs as a separate process 81 + // because xcap (deliberately, on Windows) refuses to enumerate the 82 + // calling process's own windows; from here the app's window is 83 + // just another process's window, on every platform. 84 + Some("--devtools-capture") => { 85 + let result = match ( 86 + args.get(1).and_then(|pid| pid.parse::<u32>().ok()), 87 + args.get(2), 88 + ) { 89 + (Some(pid), Some(path)) => capture_window_of_pid(pid, Path::new(path)), 90 + _ => Err("usage: trawler --devtools-capture <pid> <path>".into()), 91 + }; 92 + match result { 93 + Ok(path) => println!("{path}"), 94 + Err(err) => { 95 + eprintln!("{err}"); 96 + std::process::exit(1); 97 + } 98 + } 99 + true 100 + } 101 + _ => false, 102 + } 103 + } 104 + 105 + /// Start the automation server if `TRAWLER_DEVTOOLS=1`. Failure to start 106 + /// (port bind, port-file write) is logged and otherwise ignored — the app 107 + /// itself must never be affected by devtools. 108 + pub fn maybe_start(graph_dir: PathBuf, window: WindowHandle<TrawlerApp>, cx: &mut App) { 109 + if std::env::var("TRAWLER_DEVTOOLS").as_deref() != Ok("1") { 110 + return; 111 + } 112 + if let Err(err) = start(graph_dir, window, cx) { 113 + eprintln!("trawler devtools failed to start: {err}"); 114 + } 115 + } 116 + 117 + fn start( 118 + graph_dir: PathBuf, 119 + window: WindowHandle<TrawlerApp>, 120 + cx: &mut App, 121 + ) -> std::io::Result<()> { 122 + let listener = TcpListener::bind(("127.0.0.1", 0))?; 123 + let port = listener.local_addr()?.port(); 124 + std::fs::write(graph_dir.join(PORT_FILE), port.to_string())?; 125 + println!("trawler devtools listening on 127.0.0.1:{port}"); 126 + 127 + let (tx, mut rx) = async_mpsc::unbounded::<(Request, Reply)>(); 128 + std::thread::spawn(move || accept_loop(listener, tx)); 129 + 130 + cx.spawn(async move |cx| { 131 + while let Some((request, reply)) = rx.next().await { 132 + handle_request(request, window, cx, reply); 133 + } 134 + }) 135 + .detach(); 136 + Ok(()) 137 + } 138 + 139 + /// Where a command's single response goes. The listener thread blocks on 140 + /// this, which is what serializes one-response-per-request in order. 141 + type Reply = sync_mpsc::Sender<Value>; 142 + 143 + fn accept_loop(listener: TcpListener, tx: async_mpsc::UnboundedSender<(Request, Reply)>) { 144 + // Clients are served one at a time: a dev session drives one 145 + // connection, and interleaving two clients' keystrokes would be 146 + // meaningless anyway. 147 + for stream in listener.incoming() { 148 + let Ok(stream) = stream else { continue }; 149 + if !serve_client(stream, &tx) { 150 + return; // app side hung up: stop accepting 151 + } 152 + } 153 + } 154 + 155 + /// Serve one client connection. Returns `false` once the foreground task 156 + /// is gone (app shutting down). 157 + fn serve_client(stream: TcpStream, tx: &async_mpsc::UnboundedSender<(Request, Reply)>) -> bool { 158 + let Ok(read_half) = stream.try_clone() else { 159 + return true; 160 + }; 161 + let mut writer = stream; 162 + for line in BufReader::new(read_half).lines() { 163 + let Ok(line) = line else { break }; 164 + if line.trim().is_empty() { 165 + continue; 166 + } 167 + let response = match serde_json::from_str::<Request>(&line) { 168 + Err(err) => error_response(format!("bad request: {err}")), 169 + Ok(request) => { 170 + let (reply_tx, reply_rx) = sync_mpsc::channel(); 171 + if tx.unbounded_send((request, reply_tx)).is_err() { 172 + return false; 173 + } 174 + match reply_rx.recv() { 175 + Ok(value) => value, 176 + Err(_) => return false, 177 + } 178 + } 179 + }; 180 + if writeln!(writer, "{response}").is_err() { 181 + break; 182 + } 183 + } 184 + true 185 + } 186 + 187 + fn error_response(message: impl Into<String>) -> Value { 188 + json!({ "ok": false, "error": message.into() }) 189 + } 190 + 191 + /// Execute one command on the main thread and send its reply. Runs inside 192 + /// the foreground task; must never panic (a client can send anything). 193 + fn handle_request( 194 + request: Request, 195 + window: WindowHandle<TrawlerApp>, 196 + cx: &mut AsyncApp, 197 + reply: Reply, 198 + ) { 199 + let response = match request { 200 + Request::Keys { keys } => dispatch_keys(&keys, window, cx), 201 + Request::Type { text } => dispatch_text(&text, window, cx), 202 + Request::Dump => dump(window, cx), 203 + Request::Bounds => bounds(window, cx), 204 + Request::Screenshot { path } => { 205 + // Replies from its own thread once the PNG is written. 206 + screenshot_in_background(path, reply); 207 + return; 208 + } 209 + }; 210 + let _ = reply.send(response); 211 + } 212 + 213 + /// `keys`: parse every keystroke first (so an invalid sequence dispatches 214 + /// nothing), then dispatch each through the window keymap. 215 + /// 216 + /// Dispatch goes through [`gpui::AnyWindowHandle::update`], NOT the typed 217 + /// `WindowHandle<TrawlerApp>::update`: the typed variant holds a lease on 218 + /// the root `TrawlerApp` entity for the duration of the closure, and any 219 + /// dispatched action that updates the app entity (all of them do) would 220 + /// double-lease it and abort the process. 221 + fn dispatch_keys(keys: &str, window: WindowHandle<TrawlerApp>, cx: &mut AsyncApp) -> Value { 222 + let parsed: Result<Vec<Keystroke>, _> = keys.split_whitespace().map(Keystroke::parse).collect(); 223 + let keystrokes = match parsed { 224 + Ok(keystrokes) if !keystrokes.is_empty() => keystrokes, 225 + Ok(_) => return error_response("no keystrokes given"), 226 + Err(err) => return error_response(format!("bad keystroke: {err}")), 227 + }; 228 + let result = (*window).update(cx, |_, window, cx| { 229 + for keystroke in keystrokes { 230 + window.dispatch_keystroke(keystroke, cx); 231 + } 232 + }); 233 + match result { 234 + Ok(()) => json!({ "ok": true }), 235 + Err(err) => error_response(err.to_string()), 236 + } 237 + } 238 + 239 + /// `type`: each character dispatched as an unmodified keystroke with its 240 + /// `key_char` set, exactly what a real typed key produces — unbound keys 241 + /// fall through `dispatch_keystroke` to the focused editor's 242 + /// `EntityInputHandler` (the IME path), so completion triggers, query 243 + /// re-eval, etc. all fire as if typed (design O1). 244 + fn dispatch_text(text: &str, window: WindowHandle<TrawlerApp>, cx: &mut AsyncApp) -> Value { 245 + // AnyWindowHandle for the same no-view-lease reason as `dispatch_keys`. 246 + let result = (*window).update(cx, |_, window, cx| { 247 + for ch in text.chars() { 248 + let keystroke = Keystroke { 249 + modifiers: Modifiers::default(), 250 + key: ch.to_string(), 251 + key_char: Some(ch.to_string()), 252 + }; 253 + window.dispatch_keystroke(keystroke, cx); 254 + } 255 + }); 256 + match result { 257 + Ok(()) => json!({ "ok": true }), 258 + Err(err) => error_response(err.to_string()), 259 + } 260 + } 261 + 262 + fn bounds(window: WindowHandle<TrawlerApp>, cx: &mut AsyncApp) -> Value { 263 + match window.update(cx, |_, window, _| bounds_dump(window)) { 264 + Ok(bounds) => match serde_json::to_value(&bounds) { 265 + Ok(mut value) => { 266 + value["ok"] = json!(true); 267 + value 268 + } 269 + Err(err) => error_response(err.to_string()), 270 + }, 271 + Err(err) => error_response(err.to_string()), 272 + } 273 + } 274 + 275 + fn dump(window: WindowHandle<TrawlerApp>, cx: &mut AsyncApp) -> Value { 276 + let result = window.update(cx, |app, window, cx| build_dump(app, window, cx)); 277 + match result { 278 + Ok(dump) => match serde_json::to_value(&dump) { 279 + Ok(mut value) => { 280 + value["ok"] = json!(true); 281 + value 282 + } 283 + Err(err) => error_response(err.to_string()), 284 + }, 285 + Err(err) => error_response(err.to_string()), 286 + } 287 + } 288 + 289 + // --- dump schema --------------------------------------------------------- 290 + // 291 + // Serialized straight from `TrawlerApp`/`BlockEditor` entity state (never 292 + // from rendered elements — design D5), so a UI refactor that breaks a 293 + // field breaks the build here rather than silently emitting stale shapes. 294 + 295 + #[derive(Serialize)] 296 + struct Dump { 297 + v: u64, 298 + view: ViewDump, 299 + /// The visible outline, in rendered (depth-first, fold-aware) order. 300 + rows: Vec<RowDump>, 301 + focused: Option<FocusedDump>, 302 + quick_open: Option<QuickOpenDump>, 303 + search: Option<SearchDump>, 304 + /// First day of the month the calendar picker is showing, if open. 305 + calendar_month: Option<String>, 306 + bounds: BoundsDump, 307 + } 308 + 309 + #[derive(Serialize)] 310 + #[serde(tag = "kind", rename_all = "snake_case")] 311 + enum ViewDump { 312 + Journal, 313 + Node { id: String }, 314 + } 315 + 316 + #[derive(Serialize)] 317 + struct RowDump { 318 + id: String, 319 + depth: usize, 320 + content: String, 321 + has_children: bool, 322 + folded: bool, 323 + is_query: bool, 324 + } 325 + 326 + #[derive(Serialize)] 327 + struct FocusedDump { 328 + block: String, 329 + /// Live editor buffer (may be ahead of graph storage until commit). 330 + text: String, 331 + /// Byte offset into `text`. 332 + cursor: usize, 333 + /// Byte range; empty = plain cursor. 334 + selection: (usize, usize), 335 + completion: Option<CompletionDump>, 336 + } 337 + 338 + #[derive(Serialize)] 339 + struct CompletionDump { 340 + trigger: char, 341 + query: String, 342 + candidates: Vec<String>, 343 + selected: usize, 344 + } 345 + 346 + #[derive(Serialize)] 347 + struct QuickOpenDump { 348 + query: String, 349 + items: Vec<String>, 350 + selected: usize, 351 + } 352 + 353 + #[derive(Serialize)] 354 + struct SearchDump { 355 + query: String, 356 + /// Block ids of the hits, in result order. 357 + results: Vec<String>, 358 + selected: usize, 359 + } 360 + 361 + #[derive(Serialize)] 362 + struct BoundsDump { 363 + x: f64, 364 + y: f64, 365 + width: f64, 366 + height: f64, 367 + scale: f32, 368 + } 369 + 370 + fn build_dump(app: &TrawlerApp, window: &gpui::Window, cx: &gpui::Context<TrawlerApp>) -> Dump { 371 + let outline = Outline::new(app.storage.doc()); 372 + 373 + let view = match &app.view { 374 + View::Journal => ViewDump::Journal, 375 + View::Node(id) => ViewDump::Node { id: id.to_string() }, 376 + }; 377 + 378 + let rows = app 379 + .rows 380 + .iter() 381 + .map(|row| RowDump { 382 + id: row.id.to_string(), 383 + depth: row.depth, 384 + content: outline.content(row.id).unwrap_or_default(), 385 + has_children: row.has_children, 386 + folded: row.folded, 387 + is_query: row.is_query, 388 + }) 389 + .collect(); 390 + 391 + let focused = app.editor.as_ref().map(|editor| { 392 + let input = editor.input.read(cx); 393 + FocusedDump { 394 + block: editor.block.to_string(), 395 + text: input.value().to_string(), 396 + cursor: input.cursor_offset(), 397 + selection: { 398 + let range = input.selection(); 399 + (range.start, range.end) 400 + }, 401 + completion: input 402 + .completion_state() 403 + .map(|(trigger, query, candidates, selected)| CompletionDump { 404 + trigger, 405 + query: query.to_string(), 406 + candidates: candidates.iter().map(|c| c.display.clone()).collect(), 407 + selected, 408 + }), 409 + } 410 + }); 411 + 412 + let quick_open = app.quick_open.as_ref().map(|state| QuickOpenDump { 413 + query: state.query.clone(), 414 + items: state.results.iter().map(|(_, name)| name.clone()).collect(), 415 + selected: state.selected, 416 + }); 417 + 418 + let search = app.search_open.as_ref().map(|state| SearchDump { 419 + query: state.query.clone(), 420 + results: state 421 + .results 422 + .iter() 423 + .map(|hit| hit.block.to_string()) 424 + .collect(), 425 + selected: state.selected, 426 + }); 427 + 428 + let calendar_month = app 429 + .calendar_open 430 + .as_ref() 431 + .map(|state| state.month.format("%Y-%m-%d").to_string()); 432 + 433 + Dump { 434 + v: DUMP_VERSION, 435 + view, 436 + rows, 437 + focused, 438 + quick_open, 439 + search, 440 + calendar_month, 441 + bounds: bounds_dump(window), 442 + } 443 + } 444 + 445 + fn bounds_dump(window: &gpui::Window) -> BoundsDump { 446 + let bounds = window.bounds(); 447 + BoundsDump { 448 + x: f64::from(bounds.origin.x), 449 + y: f64::from(bounds.origin.y), 450 + width: f64::from(bounds.size.width), 451 + height: f64::from(bounds.size.height), 452 + scale: window.scale_factor(), 453 + } 454 + } 455 + 456 + // --- screenshot ---------------------------------------------------------- 457 + 458 + fn screenshot_in_background(path: PathBuf, reply: Reply) { 459 + std::thread::spawn(move || { 460 + let response = match capture_via_helper(&path) { 461 + Ok(path) => json!({ "ok": true, "path": path }), 462 + Err(err) => error_response(err), 463 + }; 464 + let _ = reply.send(response); 465 + }); 466 + } 467 + 468 + /// Capture the app window by re-executing this binary as a short-lived 469 + /// helper (`--devtools-capture <our-pid> <path>`). xcap's Windows backend 470 + /// deliberately filters out the calling process's own windows (a 471 + /// GetWindowText-deadlock precaution), so in-process capture can never see 472 + /// us there — from a child process, the app window is capturable with the 473 + /// same code on every platform. 474 + fn capture_via_helper(path: &Path) -> Result<String, String> { 475 + let exe = std::env::current_exe().map_err(|err| format!("current_exe: {err}"))?; 476 + let output = std::process::Command::new(exe) 477 + .arg("--devtools-capture") 478 + .arg(std::process::id().to_string()) 479 + .arg(path) 480 + .output() 481 + .map_err(|err| format!("spawn capture helper: {err}"))?; 482 + if output.status.success() { 483 + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) 484 + } else { 485 + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) 486 + } 487 + } 488 + 489 + /// Helper-process side: capture the (non-minimized) window owned by `pid` 490 + /// to a PNG via `xcap`. Best-effort per platform (design D6): failures — 491 + /// no capturable window, Wayland compositor limitations, permission not 492 + /// granted on macOS — surface as an error reply and never affect any 493 + /// other command. 494 + fn capture_window_of_pid(pid: u32, path: &Path) -> Result<String, String> { 495 + let windows = xcap::Window::all().map_err(|err| format!("window enumeration failed: {err}"))?; 496 + let window = windows 497 + .into_iter() 498 + .filter(|w| w.pid().is_ok_and(|p| p == pid)) 499 + .find(|w| !w.is_minimized().unwrap_or(false)) 500 + .ok_or_else(|| format!("no capturable window for pid {pid}"))?; 501 + let image = window 502 + .capture_image() 503 + .map_err(|err| format!("capture failed: {err}"))?; 504 + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { 505 + std::fs::create_dir_all(parent).map_err(|err| format!("create {parent:?}: {err}"))?; 506 + } 507 + image 508 + .save(path) 509 + .map_err(|err| format!("save {path:?}: {err}"))?; 510 + Ok(path.display().to_string()) 511 + } 512 + 513 + // --- protocol tests (run with: cargo test -p trawler --features devtools) 514 + 515 + #[cfg(test)] 516 + mod tests { 517 + use super::*; 518 + 519 + #[test] 520 + fn parses_every_command() { 521 + assert_eq!( 522 + serde_json::from_str::<Request>(r#"{"cmd":"keys","keys":"ctrl-k"}"#).unwrap(), 523 + Request::Keys { 524 + keys: "ctrl-k".into() 525 + } 526 + ); 527 + assert_eq!( 528 + serde_json::from_str::<Request>(r#"{"cmd":"type","text":"hello [["}"#).unwrap(), 529 + Request::Type { 530 + text: "hello [[".into() 531 + } 532 + ); 533 + assert_eq!( 534 + serde_json::from_str::<Request>(r#"{"cmd":"dump"}"#).unwrap(), 535 + Request::Dump 536 + ); 537 + assert_eq!( 538 + serde_json::from_str::<Request>(r#"{"cmd":"bounds"}"#).unwrap(), 539 + Request::Bounds 540 + ); 541 + assert_eq!( 542 + serde_json::from_str::<Request>(r#"{"cmd":"screenshot","path":"shot.png"}"#).unwrap(), 543 + Request::Screenshot { 544 + path: PathBuf::from("shot.png") 545 + } 546 + ); 547 + } 548 + 549 + #[test] 550 + fn rejects_unknown_and_malformed_requests() { 551 + assert!(serde_json::from_str::<Request>(r#"{"cmd":"reboot"}"#).is_err()); 552 + assert!(serde_json::from_str::<Request>(r#"{"cmd":"keys"}"#).is_err()); 553 + assert!(serde_json::from_str::<Request>("not json").is_err()); 554 + // Unknown extra fields are tolerated for forward compatibility. 555 + assert_eq!( 556 + serde_json::from_str::<Request>(r#"{"cmd":"dump","x":1}"#).unwrap(), 557 + Request::Dump 558 + ); 559 + } 560 + 561 + #[test] 562 + fn error_responses_are_shaped_as_specified() { 563 + let value = error_response("nope"); 564 + assert_eq!(value["ok"], json!(false)); 565 + assert_eq!(value["error"], json!("nope")); 566 + } 567 + }
+24
crates/trawler/src/editor.rs
··· 317 317 } 318 318 } 319 319 320 + /// The current selection as a byte range of the content (empty range = 321 + /// plain cursor), for the UI tests and the devtools state dump. 322 + // Unused in a plain (non-test, non-devtools) binary build. 323 + #[cfg_attr(not(any(test, feature = "devtools")), allow(dead_code))] 324 + pub(crate) fn selection(&self) -> Range<usize> { 325 + self.selected_range.clone() 326 + } 327 + 328 + /// Read-only view of the completion popup, for the UI tests and the 329 + /// devtools state dump: `(trigger, query, candidates, selected)`. 330 + /// `None` when no completion is active. 331 + // Unused in a plain (non-test, non-devtools) binary build. 332 + #[cfg_attr(not(any(test, feature = "devtools")), allow(dead_code))] 333 + pub(crate) fn completion_state(&self) -> Option<(char, &str, &[CompletionCandidate], usize)> { 334 + self.completion.as_ref().map(|state| { 335 + ( 336 + state.trigger, 337 + &self.content[state.query_range.clone()], 338 + state.candidates.as_slice(), 339 + state.selected, 340 + ) 341 + }) 342 + } 343 + 320 344 fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) { 321 345 self.selected_range = offset..offset; 322 346 cx.notify();
+23 -8
crates/trawler/src/main.rs
··· 29 29 //! its backlinks. Backlinks are always shown below the main content for 30 30 //! any non-Journal view (spec: "Backlinks are always visible"). 31 31 32 + #[cfg(feature = "devtools")] 33 + mod devtools; 32 34 mod editor; 33 35 mod markdown; 34 36 mod scheme_highlight; 37 + #[cfg(test)] 38 + mod ui_tests; 35 39 36 40 use std::collections::{HashMap, HashSet}; 37 41 use std::path::{Path, PathBuf}; ··· 2811 2815 } 2812 2816 2813 2817 fn main() { 2818 + #[cfg(feature = "devtools")] 2819 + if devtools::handle_cli() { 2820 + return; 2821 + } 2822 + 2814 2823 let graph_dir = default_graph_dir(); 2815 2824 Application::new().run(move |cx: &mut App| { 2816 2825 editor::init(cx); 2817 2826 init_keymap(cx); 2818 2827 let bounds = Bounds::centered(None, size(px(900.0), px(600.0)), cx); 2819 - cx.open_window( 2820 - WindowOptions { 2821 - window_bounds: Some(WindowBounds::Windowed(bounds)), 2822 - ..Default::default() 2823 - }, 2824 - move |window, cx| cx.new(|cx| TrawlerApp::new(graph_dir.clone(), window, cx)), 2825 - ) 2826 - .unwrap(); 2828 + let open_dir = graph_dir.clone(); 2829 + let window = cx 2830 + .open_window( 2831 + WindowOptions { 2832 + window_bounds: Some(WindowBounds::Windowed(bounds)), 2833 + ..Default::default() 2834 + }, 2835 + move |window, cx| cx.new(|cx| TrawlerApp::new(open_dir.clone(), window, cx)), 2836 + ) 2837 + .unwrap(); 2838 + #[cfg(feature = "devtools")] 2839 + devtools::maybe_start(graph_dir, window, cx); 2840 + #[cfg(not(feature = "devtools"))] 2841 + let _ = window; 2827 2842 cx.activate(true); 2828 2843 }); 2829 2844 }
+375
crates/trawler/src/ui_tests.rs
··· 1 + //! Headless gpui integration tests driving the real [`TrawlerApp`] view 2 + //! (openspec change add-dev-automation, capability ui-test-harness). 3 + //! 4 + //! Every test opens the actual app over a seeded fixture graph in a 5 + //! tempdir, dispatches input through the real keymap with 6 + //! `simulate_keystrokes`/`simulate_input`, and asserts on entity state — 7 + //! never on rendered pixels. Runs on gpui's fake test platform: no OS 8 + //! window, no GPU, identical on every platform and in CI. 9 + 10 + use std::path::PathBuf; 11 + 12 + use gpui::{Entity, TestAppContext, VisualTestContext}; 13 + use loro::TreeID; 14 + use trawler_core::outline::Outline; 15 + 16 + use crate::TrawlerApp; 17 + 18 + fn fixture_dir(name: &str) -> PathBuf { 19 + let dir = std::env::temp_dir().join(format!( 20 + "trawler-ui-test-{name}-{:?}", 21 + std::thread::current().id() 22 + )); 23 + let _ = std::fs::remove_dir_all(&dir); 24 + let seeded = trawler_core::fixtures::seed(&dir).expect("seed fixture graph"); 25 + drop(seeded); // release the storage handle; the app reopens the dir 26 + dir 27 + } 28 + 29 + /// Open the real app over a seeded fixture graph in a test window. 30 + fn open_app<'a>( 31 + name: &str, 32 + cx: &'a mut TestAppContext, 33 + ) -> (Entity<TrawlerApp>, &'a mut VisualTestContext) { 34 + let dir = fixture_dir(name); 35 + cx.update(|cx| { 36 + crate::editor::init(cx); 37 + crate::init_keymap(cx); 38 + }); 39 + cx.add_window_view(|window, cx| TrawlerApp::new(dir, window, cx)) 40 + } 41 + 42 + /// Find the unique block whose content equals `content`, anywhere in the 43 + /// outline — how tests address fixture blocks without hardcoding ids. 44 + fn block_by_content(app: &Entity<TrawlerApp>, cx: &mut VisualTestContext, content: &str) -> TreeID { 45 + app.update(cx, |app, _cx| { 46 + let outline = Outline::new(app.storage.doc()); 47 + let mut stack: Vec<TreeID> = outline.children(None); 48 + let mut found = Vec::new(); 49 + while let Some(id) = stack.pop() { 50 + if outline.content(id).unwrap_or_default() == content { 51 + found.push(id); 52 + } 53 + stack.extend(outline.children(Some(id))); 54 + } 55 + assert_eq!( 56 + found.len(), 57 + 1, 58 + "expected exactly one block with content {content:?}, found {}", 59 + found.len() 60 + ); 61 + found[0] 62 + }) 63 + } 64 + 65 + /// Focus `block` through the app's own focus path, as a click would. 66 + fn focus_block(app: &Entity<TrawlerApp>, cx: &mut VisualTestContext, block: TreeID) { 67 + app.update_in(cx, |app, window, cx| app.focus_block(block, window, cx)); 68 + cx.run_until_parked(); 69 + } 70 + 71 + fn focused_block(app: &Entity<TrawlerApp>, cx: &mut VisualTestContext) -> TreeID { 72 + app.update(cx, |app, _cx| { 73 + app.editor 74 + .as_ref() 75 + .expect("a block editor is focused") 76 + .block 77 + }) 78 + } 79 + 80 + /// The content of the currently focused block's live editor buffer. 81 + fn focused_editor_text(app: &Entity<TrawlerApp>, cx: &mut VisualTestContext) -> String { 82 + app.update(cx, |app, cx| { 83 + let editor = app.editor.as_ref().expect("a block editor is focused"); 84 + editor.input.read(cx).value().to_string() 85 + }) 86 + } 87 + 88 + fn focused_cursor_offset(app: &Entity<TrawlerApp>, cx: &mut VisualTestContext) -> usize { 89 + app.update(cx, |app, cx| { 90 + let editor = app.editor.as_ref().expect("a block editor is focused"); 91 + editor.input.read(cx).cursor_offset() 92 + }) 93 + } 94 + 95 + /// `(trigger, query, candidate display strings)` of the open completion 96 + /// popup, or `None`. 97 + fn completion_popup( 98 + app: &Entity<TrawlerApp>, 99 + cx: &mut VisualTestContext, 100 + ) -> Option<(char, String, Vec<String>)> { 101 + app.update(cx, |app, cx| { 102 + let editor = app.editor.as_ref()?; 103 + let (trigger, query, candidates, _selected) = editor.input.read(cx).completion_state()?; 104 + Some(( 105 + trigger, 106 + query.to_string(), 107 + candidates.iter().map(|c| c.display.clone()).collect(), 108 + )) 109 + }) 110 + } 111 + 112 + /// Navigate to a page through quick open, exactly as a user would: 113 + /// Ctrl+K, type the page name, Enter. Keystrokes only dispatch to blocks 114 + /// whose row is actually rendered, so tests MUST navigate to the page 115 + /// holding their target block before focusing it — the journal view only 116 + /// renders journal pages. 117 + fn open_page(cx: &mut VisualTestContext, name: &str) { 118 + cx.simulate_keystrokes("ctrl-k"); 119 + cx.simulate_input(name); 120 + cx.simulate_keystrokes("enter"); 121 + } 122 + 123 + /// Place the cursor `chars` characters into the focused block's (single- 124 + /// line) content, via the keyboard like a user would. 125 + fn place_cursor(cx: &mut VisualTestContext, chars: usize) { 126 + cx.simulate_keystrokes("home"); 127 + for _ in 0..chars { 128 + cx.simulate_keystrokes("right"); 129 + } 130 + } 131 + 132 + /// Persisted content of `block`, read back from graph storage. 133 + fn stored_content(app: &Entity<TrawlerApp>, cx: &mut VisualTestContext, block: TreeID) -> String { 134 + app.update(cx, |app, _cx| { 135 + Outline::new(app.storage.doc()) 136 + .content(block) 137 + .expect("block exists") 138 + }) 139 + } 140 + 141 + fn children_of( 142 + app: &Entity<TrawlerApp>, 143 + cx: &mut VisualTestContext, 144 + parent: Option<TreeID>, 145 + ) -> Vec<TreeID> { 146 + app.update(cx, |app, _cx| { 147 + Outline::new(app.storage.doc()).children(parent) 148 + }) 149 + } 150 + 151 + fn parent_of( 152 + app: &Entity<TrawlerApp>, 153 + cx: &mut VisualTestContext, 154 + block: TreeID, 155 + ) -> Option<TreeID> { 156 + app.update(cx, |app, _cx| Outline::new(app.storage.doc()).parent(block)) 157 + } 158 + 159 + // --- 2.2 smoke --------------------------------------------------------- 160 + 161 + #[gpui::test] 162 + fn smoke_typing_lands_in_focused_block(cx: &mut TestAppContext) { 163 + let (app, cx) = open_app("smoke", cx); 164 + 165 + // Opening the app lands on today's journal page with a focused, empty 166 + // block (spec: journal/"Opening the app"). 167 + assert_eq!(focused_editor_text(&app, cx), ""); 168 + 169 + cx.simulate_input("x"); 170 + 171 + assert_eq!(focused_editor_text(&app, cx), "x"); 172 + // Typing collapses any selection to a caret after the inserted text. 173 + app.update(cx, |app, cx| { 174 + let editor = app.editor.as_ref().expect("focused"); 175 + assert_eq!(editor.input.read(cx).selection(), 1..1); 176 + }); 177 + } 178 + 179 + // --- 2.4 block structure edits ----------------------------------------- 180 + 181 + #[gpui::test] 182 + fn enter_splits_block_at_cursor(cx: &mut TestAppContext) { 183 + let (app, cx) = open_app("enter-splits", cx); 184 + let block = block_by_content(&app, cx, "Deep work"); 185 + let parent = parent_of(&app, cx, block).expect("journal block has a page parent"); 186 + 187 + focus_block(&app, cx, block); 188 + place_cursor(cx, "Deep".len()); 189 + cx.simulate_keystrokes("enter"); 190 + 191 + // Head stays on the original block; tail moved to a new next sibling. 192 + assert_eq!(stored_content(&app, cx, block), "Deep"); 193 + let siblings = children_of(&app, cx, Some(parent)); 194 + let pos = siblings.iter().position(|&s| s == block).unwrap(); 195 + let new_block = siblings[pos + 1]; 196 + assert_eq!(stored_content(&app, cx, new_block), " work"); 197 + 198 + // Focus moved to the new sibling. (Current behavior places the cursor 199 + // at the end of the new block's content — `focus_block` always opens 200 + // an editor with the cursor at the end.) 201 + assert_eq!(focused_block(&app, cx), new_block); 202 + assert_eq!(focused_cursor_offset(&app, cx), " work".len()); 203 + 204 + // The split block's children stayed with the head block. 205 + assert!(!children_of(&app, cx, Some(block)).is_empty()); 206 + } 207 + 208 + #[gpui::test] 209 + fn shift_enter_inserts_newline_within_block(cx: &mut TestAppContext) { 210 + let (app, cx) = open_app("shift-enter", cx); 211 + open_page(cx, "trawler-design"); 212 + let block = block_by_content(&app, cx, "Goals"); 213 + let parent = parent_of(&app, cx, block).unwrap(); 214 + let sibling_count = children_of(&app, cx, Some(parent)).len(); 215 + 216 + focus_block(&app, cx, block); 217 + cx.simulate_keystrokes("end"); 218 + cx.simulate_keystrokes("shift-enter"); 219 + cx.simulate_input("subtitle"); 220 + 221 + // Still one block — the newline lives inside its content. 222 + assert_eq!(focused_editor_text(&app, cx), "Goals\nsubtitle"); 223 + assert_eq!(focused_block(&app, cx), block); 224 + assert_eq!(children_of(&app, cx, Some(parent)).len(), sibling_count); 225 + } 226 + 227 + #[gpui::test] 228 + fn backspace_at_start_merges_into_previous_sibling(cx: &mut TestAppContext) { 229 + let (app, cx) = open_app("backspace-merges", cx); 230 + open_page(cx, "reading-list"); 231 + let first = block_by_content(&app, cx, "The Sea Around Us #book"); 232 + let second = block_by_content(&app, cx, "Under the Sea-Wind #book"); 233 + let parent = parent_of(&app, cx, second).unwrap(); 234 + let before_count = children_of(&app, cx, Some(parent)).len(); 235 + 236 + focus_block(&app, cx, second); 237 + cx.simulate_keystrokes("home"); 238 + cx.simulate_keystrokes("backspace"); 239 + 240 + assert_eq!( 241 + stored_content(&app, cx, first), 242 + "The Sea Around Us #bookUnder the Sea-Wind #book" 243 + ); 244 + let siblings = children_of(&app, cx, Some(parent)); 245 + assert_eq!(siblings.len(), before_count - 1); 246 + assert!(!siblings.contains(&second)); 247 + assert_eq!(focused_block(&app, cx), first); 248 + } 249 + 250 + // --- 2.5 outline movement ---------------------------------------------- 251 + 252 + #[gpui::test] 253 + fn tab_indents_under_previous_sibling_and_shift_tab_outdents(cx: &mut TestAppContext) { 254 + let (app, cx) = open_app("indent-outdent", cx); 255 + open_page(cx, "trawler-design"); 256 + let tasks = block_by_content(&app, cx, "Tasks"); 257 + let goals = block_by_content(&app, cx, "Goals"); 258 + let page = parent_of(&app, cx, tasks).unwrap(); 259 + let goals_children_before = children_of(&app, cx, Some(goals)); 260 + let tasks_children_before = children_of(&app, cx, Some(tasks)); 261 + 262 + focus_block(&app, cx, tasks); 263 + place_cursor(cx, 2); 264 + cx.simulate_keystrokes("tab"); 265 + 266 + // "Tasks" (with its whole subtree) is now the last child of "Goals", 267 + // still focused, cursor preserved (spec: outline-editor/Tab). 268 + assert_eq!(parent_of(&app, cx, tasks), Some(goals)); 269 + let goals_children = children_of(&app, cx, Some(goals)); 270 + assert_eq!(goals_children.len(), goals_children_before.len() + 1); 271 + assert_eq!(*goals_children.last().unwrap(), tasks); 272 + assert_eq!(children_of(&app, cx, Some(tasks)), tasks_children_before); 273 + assert_eq!(focused_block(&app, cx), tasks); 274 + assert_eq!(focused_cursor_offset(&app, cx), 2); 275 + 276 + cx.simulate_keystrokes("shift-tab"); 277 + 278 + // Outdent: back at page level, as the sibling immediately after 279 + // "Goals", subtree still intact. 280 + assert_eq!(parent_of(&app, cx, tasks), Some(page)); 281 + let page_children = children_of(&app, cx, Some(page)); 282 + let goals_pos = page_children.iter().position(|&b| b == goals).unwrap(); 283 + assert_eq!(page_children[goals_pos + 1], tasks); 284 + assert_eq!(children_of(&app, cx, Some(tasks)), tasks_children_before); 285 + } 286 + 287 + #[gpui::test] 288 + fn alt_up_down_reorders_among_siblings(cx: &mut TestAppContext) { 289 + let (app, cx) = open_app("reorder", cx); 290 + let reviewed = block_by_content( 291 + &app, 292 + cx, 293 + "Reviewed [[trawler-design]] and updated the goals", 294 + ); 295 + let parent = parent_of(&app, cx, reviewed).unwrap(); 296 + let original = children_of(&app, cx, Some(parent)); 297 + let pos = original.iter().position(|&b| b == reviewed).unwrap(); 298 + assert!(pos > 0, "fixture places this block after another sibling"); 299 + 300 + focus_block(&app, cx, reviewed); 301 + cx.simulate_keystrokes("alt-up"); 302 + 303 + let after_up = children_of(&app, cx, Some(parent)); 304 + assert_eq!(after_up.iter().position(|&b| b == reviewed), Some(pos - 1)); 305 + assert_eq!(focused_block(&app, cx), reviewed); 306 + 307 + cx.simulate_keystrokes("alt-down"); 308 + 309 + assert_eq!(children_of(&app, cx, Some(parent)), original); 310 + } 311 + 312 + // --- 2.6 focus movement ------------------------------------------------- 313 + 314 + #[gpui::test] 315 + fn up_down_at_content_boundary_moves_focus_between_blocks(cx: &mut TestAppContext) { 316 + let (app, cx) = open_app("focus-movement", cx); 317 + let started = block_by_content(&app, cx, "Started the trawler dogfood log #trawler"); 318 + let reviewed = block_by_content( 319 + &app, 320 + cx, 321 + "Reviewed [[trawler-design]] and updated the goals", 322 + ); 323 + 324 + focus_block(&app, cx, started); 325 + cx.simulate_keystrokes("down"); 326 + 327 + // Down on a single-line block is a content boundary: focus moves to 328 + // the next visible block. 329 + assert_eq!(focused_block(&app, cx), reviewed); 330 + 331 + cx.simulate_keystrokes("up"); 332 + 333 + assert_eq!(focused_block(&app, cx), started); 334 + } 335 + 336 + // --- 2.7 completion ------------------------------------------------------ 337 + 338 + #[gpui::test] 339 + fn double_bracket_opens_page_completion_and_escape_dismisses(cx: &mut TestAppContext) { 340 + let (app, cx) = open_app("page-completion", cx); 341 + 342 + // Today's journal block starts empty and focused. 343 + cx.simulate_input("see [[trawler"); 344 + 345 + let (trigger, query, candidates) = 346 + completion_popup(&app, cx).expect("completion popup is open"); 347 + assert_eq!(trigger, '['); 348 + assert_eq!(query, "trawler"); 349 + assert!( 350 + candidates.contains(&"trawler-design".to_string()), 351 + "fixture page should be offered: {candidates:?}" 352 + ); 353 + 354 + cx.simulate_keystrokes("escape"); 355 + 356 + assert_eq!(completion_popup(&app, cx), None); 357 + // Dismissal preserves what was typed. 358 + assert_eq!(focused_editor_text(&app, cx), "see [[trawler"); 359 + } 360 + 361 + #[gpui::test] 362 + fn hash_opens_tag_completion(cx: &mut TestAppContext) { 363 + let (app, cx) = open_app("tag-completion", cx); 364 + 365 + cx.simulate_input("#boo"); 366 + 367 + let (trigger, query, candidates) = 368 + completion_popup(&app, cx).expect("completion popup is open"); 369 + assert_eq!(trigger, '#'); 370 + assert_eq!(query, "boo"); 371 + assert!( 372 + candidates.contains(&"#book".to_string()), 373 + "fixture tag should be offered: {candidates:?}" 374 + ); 375 + }
+12 -4
openspec/changes/add-dev-automation/design.md
··· 89 89 - [Keystroke injection races async work (query re-eval, journal rollover timer)] → Replies are sent after the dispatched action returns, and `dump` reads current state at time of call; clients poll `dump` for settled state rather than the server guessing quiescence. If this proves insufficient, a `settled` command that awaits pending foreground tasks is the escape hatch (open question O2). 90 90 - [`gpui 0.2.2` test-support has rough edges on the published crate vs. Zed's in-tree usage] → The smoke test in task one surfaces this immediately; worst case the harness pins what works and documents gaps rather than blocking the dev server track. 91 91 92 - ## Open Questions 92 + ## Open Questions — resolved during implementation 93 93 94 - - **O1 — `type` implementation detail**: whether text insertion goes through per-character `dispatch_keystroke` or the platform text-input (IME) path; decide during implementation by whichever matches what real typing exercises in `BlockEditor`. 95 - - **O2 — quiescence**: is "reply after dispatch + client polls `dump`" sufficient for the query-block debounce (~hundreds of ms) workflows, or do we need a `settled`/`wait-idle` command in v1? 96 - - **O3 — fixture scope**: exact fixture inventory (how many pages/blocks, which query examples) — small enough to eyeball in a screenshot, rich enough to exercise references, tags, properties, and a query block. Proposed starting point: one journal page (fixed date), two named pages, ~20 blocks, one query block; refine while implementing. 94 + - **O1 — `type` implementation** (resolved): both paths at once, for free. Each character is dispatched as an unmodified `Keystroke` with `key_char` set; `Window::dispatch_keystroke` runs keymap dispatch first and falls through to the focused editor's `EntityInputHandler` (the IME path) for unbound keys. Verified live: `type "[["` opens reference completion exactly as real typing does. 95 + - **O2 — quiescence** (resolved for v1): reply-after-dispatch plus client-polls-`dump` was sufficient in live verification, including quick-open navigation flows. No `settled` command in v1; it remains the escape hatch if query-debounce (~400ms) workflows ever need it. The dev-loop skill documents the polling convention. 96 + - **O3 — fixture inventory** (resolved): 19 blocks — journal page `2026-07-10` (6 blocks, three levels deep, `#trawler`/`#project` tags, `[[trawler-design]]`/`[[reading-list]]` refs), `trawler-design` (Goals/Tasks subtrees, `due`/`priority`/`done` typed properties, the query block `(table (tag 'project) '("due" "priority"))`), `reading-list` (two `#book` blocks with `author`/`rating` properties, one `[[2026-07-10]]` date reference). Loro peer id pinned to `1`, so block ids themselves are deterministic across machines. 97 + 98 + ## Implementation notes — deviations discovered while building 99 + 100 + - **D6 amendment — screenshot runs in a helper process.** xcap's Windows backend deliberately refuses to enumerate the calling process's own windows (a `GetWindowText`-deadlock precaution), so in-process capture can never see the app on Windows. The `screenshot` command re-executes the trawler binary as a short-lived helper (`--devtools-capture <pid> <path>`); from a child process the app window is capturable with identical code on every platform. Verified on Windows: xcap's GDI path uses `PrintWindow` with `PW_RENDERFULLCONTENT`, which captures the D3D11 swapchain correctly. 101 + - **D4 amendment — input dispatch must not lease the root view.** `WindowHandle<TrawlerApp>::update` holds a lease on the app entity for the closure's duration; a dispatched action that updates the app (all of them) then double-leases it and aborts the process (found in live verification, first `keys ctrl-k`). `keys`/`type` dispatch through `AnyWindowHandle::update`, which provides the window without leasing the view. `dump`/`bounds` still use the typed handle — they only read. 102 + - **Keystrokes only reach rendered rows.** GPUI dispatches through the focused element's context chain, and a `BlockEditor` registers its input handler during paint — so focusing a block whose row isn't rendered (e.g. a `trawler-design` block while the journal view is showing) silently drops input. UI tests and dev-server clients must navigate to the page first (quick-open: `ctrl-k` → type name → `enter`); documented in `ui_tests.rs` and the dev-loop skill. 103 + - **`Request` tolerates unknown JSON fields** (serde cannot `deny_unknown_fields` on an internally tagged enum) — kept deliberately as forward compatibility and pinned by a protocol test. 104 + - **ui-test-harness spec scenarios were relaxed to match actual app behavior**: after Enter-split, focus moves to the new sibling but the cursor sits at the *end* of its content (not the start), because `focus_block` always opens an editor with the cursor at the end; same for Backspace-merge (focus on merged block, cursor at end, not at the join point). The authoritative outline-editor spec pins neither, so the tests document current behavior rather than redefine it — flagged as a possible UX polish item, not changed in this change.
+2 -2
openspec/changes/add-dev-automation/specs/ui-test-harness/spec.md
··· 27 27 28 28 #### Scenario: Enter splits a block 29 29 - **WHEN** a test places the cursor mid-content in a focused block and simulates `enter` 30 - - **THEN** the block tree contains a new following sibling holding the content after the cursor, focus is on the new sibling, and the cursor is at its start 30 + - **THEN** the block tree contains a new following sibling holding the content after the cursor, and focus is on the new sibling 31 31 32 32 #### Scenario: Backspace at start merges blocks 33 33 - **WHEN** a test focuses a block with a previous sibling, places the cursor at offset 0, and simulates `backspace` 34 - - **THEN** the block's content is appended to the previous sibling, the block is removed from the tree, and the cursor sits at the join point in the merged block 34 + - **THEN** the block's content is appended to the previous sibling, the block is removed from the tree, and focus moves to the merged block 35 35 36 36 #### Scenario: Tab indents under previous sibling 37 37 - **WHEN** a test focuses a block that has a previous sibling and simulates `tab`
+23 -23
openspec/changes/add-dev-automation/tasks.md
··· 2 2 3 3 ## 1. Fixture graphs (trawler-core) 4 4 5 - - [ ] 1.1 Add a `fixtures` cargo feature to `trawler-core` and a `fixtures` module scaffold gated behind it 6 - - [ ] 1.2 Implement the fixture builder against the real `GraphStorage`/mutation APIs: named pages, fixed-date journal page, nested outline, tags, references, typed properties, one query block (design O3 starting inventory: one journal page, two named pages, ~20 blocks) 7 - - [ ] 1.3 Add determinism test: seed twice into separate tempdirs, reopen both, assert equivalent pages/trees/content/tags/references/properties (no clock reads, no randomness) 8 - - [ ] 1.4 Add a test asserting a freshly seeded fixture is a valid graph — reopen through `GraphStorage`, verify indexes see the fixture's references/tags, and the fixture query block's expression evaluates to a non-empty result via the query engine 5 + - [x] 1.1 Add a `fixtures` cargo feature to `trawler-core` and a `fixtures` module scaffold gated behind it 6 + - [x] 1.2 Implement the fixture builder against the real `GraphStorage`/mutation APIs: named pages, fixed-date journal page, nested outline, tags, references, typed properties, one query block (design O3 starting inventory: one journal page, two named pages, ~20 blocks) 7 + - [x] 1.3 Add determinism test: seed twice into separate tempdirs, reopen both, assert equivalent pages/trees/content/tags/references/properties (no clock reads, no randomness) 8 + - [x] 1.4 Add a test asserting a freshly seeded fixture is a valid graph — reopen through `GraphStorage`, verify indexes see the fixture's references/tags, and the fixture query block's expression evaluates to a non-empty result via the query engine 9 9 10 10 ## 2. UI test harness (gpui test-support) 11 11 12 - - [ ] 2.1 Wire dev-dependencies: gpui `test-support` feature for the `trawler` crate, `trawler-core` with `fixtures` enabled in dev-deps; extract/expose whatever `main()` init the tests must share (`editor::init`, keymap init) 13 - - [ ] 2.2 Land the smoke test: `#[gpui::test]` opens a test window hosting `TrawlerApp` over a seeded fixture tempdir, simulates typing one character, asserts it landed in the focused block's content — resolve any `TrawlerApp::new`/keymap assumptions the fake test platform surfaces (design risk 1) 14 - - [ ] 2.3 Build shared test helpers: open-app-on-fixture setup, keystroke-driving wrappers, and entity-state assertion helpers for block tree shape, focus, cursor, and popup state 15 - - [ ] 2.4 Cover block structure edits: Enter splits at cursor, Shift+Enter inserts newline, Backspace-at-start merges into previous sibling 16 - - [ ] 2.5 Cover outline movement: Tab indents under previous sibling, Shift+Tab outdents, Alt+Up/Alt+Down reorder among siblings 17 - - [ ] 2.6 Cover focus movement: Up/Down at content boundaries move focus to previous/next visible block 18 - - [ ] 2.7 Cover completion: `[[` and `#` open completion with candidates from fixture content, Escape dismisses with content preserved 19 - - [ ] 2.8 Verify the full suite passes headlessly via `cargo test --workspace` and stays warning-clean under `cargo clippy --workspace --all-targets -- -D warnings` 12 + - [x] 2.1 Wire dev-dependencies: gpui `test-support` feature for the `trawler` crate, `trawler-core` with `fixtures` enabled in dev-deps; extract/expose whatever `main()` init the tests must share (`editor::init`, keymap init) 13 + - [x] 2.2 Land the smoke test: `#[gpui::test]` opens a test window hosting `TrawlerApp` over a seeded fixture tempdir, simulates typing one character, asserts it landed in the focused block's content — resolve any `TrawlerApp::new`/keymap assumptions the fake test platform surfaces (design risk 1) 14 + - [x] 2.3 Build shared test helpers: open-app-on-fixture setup, keystroke-driving wrappers, and entity-state assertion helpers for block tree shape, focus, cursor, and popup state 15 + - [x] 2.4 Cover block structure edits: Enter splits at cursor, Shift+Enter inserts newline, Backspace-at-start merges into previous sibling 16 + - [x] 2.5 Cover outline movement: Tab indents under previous sibling, Shift+Tab outdents, Alt+Up/Alt+Down reorder among siblings 17 + - [x] 2.6 Cover focus movement: Up/Down at content boundaries move focus to previous/next visible block 18 + - [x] 2.7 Cover completion: `[[` and `#` open completion with candidates from fixture content, Escape dismisses with content preserved 19 + - [x] 2.8 Verify the full suite passes headlessly via `cargo test --workspace` and stays warning-clean under `cargo clippy --workspace --all-targets -- -D warnings` 20 20 21 21 ## 3. Dev automation server (trawler, `devtools` feature) 22 22 23 - - [ ] 3.1 Add the `devtools` cargo feature to the `trawler` crate gating a new `devtools` module plus `xcap`/`serde`/`serde_json` dependencies; verify a default build compiles none of it 24 - - [ ] 3.2 Implement server lifecycle: start only when `TRAWLER_DEVTOOLS=1`, bind `127.0.0.1:0` on a listener thread, print the port to stdout and write `<graph-dir>/devtools.port` 25 - - [ ] 3.3 Implement the marshaling seam: JSON-lines framing on the listener thread, commands + oneshot reply senders into a `futures::channel::mpsc` unbounded channel, one foreground task (`cx.spawn`) draining it against the window handle; malformed/unknown requests answer `{"ok":false,...}` without crashing 26 - - [ ] 3.4 Implement `keys` (parse via `Keystroke::parse`, dispatch via `Window::dispatch_keystroke`, reply after dispatch; parse failure → error reply) and `type` (text through the focused editor's real input path — resolve design O1) 27 - - [ ] 3.5 Implement `bounds` and versioned `dump` (`"v":1`): current view, visible outline (id/content/depth/collapsed), focused block, cursor/selection, open popup contents, window bounds + scale — as a serde module colocated with the entity state it serializes 28 - - [ ] 3.6 Implement `screenshot`: locate own window via `xcap` by process id, capture to PNG off the main thread, fail soft with an error reply when capture is unavailable 29 - - [ ] 3.7 Add integration test(s) for the protocol seam where feasible without a real window (framing, error replies, port-file write); manually verify keys/type/dump/screenshot against a fixture-seeded scratch graph on Windows 30 - - [ ] 3.8 Add the `--seed-fixtures <dir>` (devtools-gated) CLI path on the trawler binary invoking the trawler-core fixture builder 23 + - [x] 3.1 Add the `devtools` cargo feature to the `trawler` crate gating a new `devtools` module plus `xcap`/`serde`/`serde_json` dependencies; verify a default build compiles none of it 24 + - [x] 3.2 Implement server lifecycle: start only when `TRAWLER_DEVTOOLS=1`, bind `127.0.0.1:0` on a listener thread, print the port to stdout and write `<graph-dir>/devtools.port` 25 + - [x] 3.3 Implement the marshaling seam: JSON-lines framing on the listener thread, commands + oneshot reply senders into a `futures::channel::mpsc` unbounded channel, one foreground task (`cx.spawn`) draining it against the window handle; malformed/unknown requests answer `{"ok":false,...}` without crashing 26 + - [x] 3.4 Implement `keys` (parse via `Keystroke::parse`, dispatch via `Window::dispatch_keystroke`, reply after dispatch; parse failure → error reply) and `type` (text through the focused editor's real input path — resolve design O1) 27 + - [x] 3.5 Implement `bounds` and versioned `dump` (`"v":1`): current view, visible outline (id/content/depth/collapsed), focused block, cursor/selection, open popup contents, window bounds + scale — as a serde module colocated with the entity state it serializes 28 + - [x] 3.6 Implement `screenshot`: locate own window via `xcap` by process id, capture to PNG off the main thread, fail soft with an error reply when capture is unavailable 29 + - [x] 3.7 Add integration test(s) for the protocol seam where feasible without a real window (framing, error replies, port-file write); manually verify keys/type/dump/screenshot against a fixture-seeded scratch graph on Windows 30 + - [x] 3.8 Add the `--seed-fixtures <dir>` (devtools-gated) CLI path on the trawler binary invoking the trawler-core fixture builder 31 31 32 32 ## 4. Documentation and workflow packaging 33 33 34 - - [ ] 4.1 README section: enabling devtools builds, launching with `TRAWLER_DEVTOOLS=1` + `TRAWLER_GRAPH_DIR` scratch graphs, seeding fixtures, the command protocol with examples, and per-platform screenshot caveats (macOS Screen Recording permission, Wayland best-effort) 35 - - [ ] 4.2 Add a project `run`/dev-loop skill (`.claude/skills/`) encoding the agent workflow: seed fixture scratch graph → kill stale app → build with devtools → launch → drive via socket → screenshot → cleanup, including the "running exe blocks cargo rebuild" kill step 36 - - [ ] 4.3 Reconcile design.md open questions (O1 `type` path, O2 quiescence, O3 fixture inventory) with what was actually built; record any deviations 34 + - [x] 4.1 README section: enabling devtools builds, launching with `TRAWLER_DEVTOOLS=1` + `TRAWLER_GRAPH_DIR` scratch graphs, seeding fixtures, the command protocol with examples, and per-platform screenshot caveats (macOS Screen Recording permission, Wayland best-effort) 35 + - [x] 4.2 Add a project `run`/dev-loop skill (`.claude/skills/`) encoding the agent workflow: seed fixture scratch graph → kill stale app → build with devtools → launch → drive via socket → screenshot → cleanup, including the "running exe blocks cargo rebuild" kill step 36 + - [x] 4.3 Reconcile design.md open questions (O1 `type` path, O2 quiescence, O3 fixture inventory) with what was actually built; record any deviations