From b7b9f7d11e3b7f2ad6188ee835ecc0dcc5bb9b00 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 12:09:19 -0800 Subject: [PATCH 1/6] feat(scripts): scenarios prove a fix in the real app with e2e A scenario is an e2e test (scripts/scenarios/*.e2e.ts) that runs against the real dev stack with the replay agent as the model. e2e starts the app, drives the browser and records the run; one fixture seeds T3 state through the app's CLI. `scenarios:prove` runs a scenario on a base and a head commit and reports failsOnBase and passesOnHead with captures side by side. Co-Authored-By: Claude Fable 5.2 --- .gitignore | 1 + AGENTS.md | 2 +- docs/README.md | 1 + docs/operations/scenarios.md | 120 ++++ knip.jsonc | 4 + package.json | 2 + pnpm-lock.yaml | 570 ++++++++++++++++++ scripts/e2e.config.ts | 39 ++ scripts/package.json | 3 + scripts/prove-scenario.test.ts | 88 +++ scripts/prove-scenario.ts | 221 +++++++ .../add-project-tab-reaches-submit.e2e.ts | 23 + scripts/scenarios/replay-turn-streams.e2e.ts | 26 + scripts/scenarios/start-app.ts | 76 +++ scripts/scenarios/t3.ts | 83 +++ 15 files changed, 1258 insertions(+), 1 deletion(-) create mode 100644 docs/operations/scenarios.md create mode 100644 scripts/e2e.config.ts create mode 100644 scripts/prove-scenario.test.ts create mode 100644 scripts/prove-scenario.ts create mode 100644 scripts/scenarios/add-project-tab-reaches-submit.e2e.ts create mode 100644 scripts/scenarios/replay-turn-streams.e2e.ts create mode 100644 scripts/scenarios/start-app.ts create mode 100644 scripts/scenarios/t3.ts diff --git a/.gitignore b/.gitignore index b6b793e020ef1..85b7c5b4bf02f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ apps/mobile/.android-cache/ artifacts/app-store/screenshots/ artifacts/mobile-e2e/ artifacts/mobile-behavior/ +scripts/.e2e/ .github/pr-assets/ native/**/target/ node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 2effa629d25e0..87f21a8a0a2f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - [Testing](docs/internals/testing.md) carries these same test rules, plus review guidance, for review bots. Change both together. - **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite. - Backend behavior changes ship with focused tests for that behavior. -- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. +- Proof for a bug fix depends on the bug. Logic, data, state, and protocol bugs get a failing test first (the fewest that fail on `main` for that reason, in an existing case of the module's test file where one fits), then the fix. Purely visual bugs (spacing, colour, alignment, a layout shift) get before/after captures as their proof and no test: do not add UI tests for nitpicks, and do not ask for one in review when a capture already shows it. A server-only fix with no visible surface ships the test plus a short runtime transcript. For a bug a user can reproduce in the web client, `vp run scenarios:prove` shows it failing on base and passing on the fix in the real app; see [Scenarios](docs/operations/scenarios.md). - The server is event-sourced, and side effects run after the command commits. In tests, drain the effect worker (`OrchestrationEffectWorkerV2.drain`) or await the specific persisted event or `Deferred` that marks the milestone. Never wait on sleeps or polling. A test that needs a timeout to pass is wrong. Concretely: - `it.effect` runs on a `TestClock`; `Effect.sleep` only advances through `TestClock.adjust`. `it.live` is wall-clock and only for subprocess or network fixtures. - No `yieldNow` or `sleep` polling loops. Await the `Deferred`, take from the `Queue`, or drain the worker. diff --git a/docs/README.md b/docs/README.md index 3c98c72238820..264d509d97642 100644 --- a/docs/README.md +++ b/docs/README.md @@ -65,6 +65,7 @@ source alone does not explain. Most code changes do not need an internal documen ### Runbooks - [Development and local builds](./operations/development.md) +- [Scenarios: real-app proof for a fix](./operations/scenarios.md) - [T3 Connect setup](./operations/connect-setup.md) - [Release](./operations/release.md) - [Observability](./operations/observability.md) diff --git a/docs/operations/scenarios.md b/docs/operations/scenarios.md new file mode 100644 index 0000000000000..377299b240a61 --- /dev/null +++ b/docs/operations/scenarios.md @@ -0,0 +1,120 @@ +# Scenarios + +A scenario is an [e2e](https://e2e.com/docs) test that shows one behavior in the real app: a real +server on its own T3 home, real HTTP and WebSocket, and a real browser on the real web client. The +dev replay agent stands in for the model, so a run is free and repeatable. Use one as the proof +for a bug fix: it fails on the base commit and passes on the fix. + +Scenarios live in [`scripts/scenarios`](../../scripts/scenarios) as `*.e2e.ts`. e2e is the +runner; its docs ship in `scripts/node_modules/e2e/docs` and `e2e guide` prints them. + +## Write one + +```ts +import { expect } from "e2e"; + +import { test } from "./t3.ts"; + +test("Tab moves from the add-project Git URL field to its submit button", async ({ + t3, + app, + screen, + browser, +}) => { + await t3.project("demo"); + await t3.open(); + + await screen.getByRole("button", "Add project").tap(); + await screen.getByText("Git URL").tap(); + await screen.getByRole("combobox").fill("https://github.com/pingdotgg/t3code.git"); + const submit = screen.getByRole("button", "Continue (Enter)"); + await expect(submit).toBeEnabled(); + + await browser.keyboard.press("Tab"); + await app.screenshot("after-tab"); + await expect(submit).toBeFocused(); +}); +``` + +`screen`, `browser`, `app`, and `expect` are e2e's own. [`t3.ts`](../../scripts/scenarios/t3.ts) +adds one fixture for what e2e cannot know about T3 Code: + +- `t3.project(title, files)` creates a git repository and adds it as a project. +- `t3.thread({ project, title, messages })` starts a thread and plays one replayed turn per + message before returning, so the thread has real history. `model` picks the recording. +- `t3.open(path)` opens the app, already signed in. + +State is created through the app's own CLI, so it is real events and works on a base commit +whose schema differs. New threads default to the replay agent's `showcase` recording: type a +message and send it. To play something else, add a recording to the +[replay agent](development.md#replay-provider). + +Take an `app.screenshot("label")` where the bug shows. The proof pairs base and head captures by +label. e2e adds a capture at the point of failure itself. + +Write only `expect` assertions for the thing the bug breaks. A proof counts a failed assertion as +the bug. A control that cannot be found, or an app that does not start, counts as a scenario that +could not run. + +Keep `agent.act` and `agent.assert` out of scenarios: they call a model, and no model is +configured. + +## Run + +```sh +vp run scenarios # all of them +cd scripts && pnpm exec e2e run scenarios/replay-turn-streams.e2e.ts --headed +``` + +e2e starts the dev stack through [`start-app.ts`](../../scripts/scenarios/start-app.ts) on a free +port, runs the tests, and stops it. Results land in `scripts/.e2e`: `report.json`, `summary.md`, +and per test a `trace.md`, its screenshots, and a video. The first run downloads e2e's browser. +`e2e run --help` lists the rest: `--grep`, `--watch`, `--last-failed`, `--changed-since`. + +## Prove a fix + +```sh +vp run scenarios:prove scripts/scenarios/add-project-tab-reaches-submit.e2e.ts \ + --base e509db3a2f^ --head e509db3a2f --upload-command "ghdrop upload" +``` + +[`prove-scenario.ts`](../../scripts/prove-scenario.ts) runs `e2e run` for that scenario on both +commits and compares the two reports. It prints, and writes to `scripts/.e2e/prove/proof.json`, +`failsOnBase`, `passesOnHead`, `baseSha`, `headSha`, `verdict`, each side's `steps`, `captures`, +`videos`, and `markdown`: a table with base and head captures side by side, each labelled with +its commit, ready for a pull request description. `--upload-command` names a command that takes +the capture paths and prints one URL per line. `--head` defaults to `HEAD`. + +It exits 0 only when the scenario fails on base and passes on head, 1 when it ran and did not +prove the fix, and 2 when it could not run. A scenario that passes on base does not reproduce the +bug, and the verdict says so. + +The scenario and e2e always come from your checkout. Only the app comes from the commit under +test: a temporary worktree beside your checkout, installed with `vp i` and removed afterwards. A +commit that is your clean `HEAD` runs in place. So a new scenario can prove a fix against a base +that predates it. + +## Other tiers + +Only the web client with the replay agent runs today. + +- **Mobile.** e2e's `@e2e-dev/mobile` engine drives iOS and Android through agent-device, which + this repository already uses. A mobile target in + [`e2e.config.ts`](../../scripts/e2e.config.ts) and a `t3` fixture that pairs the device would + run scenarios written with `screen` locators unchanged. Tests that use `browser` are web-only. +- **Live provider.** Point the default model in `start-app.ts` at a real provider's cheapest + model. Assertions on exact reply text would not hold. + +## In CI + +No job runs scenarios, and none should run all of them on every pull request. e2e can select the +ones a branch touches with `e2e run --changed-since origin/main` once +[test filters](https://e2e.com/docs/filters) are configured, and `@e2e-dev/github` posts the +result as a pull request comment. + +## What a scenario cannot cover + +- Desktop shell behavior (menus, windows, IPC), and the mobile app until it has a target. +- Provider-specific behavior. The replay agent speaks generic ACP. +- State no command can create, such as a run stopped by a usage limit. +- Timing. Replay streaming is simulated, so a scenario cannot show a performance regression. diff --git a/knip.jsonc b/knip.jsonc index ed4bf19129645..08dbc39f9bd29 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -31,6 +31,10 @@ "linux-after-pack.ts", "mobile-behavior/run.ts", "mobile-native-client.ts", + // e2e loads its config and the scenarios by path, and starts the app script. + "e2e.config.ts", + "prove-scenario.ts", + "scenarios/*.ts", "setup-worktree.ts", "sign-macos.ts", "prepare-server-compile-cache.cjs", diff --git a/package.json b/package.json index c43ef19c7b856..9017f1117394b 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "mobile:install": "node scripts/mobile-native-client.ts ensure", "mobile:behavior": "node scripts/mobile-behavior.ts", "mobile:e2e": "node scripts/mobile-e2e.ts", + "scenarios": "vp run --filter @t3tools/scripts scenarios", + "scenarios:prove": "node scripts/prove-scenario.ts", "test": "vp run -r test", "test:cached": "node scripts/test-cached.ts", "test:perf:v2-wire": "vp test run apps/server/src/orchestration-v2/WireProjection.test.ts apps/server/src/orchestration-v2/threadHistoryPaging.test.ts apps/server/src/orchestration-v2/ThreadStream.test.ts apps/server/src/orchestration-v2/ThreadTransportPerformance.test.ts apps/server/src/orchestration-v2/ShellStream.test.ts apps/server/src/orchestration-v2/CommandPolicy.test.ts apps/server/src/orchestration-v2/runtimeLayer.test.ts apps/server/src/ws.test.ts packages/contracts/src/orchestrationV2.test.ts packages/client-runtime/src/operations/commands.test.ts packages/client-runtime/src/operations/commands.performance.test.ts packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts packages/client-runtime/src/state/threadHistoryController.test.ts packages/client-runtime/src/state/threadHistoryMerge.test.ts packages/client-runtime/src/state/threads-sync.test.ts packages/client-runtime/src/state/shellReducer.test.ts", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff6392397431e..67717413606fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2455,6 +2455,9 @@ importers: scripts: dependencies: + '@e2e-dev/web': + specifier: 0.15.5 + version: 0.15.5(e2e@0.23.0) '@effect/platform-node': specifier: 4.1.2 version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) @@ -2476,6 +2479,9 @@ importers: agent-device: specifier: 0.21.32 version: 0.21.32 + e2e: + specifier: 0.23.0 + version: 0.23.0 effect: specifier: 4.1.2 version: 4.1.2 @@ -2535,6 +2541,10 @@ packages: resolution: {integrity: sha512-IpefF5ssZVOZCD4Wu7zxt6PwZoGT2HLVjxpJ10Bjx6EAmRfLYJPjyrfxb0RYDlwvXnDb9nkQ7Do/oJVDuRFzew==} engines: {node: '>=18'} + '@ai-sdk/provider@4.0.36': + resolution: {integrity: sha512-GqvWZSZdBelCGughlBdOoupT258rgM3YMbaLAS9WSZJ0vlt8c9vm3My+ZT31mYhiReMCMCF2qF2axnGbILpk1A==} + engines: {node: '>=22'} + '@alchemy.run/acme@2.0.0-beta.85': resolution: {integrity: sha512-hOV18VHG6ivrbPnk35hoqlCQPc6bR8b9soGmHlSkbnPIgVZUg7jpliF9AztSSJbJK4GHcBFc9hWetwCRHzGYAg==} peerDependencies: @@ -3712,6 +3722,13 @@ packages: peerDependencies: react: '>=16.8.0' + '@e2e-dev/web@0.15.5': + resolution: {integrity: sha512-fUXrSgfii6w/q7OyX9/kEIDLpIMHpEkTH81hrR8WLnqk8li3bRyXJQBGWqtq9NoWpS2UANnhMI45n6Fv1ErCnw==} + engines: {node: ^22.22.3 || >=24.8.0} + hasBin: true + peerDependencies: + e2e: '>=0.22.1 <1' + '@effect/atom-react@4.1.2': resolution: {integrity: sha512-XRAm4lks2kzNFQIz9QTGKR02CaDTR9VJNRXDjDAR8OzM15ybrK4YkB7OlFZ9U+hxXe3DXJZx7RU1/xI2Em2PGQ==} peerDependencies: @@ -4817,6 +4834,10 @@ packages: '@material/material-color-utilities@0.3.0': resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==} + '@modelcontextprotocol/core@2.4.0': + resolution: {integrity: sha512-z8QBKqdfXLPzkLQuG4xp8hrVEMm1PZ1IgxMN2sMbJiFiAKOWXnAzogXn2W4aqIBlP1ea6U5yiqVquvUNM1Cj6Q==} + engines: {node: '>=20'} + '@modelcontextprotocol/sdk@1.32.2': resolution: {integrity: sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==} engines: {node: '>=18'} @@ -4827,6 +4848,10 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/server@2.4.0': + resolution: {integrity: sha512-rgR9yxSaAJ+bcsi/XroT/S2AfPiIWloXAnrdfYSCms9ivVju0ishu/wW0J21Ho6+ZOR1r7RyeRbjiRuF87ix5Q==} + engines: {node: '>=20'} + '@muse-code/sdk@1.4.6': resolution: {integrity: sha512-pUENCnmn9q4e2xMXicdRbzbSMdH1fiyTIh5fVBXuxNdNP2ZKA0kwQz5fWMxIB8wzxS2ZyH0v27dvfbZuGuoUjg==} engines: {node: '>=20'} @@ -5131,6 +5156,12 @@ packages: cpu: [arm] os: [android] + '@oxc-parser/binding-android-arm-eabi@0.157.0': + resolution: {integrity: sha512-zFtSEal1K/17LATxVs0VcT/Itt+fxUQ9Ni8HtG+pPcEqy/TenRO74XffkY2dBiy3Pd+w0FuebwCZ6PJx1z7scQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@oxc-parser/binding-android-arm64@0.147.0': resolution: {integrity: sha512-emjQHOYJaomo4ykaXQ1EItunr/I94Nk01oqBmU4dSkKSTupIDx6OysVDf2e8Eytm77rb+4ZxzgElyWP7rcEX7A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5143,6 +5174,12 @@ packages: cpu: [arm64] os: [android] + '@oxc-parser/binding-android-arm64@0.157.0': + resolution: {integrity: sha512-WSlkFUnjamuv+2+TXW5F1rp2I24KVEXj4S+H7J7Y8T/KLR2xXbHj8lgxbZZtjSKi3Z/aGGrlFEOy7EmCWpIUlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@oxc-parser/binding-darwin-arm64@0.147.0': resolution: {integrity: sha512-kXvBPJL7RmDPJ2mze/vXPPVQimCDtFr9OFLjf7dyhV5Dx64cgcXh9KKrA1sMWvCObvJll9CZZUO0FBlFwD0l6A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5155,6 +5192,12 @@ packages: cpu: [arm64] os: [darwin] + '@oxc-parser/binding-darwin-arm64@0.157.0': + resolution: {integrity: sha512-Usk8UoURmtG2qBLwrO+il+kf6CahEJJriRQACuAwF2CqcWqGsvl9xj9JIMDxP9VTinOt/BZUuDZxcpn68QKsAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@oxc-parser/binding-darwin-x64@0.147.0': resolution: {integrity: sha512-mgFF8pLU6R64LbT27lSrtVRspVC/3IcZ0qyIikzmi78Y3Ik2OPnlAHHI0UEBRcC3qmNgtjaef7zkFt7/uPxIcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5167,6 +5210,12 @@ packages: cpu: [x64] os: [darwin] + '@oxc-parser/binding-darwin-x64@0.157.0': + resolution: {integrity: sha512-yOvjPixSD2sYJxtQXOzprXdV1xbr2RZyEnbkwFnnxz332Q4+gwkQgCGXZkFlQpc95Fk4XFITACfbFzze4XXktA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@oxc-parser/binding-freebsd-x64@0.147.0': resolution: {integrity: sha512-v38aiF11qufOTBcCAKL4skgQf0zJ4NEvRlivq7B5kHrlyvjCLjvNrMtNWDTz1SDUL6/xVsJRmLDxv2e+Cp4oWw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5179,6 +5228,12 @@ packages: cpu: [x64] os: [freebsd] + '@oxc-parser/binding-freebsd-x64@0.157.0': + resolution: {integrity: sha512-BLTsP8TWvmV53jgGBynwv2DR3hKtjpdpL6fXnQs2Fg5P6HsFTShFjrgQrHMSM1cA4lGmlcj7tdTx96RoJfYuuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': resolution: {integrity: sha512-AeIiBbwUaP0H1+4/qGW9l5qHecS/+XA5iMuieVcGb1T+tyc2dVGspFW13BWk/XrLsiGP/CiDJTJqAPLLCzZHkw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5191,6 +5246,12 @@ packages: cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-gnueabihf@0.157.0': + resolution: {integrity: sha512-wGMDInIrhayQpNxwpXIWqa2QiKk+/6RxOi4GxPVoqmwrCdn2xj3xXyDUm3RujxQaraZjeWfh0+h9nedoivnL1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': resolution: {integrity: sha512-/41MKPW4RgPY4DJco0NCF0RYX3IMZaVlRNMNzvhaxRavc7tN3Txm+qllZbh0aMRs0VHdgUlbI8TcAOiTai4TKg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5203,6 +5264,12 @@ packages: cpu: [arm] os: [linux] + '@oxc-parser/binding-linux-arm-musleabihf@0.157.0': + resolution: {integrity: sha512-N6OLfOnku4otlU6eF+HLU+1fH2XAaItc+c7dE+S09SzRZHvkaltU9aGTlMhmAIee95hOqkOCVFHNzQYJ1KS+gQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': resolution: {integrity: sha512-bmpw/RPhVXgZbtb3xBDuwW5s8+LvZYdqcDSX/sP2ltL77aTio3DP/B5ZTwwgoJ6Mr9vJs4RrmgEKW9XkLNUU1g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5217,6 +5284,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-arm64-gnu@0.157.0': + resolution: {integrity: sha512-YfY+qZ8d9tHJEvIq+zXyCcSve/jWgTNDiDtr1W4WVMh5+4fsOjYtH2JDIXIXUkILZzPKgJYbQ08Y/mwhr1gQLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-arm64-musl@0.147.0': resolution: {integrity: sha512-gd7VX/FDVOw6mjQcu45iIcp4QkgybgJwh3a0OFG2NxmPCj628mQWD96QGu1kK8+mZF9qK4b/gIEyC63vQoB7+Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5231,6 +5305,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-arm64-musl@0.157.0': + resolution: {integrity: sha512-ytZeO8BKkezwkLoWH3hK0R9Jd+S1BQhcQaWiSAWhXE7Dncq1XiJKb4w5hCe4lwl2ytgoSBgB0XEABhwD2/IhJg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': resolution: {integrity: sha512-HnAzcfki7dSUNHf510Q2NmbJlz8Ys7rn8l9l588Pkx0tYe1BHLZnmELIgqizJ4WPhHGSwN8Ce+B/menVxS3odA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5245,6 +5326,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-ppc64-gnu@0.157.0': + resolution: {integrity: sha512-54w3SexApRhsw3DOAXBZ85rEMR2y29NDOUr18t69fcsnemS/N1YU+zQddHCol/HDhBPyLHg5fpxChRCSzxHwqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': resolution: {integrity: sha512-qlkOL6wT44U+fT5s/+sR6Shx0OdwvQF83JyIPZUxG/ovqZF5/7atOtjH+JPZ5/7ATQLbFBBSmghcy/+2NVB/ew==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5259,6 +5347,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-riscv64-gnu@0.157.0': + resolution: {integrity: sha512-byg0y1OHdjjhll0qWJQyowKdCxM/zCKysIIOqkWgCid/cR+RU5nTz/63OeAZvIb1z/VB1CyypDRdLn2Ov3r+HQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': resolution: {integrity: sha512-DlefD7L7sMXs/3hIBH23Egk0phj8kG0SA81dVGOQ3S1ekjOlmTLH2E+F2Thwfh1slKx6aH+lNc5fQYAw0GU7/g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5273,6 +5368,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-riscv64-musl@0.157.0': + resolution: {integrity: sha512-CiQrt8esQTeL17kscbsfpdx5cywItx3y95YejsihET68oYnxHjbEgj8JaqlIWmhpL2h98piqPBLhWmZbp67nQQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': resolution: {integrity: sha512-Xqpagk/031IvZ4svrk2FF01YEqM/iN3MJV3SVZadKg/CsGlDCGoREqKHXYnoV5+8SfGe/m6RM1szXFLusTu/Uw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5287,6 +5389,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-s390x-gnu@0.157.0': + resolution: {integrity: sha512-wSzuUshJVxcrx0c9UJYUOuUf0XHThC1+f8CEfeFbosg7FzTkjCdt89JKKU7BH8C9eTPrnJ5ir4QM0pWpVSmHnA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.147.0': resolution: {integrity: sha512-QioQOeUbI4ATUr0S2z88uA3Cds2R3Mm5Ge7U8XNYtlTb2GJF3rWlcj70z0AJhhOlbdm0YgVjqPBldUNbFylDIg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5301,6 +5410,13 @@ packages: os: [linux] libc: [glibc] + '@oxc-parser/binding-linux-x64-gnu@0.157.0': + resolution: {integrity: sha512-C4BY4QSVzN6R03Ner3r6EtIvLNR4VW4RAb63m+L0tiF/zD7grkBHMn1svyrOBFR4i32+GWJHu05xC4gGQYBhtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@oxc-parser/binding-linux-x64-musl@0.147.0': resolution: {integrity: sha512-NXy1tv/OdC+pPTwf9RiCZWPK53V/Xq/2cjSnjOSyKopajdaDqMIkgtDY+jXZemp2e8px5FeWfY2L2LwhKZQovg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5315,6 +5431,13 @@ packages: os: [linux] libc: [musl] + '@oxc-parser/binding-linux-x64-musl@0.157.0': + resolution: {integrity: sha512-kN3LdZ2xp9z+TLuzYUWPR/mz/HrKd20vMCGWyby1d8T8K5t5NPemcqfSv3y0oMwXK8OF3lW7MNJnc3uWphvwvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@oxc-parser/binding-openharmony-arm64@0.147.0': resolution: {integrity: sha512-GpGWZ6oKz4bjCWW9Mz5pCaGPyk2Aaze6zEoaslIQqpSLtpx5pXj/ap5gUNb5Jn2LIbqWyjkGLX9yv3NMuNcBVQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5327,6 +5450,12 @@ packages: cpu: [arm64] os: [openharmony] + '@oxc-parser/binding-openharmony-arm64@0.157.0': + resolution: {integrity: sha512-cQKUrgBAFUaGcqp9Umb3GToFkvow92YeJxJuzbk8YPnButv9aY5HLyKUpxxh4WqJvP6POiNR5fUDDB5wI8Wiaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': resolution: {integrity: sha512-a8mlt7CC8z7LUdCfaxhff4kCd+vSjE+NEFL0cxA8ukfuSnvAto/pWTjytW4BuVLnQGcCVdHJwcRKOfs++H+tjw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5339,6 +5468,12 @@ packages: cpu: [arm64] os: [win32] + '@oxc-parser/binding-win32-arm64-msvc@0.157.0': + resolution: {integrity: sha512-qZsymd8L67o5bpzZ7gVO7qJUIF9Uf0/mXcoG0MDchBvdL7Q2tAF4aZBZFqc/YRd0WP3jTvqjs3CxlFhHgi48dA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': resolution: {integrity: sha512-M5ViVDBcFLnl2632AuuWuP35zEL5oikK1jTx8r3+902VEDeSNHxFZAB6RZyfZ7MU6Oi5QTOG8MmMkIeHsudSaw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5351,6 +5486,12 @@ packages: cpu: [ia32] os: [win32] + '@oxc-parser/binding-win32-ia32-msvc@0.157.0': + resolution: {integrity: sha512-Ro9PcgwvRglGKI9fEYkbTKvIgh/hzWjv+yPyCTNuxp173i79YbTkkYeOvAEC9SFQPZXYr4+SuIrpCIaWN6sbUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.147.0': resolution: {integrity: sha512-DUaE13OwnUSlHpLZNcC/nuT10ivlWqc5EZgsfgXuAmWYw0r3nDxGeLD1zlGwYwIgVk1/ZMAxoXpV+05stvbHaA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5363,10 +5504,20 @@ packages: cpu: [x64] os: [win32] + '@oxc-parser/binding-win32-x64-msvc@0.157.0': + resolution: {integrity: sha512-Ea0fqR+HLzzCt9tYhKbb6aJlM6wBXKWfuDwLhcG519B8HGtxthDzLGfbqMQdlX2Ii5gAZt/Hgxig/iApdxZSzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/runtime@0.155.0': resolution: {integrity: sha512-VbW9raczGS5ZZ1FMunUBYHe4uV/R684qlQCoMdE3n5JT1PcqQsvmFpMYkF8AD+W04fBLGAq6Tr7YsTk0jjvDBA==} engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-project/runtime@0.157.0': + resolution: {integrity: sha512-srnUknwVoji5JdmU664pA4XyjBJXmgxP8rWlnZhFovzw6XQxaICGWAuScZdlLmIHI+ZRc7WgeiC2CURF4tAszQ==} + engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} @@ -5385,6 +5536,9 @@ packages: '@oxc-project/types@0.155.0': resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} + '@oxc-project/types@0.157.0': + resolution: {integrity: sha512-95iTox1qdt6WGWce3xwNOVayByOfLaYAq+qGPpub6NWbBh9sv0GG3+zyfh9ZBCO/jVu5A0EWI+qQI7g6fevjxg==} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} cpu: [arm] @@ -5610,6 +5764,128 @@ packages: cpu: [x64] os: [win32] + '@oxc-transform/binding-android-arm-eabi@0.157.0': + resolution: {integrity: sha512-uRHbSeooFBkwRx9DkedM9YPMAvnwPd9ds6iu+ki9yerAGgTe7dhQAgvrbxrbnhQF3L43QR2LMEkV1zobNpI2cQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-transform/binding-android-arm64@0.157.0': + resolution: {integrity: sha512-sZAV2CvG3hqJPG6O3OYF5hXfbWkb+kJQvYdfa0lC788dpQQI+zBR7RpFDpAY+3u4WpV2+v1H8SfSOdsw7aJ1ug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-transform/binding-darwin-arm64@0.157.0': + resolution: {integrity: sha512-/vxwVl483evp5RmWHoz999TL3RW3ghgOlgyd8VNGkBeFgxm2lJRKa0MKzbP7l0v6uIF6789yNe8b7B8BgaTWwA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-transform/binding-darwin-x64@0.157.0': + resolution: {integrity: sha512-Uoqdq4CaoMh2hyeOl5L2f8cnXOW36KZR2/MzY4E4zAkO2xKYaWHa/XWNPkFeTfnaOi9lmMIvZAYZ8R7ELxRhPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-transform/binding-freebsd-x64@0.157.0': + resolution: {integrity: sha512-QFjfMWa/oPK40lyWBCQR8/PlnpgVdPN6yOZqU5HJIzWyo0FwPd7tLpig7rtQgiBEhj+XoyxexXHUVFlwA9Oa/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-transform/binding-linux-arm-gnueabihf@0.157.0': + resolution: {integrity: sha512-aQHBtkX/75l8/hvBTggNLpfzPzHZ1vN/qxK9h+r0Gas5XaI4yeW9Qd/VPxGskBgtjmN3BfHXQvAFaj+R9vyBGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform/binding-linux-arm-musleabihf@0.157.0': + resolution: {integrity: sha512-dJVOATtEl0LxQuYXTsSAWRay19J5uq7bs1PBHBktEwoCdbXUa8Tm2lPZaX2QuEqC98xk7yAWMOlesv40x6m1rA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform/binding-linux-arm64-gnu@0.157.0': + resolution: {integrity: sha512-Jdt0PpTwGWEqFkDqkRrR6yJNMevQebaQFnFZIxxnNXPVDze6mNxpEaPvJEmH0Dxzk1vQQm/I/SM08PAAb2qjpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-arm64-musl@0.157.0': + resolution: {integrity: sha512-qCLNQRA3KLaiCG9uXQmFbhjoetlCyfFwFdSXqbVVPCUPi1VFhuBGFltb2ZOMrxE2zMyh9RD27xzVULm+SRB1/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-transform/binding-linux-ppc64-gnu@0.157.0': + resolution: {integrity: sha512-B5bEXT0mG88o2tIoKK/x2KOoXhbab5SjjBm5WfWlcpKLfr8ioYeZPPNT2gZN6FMq+pYuJA3f+i9dFIVlaqFz6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-riscv64-gnu@0.157.0': + resolution: {integrity: sha512-dDYzPGj3/SrcCwWK78FDXg5UXjJRxhVXADxIHvxiCFAeOAUGBFqBS1ykxw/VcsMueDr2QVgsYySPp+VB6fUNSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-riscv64-musl@0.157.0': + resolution: {integrity: sha512-zfplsQ80jaRXnD/l9d1w4awRanMkEiR+rlTJbxOkB+FnuypwHU3JYVJlrQLr/dph3XIrQSkvxsitY2XpyHdLpw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-transform/binding-linux-s390x-gnu@0.157.0': + resolution: {integrity: sha512-X7rmxwvgEQrg2qs0goelrFU5X1m5yaTNdnNBT94bK0cQB5q+E7HCxV8eX9wK3qMP8p2bae6N8+ck4gW9XGKBBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-x64-gnu@0.157.0': + resolution: {integrity: sha512-wi5EV43pRIiodHVttWD/785k78IMEyKUaSN1o9JN3Yu1GFBCXJEy9wU4J2V6//4iCeEFywdzQiMbHizG4cl0eQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-x64-musl@0.157.0': + resolution: {integrity: sha512-ZK6gmsC1rGiqov3mRltKCwJiRxAhEv7l72TdfClGlLy1kH97GiPsnhLnIiCfqRwahN3W9DX98xMoijhtI/taTg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-transform/binding-openharmony-arm64@0.157.0': + resolution: {integrity: sha512-gz2syX2odmf8qzbiEVy+y+giea43tXnTavzy44/JsopYvUodDWOx4aZzu+ZrQZsbfmae1ntDv3NZWyTkbip2yQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-transform/binding-win32-arm64-msvc@0.157.0': + resolution: {integrity: sha512-44/lSyHppcduYVtPpWURyUTB8IINPqmwwDFbZKxClGARBhDcPgcvcgI6X4niSUWYJTBHf7vT+OcxmWygRc17dQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-transform/binding-win32-ia32-msvc@0.157.0': + resolution: {integrity: sha512-S7a7wqrwQAMTUCocS4weusiQLNY2WP9kJCGCOD1in6PRq1vQFkPt52mh1GLQCzCH+HMBEMx+wK9XotVZBRIVuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-transform/binding-win32-x64-msvc@0.157.0': + resolution: {integrity: sha512-lADvsdq/qHDlkdgzaaJbcwQZOEdNcjrLFW/MdJDG3AhUQSIINibJly2U9Q/yMsoUx/tAnR31gRvWWkLjHq5lxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxfmt/binding-android-arm-eabi@0.74.0': resolution: {integrity: sha512-xxQwrf5EPI5zxvuCTW8r6tzCTjPQ+h6+HqJ0iRKRy2zS1yBllt/xLVjoIyUc+FnR7g0a5g1SZv2yyDYhTItLbA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7017,6 +7293,9 @@ packages: peerDependencies: vitest: 5.0.5 + '@vitest/expect@5.1.0': + resolution: {integrity: sha512-Okx71SHCjnfax7YLaxcJzZINi71BF2S5FezzQipheEQsiBAB0dhRBwuQ7uCJUH0/eGAT6HgAtmVVzrEZo6Qmcw==} + '@vitest/mocker@5.0.5': resolution: {integrity: sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==} peerDependencies: @@ -7028,12 +7307,18 @@ packages: '@vitest/pretty-format@5.0.5': resolution: {integrity: sha512-24sSdIlMA9DVPXnYiB9siwU0bTd887Y9jX3+vabztCSsvHwo7ipvJ/a2L3Rljw8+2rK1f5fCj3h5U9I/Jsqgbw==} + '@vitest/pretty-format@5.1.0': + resolution: {integrity: sha512-IRx5mc30CrFzyMNni6SZKQ1r53kv049Mcvvk0WbWcD6PUz/bFjG4zYv0KosQAeonPpGp5WxqYBMqdSOfJfvVWw==} + '@vitest/snapshot@5.0.5': resolution: {integrity: sha512-dHyFElWMQy0wFBGrIaPPeUWfJFh+jAUrv46ImIJGd8CADdKV3mlrAu3FyD0wesdxELFCs9M807e040Kba/XKtA==} '@vitest/spy@5.0.5': resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==} + '@vitest/spy@5.1.0': + resolution: {integrity: sha512-2DPO5OMHWKrBngC+fCmTQqemNF0gidRhp3lxbBmRhW+M1XJrfgPpqNqWVevXtn2f2ufD5ZaR1B6B641aAlcX5A==} + '@vitest/ui@5.0.5': resolution: {integrity: sha512-dCoGUOdgmCaSB2g+WQ/spPCBkytAWdNUkFSXY4z+eYp2d8JEOY8v0uDRaJTG9qf/kTlTVZ4CuYMYMz/f1HS/qQ==} peerDependencies: @@ -7042,6 +7327,9 @@ packages: '@vitest/utils@5.0.5': resolution: {integrity: sha512-/rBu7gzLDQI/SC79Tn98b5DwOgaHU/Gt4I9OVeP3YofzNaYtbImRCOJQmT1S6vOJg9gEIlWC+r3QxKkjuSa1nQ==} + '@vitest/utils@5.1.0': + resolution: {integrity: sha512-V+qgCwkvVw+s1t1S5TaoBHsLi5Sz/YmohCMoOu0mZ8ZQst2bKhRYARAQucWpVg8D0mv0x5MHcWXY5QdOsoiGBQ==} + '@voidzero-dev/vite-plus-core@1.4.0': resolution: {integrity: sha512-LTGXCdZ58qJKfuCOmf0hnZOVA64eOgzDQOqYpI+71tPxk/0cGqPQhYDGQQZoEvNLAn1G2Lo4gKadsQdWxb99WQ==} engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} @@ -7931,6 +8219,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@15.1.0: resolution: {integrity: sha512-cgnQ88TRrWcC7pVq46RzJVRMktu3W2RcbnoidnfBsl1bmDCBwJS/udcIZpKoCrbAYYaCqsz1yZCVIgbIaU3ddQ==} engines: {node: '>=22.12.0'} @@ -8420,6 +8712,31 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + e2e@0.23.0: + resolution: {integrity: sha512-x4wbcLDbwtmB9avyruqQX2j77i8HynAGG74vxA2SVSCJwnD7CaioyfBOxKU3L7J7K7cWIDU0LNz3yR5j29UjNQ==} + engines: {node: ^22.22.3 || >=24.8.0} + hasBin: true + peerDependencies: + '@ai-sdk/anthropic': ^4.0.0 + '@ai-sdk/google': ^4.0.0 + '@ai-sdk/openai': ^4.0.0 + '@ai-sdk/openai-compatible': ^3.0.0 + '@ai-sdk/xai': ^5.0.0 + ai: ^7.0.0 + peerDependenciesMeta: + '@ai-sdk/anthropic': + optional: true + '@ai-sdk/google': + optional: true + '@ai-sdk/openai': + optional: true + '@ai-sdk/openai-compatible': + optional: true + '@ai-sdk/xai': + optional: true + ai: + optional: true + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -8980,6 +9297,9 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-png@8.0.0: + resolution: {integrity: sha512-gCysNasJ8KEMgfdYIKd/wTDo6ENK1PWT0RJO7O+0pgmuHPw2O6tA1WvdxFRJoLf9V8yFYpG0FA1YgI8X97OhJA==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} @@ -10393,6 +10713,10 @@ packages: resolution: {integrity: sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-parser@0.157.0: + resolution: {integrity: sha512-2MJ9tZGXUbEIhQT/O2OPLlYCgur75ES26t2Pb1sYrvDQcRjrE7GmubkALgPEB6LzOY6sM81AAHKfl5M/FgnjJg==} + engines: {node: ^20.19.0 || >=22.12.0} + oxc-resolver@11.24.2: resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} @@ -10400,6 +10724,10 @@ packages: resolution: {integrity: sha512-CAx+SKDU4uSOmQnyn7daMj/v/h8tF3fnaUMi4yImKAARPnXV0BieHe/qw1LTcK4KlnIuAhpfUJfSfNKIBh8HqQ==} engines: {node: ^20.19.0 || >=22.12.0} + oxc-transform@0.157.0: + resolution: {integrity: sha512-2H8XPFEbZQv/uoJ863a8YHdNrWWfETcKJ/fVutW7X1ryA5UhhNqaJsV1ib9y2pHOoHfofBIMzfIBCQmzLOVnTA==} + engines: {node: ^20.19.0 || >=22.12.0} + oxfmt@0.74.0: resolution: {integrity: sha512-G4OP7FgwGYZR5ttRpCOOfJdyRsSyvtn34vSXnjDq6Oqed5Dufg5mB44mank+51nAdHOKuTJ0lj0mksY7L+N61A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -10592,6 +10920,10 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pixelmatch@8.0.0: + resolution: {integrity: sha512-cIqOLfh5k52us715qf3B3+kTOxZv+WDv77HN7xokLScYQGYQHi4m/ynoDIPpvF2LvCyUJGyvzY64i0QCXtzu1g==} + hasBin: true + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -10609,6 +10941,11 @@ packages: engines: {node: '>=18'} hasBin: true + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + playwright@1.60.0: resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} @@ -12346,6 +12683,10 @@ snapshots: dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@4.0.36': + dependencies: + json-schema: 0.4.0 + '@alchemy.run/acme@2.0.0-beta.85(alchemy@2.0.0-beta.85(patch_hash=07cc8c7e14b3b9af143ddd40a059f117c27e55139fbf20bd5dde2eb30509cedd)(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)))(effect@4.1.2)': dependencies: '@distilled.cloud/acme': 1.0.0-rc.18(effect@4.1.2) @@ -13513,6 +13854,11 @@ snapshots: react: 19.3.0 tslib: 2.8.1 + '@e2e-dev/web@0.15.5(e2e@0.23.0)': + dependencies: + e2e: 0.23.0 + playwright-core: 1.64.1 + '@effect/atom-react@4.1.2(effect@4.1.2)(react@19.3.0)(scheduler@0.28.0)': dependencies: effect: 4.1.2 @@ -14742,6 +15088,10 @@ snapshots: '@material/material-color-utilities@0.3.0': {} + '@modelcontextprotocol/core@2.4.0': + dependencies: + zod: 4.6.7 + '@modelcontextprotocol/sdk@1.32.2(supports-color@8.1.1)(zod@4.6.7)': dependencies: '@hono/node-server': 2.1.6(hono@4.13.17) @@ -14764,6 +15114,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/server@2.4.0': + dependencies: + '@modelcontextprotocol/core': 2.4.0 + zod: 4.6.7 + '@muse-code/sdk@1.4.6(patch_hash=7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101)(@types/node@24.12.4)': optionalDependencies: '@types/node': 24.12.4 @@ -14994,116 +15349,175 @@ snapshots: '@oxc-parser/binding-android-arm-eabi@0.148.0': optional: true + '@oxc-parser/binding-android-arm-eabi@0.157.0': + optional: true + '@oxc-parser/binding-android-arm64@0.147.0': optional: true '@oxc-parser/binding-android-arm64@0.148.0': optional: true + '@oxc-parser/binding-android-arm64@0.157.0': + optional: true + '@oxc-parser/binding-darwin-arm64@0.147.0': optional: true '@oxc-parser/binding-darwin-arm64@0.148.0': optional: true + '@oxc-parser/binding-darwin-arm64@0.157.0': + optional: true + '@oxc-parser/binding-darwin-x64@0.147.0': optional: true '@oxc-parser/binding-darwin-x64@0.148.0': optional: true + '@oxc-parser/binding-darwin-x64@0.157.0': + optional: true + '@oxc-parser/binding-freebsd-x64@0.147.0': optional: true '@oxc-parser/binding-freebsd-x64@0.148.0': optional: true + '@oxc-parser/binding-freebsd-x64@0.157.0': + optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': optional: true '@oxc-parser/binding-linux-arm-gnueabihf@0.148.0': optional: true + '@oxc-parser/binding-linux-arm-gnueabihf@0.157.0': + optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': optional: true '@oxc-parser/binding-linux-arm-musleabihf@0.148.0': optional: true + '@oxc-parser/binding-linux-arm-musleabihf@0.157.0': + optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': optional: true '@oxc-parser/binding-linux-arm64-gnu@0.148.0': optional: true + '@oxc-parser/binding-linux-arm64-gnu@0.157.0': + optional: true + '@oxc-parser/binding-linux-arm64-musl@0.147.0': optional: true '@oxc-parser/binding-linux-arm64-musl@0.148.0': optional: true + '@oxc-parser/binding-linux-arm64-musl@0.157.0': + optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': optional: true '@oxc-parser/binding-linux-ppc64-gnu@0.148.0': optional: true + '@oxc-parser/binding-linux-ppc64-gnu@0.157.0': + optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': optional: true '@oxc-parser/binding-linux-riscv64-gnu@0.148.0': optional: true + '@oxc-parser/binding-linux-riscv64-gnu@0.157.0': + optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': optional: true '@oxc-parser/binding-linux-riscv64-musl@0.148.0': optional: true + '@oxc-parser/binding-linux-riscv64-musl@0.157.0': + optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': optional: true '@oxc-parser/binding-linux-s390x-gnu@0.148.0': optional: true + '@oxc-parser/binding-linux-s390x-gnu@0.157.0': + optional: true + '@oxc-parser/binding-linux-x64-gnu@0.147.0': optional: true '@oxc-parser/binding-linux-x64-gnu@0.148.0': optional: true + '@oxc-parser/binding-linux-x64-gnu@0.157.0': + optional: true + '@oxc-parser/binding-linux-x64-musl@0.147.0': optional: true '@oxc-parser/binding-linux-x64-musl@0.148.0': optional: true + '@oxc-parser/binding-linux-x64-musl@0.157.0': + optional: true + '@oxc-parser/binding-openharmony-arm64@0.147.0': optional: true '@oxc-parser/binding-openharmony-arm64@0.148.0': optional: true + '@oxc-parser/binding-openharmony-arm64@0.157.0': + optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': optional: true '@oxc-parser/binding-win32-arm64-msvc@0.148.0': optional: true + '@oxc-parser/binding-win32-arm64-msvc@0.157.0': + optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': optional: true '@oxc-parser/binding-win32-ia32-msvc@0.148.0': optional: true + '@oxc-parser/binding-win32-ia32-msvc@0.157.0': + optional: true + '@oxc-parser/binding-win32-x64-msvc@0.147.0': optional: true '@oxc-parser/binding-win32-x64-msvc@0.148.0': optional: true + '@oxc-parser/binding-win32-x64-msvc@0.157.0': + optional: true + '@oxc-project/runtime@0.155.0': {} + '@oxc-project/runtime@0.157.0': {} + '@oxc-project/types@0.127.0': optional: true @@ -15118,6 +15532,8 @@ snapshots: '@oxc-project/types@0.155.0': {} + '@oxc-project/types@0.157.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.24.2': optional: true @@ -15236,6 +15652,63 @@ snapshots: '@oxc-transform-react/binding-win32-x64-msvc@0.152.0': optional: true + '@oxc-transform/binding-android-arm-eabi@0.157.0': + optional: true + + '@oxc-transform/binding-android-arm64@0.157.0': + optional: true + + '@oxc-transform/binding-darwin-arm64@0.157.0': + optional: true + + '@oxc-transform/binding-darwin-x64@0.157.0': + optional: true + + '@oxc-transform/binding-freebsd-x64@0.157.0': + optional: true + + '@oxc-transform/binding-linux-arm-gnueabihf@0.157.0': + optional: true + + '@oxc-transform/binding-linux-arm-musleabihf@0.157.0': + optional: true + + '@oxc-transform/binding-linux-arm64-gnu@0.157.0': + optional: true + + '@oxc-transform/binding-linux-arm64-musl@0.157.0': + optional: true + + '@oxc-transform/binding-linux-ppc64-gnu@0.157.0': + optional: true + + '@oxc-transform/binding-linux-riscv64-gnu@0.157.0': + optional: true + + '@oxc-transform/binding-linux-riscv64-musl@0.157.0': + optional: true + + '@oxc-transform/binding-linux-s390x-gnu@0.157.0': + optional: true + + '@oxc-transform/binding-linux-x64-gnu@0.157.0': + optional: true + + '@oxc-transform/binding-linux-x64-musl@0.157.0': + optional: true + + '@oxc-transform/binding-openharmony-arm64@0.157.0': + optional: true + + '@oxc-transform/binding-win32-arm64-msvc@0.157.0': + optional: true + + '@oxc-transform/binding-win32-ia32-msvc@0.157.0': + optional: true + + '@oxc-transform/binding-win32-x64-msvc@0.157.0': + optional: true + '@oxfmt/binding-android-arm-eabi@0.74.0': optional: true @@ -16580,6 +17053,15 @@ snapshots: - vue-tsc - yaml + '@vitest/expect@5.1.0': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 5.1.0 + '@vitest/utils': 5.1.0 + chai: 6.3.0 + tinyrainbow: 3.2.0 + '@vitest/mocker@5.0.5(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -16614,6 +17096,10 @@ snapshots: dependencies: tinyrainbow: 3.2.0 + '@vitest/pretty-format@5.1.0': + dependencies: + tinyrainbow: 3.2.0 + '@vitest/snapshot@5.0.5': dependencies: '@vitest/pretty-format': 5.0.5 @@ -16623,6 +17109,8 @@ snapshots: '@vitest/spy@5.0.5': {} + '@vitest/spy@5.1.0': {} + '@vitest/ui@5.0.5(vitest@5.0.5)': dependencies: '@vitest/utils': 5.0.5 @@ -16639,6 +17127,12 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.2.0 + '@vitest/utils@5.1.0': + dependencies: + '@vitest/pretty-format': 5.1.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 + '@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.155.0 @@ -17537,6 +18031,8 @@ snapshots: commander@14.0.3: {} + commander@15.0.0: {} + commander@15.1.0: {} commander@2.20.3: {} @@ -17879,6 +18375,23 @@ snapshots: duplexer@0.1.2: {} + e2e@0.23.0: + dependencies: + '@ai-sdk/provider': 4.0.36 + '@clack/prompts': 1.8.1 + '@jridgewell/remapping': 2.3.5 + '@modelcontextprotocol/server': 2.4.0 + '@oxc-project/runtime': 0.157.0 + '@vitest/expect': 5.1.0 + commander: 15.0.0 + fast-png: 8.0.0 + get-tsconfig: 4.14.3 + oxc-parser: 0.157.0 + oxc-transform: 0.157.0 + picocolors: 1.1.1 + pixelmatch: 8.0.0 + zod: 4.6.7 + ee-first@1.1.1: {} effect@4.1.2: {} @@ -18585,6 +19098,11 @@ snapshots: fast-json-stable-stringify@2.1.0: optional: true + fast-png@8.0.0: + dependencies: + fflate: 0.8.3 + iobuffer: 6.0.1 + fast-safe-stringify@2.1.1: {} fast-sha256@1.3.0: {} @@ -20454,6 +20972,30 @@ snapshots: '@oxc-parser/binding-win32-x64-msvc': 0.148.0 optional: true + oxc-parser@0.157.0: + dependencies: + '@oxc-project/types': 0.157.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.157.0 + '@oxc-parser/binding-android-arm64': 0.157.0 + '@oxc-parser/binding-darwin-arm64': 0.157.0 + '@oxc-parser/binding-darwin-x64': 0.157.0 + '@oxc-parser/binding-freebsd-x64': 0.157.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.157.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.157.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.157.0 + '@oxc-parser/binding-linux-arm64-musl': 0.157.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.157.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.157.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.157.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.157.0 + '@oxc-parser/binding-linux-x64-gnu': 0.157.0 + '@oxc-parser/binding-linux-x64-musl': 0.157.0 + '@oxc-parser/binding-openharmony-arm64': 0.157.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.157.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.157.0 + '@oxc-parser/binding-win32-x64-msvc': 0.157.0 + oxc-resolver@11.24.2: optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.24.2 @@ -20498,6 +21040,28 @@ snapshots: '@oxc-transform-react/binding-win32-ia32-msvc': 0.152.0 '@oxc-transform-react/binding-win32-x64-msvc': 0.152.0 + oxc-transform@0.157.0: + optionalDependencies: + '@oxc-transform/binding-android-arm-eabi': 0.157.0 + '@oxc-transform/binding-android-arm64': 0.157.0 + '@oxc-transform/binding-darwin-arm64': 0.157.0 + '@oxc-transform/binding-darwin-x64': 0.157.0 + '@oxc-transform/binding-freebsd-x64': 0.157.0 + '@oxc-transform/binding-linux-arm-gnueabihf': 0.157.0 + '@oxc-transform/binding-linux-arm-musleabihf': 0.157.0 + '@oxc-transform/binding-linux-arm64-gnu': 0.157.0 + '@oxc-transform/binding-linux-arm64-musl': 0.157.0 + '@oxc-transform/binding-linux-ppc64-gnu': 0.157.0 + '@oxc-transform/binding-linux-riscv64-gnu': 0.157.0 + '@oxc-transform/binding-linux-riscv64-musl': 0.157.0 + '@oxc-transform/binding-linux-s390x-gnu': 0.157.0 + '@oxc-transform/binding-linux-x64-gnu': 0.157.0 + '@oxc-transform/binding-linux-x64-musl': 0.157.0 + '@oxc-transform/binding-openharmony-arm64': 0.157.0 + '@oxc-transform/binding-win32-arm64-msvc': 0.157.0 + '@oxc-transform/binding-win32-ia32-msvc': 0.157.0 + '@oxc-transform/binding-win32-x64-msvc': 0.157.0 + oxfmt@0.74.0(@astrojs/compiler-rs@0.5.1)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.2.0 @@ -20701,6 +21265,10 @@ snapshots: picomatch@4.0.7: {} + pixelmatch@8.0.0: + dependencies: + pngjs: 7.0.0 + pkce-challenge@5.0.1: {} pkg-up@3.1.0: @@ -20718,6 +21286,8 @@ snapshots: playwright-core@1.60.0: {} + playwright-core@1.64.1: {} + playwright@1.60.0: dependencies: playwright-core: 1.60.0 diff --git a/scripts/e2e.config.ts b/scripts/e2e.config.ts new file mode 100644 index 0000000000000..a4bf2a1ef53f8 --- /dev/null +++ b/scripts/e2e.config.ts @@ -0,0 +1,39 @@ +// @effect-diagnostics nodeBuiltinImport:off +// Scenarios: e2e tests that run against the real dev stack. See docs/operations/scenarios.md. +import * as NodePath from "node:path"; + +import { web } from "@e2e-dev/web"; +import type { E2EConfig } from "e2e"; + +/** The checkout whose app runs. `prove-scenario.ts` points it at a base or head worktree. */ +export const APP_DIR = NodePath.resolve(process.env.T3_SCENARIO_APP_DIR ?? ".."); +/** A T3 home only scenarios use, recreated on every run. `.t3` is gitignored. */ +export const T3_HOME = NodePath.resolve(import.meta.dirname, "../.t3/scenarios"); + +export default { + tests: "scenarios/**/*.e2e.ts", + targets: [ + { + engine: web({ viewport: { width: 1280, height: 800 } }), + app: { + url: "http://127.0.0.1:0", + // Vite answers before the server does; this path is proxied to the server. + readyUrl: "http://127.0.0.1:{port}/.well-known/t3/environment", + command: { + executable: process.execPath, + args: [NodePath.join(import.meta.dirname, "scenarios/start-app.ts"), T3_HOME, "{port}"], + cwd: APP_DIR, + startupTimeout: 300_000, + log: ".e2e/app.log", + }, + }, + }, + ], + // One server holds every test's state, and a dev stack is heavy. + workers: 1, + timeout: 120_000, + assertionTimeout: 15_000, + video: "on", + trace: "on", + reporters: ["list", "markdown"], +} satisfies E2EConfig; diff --git a/scripts/package.json b/scripts/package.json index 02c0caad7f933..c2159cf09f7a0 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -4,9 +4,11 @@ "type": "module", "scripts": { "typecheck": "effect-tsgo tsrs --noEmit", + "scenarios": "E2E_TELEMETRY_DISABLED=1 e2e run", "test": "vp test run --config ../vite.config.ts --dir ." }, "dependencies": { + "@e2e-dev/web": "0.15.5", "@effect/platform-node": "catalog:", "@electron/asar": "^4.3.0", "@electron/osx-sign": "2.7.0", @@ -14,6 +16,7 @@ "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", "agent-device": "0.21.32", + "e2e": "0.23.0", "effect": "catalog:", "playwright-core": "1.60.0", "plist": "catalog:", diff --git a/scripts/prove-scenario.test.ts b/scripts/prove-scenario.test.ts new file mode 100644 index 0000000000000..c432bbdddadd8 --- /dev/null +++ b/scripts/prove-scenario.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { buildProof, readSide } from "./prove-scenario.ts"; + +const report = (exitCode: number, error?: { code: string; message: string }) => ({ + run: { + exitCode, + results: [ + { titlePath: ["Another scenario the run did not select"], attempts: [] }, + { + titlePath: ["Tab reaches the submit button"], + attempts: [ + { + steps: [ + { api: "app.open", label: "/pair#token=secret", status: "passed" }, + { api: "expect.toBeFocused", label: "Continue", status: error ? "failed" : "passed" }, + ], + ...(error === undefined ? {} : { error }), + artifacts: [ + { kind: "screenshot", path: "tab/attempt-1/screenshots/001-after-tab.png" }, + { kind: "video", path: "tab/attempt-1/video/video.webm" }, + ], + }, + ], + }, + ], + }, +}); + +const passed = report(0); +const failed = report(1, { code: "ASSERTION_FAILED", message: "expected: focused" }); +// The control the scenario clicks is missing: the scenario is broken, the bug is not shown. +const brokenScenario = report(1, { code: "LOCATOR_NOT_FOUND", message: "no node" }); + +describe("prove-scenario", () => { + it.each([ + { base: failed, head: passed, failsOnBase: true, passesOnHead: true, verdict: /^Proven/ }, + // A "fix" for behavior that never happens: the scenario must fail before it can prove. + { + base: passed, + head: passed, + failsOnBase: false, + passesOnHead: true, + verdict: /passes on base/, + }, + { base: failed, head: failed, failsOnBase: true, passesOnHead: false, verdict: /still fails/ }, + { + base: brokenScenario, + head: passed, + failsOnBase: false, + passesOnHead: true, + verdict: /could not run on base/, + }, + // e2e writes no report when the app does not start. + { + base: undefined, + head: passed, + failsOnBase: false, + passesOnHead: true, + verdict: /could not run on base/, + }, + ])("reports $verdict", (row) => { + const proof = buildProof( + readSide("aaaaaaaaaaaa", "/out/base", row.base), + readSide("bbbbbbbbbbbb", "/out/head", row.head), + (path) => path, + ); + expect(proof).toMatchObject({ failsOnBase: row.failsOnBase, passesOnHead: row.passesOnHead }); + expect(proof.verdict).toMatch(row.verdict); + }); + + it("labels captures with their side and commit and keeps the pairing token out", () => { + const proof = buildProof( + readSide("aaaaaaaaaaaa", "/out/base", failed), + readSide("bbbbbbbbbbbb", "/out/head", passed), + (path) => `https://files.example${path}`, + ); + expect(proof.markdown).toContain("| | Base `aaaaaaaaaa` | Head `bbbbbbbbbb` |"); + expect(proof.markdown).toContain( + "![Base aaaaaaaaaa: after tab](https://files.example/out/base/results/tab/attempt-1/screenshots/001-after-tab.png)", + ); + expect(proof.videos).toEqual([ + "/out/base/results/tab/attempt-1/video/video.webm", + "/out/head/results/tab/attempt-1/video/video.webm", + ]); + expect(JSON.stringify(proof)).not.toContain("secret"); + }); +}); diff --git a/scripts/prove-scenario.ts b/scripts/prove-scenario.ts new file mode 100644 index 0000000000000..fdad731b73509 --- /dev/null +++ b/scripts/prove-scenario.ts @@ -0,0 +1,221 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off +// Runs one scenario with `e2e run` on a base and a head commit and says whether it fails on +// base and passes on head. See docs/operations/scenarios.md. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; + +const SCRIPTS = import.meta.dirname; +const REPO_ROOT = NodePath.dirname(SCRIPTS); + +/** The parts of e2e's report.json (schema/report-v1.schema.json) a proof reads. */ +interface E2EReport { + readonly run: { + readonly exitCode: number; + readonly results: ReadonlyArray<{ + readonly titlePath: ReadonlyArray; + readonly attempts: ReadonlyArray<{ + readonly steps: ReadonlyArray<{ api: string; label?: string; status: string }>; + readonly error?: { readonly code: string; readonly message: string }; + readonly artifacts?: ReadonlyArray<{ readonly kind: string; readonly path?: string }>; + }>; + }>; + }; +} + +export interface SideResult { + readonly sha: string; + /** `error`: the scenario could not run, which says nothing about the behavior. */ + readonly status: "passed" | "failed" | "error"; + readonly title: string; + readonly message?: string; + readonly steps: ReadonlyArray<{ readonly step: string; readonly status: string }>; + /** Absolute capture paths, in the order the scenario took them. */ + readonly captures: ReadonlyArray; + readonly videos: ReadonlyArray; +} + +/** Only a failed expectation shows the bug. A missing control or a dead app shows nothing. */ +export function readSide(sha: string, outDir: string, report: E2EReport | undefined): SideResult { + // The report also lists the tests the run did not select, with no attempts. + const result = report?.run.results.find((candidate) => candidate.attempts.length > 0); + const attempt = result?.attempts.at(-1); + if (report === undefined || result === undefined || attempt === undefined) { + const message = "e2e wrote no report: the app did not start, or no test was selected"; + return { sha, status: "error", title: "", message, steps: [], captures: [], videos: [] }; + } + const files = (kind: string) => + (attempt.artifacts ?? []).flatMap((artifact) => + artifact.kind === kind && artifact.path !== undefined + ? [NodePath.join(outDir, "results", artifact.path)] + : [], + ); + const error = attempt.error; + return { + sha, + status: + report.run.exitCode === 0 + ? "passed" + : error?.code === "ASSERTION_FAILED" + ? "failed" + : "error", + title: result.titlePath.join(" > "), + ...(error === undefined ? {} : { message: `${error.code}: ${error.message}` }), + steps: attempt.steps.map((step) => ({ + // The sign-in step's label holds the run's pairing token. + step: `${step.api} ${step.label?.includes("token=") ? "" : (step.label ?? "")}`, + status: step.status, + })), + captures: files("screenshot"), + videos: files("video"), + }; +} + +const short = (sha: string) => sha.slice(0, 10); +const captureLabel = (path: string) => + NodePath.basename(path, ".png").replace(/^\d+-/, "").replaceAll("-", " "); + +/** `link` maps a capture path to what the Markdown should point at, such as an uploaded URL. */ +export function buildProof(base: SideResult, head: SideResult, link: (path: string) => string) { + const baseSha = `\`${short(base.sha)}\``; + const headSha = `\`${short(head.sha)}\``; + const broken = base.status === "error" ? base : head.status === "error" ? head : undefined; + const verdict = + broken !== undefined + ? `Not proven: the scenario could not run on ${broken === base ? `base ${baseSha}` : `head ${headSha}`} (${broken.message ?? "unknown error"}).` + : base.status === "passed" + ? head.status === "passed" + ? `Not proven: the scenario passes on base ${baseSha} too, so it does not reproduce the bug.` + : `Not proven: the scenario passes on base ${baseSha} and fails on head ${headSha}, so head breaks it.` + : head.status === "passed" + ? `Proven: the scenario fails on base ${baseSha} and passes on head ${headSha}.` + : `Not proven: the scenario fails on base ${baseSha} and still fails on head ${headSha}.`; + + const labels = [...new Set([...base.captures, ...head.captures].map(captureLabel))]; + const cell = (side: string, result: SideResult, label: string) => { + const capture = result.captures.find((path) => captureLabel(path) === label); + return capture === undefined + ? "" + : `![${side} ${short(result.sha)}: ${label}](${link(capture)})`; + }; + const outcome = (result: SideResult) => + result.status === "passed" + ? "✅ passed" + : `${result.status === "failed" ? "❌ failed" : "⚠️ could not run"}
${(result.message ?? "").replaceAll("\n", "
").replaceAll("|", "\\|").replaceAll("

", "
")}`; + const markdown = [ + `### Scenario: ${head.title || base.title}`, + "", + `**${verdict}**`, + "", + `| | Base ${baseSha} | Head ${headSha} |`, + "| --- | --- | --- |", + `| Result | ${outcome(base)} | ${outcome(head)} |`, + ...labels.map( + (label) => `| ${label} | ${cell("Base", base, label)} | ${cell("Head", head, label)} |`, + ), + "", + ].join("\n"); + return { + scenario: head.title || base.title, + failsOnBase: base.status === "failed", + passesOnHead: head.status === "passed", + baseSha: base.sha, + headSha: head.sha, + verdict, + steps: { base: base.steps, head: head.steps }, + captures: [...base.captures, ...head.captures], + videos: [...base.videos, ...head.videos], + markdown, + base, + head, + }; +} + +const git = (args: ReadonlyArray) => + NodeChildProcess.execFileSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" }).trim(); + +/** Runs `run` in a checkout of `sha`: this one when it already is that commit, else a worktree. */ +function withCheckout(sha: string, run: (appDir: string) => A): A { + const clean = git(["status", "--porcelain", "--untracked-files=no"]).length === 0; + if (clean && git(["rev-parse", "HEAD"]) === sha) return run(REPO_ROOT); + // Beside this checkout, not in the temp directory, so tooling resolves as it does here. + const worktree = `${REPO_ROOT}-scenario-${short(sha)}`; + git(["worktree", "add", "--quiet", "--detach", "--force", worktree, sha]); + try { + NodeChildProcess.execFileSync("vp", ["i"], { cwd: worktree, stdio: ["ignore", 2, 2] }); + return run(worktree); + } finally { + git(["worktree", "remove", "--force", worktree]); + } +} + +if (import.meta.main) { + const { values, positionals } = NodeUtil.parseArgs({ + allowPositionals: true, + options: { + base: { type: "string" }, + head: { type: "string", default: "HEAD" }, + "upload-command": { type: "string" }, + }, + }); + const [scenario] = positionals; + if (scenario === undefined || values.base === undefined) { + console.error( + 'Usage: node scripts/prove-scenario.ts --base [--head ] [--upload-command ""]', + ); + process.exit(2); + } + const scenarioPath = NodePath.resolve(scenario); + const sides = (["base", "head"] as const).map((side) => { + const sha = git([ + "rev-parse", + "--verify", + `${side === "base" ? values.base : values.head}^{commit}`, + ]); + const outDir = NodePath.join(SCRIPTS, ".e2e/prove", side); + NodeFS.rmSync(outDir, { recursive: true, force: true }); + withCheckout(sha, (appDir) => + // The scenario and e2e come from this checkout; only the app comes from `appDir`. + NodeChildProcess.spawnSync( + NodePath.join(SCRIPTS, "node_modules/.bin/e2e"), + ["run", scenarioPath, "--output", NodePath.relative(SCRIPTS, outDir)], + { + cwd: SCRIPTS, + stdio: ["ignore", 2, 2], + env: { ...process.env, T3_SCENARIO_APP_DIR: appDir, E2E_TELEMETRY_DISABLED: "1" }, + }, + ), + ); + const reportPath = NodePath.join(outDir, "report.json"); + const report = NodeFS.existsSync(reportPath) + ? (JSON.parse(NodeFS.readFileSync(reportPath, "utf8")) as E2EReport) + : undefined; + return readSide(sha, outDir, report); + }); + const [base, head] = sides as [SideResult, SideResult]; + const captures = [...base.captures, ...head.captures]; + const links = new Map(); + if (values["upload-command"] !== undefined && captures.length > 0) { + const [command, ...args] = values["upload-command"].split(/\s+/).filter(Boolean); + const urls = NodeChildProcess.execFileSync(command!, [...args, ...captures], { + encoding: "utf8", + }); + for (const [index, url] of urls.trim().split("\n").entries()) links.set(captures[index]!, url); + } + const proveDir = NodePath.join(SCRIPTS, ".e2e/prove"); + const proof = buildProof( + base, + head, + (path) => links.get(path) ?? NodePath.relative(proveDir, path), + ); + NodeFS.writeFileSync(NodePath.join(proveDir, "proof.md"), proof.markdown); + NodeFS.writeFileSync( + NodePath.join(proveDir, "proof.json"), + `${JSON.stringify(proof, null, 2)}\n`, + ); + console.log(JSON.stringify(proof, null, 2)); + const couldNotRun = base.status === "error" || head.status === "error"; + process.exit(proof.failsOnBase && proof.passesOnHead ? 0 : couldNotRun ? 2 : 1); +} diff --git a/scripts/scenarios/add-project-tab-reaches-submit.e2e.ts b/scripts/scenarios/add-project-tab-reaches-submit.e2e.ts new file mode 100644 index 0000000000000..55ce2d6ef21b2 --- /dev/null +++ b/scripts/scenarios/add-project-tab-reaches-submit.e2e.ts @@ -0,0 +1,23 @@ +import { expect } from "e2e"; + +import { test } from "./t3.ts"; + +test("Tab moves from the add-project Git URL field to its submit button", async ({ + t3, + app, + screen, + browser, +}) => { + await t3.project("demo"); + await t3.open(); + + await screen.getByRole("button", "Add project").tap(); + await screen.getByText("Git URL").tap(); + await screen.getByRole("combobox").fill("https://github.com/pingdotgg/t3code.git"); + const submit = screen.getByRole("button", "Continue (Enter)"); + await expect(submit).toBeEnabled(); + + await browser.keyboard.press("Tab"); + await app.screenshot("after-tab"); + await expect(submit).toBeFocused(); +}); diff --git a/scripts/scenarios/replay-turn-streams.e2e.ts b/scripts/scenarios/replay-turn-streams.e2e.ts new file mode 100644 index 0000000000000..26db4aaa333e9 --- /dev/null +++ b/scripts/scenarios/replay-turn-streams.e2e.ts @@ -0,0 +1,26 @@ +import { expect } from "e2e"; + +import { test } from "./t3.ts"; + +test("A replayed turn streams a reply, shows its tool calls, and finishes", async ({ + t3, + app, + screen, +}) => { + await t3.project("demo", { "src/math.ts": "export const sum = 1 + 1;\n" }); + await t3.open(); + + await screen.getByRole("textbox").fill("Give me a short tour"); + await screen.getByRole("textbox").press("Enter"); + const stop = screen.getByRole("button", "Stop generation"); + await expect( + screen.getByText("The project has a single math module", { exact: false }), + ).toBeVisible(); + await expect(stop).toBeVisible(); + await app.screenshot("streaming"); + + await expect(screen.getByText("This turn is a replay.", { exact: false })).toBeVisible(); + await expect(stop).toBeHidden(); + await expect(screen.getByText("Give me a short tour").first()).toBeVisible(); + await app.screenshot("finished"); +}); diff --git a/scripts/scenarios/start-app.ts b/scripts/scenarios/start-app.ts new file mode 100644 index 0000000000000..8c52cc554d308 --- /dev/null +++ b/scripts/scenarios/start-app.ts @@ -0,0 +1,76 @@ +// @effect-diagnostics nodeBuiltinImport:off +// The app command of e2e.config.ts: starts this checkout's dev stack on a fresh T3 home. +// Usage: node start-app.ts , run from the checkout whose app should start. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeNet from "node:net"; +import * as NodePath from "node:path"; +import * as NodeReadline from "node:readline"; + +const [home, webPort] = process.argv.slice(2); +if (home === undefined || webPort === undefined) { + throw new Error("Usage: start-app.ts "); +} + +// The dev runner's web port is 5733 plus an offset. Mirror of BASE_WEB_PORT in dev-runner.ts. +const BASE_WEB_PORT = 5733; +// The replay agent `vp run dev` registers: free, recorded turns through the real adapter. +const REPLAY = { instanceId: "devReplay", model: "showcase" }; + +// The server labels its environment with the host's name, probed through these commands. +// Captures must not depend on, or show, the machine that made them. +const HOST_LABEL_SHIM = `#!/bin/sh +case "$*" in + "--pretty" | "--get ComputerName") echo "Scenario" ;; + *) for real in /usr/bin /usr/sbin /bin; do + [ -x "$real/$(basename "$0")" ] && exec "$real/$(basename "$0")" "$@" + done + exit 127 ;; +esac +`; + +NodeFS.rmSync(home, { recursive: true, force: true, maxRetries: 5 }); +NodeFS.mkdirSync(NodePath.join(home, "userdata"), { recursive: true }); +NodeFS.mkdirSync(NodePath.join(home, "bin")); +for (const command of ["hostnamectl", "scutil"]) { + NodeFS.writeFileSync(NodePath.join(home, "bin", command), HOST_LABEL_SHIM, { mode: 0o755 }); +} +// New threads and generated text (titles) go to the replay agent, so no run reaches a paid model. +NodeFS.writeFileSync( + NodePath.join(home, "userdata/settings.json"), + JSON.stringify({ defaultModelSelection: REPLAY, textGenerationModelSelection: REPLAY }), +); + +const serverPort = await new Promise((resolve) => { + const probe = NodeNet.createServer().listen(0, "127.0.0.1", () => { + const { port } = probe.address() as NodeNet.AddressInfo; + probe.close(() => resolve(port)); + }); +}); + +const child = NodeChildProcess.spawn( + process.execPath, + ["scripts/dev-runner.ts", "dev", "--home-dir", home, "--no-seed", "--port", String(serverPort)], + { + stdio: ["ignore", "pipe", "inherit"], + env: { + ...process.env, + PATH: `${NodePath.join(home, "bin")}${NodePath.delimiter}${process.env.PATH ?? ""}`, + T3CODE_PORT_OFFSET: String(Number(webPort) - BASE_WEB_PORT), + T3CODE_DEV_ALLOWED_ORIGINS: `http://127.0.0.1:${webPort}`, + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "0", + // Makes the startup pairing link reusable, so every test can sign in with it. + T3CODE_DEV_AUTH_TOKEN: NodeCrypto.randomBytes(32).toString("hex"), + }, + }, +); +NodeReadline.createInterface({ input: child.stdout }).on("line", (line) => { + const url = /pairingUrl:\s*(\S+)/.exec(line)?.[1]; + if (url === undefined) return void process.stdout.write(`${line}\n`); + // The link is a credential: it goes to a file the tests read, never to the log. + const { pathname, search, hash } = new URL(url); + NodeFS.writeFileSync(NodePath.join(home, "pairing-path"), `${pathname}${search}${hash}`); +}); +for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, () => child.kill(signal)); +child.on("exit", (code) => process.exit(code ?? 1)); diff --git a/scripts/scenarios/t3.ts b/scripts/scenarios/t3.ts new file mode 100644 index 0000000000000..5c94f210594e3 --- /dev/null +++ b/scripts/scenarios/t3.ts @@ -0,0 +1,83 @@ +// @effect-diagnostics nodeBuiltinImport:off +// `test` for scenarios: e2e's web test plus `t3`, which seeds state and opens the signed-in app. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; + +import { test as base } from "@e2e-dev/web"; + +import { APP_DIR, T3_HOME } from "../e2e.config.ts"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); + +/** The app's own CLI against the running server: state gets real events, not inserted rows. */ +const cli = (args: ReadonlyArray) => + execFile(process.execPath, ["apps/server/src/bin.ts", ...args, "--base-dir", T3_HOME], { + cwd: APP_DIR, + env: { PATH: process.env.PATH, HOME: process.env.HOME }, + }); + +export interface T3 { + /** Creates a git repository holding `files` and adds it as a project. Returns its path. */ + readonly project: (title: string, files?: Readonly>) => Promise; + /** + * Starts a thread in a project and plays one replayed turn per message before returning. + * `model` is a dev replay recording: showcase, todo-list, approval, question, multi-turn. + */ + readonly thread: (input: { + readonly project: string; + readonly title: string; + readonly messages: ReadonlyArray; + readonly model?: string; + }) => Promise; + /** Opens the app, signed in, at `path`. */ + readonly open: (path?: string) => Promise; +} + +export const test = base.extend<{ t3: T3 }>({ + t3: async ({ app }, use) => { + const projects: Array = []; + await use({ + project: async (title, files = {}) => { + // Its own folder per test: the server and this home outlive one test. + NodeFS.mkdirSync(NodePath.join(T3_HOME, "workspaces"), { recursive: true }); + const parent = NodeFS.mkdtempSync(NodePath.join(T3_HOME, "workspaces", "w-")); + const workspace = NodePath.join(parent, title); + for (const [file, content] of Object.entries({ "README.md": "", ...files })) { + NodeFS.mkdirSync(NodePath.dirname(NodePath.join(workspace, file)), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(workspace, file), content); + } + const git = (args: ReadonlyArray) => execFile("git", args, { cwd: workspace }); + const author = ["-c", "user.name=Scenario", "-c", "user.email=scenario@example.invalid"]; + await git(["init", "--quiet", "--initial-branch=main"]); + await git(["add", "--all"]); + await git([...author, "commit", "--quiet", "--message", "Initial commit"]); + await cli(["project", "add", workspace, "--title", title]); + projects.push(workspace); + return workspace; + }, + thread: async ({ project, title, messages, model = "multi-turn" }) => { + // Exit code 2 means the turn waits for an answer, which a scenario may want to start from. + const turn = (args: ReadonlyArray) => + cli(["thread", ...args, "--wait", "--json"]).catch( + (error: { code?: unknown; stdout?: string }) => { + if (error.code !== 2 || error.stdout === undefined) throw error; + return { stdout: error.stdout }; + }, + ); + const [first = "Start", ...rest] = messages; + const replay = ["--provider", "devReplay", "--model", model]; + const started = await turn(["start", project, first, "--title", title, ...replay]); + const { threadId } = JSON.parse(started.stdout) as { threadId: string }; + for (const message of rest) await turn(["send", threadId, message]); + }, + open: async (path = "/") => { + await app.open(NodeFS.readFileSync(NodePath.join(T3_HOME, "pairing-path"), "utf8")); + if (path !== "/") await app.open(path); + }, + }); + // The server outlives the test, so the next one must not see this one's projects. + for (const workspace of projects) await cli(["project", "remove", workspace, "--force"]); + }, +}); From 9215dba3eec304bb4e698324bbd995bc03dce6ef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 12:09:21 -0800 Subject: [PATCH 2/6] chore: bump Playwright to 1.64.1 e2e's web engine pins playwright-core 1.64.1. Moving the server, desktop, web and scripts packages to the same version leaves one Playwright and one browser download. Its accessibility snapshot no longer names a table row after its cells, so the masked-field test drops that one assertion. Co-Authored-By: Claude Fable 5.2 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- .../src/preview/ServerBrowserPage.test.ts | 1 - apps/web/package.json | 2 +- pnpm-lock.yaml | 223 ++++++++---------- scripts/package.json | 2 +- 6 files changed, 107 insertions(+), 125 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4a4884ba73e29..ab4e1ed382dab 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -27,7 +27,7 @@ "electron-updater": "^6.8.9", "electron-webauthn": "catalog:", "ffi-rs": "1.3.2", - "playwright-core": "1.60.0", + "playwright-core": "1.64.1", "react-grab": "^0.1.32", "tldts": "catalog:" }, diff --git a/apps/server/package.json b/apps/server/package.json index 284084065e61a..d7e4d21f08b82 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -67,7 +67,7 @@ "effect": "catalog:", "ffi-rs": "1.3.2", "node-pty": "^1.2.0-beta.15", - "playwright-core": "1.60.0", + "playwright-core": "1.64.1", "proper-lockfile": "4.1.2", "saxes": "6.0.0", "stream-chain": "^4.2.5", diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 78f03f1f91b72..855d30f80ee0d 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -180,7 +180,6 @@ describe("server browser element refs", () => { ); expect(result.visibleText).not.toContain("editable-pass"); expect(result.visibleText).toContain("Order ••••••••-2"); - expect(tree).toMatch(/row "password: •+"/); expect(tree).toMatch(/textbox "Username" [^\n]*: maria$/m); const line = tree.split("\n").find((line) => line.includes('textbox "Password"')); expect(line).toMatch(/: •+$/); diff --git a/apps/web/package.json b/apps/web/package.json index e1dc0fbf90c1e..6cb69713aec27 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -111,7 +111,7 @@ "compression": "^1.8.2", "micromark-util-types": "catalog:", "oxc-transform-react": "~0.152.0", - "playwright": "1.60.0", + "playwright": "1.64.1", "tailwindcss": "^4.0.0", "vite": "catalog:", "vite-plus": "catalog:" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67717413606fc..d302984769f1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -409,7 +409,7 @@ importers: version: 6.34.0 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/desktop: dependencies: @@ -465,8 +465,8 @@ importers: specifier: 1.3.2 version: 1.3.2 playwright-core: - specifier: 1.60.0 - version: 1.60.0 + specifier: 1.64.1 + version: 1.64.1 react-grab: specifier: ^0.1.32 version: 0.1.44(react@19.3.0) @@ -476,7 +476,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -494,7 +494,7 @@ importers: version: 4.3.3 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/marketing: dependencies: @@ -787,7 +787,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@pierre/trees': specifier: 1.0.0-beta.6 version: 1.0.0-beta.6(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) @@ -912,8 +912,8 @@ importers: specifier: 1.2.0-beta.15 version: 1.2.0-beta.15(patch_hash=f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651) playwright-core: - specifier: 1.60.0 - version: 1.60.0 + specifier: 1.64.1 + version: 1.64.1 proper-lockfile: specifier: 4.1.2 version: 4.1.2 @@ -947,7 +947,7 @@ importers: version: 4.1.2(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.1.2) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@handy-computer/models': specifier: 0.2.3 version: 0.2.3 @@ -1004,7 +1004,7 @@ importers: version: 16.14.1 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) apps/web: dependencies: @@ -1248,7 +1248,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@tailwindcss/vite': specifier: 4.3.3 version: 4.3.3(@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)) @@ -1290,7 +1290,7 @@ importers: version: 6.1.2(@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(babel-plugin-react-compiler@1.0.0)(oxc-transform-react@0.152.0) '@vitest/browser-playwright': specifier: 5.0.5 - version: 5.0.5(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) + version: 5.0.5(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) compression: specifier: ^1.8.2 version: 1.8.2(supports-color@8.1.1) @@ -1301,8 +1301,8 @@ importers: specifier: ~0.152.0 version: 0.152.0 playwright: - specifier: 1.60.0 - version: 1.60.0 + specifier: 1.64.1 + version: 1.64.1 tailwindcss: specifier: 4.3.3 version: 4.3.3 @@ -1311,7 +1311,7 @@ importers: version: '@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) evals: dependencies: @@ -1333,13 +1333,13 @@ importers: devDependencies: '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) infra/relay: dependencies: @@ -1394,7 +1394,7 @@ importers: devDependencies: '@alchemy.run/test': specifier: 'catalog:' - version: 2.0.0-beta.85(patch_hash=29c0539ea71d9cc372f0b16558640599beb9bc609605feb2a493da268d72713e)(be874ca08ab53e444d5d72e167a81f3f) + version: 2.0.0-beta.85(patch_hash=29c0539ea71d9cc372f0b16558640599beb9bc609605feb2a493da268d72713e)(93775fed45909d71c0b8bba962f026cf) '@cloudflare/workers-types': specifier: ^4.20260601.1 version: 4.20260604.1 @@ -1403,7 +1403,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@electric-sql/pglite': specifier: 0.3.15 version: 0.3.15 @@ -1418,7 +1418,7 @@ importers: version: '@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) infra/task-cache: dependencies: @@ -1446,13 +1446,13 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-plugin-t3code: dependencies: @@ -1468,10 +1468,10 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/client-runtime: dependencies: @@ -1535,13 +1535,13 @@ importers: devDependencies: '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/three': specifier: 0.180.0 version: 0.180.0 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/contracts: dependencies: @@ -1551,10 +1551,10 @@ importers: devDependencies: '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-acp: dependencies: @@ -1570,13 +1570,13 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/effect-codex-app-server: dependencies: @@ -1592,13 +1592,13 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-acp: dependencies: @@ -1623,7 +1623,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -1632,7 +1632,7 @@ importers: version: link:../effect-acp vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-acp-registry: dependencies: @@ -1660,7 +1660,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/provider-testing': specifier: workspace:* version: link:../provider-testing @@ -1672,7 +1672,7 @@ importers: version: link:../effect-acp vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-core: dependencies: @@ -1691,13 +1691,13 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-cursor: dependencies: @@ -1725,7 +1725,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/provider-testing': specifier: workspace:* version: link:../provider-testing @@ -1734,7 +1734,7 @@ importers: version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-fx: dependencies: @@ -1759,7 +1759,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -1768,7 +1768,7 @@ importers: version: link:../effect-acp vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-grok: dependencies: @@ -1793,7 +1793,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/provider-testing': specifier: workspace:* version: link:../provider-testing @@ -1805,7 +1805,7 @@ importers: version: link:../effect-acp vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-kiro: dependencies: @@ -1827,7 +1827,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -1836,7 +1836,7 @@ importers: version: link:../effect-acp vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-muse: dependencies: @@ -1861,7 +1861,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/provider-testing': specifier: workspace:* version: link:../provider-testing @@ -1870,7 +1870,7 @@ importers: version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-opencode: dependencies: @@ -1904,7 +1904,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/provider-testing': specifier: workspace:* version: link:../provider-testing @@ -1913,7 +1913,7 @@ importers: version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-pi: dependencies: @@ -1935,7 +1935,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/provider-testing': specifier: workspace:* version: link:../provider-testing @@ -1944,7 +1944,7 @@ importers: version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/provider-testing: dependencies: @@ -1982,13 +1982,13 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/secret-provider-definitions: dependencies: @@ -2022,13 +2022,13 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/shared: dependencies: @@ -2095,7 +2095,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/mdast': specifier: ^4.0.4 version: 4.0.4 @@ -2107,7 +2107,7 @@ importers: version: 2.0.2 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-azure-devops: dependencies: @@ -2132,7 +2132,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/source-control-testing': specifier: workspace:* version: link:../source-control-testing @@ -2141,7 +2141,7 @@ importers: version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-bitbucket: dependencies: @@ -2166,7 +2166,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/source-control-testing': specifier: workspace:* version: link:../source-control-testing @@ -2175,7 +2175,7 @@ importers: version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-core: dependencies: @@ -2191,13 +2191,13 @@ importers: devDependencies: '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-cursor-origin: dependencies: @@ -2222,13 +2222,13 @@ importers: devDependencies: '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-definitions: dependencies: @@ -2280,7 +2280,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/source-control-testing': specifier: workspace:* version: link:../source-control-testing @@ -2289,7 +2289,7 @@ importers: version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-gitcafe: dependencies: @@ -2311,7 +2311,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/source-control-testing': specifier: workspace:* version: link:../source-control-testing @@ -2320,7 +2320,7 @@ importers: version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-github: dependencies: @@ -2348,7 +2348,7 @@ importers: version: 4.1.2(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(effect@4.1.2) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/source-control-testing': specifier: workspace:* version: link:../source-control-testing @@ -2357,7 +2357,7 @@ importers: version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-gitlab: dependencies: @@ -2376,7 +2376,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@t3tools/source-control-testing': specifier: workspace:* version: link:../source-control-testing @@ -2385,7 +2385,7 @@ importers: version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/source-control-testing: dependencies: @@ -2426,13 +2426,13 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages/tailscale: dependencies: @@ -2445,13 +2445,13 @@ importers: devDependencies: '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) scripts: dependencies: @@ -2486,8 +2486,8 @@ importers: specifier: 4.1.2 version: 4.1.2 playwright-core: - specifier: 1.60.0 - version: 1.60.0 + specifier: 1.64.1 + version: 1.64.1 plist: specifier: 'catalog:' version: 3.1.1 @@ -2500,7 +2500,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.1.2 - version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) '@types/plist': specifier: 'catalog:' version: 3.0.5 @@ -2518,7 +2518,7 @@ importers: version: typescript@6.0.3 vite-plus: specifier: 1.4.0 - version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + version: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) packages: @@ -9465,11 +9465,6 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -10936,19 +10931,14 @@ packages: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} - playwright-core@1.60.0: - resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} - engines: {node: '>=18'} - hasBin: true - playwright-core@1.64.1: resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} engines: {node: '>=20'} hasBin: true - playwright@1.60.0: - resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} - engines: {node: '>=18'} + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} hasBin: true plist@3.1.0: @@ -12812,12 +12802,12 @@ snapshots: '@effect/sql-pg': 4.1.2(effect@4.1.2) pg: 8.23.0 - '@alchemy.run/test@2.0.0-beta.85(patch_hash=29c0539ea71d9cc372f0b16558640599beb9bc609605feb2a493da268d72713e)(be874ca08ab53e444d5d72e167a81f3f)': + '@alchemy.run/test@2.0.0-beta.85(patch_hash=29c0539ea71d9cc372f0b16558640599beb9bc609605feb2a493da268d72713e)(93775fed45909d71c0b8bba962f026cf)': dependencies: '@effect/platform-bun': 4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6) effect: 4.1.2 optionalDependencies: - '@effect/vitest': 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@effect/vitest': 4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) alchemy: 2.0.0-beta.85(patch_hash=07cc8c7e14b3b9af143ddd40a059f117c27e55139fbf20bd5dde2eb30509cedd)(@effect/platform-bun@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(utf-8-validate@6.0.6))(@effect/platform-node@4.1.2(bufferutil@4.1.0)(effect@4.1.2)(redis@6.2.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(effect@4.1.2)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(ws@8.22.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) vitest: 5.0.5(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) transitivePeerDependencies: @@ -13949,10 +13939,10 @@ snapshots: '@effect/tsgo-win32-arm64': 0.67.1 '@effect/tsgo-win32-x64': 0.67.1 - '@effect/vitest@4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)': + '@effect/vitest@4.1.2(patch_hash=299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7)(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(effect@4.1.2)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)': dependencies: effect: 4.1.2 - vite-plus: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) transitivePeerDependencies: - '@arethetypeswrong/core' - '@astrojs/compiler-rs' @@ -16950,11 +16940,11 @@ snapshots: babel-plugin-react-compiler: 1.0.0 oxc-transform-react: 0.152.0 - '@vitest/browser-playwright@5.0.5(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0)': + '@vitest/browser-playwright@5.0.5(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0)': dependencies: '@vitest/browser': 5.0.5(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) '@vitest/mocker': 5.0.5(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) - playwright: 1.60.0 + playwright: 1.64.1 tinyrainbow: 3.2.0 vitest: 5.0.5(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) transitivePeerDependencies: @@ -19288,9 +19278,6 @@ snapshots: fs.realpath@1.0.0: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -21062,7 +21049,7 @@ snapshots: '@oxc-transform/binding-win32-ia32-msvc': 0.157.0 '@oxc-transform/binding-win32-x64-msvc': 0.157.0 - oxfmt@0.74.0(@astrojs/compiler-rs@0.5.1)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxfmt@0.74.0(@astrojs/compiler-rs@0.5.1)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.2.0 optionalDependencies: @@ -21086,7 +21073,7 @@ snapshots: '@oxfmt/binding-win32-arm64-msvc': 0.74.0 '@oxfmt/binding-win32-ia32-msvc': 0.74.0 '@oxfmt/binding-win32-x64-msvc': 0.74.0 - vite-plus: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) oxlint-tsgolint@7.0.2004: optionalDependencies: @@ -21097,7 +21084,7 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2004 '@oxlint-tsgolint/win32-x64': 7.0.2004 - oxlint@1.89.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): + oxlint@1.89.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.89.0 '@oxlint/binding-android-arm64': 1.89.0 @@ -21119,7 +21106,7 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.89.0 '@oxlint/binding-win32-x64-msvc': 1.89.0 oxlint-tsgolint: 7.0.2004 - vite-plus: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + vite-plus: 1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) p-cancelable@2.1.1: {} @@ -21284,15 +21271,11 @@ snapshots: pvutils: 1.1.5 tslib: 2.8.1 - playwright-core@1.60.0: {} - playwright-core@1.64.1: {} - playwright@1.60.0: + playwright@1.64.1: dependencies: - playwright-core: 1.60.0 - optionalDependencies: - fsevents: 2.3.2 + playwright-core: 1.64.1 plist@3.1.0: dependencies: @@ -22753,7 +22736,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): + vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0): dependencies: '@oxc-project/runtime': 0.155.0 '@oxc-project/types': 0.155.0 @@ -22765,13 +22748,13 @@ snapshots: '@vitest/snapshot': 5.0.5 '@vitest/spy': 5.0.5 '@vitest/utils': 5.0.5 - oxfmt: 0.74.0(@astrojs/compiler-rs@0.5.1)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) - oxlint: 1.89.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxfmt: 0.74.0(@astrojs/compiler-rs@0.5.1)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) + oxlint: 1.89.0(oxlint-tsgolint@7.0.2004)(vite-plus@1.4.0(@astrojs/compiler-rs@0.5.1)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)) oxlint-tsgolint: 7.0.2004 vite: '@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' vitest: 5.0.5(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/browser-preview@5.0.5)(@vitest/ui@5.0.5)(@voidzero-dev/vite-plus-core@1.4.0(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(@volar/typescript@2.4.28(typescript@6.0.3))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0) optionalDependencies: - '@vitest/browser-playwright': 5.0.5(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) + '@vitest/browser-playwright': 5.0.5(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) '@voidzero-dev/vite-plus-darwin-arm64': 1.4.0 '@voidzero-dev/vite-plus-darwin-x64': 1.4.0 '@voidzero-dev/vite-plus-linux-arm64-gnu': 1.4.0 @@ -22780,7 +22763,7 @@ snapshots: '@voidzero-dev/vite-plus-linux-x64-musl': 1.4.0 '@voidzero-dev/vite-plus-win32-arm64-msvc': 1.4.0 '@voidzero-dev/vite-plus-win32-x64-msvc': 1.4.0 - playwright: 1.60.0 + playwright: 1.64.1 transitivePeerDependencies: - '@arethetypeswrong/core' - '@astrojs/compiler-rs' @@ -22860,7 +22843,7 @@ snapshots: why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 24.12.4 - '@vitest/browser-playwright': 5.0.5(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.60.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) + '@vitest/browser-playwright': 5.0.5(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(playwright@1.64.1)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) '@vitest/browser-preview': 5.0.5(@types/node@24.12.4)(@volar/typescript@2.4.28(typescript@6.0.3))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@5.0.5)(yaml@2.9.0) '@vitest/ui': 5.0.5(vitest@5.0.5) jsdom: 30.1.0(@noble/hashes@1.8.0) diff --git a/scripts/package.json b/scripts/package.json index c2159cf09f7a0..d67c8e46f1da2 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -18,7 +18,7 @@ "agent-device": "0.21.32", "e2e": "0.23.0", "effect": "catalog:", - "playwright-core": "1.60.0", + "playwright-core": "1.64.1", "plist": "catalog:", "pngjs": "7.0.0", "sharp": "0.35.5" From 4d064af51f0ef4ff94df8a82a10e964ab69da994 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 14:12:37 -0800 Subject: [PATCH 3/6] feat(scripts): iOS scenarios on a pooled simulator e2e's mobile engine leases an iPhone from the Simlock pool through agent-device's lease API. A mobile `t3` fixture seeds state like the web one, installs the dev client for the checkout's native code, loads it from Metro and pairs it. Scenarios name their platform, so a web-only run leases nothing. Co-Authored-By: Claude Fable 5.2 --- docs/operations/scenarios.md | 94 +++++++++----- knip.jsonc | 2 +- pnpm-lock.yaml | 30 ++++- scripts/e2e.config.ts | 119 +++++++++++++++++- scripts/package.json | 3 +- scripts/prove-scenario.ts | 23 +++- .../add-project-tab-reaches-submit.e2e.ts | 35 +++--- .../mobile-opens-a-seeded-thread.e2e.ts | 21 ++++ scripts/scenarios/replay-turn-streams.e2e.ts | 42 +++---- scripts/scenarios/start-app.ts | 117 ++++++++++++----- scripts/scenarios/t3-mobile.ts | 86 +++++++++++++ scripts/scenarios/t3.ts | 110 +++++++++------- 12 files changed, 523 insertions(+), 159 deletions(-) create mode 100644 scripts/scenarios/mobile-opens-a-seeded-thread.e2e.ts create mode 100644 scripts/scenarios/t3-mobile.ts diff --git a/docs/operations/scenarios.md b/docs/operations/scenarios.md index 377299b240a61..e28cbaab3316c 100644 --- a/docs/operations/scenarios.md +++ b/docs/operations/scenarios.md @@ -1,7 +1,8 @@ # Scenarios A scenario is an [e2e](https://e2e.com/docs) test that shows one behavior in the real app: a real -server on its own T3 home, real HTTP and WebSocket, and a real browser on the real web client. The +server on its own T3 home, real HTTP and WebSocket, and a real browser on the web client or a +pooled iPhone simulator running the mobile app. The dev replay agent stands in for the model, so a run is free and repeatable. Use one as the proof for a bug fix: it fails on the base commit and passes on the fix. @@ -13,27 +14,26 @@ runner; its docs ship in `scripts/node_modules/e2e/docs` and `e2e guide` prints ```ts import { expect } from "e2e"; -import { test } from "./t3.ts"; - -test("Tab moves from the add-project Git URL field to its submit button", async ({ - t3, - app, - screen, - browser, -}) => { - await t3.project("demo"); - await t3.open(); - - await screen.getByRole("button", "Add project").tap(); - await screen.getByText("Git URL").tap(); - await screen.getByRole("combobox").fill("https://github.com/pingdotgg/t3code.git"); - const submit = screen.getByRole("button", "Continue (Enter)"); - await expect(submit).toBeEnabled(); - - await browser.keyboard.press("Tab"); - await app.screenshot("after-tab"); - await expect(submit).toBeFocused(); -}); +import { test, WEB } from "./t3.ts"; + +test( + "Tab moves from the add-project Git URL field to its submit button", + WEB, + async ({ t3, app, screen, browser }) => { + await t3.project("demo"); + await t3.open(); + + await screen.getByRole("button", "Add project").tap(); + await screen.getByText("Git URL").tap(); + await screen.getByRole("combobox").fill("https://github.com/pingdotgg/t3code.git"); + const submit = screen.getByRole("button", "Continue (Enter)"); + await expect(submit).toBeEnabled(); + + await browser.keyboard.press("Tab"); + await app.screenshot("after-tab"); + await expect(submit).toBeFocused(); + }, +); ``` `screen`, `browser`, `app`, and `expect` are e2e's own. [`t3.ts`](../../scripts/scenarios/t3.ts) @@ -94,14 +94,50 @@ test: a temporary worktree beside your checkout, installed with `vp i` and remov commit that is your clean `HEAD` runs in place. So a new scenario can prove a fix against a base that predates it. -## Other tiers +## iOS + +An iOS scenario imports from [`t3-mobile.ts`](../../scripts/scenarios/t3-mobile.ts) and passes +`IOS` where a web one passes `WEB`: + +```ts +import { expect } from "e2e"; -Only the web client with the replay agent runs today. +import { IOS, test } from "./t3-mobile.ts"; + +test( + "The iOS app lists a seeded thread and opens its history", + IOS, + async ({ t3, app, screen }) => { + const project = await t3.project("demo"); + await t3.thread({ project, title: "Earlier work", messages: ["first", "second"] }); + await t3.open(); + + const thread = screen.getByText("Earlier work", { exact: false }).first(); + await expect(thread).toBeVisible({ timeout: 120_000 }); + await thread.tap(); + await expect(screen.getByText("second fixture turn complete", { exact: false })).toBeVisible(); + }, +); +``` + +`t3.project` and `t3.thread` are the same. `t3.open()` installs the dev client for the checkout's +native fingerprint, loads it from Metro, and pairs it with the scenario's server. `device` is +e2e's [device fixture](https://e2e.com/docs/mobile#the-device-fixture) for system actions. + +The run leases an iPhone 18 Pro from the Simlock pool, so it needs `SIMLOCK_AGENT_DEVICE_URL` +and `AGENT_DEVICE_DAEMON_AUTH_TOKEN`, as `mobile:e2e` does, and a gateway on Simlock 0.7.0 or +later. A web-only run leases nothing. The device reaches this machine over the tailnet on ports +13990 (server) and 8190 (Metro); set `T3_SCENARIO_SERVER_PORT` and `T3_SCENARIO_METRO_PORT` when +another run on the machine holds them. An iOS run takes two to three minutes, most of it the +device and the first bundle. `e2e run --target web` or `--target ios` runs one side. + +`scenarios:prove` works the same for an iOS scenario. Each commit gets the dev client built for +its own native code. + +## Other tiers -- **Mobile.** e2e's `@e2e-dev/mobile` engine drives iOS and Android through agent-device, which - this repository already uses. A mobile target in - [`e2e.config.ts`](../../scripts/e2e.config.ts) and a `t3` fixture that pairs the device would - run scenarios written with `screen` locators unchanged. Tests that use `browser` are web-only. +- **Android.** Add a target with `mobile({ platform: "android", device: simlock("Pixel 9") })` + and open the dev client with the Android form of the Expo link. Not built. - **Live provider.** Point the default model in `start-app.ts` at a real provider's cheapest model. Assertions on exact reply text would not hold. @@ -114,7 +150,7 @@ result as a pull request comment. ## What a scenario cannot cover -- Desktop shell behavior (menus, windows, IPC), and the mobile app until it has a target. +- Desktop shell behavior (menus, windows, IPC), and Android. - Provider-specific behavior. The replay agent speaks generic ACP. - State no command can create, such as a run stopped by a usage limit. - Timing. Replay streaming is simulated, so a scenario cannot show a performance regression. diff --git a/knip.jsonc b/knip.jsonc index 08dbc39f9bd29..068c8cf84ab69 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -41,7 +41,7 @@ "smoke-desktop-app.ts", "verify-windows-desktop-artifact.ts", ], - "ignoreDependencies": ["agent-device", "esbuild"], + "ignoreDependencies": ["esbuild"], // The `t3` npm package's files: copied into the package, never imported. // knip-schemas.test.ts writes a throwaway project to .knip-test-*, which a // knip run beside it in check:changed must not report. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d302984769f1f..e7265664f0a44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2455,6 +2455,9 @@ importers: scripts: dependencies: + '@e2e-dev/mobile': + specifier: 0.12.6 + version: 0.12.6(e2e@0.23.0) '@e2e-dev/web': specifier: 0.15.5 version: 0.15.5(e2e@0.23.0) @@ -2477,8 +2480,8 @@ importers: specifier: workspace:* version: link:../packages/tailscale agent-device: - specifier: 0.21.32 - version: 0.21.32 + specifier: 0.21.36 + version: 0.21.36 e2e: specifier: 0.23.0 version: 0.23.0 @@ -3722,6 +3725,16 @@ packages: peerDependencies: react: '>=16.8.0' + '@e2e-dev/mobile@0.12.6': + resolution: {integrity: sha512-V56hOt5cJsoC9M8kMIjsCKOY2pEPHb4awYMLP699yMO4aNUUnpALYK/R2ik6C64CLEq4N1aibLmD7ErPZVc97w==} + engines: {node: ^22.22.3 || >=24.8.0} + peerDependencies: + ai: ^7.0.0 + e2e: '>=0.22.1 <1' + peerDependenciesMeta: + ai: + optional: true + '@e2e-dev/web@0.15.5': resolution: {integrity: sha512-fUXrSgfii6w/q7OyX9/kEIDLpIMHpEkTH81hrR8WLnqk8li3bRyXJQBGWqtq9NoWpS2UANnhMI45n6Fv1ErCnw==} engines: {node: ^22.22.3 || >=24.8.0} @@ -7678,8 +7691,8 @@ packages: engines: {node: '>=18.18'} hasBin: true - agent-device@0.21.32: - resolution: {integrity: sha512-gfpNs+qeCHKKLY3mqD/NpC7Et7wh5qxNvM9A2cPOhOpzX21e9UHSkO7zOMBpUK7NGtOOkbtz67jco5SVmZOPog==} + agent-device@0.21.36: + resolution: {integrity: sha512-aG88WlEHjFc/OEBQC7AJg+2LfNoVT/ogxnnzIUYUEgafdiTvBaA+X6uQ2wNmpp2ZrHLy8QIXoEOyc3NEZgJCpQ==} engines: {node: '>=22.12'} hasBin: true peerDependencies: @@ -13844,6 +13857,13 @@ snapshots: react: 19.3.0 tslib: 2.8.1 + '@e2e-dev/mobile@0.12.6(e2e@0.23.0)': + dependencies: + agent-device: 0.21.36 + e2e: 0.23.0 + pngjs: 7.0.0 + zod: 4.6.7 + '@e2e-dev/web@0.15.5(e2e@0.23.0)': dependencies: e2e: 0.23.0 @@ -17334,7 +17354,7 @@ snapshots: agent-cli-detector@0.1.7: {} - agent-device@0.21.32: {} + agent-device@0.21.36: {} agent-install@0.0.5: dependencies: diff --git a/scripts/e2e.config.ts b/scripts/e2e.config.ts index a4bf2a1ef53f8..49246eb4bddb5 100644 --- a/scripts/e2e.config.ts +++ b/scripts/e2e.config.ts @@ -1,19 +1,103 @@ -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off // Scenarios: e2e tests that run against the real dev stack. See docs/operations/scenarios.md. +import * as NodeChildProcess from "node:child_process"; +import * as NodeCrypto from "node:crypto"; import * as NodePath from "node:path"; +import * as NodeTimersPromises from "node:timers/promises"; +import { mobile, type DeviceProvider } from "@e2e-dev/mobile"; import { web } from "@e2e-dev/web"; +import { createAgentDeviceClient } from "agent-device"; import type { E2EConfig } from "e2e"; /** The checkout whose app runs. `prove-scenario.ts` points it at a base or head worktree. */ export const APP_DIR = NodePath.resolve(process.env.T3_SCENARIO_APP_DIR ?? ".."); -/** A T3 home only scenarios use, recreated on every run. `.t3` is gitignored. */ +/** T3 homes only scenarios use, recreated on every run. `.t3` is gitignored. */ export const T3_HOME = NodePath.resolve(import.meta.dirname, "../.t3/scenarios"); +export const MOBILE_T3_HOME = `${T3_HOME}-mobile`; + +/** + * A pooled device reaches this machine over the tailnet, not localhost. A mobile target has no + * `app.url`, so e2e cannot pick these ports; override them when another run holds them. + */ +export const MOBILE = { + host: + NodeChildProcess.spawnSync("tailscale", ["ip", "-4"], { encoding: "utf8" }) + .stdout?.trim() + .split("\n")[0] || "127.0.0.1", + serverPort: Number(process.env.T3_SCENARIO_SERVER_PORT ?? 13_990), + metroPort: Number(process.env.T3_SCENARIO_METRO_PORT ?? 8_190), +}; + +const startApp = NodePath.join(import.meta.dirname, "scenarios/start-app.ts"); + +/** + * Leases a device from the Simlock pool, whose gateway speaks agent-device's lease protocol. + * `retainOnClose` keeps the device when the engine closes its session between attempts. + */ +const simlock = (device: string): DeviceProvider => { + const connect = (env: Readonly>) => { + const daemonBaseUrl = env.SIMLOCK_AGENT_DEVICE_URL; + const daemonAuthToken = env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; + if (daemonBaseUrl === undefined || daemonAuthToken === undefined) { + throw new Error("SIMLOCK_AGENT_DEVICE_URL and AGENT_DEVICE_DAEMON_AUTH_TOKEN are not set"); + } + return { + daemon: { baseUrl: daemonBaseUrl, authToken: daemonAuthToken }, + client: createAgentDeviceClient({ daemonBaseUrl, daemonAuthToken }), + }; + }; + return { + name: "simlock", + acquire: async (request) => { + const { daemon, client } = connect(request.env); + const scope = { tenant: "t3-scenarios", runId: `${request.runId}-${request.slot}` }; + const allocate = () => + client.leases.allocate({ + ...scope, + platform: request.platform === "android" ? "android" : "ios", + device, + retainOnClose: true, + // Simlock takes the lease id from the caller. + leaseId: NodeCrypto.randomBytes(16).toString("hex"), + }); + // A busy gateway answers its health probe late, which the client reports as unavailable. + let lease = await allocate().catch(() => undefined); + for (let attempt = 0; lease === undefined && attempt < 12; attempt++) { + request.log(`the pool is busy; asking again in 10s`); + await NodeTimersPromises.setTimeout(10_000, undefined, { signal: request.signal }); + lease = await (attempt === 11 ? allocate() : allocate().catch(() => undefined)); + } + if (lease === undefined) throw new Error(`The pool leased no ${device}`); + const leaseScope = { ...scope, leaseId: lease.leaseId, leaseBackend: lease.backend }; + // The pool answers `boot` with the leased device; a worker hosts several, so name ours. + const platform = request.platform === "android" ? "android" : "ios"; + const leased = await createAgentDeviceClient({ + daemonBaseUrl: daemon.baseUrl, + daemonAuthToken: daemon.authToken, + ...leaseScope, + }).devices.boot({ platform }); + return { + id: lease.leaseId, + daemon, + deviceId: leased.id, + client: { + ...leaseScope, + ...(lease.leaseProvider === undefined ? {} : { leaseProvider: lease.leaseProvider }), + }, + }; + }, + release: async (lease, context) => { + await connect(context.env).client.leases.release({ leaseId: lease.id }); + }, + }; +}; export default { tests: "scenarios/**/*.e2e.ts", targets: [ { + name: "web", engine: web({ viewport: { width: 1280, height: 800 } }), app: { url: "http://127.0.0.1:0", @@ -21,17 +105,44 @@ export default { readyUrl: "http://127.0.0.1:{port}/.well-known/t3/environment", command: { executable: process.execPath, - args: [NodePath.join(import.meta.dirname, "scenarios/start-app.ts"), T3_HOME, "{port}"], + args: [startApp, "web", T3_HOME, "{port}"], cwd: APP_DIR, startupTimeout: 300_000, log: ".e2e/app.log", }, }, }, + { + name: "ios", + engine: mobile({ + platform: "ios", + device: simlock("iPhone 18 Pro"), + session: "t3-scenarios", + }), + app: { + bundleId: "com.t3tools.t3code.dev", + // Metro starts after the server listens, so it answering means both are up. + readyUrl: `http://127.0.0.1:${MOBILE.metroPort}/status`, + command: { + executable: process.execPath, + args: [ + startApp, + "mobile", + MOBILE_T3_HOME, + String(MOBILE.serverPort), + String(MOBILE.metroPort), + MOBILE.host, + ], + cwd: APP_DIR, + startupTimeout: 300_000, + log: ".e2e/mobile-app.log", + }, + }, + }, ], // One server holds every test's state, and a dev stack is heavy. workers: 1, - timeout: 120_000, + timeout: 300_000, assertionTimeout: 15_000, video: "on", trace: "on", diff --git a/scripts/package.json b/scripts/package.json index d67c8e46f1da2..716a2edb3762f 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -8,6 +8,7 @@ "test": "vp test run --config ../vite.config.ts --dir ." }, "dependencies": { + "@e2e-dev/mobile": "0.12.6", "@e2e-dev/web": "0.15.5", "@effect/platform-node": "catalog:", "@electron/asar": "^4.3.0", @@ -15,7 +16,7 @@ "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", - "agent-device": "0.21.32", + "agent-device": "0.21.36", "e2e": "0.23.0", "effect": "catalog:", "playwright-core": "1.64.1", diff --git a/scripts/prove-scenario.ts b/scripts/prove-scenario.ts index fdad731b73509..6108cbb92d7eb 100644 --- a/scripts/prove-scenario.ts +++ b/scripts/prove-scenario.ts @@ -168,6 +168,10 @@ if (import.meta.main) { process.exit(2); } const scenarioPath = NodePath.resolve(scenario); + // A proof left by an earlier run must not be read as this one's. + for (const stale of ["proof.md", "proof.json"]) { + NodeFS.rmSync(NodePath.join(SCRIPTS, ".e2e/prove", stale), { force: true }); + } const sides = (["base", "head"] as const).map((side) => { const sha = git([ "rev-parse", @@ -198,11 +202,20 @@ if (import.meta.main) { const captures = [...base.captures, ...head.captures]; const links = new Map(); if (values["upload-command"] !== undefined && captures.length > 0) { - const [command, ...args] = values["upload-command"].split(/\s+/).filter(Boolean); - const urls = NodeChildProcess.execFileSync(command!, [...args, ...captures], { - encoding: "utf8", - }); - for (const [index, url] of urls.trim().split("\n").entries()) links.set(captures[index]!, url); + const [command = "", ...args] = values["upload-command"].split(/\s+/).filter(Boolean); + // A failed upload must not lose two finished runs: the proof then points at local files. + try { + const urls = NodeChildProcess.execFileSync(command, [...args, ...captures], { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }); + for (const [index, url] of urls.trim().split("\n").entries()) { + const capture = captures[index]; + if (capture !== undefined) links.set(capture, url); + } + } catch { + console.error("The upload command failed; the proof links local capture files."); + } } const proveDir = NodePath.join(SCRIPTS, ".e2e/prove"); const proof = buildProof( diff --git a/scripts/scenarios/add-project-tab-reaches-submit.e2e.ts b/scripts/scenarios/add-project-tab-reaches-submit.e2e.ts index 55ce2d6ef21b2..b6afb9358cd2b 100644 --- a/scripts/scenarios/add-project-tab-reaches-submit.e2e.ts +++ b/scripts/scenarios/add-project-tab-reaches-submit.e2e.ts @@ -1,23 +1,22 @@ import { expect } from "e2e"; -import { test } from "./t3.ts"; +import { test, WEB } from "./t3.ts"; -test("Tab moves from the add-project Git URL field to its submit button", async ({ - t3, - app, - screen, - browser, -}) => { - await t3.project("demo"); - await t3.open(); +test( + "Tab moves from the add-project Git URL field to its submit button", + WEB, + async ({ t3, app, screen, browser }) => { + await t3.project("demo"); + await t3.open(); - await screen.getByRole("button", "Add project").tap(); - await screen.getByText("Git URL").tap(); - await screen.getByRole("combobox").fill("https://github.com/pingdotgg/t3code.git"); - const submit = screen.getByRole("button", "Continue (Enter)"); - await expect(submit).toBeEnabled(); + await screen.getByRole("button", "Add project").tap(); + await screen.getByText("Git URL").tap(); + await screen.getByRole("combobox").fill("https://github.com/pingdotgg/t3code.git"); + const submit = screen.getByRole("button", "Continue (Enter)"); + await expect(submit).toBeEnabled(); - await browser.keyboard.press("Tab"); - await app.screenshot("after-tab"); - await expect(submit).toBeFocused(); -}); + await browser.keyboard.press("Tab"); + await app.screenshot("after-tab"); + await expect(submit).toBeFocused(); + }, +); diff --git a/scripts/scenarios/mobile-opens-a-seeded-thread.e2e.ts b/scripts/scenarios/mobile-opens-a-seeded-thread.e2e.ts new file mode 100644 index 0000000000000..b5f2218df8816 --- /dev/null +++ b/scripts/scenarios/mobile-opens-a-seeded-thread.e2e.ts @@ -0,0 +1,21 @@ +import { expect } from "e2e"; + +import { IOS, test } from "./t3-mobile.ts"; + +test( + "The iOS app lists a seeded thread and opens its history", + IOS, + async ({ t3, app, screen }) => { + const project = await t3.project("demo"); + await t3.thread({ project, title: "Earlier work", messages: ["first", "second"] }); + await t3.open(); + + const thread = screen.getByText("Earlier work", { exact: false }).first(); + await expect(thread).toBeVisible({ timeout: 120_000 }); + await app.screenshot("home"); + + await thread.tap(); + await expect(screen.getByText("second fixture turn complete", { exact: false })).toBeVisible(); + await app.screenshot("thread"); + }, +); diff --git a/scripts/scenarios/replay-turn-streams.e2e.ts b/scripts/scenarios/replay-turn-streams.e2e.ts index 26db4aaa333e9..b6756a278179d 100644 --- a/scripts/scenarios/replay-turn-streams.e2e.ts +++ b/scripts/scenarios/replay-turn-streams.e2e.ts @@ -1,26 +1,26 @@ import { expect } from "e2e"; -import { test } from "./t3.ts"; +import { test, WEB } from "./t3.ts"; -test("A replayed turn streams a reply, shows its tool calls, and finishes", async ({ - t3, - app, - screen, -}) => { - await t3.project("demo", { "src/math.ts": "export const sum = 1 + 1;\n" }); - await t3.open(); +test( + "A replayed turn streams a reply, shows its tool calls, and finishes", + WEB, + async ({ t3, app, screen }) => { + await t3.project("demo", { "src/math.ts": "export const sum = 1 + 1;\n" }); + await t3.open(); - await screen.getByRole("textbox").fill("Give me a short tour"); - await screen.getByRole("textbox").press("Enter"); - const stop = screen.getByRole("button", "Stop generation"); - await expect( - screen.getByText("The project has a single math module", { exact: false }), - ).toBeVisible(); - await expect(stop).toBeVisible(); - await app.screenshot("streaming"); + await screen.getByRole("textbox").fill("Give me a short tour"); + await screen.getByRole("textbox").press("Enter"); + const stop = screen.getByRole("button", "Stop generation"); + await expect( + screen.getByText("The project has a single math module", { exact: false }), + ).toBeVisible(); + await expect(stop).toBeVisible(); + await app.screenshot("streaming"); - await expect(screen.getByText("This turn is a replay.", { exact: false })).toBeVisible(); - await expect(stop).toBeHidden(); - await expect(screen.getByText("Give me a short tour").first()).toBeVisible(); - await app.screenshot("finished"); -}); + await expect(screen.getByText("This turn is a replay.", { exact: false })).toBeVisible(); + await expect(stop).toBeHidden(); + await expect(screen.getByText("Give me a short tour").first()).toBeVisible(); + await app.screenshot("finished"); + }, +); diff --git a/scripts/scenarios/start-app.ts b/scripts/scenarios/start-app.ts index 8c52cc554d308..1ca2fad752b5a 100644 --- a/scripts/scenarios/start-app.ts +++ b/scripts/scenarios/start-app.ts @@ -1,16 +1,19 @@ -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off // The app command of e2e.config.ts: starts this checkout's dev stack on a fresh T3 home. -// Usage: node start-app.ts , run from the checkout whose app should start. +// Run from the checkout whose app should start. import * as NodeChildProcess from "node:child_process"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeNet from "node:net"; import * as NodePath from "node:path"; import * as NodeReadline from "node:readline"; +import * as NodeTimersPromises from "node:timers/promises"; -const [home, webPort] = process.argv.slice(2); -if (home === undefined || webPort === undefined) { - throw new Error("Usage: start-app.ts "); +const [surface, home, ...ports] = process.argv.slice(2); +if ((surface !== "web" && surface !== "mobile") || home === undefined || ports.length === 0) { + throw new Error( + "Usage: start-app.ts web | mobile ", + ); } // The dev runner's web port is 5733 plus an offset. Mirror of BASE_WEB_PORT in dev-runner.ts. @@ -42,35 +45,89 @@ NodeFS.writeFileSync( JSON.stringify({ defaultModelSelection: REPLAY, textGenerationModelSelection: REPLAY }), ); -const serverPort = await new Promise((resolve) => { - const probe = NodeNet.createServer().listen(0, "127.0.0.1", () => { - const { port } = probe.address() as NodeNet.AddressInfo; - probe.close(() => resolve(port)); +const env = { + ...process.env, + PATH: `${NodePath.join(home, "bin")}${NodePath.delimiter}${process.env.PATH ?? ""}`, + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "0", +}; +const children: Array = []; +const start = ( + cwd: string, + command: string, + args: ReadonlyArray, + extra: NodeJS.ProcessEnv, +) => { + const child = NodeChildProcess.spawn(command, args, { + cwd, + stdio: ["ignore", "pipe", "inherit"], + env: { ...env, ...extra }, + }); + children.push(child); + child.on("exit", (code) => process.exit(code ?? 1)); + return child; +}; +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => children.forEach((child) => child.kill(signal))); +} +const listening = (port: number) => + new Promise((resolve) => { + const socket = NodeNet.connect(port, "127.0.0.1"); + socket.once("connect", () => (socket.destroy(), resolve(true))); + socket.once("error", () => resolve(false)); }); -}); -const child = NodeChildProcess.spawn( - process.execPath, - ["scripts/dev-runner.ts", "dev", "--home-dir", home, "--no-seed", "--port", String(serverPort)], - { - stdio: ["ignore", "pipe", "inherit"], - env: { - ...process.env, - PATH: `${NodePath.join(home, "bin")}${NodePath.delimiter}${process.env.PATH ?? ""}`, +if (surface === "web") { + const [webPort] = ports; + const serverPort = await new Promise((resolve) => { + const probe = NodeNet.createServer().listen(0, "127.0.0.1", () => { + const { port } = probe.address() as NodeNet.AddressInfo; + probe.close(() => resolve(port)); + }); + }); + const runner = start( + process.cwd(), + process.execPath, + ["scripts/dev-runner.ts", "dev", "--home-dir", home, "--no-seed", "--port", String(serverPort)], + { T3CODE_PORT_OFFSET: String(Number(webPort) - BASE_WEB_PORT), T3CODE_DEV_ALLOWED_ORIGINS: `http://127.0.0.1:${webPort}`, - T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "0", // Makes the startup pairing link reusable, so every test can sign in with it. T3CODE_DEV_AUTH_TOKEN: NodeCrypto.randomBytes(32).toString("hex"), }, - }, -); -NodeReadline.createInterface({ input: child.stdout }).on("line", (line) => { - const url = /pairingUrl:\s*(\S+)/.exec(line)?.[1]; - if (url === undefined) return void process.stdout.write(`${line}\n`); - // The link is a credential: it goes to a file the tests read, never to the log. - const { pathname, search, hash } = new URL(url); - NodeFS.writeFileSync(NodePath.join(home, "pairing-path"), `${pathname}${search}${hash}`); -}); -for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, () => child.kill(signal)); -child.on("exit", (code) => process.exit(code ?? 1)); + ); + NodeReadline.createInterface({ input: runner.stdout! }).on("line", (line) => { + const url = /pairingUrl:\s*(\S+)/.exec(line)?.[1]; + if (url === undefined) return void process.stdout.write(`${line}\n`); + // The link is a credential: it goes to a file the tests read, never to the log. + const { pathname, search, hash } = new URL(url); + NodeFS.writeFileSync(NodePath.join(home, "pairing-path"), `${pathname}${search}${hash}`); + }); +} else { + // The device is another machine: the server listens on every interface, and Metro serves the + // dev client its JavaScript. Tests mint their own pairing links, so the startup one is dropped. + const [serverPort, metroPort, host] = ports as [string, string, string]; + const listen = ["--host", "0.0.0.0", "--port", serverPort]; + const server = start( + process.cwd(), + process.execPath, + ["scripts/dev-runner.ts", "dev:server", "--home-dir", home, "--no-seed", ...listen], + {}, + ); + NodeReadline.createInterface({ input: server.stdout! }).on("line", (line) => { + if (!line.includes("pairingUrl:")) process.stdout.write(`${line}\n`); + }); + while (!(await listening(Number(serverPort)))) await NodeTimersPromises.setTimeout(500); + const metro = start( + NodePath.join(process.cwd(), "apps/mobile"), + "npx", + ["expo", "start", "--dev-client", "--scheme", "t3code-dev", "--lan", "--port", metroPort], + { + APP_VARIANT: "development", + CI: "1", + // Lazily fetched chunks sometimes arrive broken on pool devices. + EXPO_NO_METRO_LAZY: "1", + REACT_NATIVE_PACKAGER_HOSTNAME: host, + }, + ); + metro.stdout!.pipe(process.stdout); +} diff --git a/scripts/scenarios/t3-mobile.ts b/scripts/scenarios/t3-mobile.ts new file mode 100644 index 0000000000000..6c99de562f157 --- /dev/null +++ b/scripts/scenarios/t3-mobile.ts @@ -0,0 +1,86 @@ +// @effect-diagnostics nodeBuiltinImport:off +// `test` for iOS scenarios: e2e's mobile test plus `t3`, which seeds state like the web one and +// opens the dev client paired with the scenario's server. +import * as NodeChildProcess from "node:child_process"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { test as base } from "@e2e-dev/mobile"; +import { expect } from "e2e"; + +import { APP_DIR, MOBILE, MOBILE_T3_HOME } from "../e2e.config.ts"; +import { makeState, type T3State } from "./t3.ts"; + +const APP_ID = "com.t3tools.t3code.dev"; +const SCHEME = "t3code-dev"; + +export interface MobileT3 extends T3State { + /** Installs the dev client if the device lacks it, loads it from Metro, and pairs it. */ + readonly open: () => Promise; +} + +/** The dev client built for this checkout's native code, from the shared cache. */ +function devClientPath(): string { + const stdout = NodeChildProcess.execFileSync( + process.execPath, + ["scripts/mobile-native-client.ts", "ensure", "ios", "--prepare"], + { cwd: APP_DIR, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, + ); + const { fingerprint } = JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}") as { + fingerprint?: string; + }; + if (fingerprint === undefined) throw new Error("mobile-native-client printed no fingerprint"); + return NodePath.join( + NodeOS.homedir(), + ".cache/t3code/native-clients/artifacts/ios/development", + fingerprint, + "T3CodeDev.app", + ); +} + +// A leased device is wiped when it returns to the pool, so one install and pairing last the run. +let installed = false; +let paired = false; + +export const test = base.extend<{ t3: MobileT3 }>({ + t3: async ({ device, screen }, use) => { + const { state, cli, cleanup } = makeState(MOBILE_T3_HOME, { + T3CODE_PORT: String(MOBILE.serverPort), + }); + await use({ + ...state, + open: async () => { + if (!installed) { + await device.installApp(devClientPath()); + installed = true; + } + // iOS asks before a link opens another app. + const openLink = async (url: string) => { + await device.openLink(url, { app: APP_ID }); + await device.alert("accept").catch(() => {}); + }; + const metro = encodeURIComponent(`http://${MOBILE.host}:${MOBILE.metroPort}`); + await openLink( + `${SCHEME}://expo-development-client/?url=${metro}&disableOnboarding=1&disableFab=1&disableAutoLaunch=1`, + ); + // The first load bundles the app; the pairing link only works once it is on screen. + if (paired) return; + await expect(screen.getByRole("button", "Add environment")).toBeVisible({ + timeout: 240_000, + }); + const origin = `http://${MOBILE.host}:${MOBILE.serverPort}`; + const pairing = ["auth", "pairing", "create", "--ttl", "15m", "--label", "scenario"]; + const created = await cli([...pairing, "--base-url", origin]); + const pairingUrl = /Pair URL:\s*(\S+)/.exec(`${created.stdout}\n${created.stderr}`)?.[1]; + if (pairingUrl === undefined) throw new Error("The server printed no pairing URL"); + const query = new URLSearchParams({ pairingUrl, autoConnect: "1" }); + await openLink(`${SCHEME}://connections/new?${query.toString()}`); + paired = true; + }, + }); + await cleanup(); + }, +}); + +/** Test option for an iOS scenario: the web target skips it, and no device is leased for it. */ +export const IOS = { platforms: ["ios"] } as const; diff --git a/scripts/scenarios/t3.ts b/scripts/scenarios/t3.ts index 5c94f210594e3..2455671cfc7a1 100644 --- a/scripts/scenarios/t3.ts +++ b/scripts/scenarios/t3.ts @@ -1,5 +1,6 @@ // @effect-diagnostics nodeBuiltinImport:off -// `test` for scenarios: e2e's web test plus `t3`, which seeds state and opens the signed-in app. +// `test` for web scenarios: e2e's web test plus `t3`, which seeds state and opens the signed-in +// app. `t3-mobile.ts` is the same for the iOS app. import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; @@ -11,14 +12,7 @@ import { APP_DIR, T3_HOME } from "../e2e.config.ts"; const execFile = NodeUtil.promisify(NodeChildProcess.execFile); -/** The app's own CLI against the running server: state gets real events, not inserted rows. */ -const cli = (args: ReadonlyArray) => - execFile(process.execPath, ["apps/server/src/bin.ts", ...args, "--base-dir", T3_HOME], { - cwd: APP_DIR, - env: { PATH: process.env.PATH, HOME: process.env.HOME }, - }); - -export interface T3 { +export interface T3State { /** Creates a git repository holding `files` and adds it as a project. Returns its path. */ readonly project: (title: string, files?: Readonly>) => Promise; /** @@ -31,53 +25,79 @@ export interface T3 { readonly messages: ReadonlyArray; readonly model?: string; }) => Promise; +} + +export interface T3 extends T3State { /** Opens the app, signed in, at `path`. */ readonly open: (path?: string) => Promise; } +/** State for the server on `home`, made through the app's own CLI: real events, not inserted rows. */ +export function makeState(home: string, env: NodeJS.ProcessEnv = {}) { + const cli = (args: ReadonlyArray) => + execFile(process.execPath, ["apps/server/src/bin.ts", ...args, "--base-dir", home], { + cwd: APP_DIR, + env: { PATH: process.env.PATH, HOME: process.env.HOME, ...env }, + }); + const projects: Array = []; + const state: T3State = { + project: async (title, files = {}) => { + // Its own folder per test: the server and this home outlive one test. + NodeFS.mkdirSync(NodePath.join(home, "workspaces"), { recursive: true }); + const parent = NodeFS.mkdtempSync(NodePath.join(home, "workspaces", "w-")); + const workspace = NodePath.join(parent, title); + for (const [file, content] of Object.entries({ "README.md": "", ...files })) { + NodeFS.mkdirSync(NodePath.dirname(NodePath.join(workspace, file)), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(workspace, file), content); + } + const git = (args: ReadonlyArray) => execFile("git", args, { cwd: workspace }); + const author = ["-c", "user.name=Scenario", "-c", "user.email=scenario@example.invalid"]; + await git(["init", "--quiet", "--initial-branch=main"]); + await git(["add", "--all"]); + await git([...author, "commit", "--quiet", "--message", "Initial commit"]); + await cli(["project", "add", workspace, "--title", title]); + projects.push(workspace); + return workspace; + }, + thread: async ({ project, title, messages, model = "multi-turn" }) => { + // Exit code 2 means the turn waits for an answer, which a scenario may want to start from. + const turn = (args: ReadonlyArray) => + cli(["thread", ...args, "--wait", "--json"]).catch( + (error: { code?: unknown; stdout?: string }) => { + if (error.code !== 2 || error.stdout === undefined) throw error; + return { stdout: error.stdout }; + }, + ); + const [first = "Start", ...rest] = messages; + const replay = ["--provider", "devReplay", "--model", model]; + const started = await turn(["start", project, first, "--title", title, ...replay]); + const { threadId } = JSON.parse(started.stdout) as { threadId: string }; + for (const message of rest) await turn(["send", threadId, message]); + }, + }; + return { + state, + cli, + // The server outlives the test, so the next one must not see this one's projects. + cleanup: async () => { + for (const workspace of projects) await cli(["project", "remove", workspace, "--force"]); + }, + }; +} + export const test = base.extend<{ t3: T3 }>({ t3: async ({ app }, use) => { - const projects: Array = []; + const { state, cleanup } = makeState(T3_HOME); await use({ - project: async (title, files = {}) => { - // Its own folder per test: the server and this home outlive one test. - NodeFS.mkdirSync(NodePath.join(T3_HOME, "workspaces"), { recursive: true }); - const parent = NodeFS.mkdtempSync(NodePath.join(T3_HOME, "workspaces", "w-")); - const workspace = NodePath.join(parent, title); - for (const [file, content] of Object.entries({ "README.md": "", ...files })) { - NodeFS.mkdirSync(NodePath.dirname(NodePath.join(workspace, file)), { recursive: true }); - NodeFS.writeFileSync(NodePath.join(workspace, file), content); - } - const git = (args: ReadonlyArray) => execFile("git", args, { cwd: workspace }); - const author = ["-c", "user.name=Scenario", "-c", "user.email=scenario@example.invalid"]; - await git(["init", "--quiet", "--initial-branch=main"]); - await git(["add", "--all"]); - await git([...author, "commit", "--quiet", "--message", "Initial commit"]); - await cli(["project", "add", workspace, "--title", title]); - projects.push(workspace); - return workspace; - }, - thread: async ({ project, title, messages, model = "multi-turn" }) => { - // Exit code 2 means the turn waits for an answer, which a scenario may want to start from. - const turn = (args: ReadonlyArray) => - cli(["thread", ...args, "--wait", "--json"]).catch( - (error: { code?: unknown; stdout?: string }) => { - if (error.code !== 2 || error.stdout === undefined) throw error; - return { stdout: error.stdout }; - }, - ); - const [first = "Start", ...rest] = messages; - const replay = ["--provider", "devReplay", "--model", model]; - const started = await turn(["start", project, first, "--title", title, ...replay]); - const { threadId } = JSON.parse(started.stdout) as { threadId: string }; - for (const message of rest) await turn(["send", threadId, message]); - }, + ...state, open: async (path = "/") => { await app.open(NodeFS.readFileSync(NodePath.join(T3_HOME, "pairing-path"), "utf8")); if (path !== "/") await app.open(path); }, }); - // The server outlives the test, so the next one must not see this one's projects. - for (const workspace of projects) await cli(["project", "remove", workspace, "--force"]); + await cleanup(); }, }); + +/** Test option for a web scenario: the iOS target skips it. */ +export const WEB = { platforms: ["web"] } as const; From f5eb1c6000ab3481f41180bd9e27da0ac7d8bfc1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 14:27:29 -0800 Subject: [PATCH 4/6] chore(server): ship agent-device 0.21.36 and expo-device-hub 0.22.1 The Device panel installs these on device hosts. agent-device gains three flags that take a value (--os, --image-tag, --provider-network-hosts), so the launcher's flag list and the eval's help fixture follow. The showcase workflow moves from 0.21.25 to the same version. Co-Authored-By: Claude Fable 5.2 --- .../workflows/mobile-showcase-screenshots.yml | 2 +- apps/server/src/device/AgentDeviceShim.ts | 5 +++- .../server/src/device/deviceToolVersions.json | 4 +-- evals/mcp-tool-use/agent-device-help.txt | 27 +++++++++---------- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/mobile-showcase-screenshots.yml b/.github/workflows/mobile-showcase-screenshots.yml index c02be425c1ab7..53b0a74a1f5aa 100644 --- a/.github/workflows/mobile-showcase-screenshots.yml +++ b/.github/workflows/mobile-showcase-screenshots.yml @@ -88,7 +88,7 @@ jobs: axe --version - name: Install foldable simulator controls - run: npm install --global agent-device@0.21.25 + run: npm install --global agent-device@0.21.36 - name: Capture iOS showcase run: pnpm screenshots:mobile --platform ios --appearance "${{ inputs.appearance }}" --theme "${{ inputs.theme }}" diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index b50f4dfbf2a40..e5b42557a1e10 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -12,7 +12,7 @@ const STALE_RUNTIME = const quoteShell = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'"; -/** agent-device 0.21.31's flags that take a value, so the launcher can find the command past them. */ +/** agent-device 0.21.36's flags that take a value, so the launcher can find the command past them. */ const VALUE_FLAGS: ReadonlyArray = [ "--activity", "--all", @@ -53,6 +53,7 @@ const VALUE_FLAGS: ReadonlyArray = [ "--header", "--hold-ms", "--host", + "--image-tag", "--in-app", "--include", "--interval-ms", @@ -79,6 +80,7 @@ const VALUE_FLAGS: ReadonlyArray = [ "--metro-port", "--network-profile", "--on-error", + "--os", "--os-version", "--out", "--pattern", @@ -101,6 +103,7 @@ const VALUE_FLAGS: ReadonlyArray = [ "--provider-geo-location", "--provider-language", "--provider-locale", + "--provider-network-hosts", "--provider-network-profile", "--provider-os-version", "--provider-project", diff --git a/apps/server/src/device/deviceToolVersions.json b/apps/server/src/device/deviceToolVersions.json index e441cc55a9d34..ce61669b26573 100644 --- a/apps/server/src/device/deviceToolVersions.json +++ b/apps/server/src/device/deviceToolVersions.json @@ -1,4 +1,4 @@ { - "agent-device": "0.21.31", - "expo-device-hub": "0.19.1" + "agent-device": "0.21.36", + "expo-device-hub": "0.22.1" } diff --git a/evals/mcp-tool-use/agent-device-help.txt b/evals/mcp-tool-use/agent-device-help.txt index a45698bfb2f04..058f29b5bf6a1 100644 --- a/evals/mcp-tool-use/agent-device-help.txt +++ b/evals/mcp-tool-use/agent-device-help.txt @@ -1,4 +1,4 @@ -agent-device 0.21.31 prints this help; apps/server/src/device/deviceToolVersions.json pins that version. Regenerate from +agent-device 0.21.36 prints this help; apps/server/src/device/deviceToolVersions.json pins that version. Regenerate from the repo root with: node scripts/node_modules/agent-device/bin/agent-device.mjs help [commands|record|screenshot|open|settings|fill] $ agent-device help @@ -54,9 +54,8 @@ Guides (agent-device help ): physical-device / ios-system-ui / maestro foldable iPhone Duo panels, the fold command, and why refs die on a pose change - $ agent-device help commands -agent-device 0.21.31 — commands +agent-device 0.21.36 — commands Full command catalog. Use agent-device help for exact flags and behavior. @@ -88,7 +87,7 @@ Commands: doctor Diagnose device, app, dev-server, and RN/Expo readiness events Read session event timeline fill [text] Replace text in a UI input - find [value] Find an element and act + find [action] [value] Find an element and act focus Focus input at screen coordinates fold [pose] Fold or unfold a foldable iPhone simulator or Android emulator gesture ... Run pan, fling, swipe, pinch, rotate, transform, tap, or drag gestures @@ -181,7 +180,6 @@ Related: agent-device help command-specific flags agent-device help manual-qa routine QA loop agent-device help workflow full automation reference - $ agent-device help record Usage: agent-device record start [path] [--scope ] [--fps ] [--quality ] [--hide-touches] | record stop | record contact-sheet [--out ] @@ -194,12 +192,11 @@ Command flags: --hide-touches Record: skip touch-overlay post-processing for faster raw benchmark videos --scope Record: app requires an active app session; device/system records the whole screen --out Output path - $ agent-device help screenshot Usage: agent-device screenshot [path] [--out ] [--overlay-refs] [--crop-on ] [--pixel-density ] [--fullscreen, --full, -f] [--scale <0.01-1>] [--no-stabilize] [--normalize-status-bar] -Capture a screenshot of the active app or web session. Choose the capture scope, density, size, or annotations through the corresponding input fields when needed. Web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. macOS app sessions default to the app window; use --fullscreen for full desktop, --scale to downscale, --crop-on to crop the capture to the frame the selector resolves on the same screen (currently iOS simulators, Android emulators, and CoreDevice physical iPhones), --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator or Android chrome. Physical iPhone crop verification covers portrait CoreDevice/devicectl captures only. XCTest, runner fallback, and physical iPad crops are refused; landscape iPhone crop is unverified. Android captures immediately by default; --normalize-status-bar also waits for transient UI to fade, and --no-stabilize keeps capture immediate. With the native macOS backend, the front window is the app's frontmost visible, nonempty normal-level (layer-0) window on the current Space: floating palettes and app-modal alerts at window level 8 are skipped, and a sheet is captured as its own window. On macOS, any --surface other than app (desktop, menubar, frontmost-app) always captures the main display and refuses an explicit --fullscreen. On single-display iOS simulators and Android devices, the result reports displayRotation, the rotation the display rendered the capture in, when the device returns a reading in time; otherwise the field is absent. +Capture a screenshot of the active app or web session. Choose the capture scope, density, size, or annotations through the corresponding input fields when needed. Web defaults to the viewport; use --fullscreen, --full, or -f for the entire page. iOS simulators default to 1x logical-point output; use --pixel-density to request a different screenshot density. macOS app sessions default to the app window; use --fullscreen for full desktop, --scale to downscale, --crop-on to crop the capture to the frame the selector resolves on the same screen (currently iOS simulators, Android emulators, and CoreDevice physical iPhones), --overlay-refs to annotate current refs, --normalize-status-bar for deterministic iOS simulator or Android chrome. Physical iPhone crop verification covers portrait CoreDevice/devicectl captures only. XCTest, runner fallback, and physical iPad crops are refused; landscape iPhone crop is unverified. Android captures immediately by default; --normalize-status-bar also waits for transient UI to fade, and --no-stabilize keeps capture immediate. With the native macOS backend, the front window is the app's frontmost visible, nonempty normal-level (layer-0) window on the current Space: floating palettes and app-modal alerts at window level 8 are skipped, and a sheet is captured as its own window. On macOS, any --surface other than app (desktop, menubar, frontmost-app) always captures the main display and refuses an explicit --fullscreen. On single-display iOS simulators and Android devices, the result reports displayRotation, the rotation the display rendered the capture in, when the device returns a reading in time; otherwise the field is absent. On iOS simulators a screenshot waits for that simulator's status bar to be free, and a request its caller cancelled while it waits reports the cancellation instead of capturing. Command flags: --out Output path @@ -210,15 +207,17 @@ Command flags: --scale <0.01-1> Screenshot: resize both dimensions by this factor (or use AGENT_DEVICE_SCREENSHOT_SCALE) --no-stabilize Screenshot: capture immediately without Android status-bar stabilization (the default) --normalize-status-bar Screenshot: temporarily normalize iOS simulator or ADB-backed Android status-bar chrome for deterministic screenshot diffs; ADB-backed Android waits for transient UI to fade - $ agent-device help open Usage: - agent-device open [appOrUrl] [url] [--activity ] [--launch-console ] [--launch-args ] [--test-ime] [--no-test-ime] [--save-script [path]] [--force, --overwrite] [--no-record] [--relaunch] [--foreground] [--timeout ] [--wait ] [--surface app|frontmost-app|desktop|menubar] [--metro-host ] [--metro-port ] [--bundle-url ] [--launch-url ] + agent-device open [appOrUrl] [url] [--activity ] [--launch-console ] [--launch-args ] [--test-ime] [--no-test-ime] [--save-script [path]] [--force, --overwrite] [--no-record] [--relaunch] [--foreground] [--timeout ] [--wait ] [--surface app|frontmost-app|desktop|menubar] [--metro-host ] [--metro-port ] [--bundle-url ] [--launch-url ] [--os ] [--image-tag ] [--physical] -Boot the selected device when needed, then open an app, deep link, or URL in a session. Use the app or URL inputs to choose what becomes the foreground automation target. Use --platform to bind URL/deep-link opens to the target platform. For iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app's Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app's launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; in both cases inspect the warning and answer with alert get then alert accept; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app's target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. A recognized prompt still visible after a failed accept also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. Only the English prompt title with Cancel and Open buttons is recognized, so on a Simulator set to another language the prompt stays on screen. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session. +Boot the selected device when needed, then open an app, deep link, or URL in a session. Use the app or URL inputs to choose what becomes the foreground automation target. Use --platform to bind URL/deep-link opens to the target platform. For local iOS simulator initial stdout/stderr, put --launch-console on this open command, for example agent-device open "Agent Device Tester" --platform ios --launch-console artifacts/launch-console.log. Expo Go/dev-client shells accept host + URL, for example agent-device open "Expo Go" exp://127.0.0.1:8081 --platform ios. macOS also supports --surface app|frontmost-app|desktop|menubar. --metro-host/--metro-port/--bundle-url choose the app's Metro server and save that address for later metro reload calls in the same session. A fresh open without Metro hints clears an older same-name binding; close also clears it. --launch-url supplies the app's launch URL; it does not select the reload server. On an iOS simulator without --launch-url, open connects a detected Expo development client through its expo-development-client URL. The app must declare a single exp+ scheme that no other installed app owns. If detection is inconclusive, the app opens normally. Bare React Native apps receive their server settings before launch. On an iOS simulator, when --launch-url raises an "Open in ?" prompt that keeps open from seeing the app, open accepts it if the URL scheme belongs to the session app (data.launchConfirmation: "accepted"); if the scheme belongs to another installed app, the prompt stays on screen and open fails with details.reason launch_confirmation_foreign_app; if the scheme owner cannot be resolved because no single installed app owns it or the lookup failed, the prompt stays on screen and open returns with data.launchConfirmation: "unanswered", launchConfirmationReason url-owner-unresolved, and a warning; a prompt that is seen but not recognized is reported the same way with launchConfirmationReason alert-unrecognized and its own warning; for url-owner-unresolved, answer the prompt with alert accept; for alert-unrecognized, read the alert with alert get first, because it can be the app's own alert (such as a permission prompt) rather than the launch confirmation, and answer with alert accept only when it is the launch confirmation; when agent-device cannot read whether a prompt is up at all (the alert read, the accept, or the runner itself failed, including after a re-hand of the URL), open stays green, reports no launchConfirmation plus data.launchConfirmationProbeFailure naming the failed step (alert-read, alert-accept, or runner), and appends a warning that says to check with alert get. The prompt is read whenever open cannot see the app afterwards, including on a slow host where the app's target discovery fails on its own deadline. When an accept leaves the app provably not running, open hands the same URL over once more and reads again; still not running, it fails with details.reason launch_confirmation_unanswered. When the runner reports its main-thread timeout for the accept and the prompt is still up, open presses Open again, but only when the time the open request has left covers a whole press and the read that checks it. A recognized prompt still visible after a failed accept, or once that time is gone, also fails with this reason, even if the app is running behind it; answer it with alert accept or alert dismiss. A simulator openurl that CoreSimulator never answers fails within 20s with details.reason ios-simulator-openurl-timeout. The prompt is recognized by the OS's own wording of its title and buttons in the Simulator's preferred languages; when the runner cannot read that wording, the prompt stays on screen as alert-unrecognized. A prompt that appears after open has already seen the app is not answered; use alert accept. A device another session is holding refuses at once; add --wait to block up to that budget for it and only then fail with DEVICE_IN_USE naming the owning session. Command flags: --force, --overwrite Force replacement of existing state (connect: replace an active connection; --save-script: overwrite an existing target instead of refusing) + --os Proxy lease: OS version or range the backend picks the device by (Android: API level); refused without such a backend + --image-tag Proxy lease: Android system image tag the backend picks the device by; refused without such a backend + --physical Proxy lease: ask the backend for a physical device instead of a simulator or emulator; refused without such a backend --surface app|frontmost-app|desktop|menubar macOS session surface for open (defaults to app) --metro-host Session-scoped Metro/debug host hint --metro-port Session-scoped Metro/debug port hint; on an emulator/simulator the host defaults to the loopback alias, physical devices still need --metro-host @@ -227,7 +226,7 @@ Command flags: --test-ime open/test/replay: activate the headless Android test IME for deterministic Unicode text entry (default on for emulators; opt-in on real devices; on test/replay it applies to the sessions the flow opens) --no-test-ime open/test/replay: keep the real Android keyboard even on emulators (opt out of the headless test IME; on test/replay it applies to the sessions the flow opens) --activity Android launch activity (package/Class, .Class, or Class) or HarmonyOS ability name; not for URL opens - --launch-console open: capture the initial iOS simulator launch console window to a file + --launch-console open: capture the initial local iOS simulator launch console window to a file --launch-args open: repeatable launch argument forwarded verbatim to the platform launch command (iOS app process args; Android adb shell am start args). Linux and macOS reject the flag. --save-script [path] Arm evidence capture on open, publish the armed recording on close; close --save-script alone (without an armed open) is rejected — start with open --save-script, or use session save-script mid-session. Optional custom output path. --relaunch open: terminate app process before launching it @@ -235,13 +234,14 @@ Command flags: --no-record Do not record this action --timeout Boot/Open/Prepare: startup budget covering the device boot (and runner preparation for prepare). Replay/Snapshot/Test: maximum wall-clock time for the command or attempt. With --settle: the settle-wait deadline (default 10s) --wait Open: wait up to n ms for a device another session is holding before failing with DEVICE_IN_USE - $ agent-device help settings Usage: agent-device settings | settings location set | settings appearance | settings text-size [extra-small|small|medium|large|extra-large|extra-extra-large|extra-extra-extra-large|accessibility-medium|accessibility-large|accessibility-extra-large|accessibility-extra-extra-large|accessibility-extra-extra-extra-large] | settings | settings fingerprint | settings clear-app-state [app-id] | settings reset-keychain clear | settings permission [full|limited] | settings permission [--app ] Read or change supported operating-system settings, animation scales, appearance, or app permissions on the selected target. Platform support varies by setting and action. A bare readable setting reads the value the target holds. A target that does not serve a setting refuses it with UNSUPPORTED_OPERATION before anything changes. +On an iOS simulator, settings wifi and settings airplane wait for screenshots and other status bar writes. The request timeout includes this wait (90 seconds for a standalone CLI request). Once the write starts, its status bar command has a separate 10-second timeout. A request or write that times out may already have changed the bar; check the bar before relying on it. Cancelling while still queued guarantees nothing was written. + Settings: wifi: Android switches Wi-Fi with svc wifi; an Apple simulator overrides only its status bar. bluetooth: Android over adb turns its Bluetooth adapter on or off with svc bluetooth, from Android 8.1. It reports the bluetooth state the device holds afterwards, or fails with bluetooth-not-applied or bluetooth-unreadable. A device without an adapter refuses with android_bluetooth_adapter_missing, and an adapter on a build older than 8.1 with android_bluetooth_command_unavailable, before anything changes. --app is refused. @@ -260,12 +260,11 @@ Settings: Command flags: --app Target an app without opening it: doctor verifies an installed app by id or name; settings applies an app-scoped change (permission, iOS location) to a bundle id or package - $ agent-device help fill Usage: agent-device fill | fill <@ref|selector> | fill --text-stdin -Replace text in a UI input selected by snapshot ref, selector, or coordinates. Pass an empty text to clear the field. Inspect unconfirmed evidence and assert the expected value or resulting screen before continuing. Prefer refs or selectors after snapshot; use recordAs to keep sensitive text out of a recorded replay while sending it to the live app. Every positional after an @ref is the replacement text, so fill @e57 good morning enters "good morning"; quote the text when the shell must preserve exact whitespace. Clear a field with an empty text argument: fill @e57 "" (the argument must be present — fill @e57 alone is a missing argument, not a clear). When visible label text also matches a non-input element, constrain the target with editable=true, for example fill 'label="Email" editable=true' "qa@example.com". To keep a secret out of argv, omit the text and pipe it in: printf %s "$PASSWORD" | fill @e57 --text-stdin (max 64 KiB, one trailing newline removed). +Replace text in a UI input selected by snapshot ref, selector, or coordinates. Pass an empty text to clear the field. Inspect unconfirmed evidence and assert the expected value or resulting screen before continuing. Prefer refs or selectors after snapshot; use recordAs to keep sensitive text out of a recorded replay while sending it to the live app. Every positional after an @ref is the replacement text, so fill @e57 good morning enters "good morning"; quote the text when the shell must preserve exact whitespace. Clear a field with an empty text argument: fill @e57 "" (the argument must be present — fill @e57 alone is a missing argument, not a clear). When visible label text also matches a non-input element, constrain the target with editable=true, for example fill 'label="Email" editable=true' "qa@example.com". To keep a secret out of argv, omit the text and pipe it in: printf %s "$PASSWORD" | fill @e57 --text-stdin (max 64 KiB, one trailing newline removed). On web, a coordinate fill selects the clicked field in the page and refuses with COMMAND_FAILED and details.reason text_entry_focus_not_observed, before it sends any text or key, when the click did not reach an editable text field it can reach: a read-only, disabled, date, checkbox, or select control, a field in a cross-origin frame, a sandboxed frame without allow-same-origin, or a closed shadow root, or a click that missed. The click has already landed, so run snapshot -i again and fill the field it reports by ref (fill @e2 text); a selector fill reaches the same coordinate path and hits the same limits. Command flags: --delay-ms Delay between typed characters From c642ff3400212d0a9c2e76dd7c92930bfa6512fd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 14:55:21 -0800 Subject: [PATCH 5/6] refactor(scripts): scenario scripts use Effect, and review fixes The app start script, the fixtures, the e2e config and the prove command run on Effect's FileSystem, Path, process and CLI services like the other scripts. - Titles no longer reach a real provider: the fallback providers are off and Codex, the server's default, is a command that only fails. - A lease is released when naming its device fails. - A test that skips itself is "could not run", not passed. - Any failure before a report exists exits 2. - Upload output is used only when its line count matches the captures. Co-Authored-By: Claude Fable 5.2 --- docs/operations/scenarios.md | 3 + scripts/e2e.config.ts | 175 ++++++++++-------- scripts/prove-scenario.test.ts | 25 ++- scripts/prove-scenario.ts | 318 +++++++++++++++++++++------------ scripts/scenarios/shell.ts | 64 +++++++ scripts/scenarios/start-app.ts | 300 +++++++++++++++++++++---------- scripts/scenarios/t3-mobile.ts | 57 +++--- scripts/scenarios/t3.ts | 145 +++++++++------ 8 files changed, 715 insertions(+), 372 deletions(-) create mode 100644 scripts/scenarios/shell.ts diff --git a/docs/operations/scenarios.md b/docs/operations/scenarios.md index e28cbaab3316c..aeb1fd03608bd 100644 --- a/docs/operations/scenarios.md +++ b/docs/operations/scenarios.md @@ -71,6 +71,9 @@ port, runs the tests, and stops it. Results land in `scripts/.e2e`: `report.json and per test a `trace.md`, its screenshots, and a video. The first run downloads e2e's browser. `e2e run --help` lists the rest: `--grep`, `--watch`, `--last-failed`, `--changed-since`. +Run one at a time per checkout: a run recreates the checkout's scenario T3 home and its `.e2e` +output. Use a second worktree for a second run. + ## Prove a fix ```sh diff --git a/scripts/e2e.config.ts b/scripts/e2e.config.ts index 49246eb4bddb5..6db320e4aa728 100644 --- a/scripts/e2e.config.ts +++ b/scripts/e2e.config.ts @@ -1,95 +1,126 @@ -// @effect-diagnostics nodeBuiltinImport:off globalTimers:off // Scenarios: e2e tests that run against the real dev stack. See docs/operations/scenarios.md. -import * as NodeChildProcess from "node:child_process"; -import * as NodeCrypto from "node:crypto"; -import * as NodePath from "node:path"; -import * as NodeTimersPromises from "node:timers/promises"; - +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { mobile, type DeviceProvider } from "@e2e-dev/mobile"; import { web } from "@e2e-dev/web"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { createAgentDeviceClient } from "agent-device"; import type { E2EConfig } from "e2e"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Schedule from "effect/Schedule"; -/** The checkout whose app runs. `prove-scenario.ts` points it at a base or head worktree. */ -export const APP_DIR = NodePath.resolve(process.env.T3_SCENARIO_APP_DIR ?? ".."); -/** T3 homes only scenarios use, recreated on every run. `.t3` is gitignored. */ -export const T3_HOME = NodePath.resolve(import.meta.dirname, "../.t3/scenarios"); -export const MOBILE_T3_HOME = `${T3_HOME}-mobile`; +import { run, ScenarioError } from "./scenarios/shell.ts"; -/** - * A pooled device reaches this machine over the tailnet, not localhost. A mobile target has no - * `app.url`, so e2e cannot pick these ports; override them when another run holds them. - */ -export const MOBILE = { - host: - NodeChildProcess.spawnSync("tailscale", ["ip", "-4"], { encoding: "utf8" }) - .stdout?.trim() - .split("\n")[0] || "127.0.0.1", - serverPort: Number(process.env.T3_SCENARIO_SERVER_PORT ?? 13_990), - metroPort: Number(process.env.T3_SCENARIO_METRO_PORT ?? 8_190), -}; +// e2e loads this module for its values, so the paths and the host are resolved once, here. +const resolved = await Effect.gen(function* () { + const path = yield* Path.Path; + const env = yield* HostProcess.Environment; + const scripts = yield* path.fromFileUrl(new URL(".", import.meta.url)); + const home = path.resolve(scripts, "../.t3/scenarios"); + // A pooled device reaches this machine over the tailnet, not localhost. + const host = yield* run("tailscale", ["ip", "-4"]).pipe( + Effect.map((result) => result.stdout.trim().split("\n")[0] ?? ""), + Effect.orElseSucceed(() => ""), + ); + return { + node: yield* HostProcess.ExecutablePath, + scripts, + /** The checkout whose app runs. `prove-scenario.ts` points it at a base or head worktree. */ + appDir: path.resolve(scripts, env.T3_SCENARIO_APP_DIR ?? ".."), + home, + startApp: path.join(scripts, "scenarios/start-app.ts"), + mobile: { + host: host || "127.0.0.1", + // A mobile target has no `app.url`, so e2e cannot pick these ports. + serverPort: Number(env.T3_SCENARIO_SERVER_PORT ?? 13_990), + metroPort: Number(env.T3_SCENARIO_METRO_PORT ?? 8_190), + }, + }; +}).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); -const startApp = NodePath.join(import.meta.dirname, "scenarios/start-app.ts"); +export const APP_DIR = resolved.appDir; +/** T3 homes only scenarios use, recreated on every run. `.t3` is gitignored. */ +export const T3_HOME = resolved.home; +export const MOBILE_T3_HOME = `${T3_HOME}-mobile`; +export const MOBILE = resolved.mobile; +const startApp = resolved.startApp; /** * Leases a device from the Simlock pool, whose gateway speaks agent-device's lease protocol. * `retainOnClose` keeps the device when the engine closes its session between attempts. */ const simlock = (device: string): DeviceProvider => { - const connect = (env: Readonly>) => { + const connect = Effect.fn("simlock.connect")(function* ( + env: Readonly>, + ) { const daemonBaseUrl = env.SIMLOCK_AGENT_DEVICE_URL; const daemonAuthToken = env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; if (daemonBaseUrl === undefined || daemonAuthToken === undefined) { - throw new Error("SIMLOCK_AGENT_DEVICE_URL and AGENT_DEVICE_DAEMON_AUTH_TOKEN are not set"); + return yield* new ScenarioError({ + message: "SIMLOCK_AGENT_DEVICE_URL and AGENT_DEVICE_DAEMON_AUTH_TOKEN are not set.", + }); } + return { daemonBaseUrl, daemonAuthToken }; + }); + const call = (description: string, request: () => Promise) => + Effect.tryPromise({ + try: request, + catch: (cause) => new ScenarioError({ message: `${description}: ${String(cause)}` }), + }); + const acquire = Effect.fn("simlock.acquire")(function* ( + request: Parameters[0], + ) { + const daemon = yield* connect(request.env); + const client = createAgentDeviceClient(daemon); + const platform = request.platform === "android" ? "android" : "ios"; + const scope = { tenant: "t3-scenarios", runId: `${request.runId}-${request.slot}` }; + // Simlock takes the lease id from the caller: 32 hex characters. + const leaseId = (yield* (yield* SecureRandom.SecureRandom).uuidV4()).replaceAll("-", ""); + const lease = yield* call(`The pool leased no ${device}`, () => + client.leases.allocate({ ...scope, platform, device, retainOnClose: true, leaseId }), + ).pipe( + // A busy gateway answers its health probe late, which the client reports as unavailable. + Effect.tapError(() => Effect.sync(() => request.log("the pool is busy; asking again"))), + Effect.retry({ schedule: Schedule.spaced("10 seconds"), times: 12 }), + ); + const leaseScope = { ...scope, leaseId: lease.leaseId, leaseBackend: lease.backend }; + // The pool answers `boot` with the leased device; a worker hosts several, so name ours. + const leased = yield* call("The pool did not name the leased device", () => + createAgentDeviceClient({ ...daemon, ...leaseScope }).devices.boot({ platform }), + ).pipe( + // e2e releases only the leases `acquire` returned. + Effect.tapError(() => + call("release", () => client.leases.release({ leaseId: lease.leaseId })).pipe( + Effect.ignore, + ), + ), + ); return { - daemon: { baseUrl: daemonBaseUrl, authToken: daemonAuthToken }, - client: createAgentDeviceClient({ daemonBaseUrl, daemonAuthToken }), + id: lease.leaseId, + daemon: { baseUrl: daemon.daemonBaseUrl, authToken: daemon.daemonAuthToken }, + deviceId: leased.id, + client: { + ...leaseScope, + ...(lease.leaseProvider === undefined ? {} : { leaseProvider: lease.leaseProvider }), + }, }; - }; + }); return { name: "simlock", - acquire: async (request) => { - const { daemon, client } = connect(request.env); - const scope = { tenant: "t3-scenarios", runId: `${request.runId}-${request.slot}` }; - const allocate = () => - client.leases.allocate({ - ...scope, - platform: request.platform === "android" ? "android" : "ios", - device, - retainOnClose: true, - // Simlock takes the lease id from the caller. - leaseId: NodeCrypto.randomBytes(16).toString("hex"), - }); - // A busy gateway answers its health probe late, which the client reports as unavailable. - let lease = await allocate().catch(() => undefined); - for (let attempt = 0; lease === undefined && attempt < 12; attempt++) { - request.log(`the pool is busy; asking again in 10s`); - await NodeTimersPromises.setTimeout(10_000, undefined, { signal: request.signal }); - lease = await (attempt === 11 ? allocate() : allocate().catch(() => undefined)); - } - if (lease === undefined) throw new Error(`The pool leased no ${device}`); - const leaseScope = { ...scope, leaseId: lease.leaseId, leaseBackend: lease.backend }; - // The pool answers `boot` with the leased device; a worker hosts several, so name ours. - const platform = request.platform === "android" ? "android" : "ios"; - const leased = await createAgentDeviceClient({ - daemonBaseUrl: daemon.baseUrl, - daemonAuthToken: daemon.authToken, - ...leaseScope, - }).devices.boot({ platform }); - return { - id: lease.leaseId, - daemon, - deviceId: leased.id, - client: { - ...leaseScope, - ...(lease.leaseProvider === undefined ? {} : { leaseProvider: lease.leaseProvider }), - }, - }; - }, - release: async (lease, context) => { - await connect(context.env).client.leases.release({ leaseId: lease.id }); - }, + acquire: (request) => + acquire(request).pipe(Effect.provide(NodeCrypto.layer), Effect.runPromise), + release: (lease, context) => + connect(context.env).pipe( + Effect.flatMap((daemon) => + call("release", () => + createAgentDeviceClient(daemon).leases.release({ leaseId: lease.id }), + ), + ), + Effect.asVoid, + Effect.runPromise, + ), }; }; @@ -104,7 +135,7 @@ export default { // Vite answers before the server does; this path is proxied to the server. readyUrl: "http://127.0.0.1:{port}/.well-known/t3/environment", command: { - executable: process.execPath, + executable: resolved.node, args: [startApp, "web", T3_HOME, "{port}"], cwd: APP_DIR, startupTimeout: 300_000, @@ -124,7 +155,7 @@ export default { // Metro starts after the server listens, so it answering means both are up. readyUrl: `http://127.0.0.1:${MOBILE.metroPort}/status`, command: { - executable: process.execPath, + executable: resolved.node, args: [ startApp, "mobile", diff --git a/scripts/prove-scenario.test.ts b/scripts/prove-scenario.test.ts index c432bbdddadd8..bfadf91bea9cc 100644 --- a/scripts/prove-scenario.test.ts +++ b/scripts/prove-scenario.test.ts @@ -2,15 +2,19 @@ import { describe, expect, it } from "@effect/vitest"; import { buildProof, readSide } from "./prove-scenario.ts"; -const report = (exitCode: number, error?: { code: string; message: string }) => ({ +const report = ( + exitCode: number, + error?: { code: string; message: string }, + status = error ? "failed" : "passed", +) => ({ run: { - exitCode, results: [ { titlePath: ["Another scenario the run did not select"], attempts: [] }, { titlePath: ["Tab reaches the submit button"], attempts: [ { + status, steps: [ { api: "app.open", label: "/pair#token=secret", status: "passed" }, { api: "expect.toBeFocused", label: "Continue", status: error ? "failed" : "passed" }, @@ -31,6 +35,8 @@ const passed = report(0); const failed = report(1, { code: "ASSERTION_FAILED", message: "expected: focused" }); // The control the scenario clicks is missing: the scenario is broken, the bug is not shown. const brokenScenario = report(1, { code: "LOCATOR_NOT_FOUND", message: "no node" }); +// e2e exits 0 when the test skips itself at runtime. +const skipped = report(0, undefined, "skipped"); describe("prove-scenario", () => { it.each([ @@ -51,6 +57,13 @@ describe("prove-scenario", () => { passesOnHead: true, verdict: /could not run on base/, }, + { + base: failed, + head: skipped, + failsOnBase: true, + passesOnHead: false, + verdict: /could not run on head/, + }, // e2e writes no report when the app does not start. { base: undefined, @@ -61,8 +74,8 @@ describe("prove-scenario", () => { }, ])("reports $verdict", (row) => { const proof = buildProof( - readSide("aaaaaaaaaaaa", "/out/base", row.base), - readSide("bbbbbbbbbbbb", "/out/head", row.head), + readSide("aaaaaaaaaaaa", "/out/base/results", row.base), + readSide("bbbbbbbbbbbb", "/out/head/results", row.head), (path) => path, ); expect(proof).toMatchObject({ failsOnBase: row.failsOnBase, passesOnHead: row.passesOnHead }); @@ -71,8 +84,8 @@ describe("prove-scenario", () => { it("labels captures with their side and commit and keeps the pairing token out", () => { const proof = buildProof( - readSide("aaaaaaaaaaaa", "/out/base", failed), - readSide("bbbbbbbbbbbb", "/out/head", passed), + readSide("aaaaaaaaaaaa", "/out/base/results", failed), + readSide("bbbbbbbbbbbb", "/out/head/results", passed), (path) => `https://files.example${path}`, ); expect(proof.markdown).toContain("| | Base `aaaaaaaaaa` | Head `bbbbbbbbbb` |"); diff --git a/scripts/prove-scenario.ts b/scripts/prove-scenario.ts index 6108cbb92d7eb..20921009ec55a 100644 --- a/scripts/prove-scenario.ts +++ b/scripts/prove-scenario.ts @@ -1,29 +1,51 @@ #!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off globalConsole:off // Runs one scenario with `e2e run` on a base and a head commit and says whether it fails on // base and passes on head. See docs/operations/scenarios.md. -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; -import * as NodeUtil from "node:util"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag } from "effect/cli"; -const SCRIPTS = import.meta.dirname; -const REPO_ROOT = NodePath.dirname(SCRIPTS); +import { run, ScenarioError } from "./scenarios/shell.ts"; /** The parts of e2e's report.json (schema/report-v1.schema.json) a proof reads. */ -interface E2EReport { - readonly run: { - readonly exitCode: number; - readonly results: ReadonlyArray<{ - readonly titlePath: ReadonlyArray; - readonly attempts: ReadonlyArray<{ - readonly steps: ReadonlyArray<{ api: string; label?: string; status: string }>; - readonly error?: { readonly code: string; readonly message: string }; - readonly artifacts?: ReadonlyArray<{ readonly kind: string; readonly path?: string }>; - }>; - }>; - }; -} +const E2EReport = Schema.Struct({ + run: Schema.Struct({ + results: Schema.Array( + Schema.Struct({ + titlePath: Schema.Array(Schema.String), + attempts: Schema.Array( + Schema.Struct({ + status: Schema.optional(Schema.String), + steps: Schema.Array( + Schema.Struct({ + api: Schema.String, + label: Schema.optional(Schema.String), + status: Schema.String, + }), + ), + error: Schema.optional(Schema.Struct({ code: Schema.String, message: Schema.String })), + artifacts: Schema.optional( + Schema.Array( + Schema.Struct({ kind: Schema.String, path: Schema.optional(Schema.String) }), + ), + ), + }), + ), + }), + ), + }), +}); +type E2EReport = typeof E2EReport.Type; +const decodeReport = Schema.decodeUnknownEffect(Schema.fromJsonString(E2EReport)); +const encodeProof = Schema.encodeEffect(fromJsonStringPretty(Schema.Unknown)); export interface SideResult { readonly sha: string; @@ -32,37 +54,46 @@ export interface SideResult { readonly title: string; readonly message?: string; readonly steps: ReadonlyArray<{ readonly step: string; readonly status: string }>; - /** Absolute capture paths, in the order the scenario took them. */ + /** Capture paths under the run's results directory, in the order the scenario took them. */ readonly captures: ReadonlyArray; readonly videos: ReadonlyArray; } /** Only a failed expectation shows the bug. A missing control or a dead app shows nothing. */ -export function readSide(sha: string, outDir: string, report: E2EReport | undefined): SideResult { +export function readSide( + sha: string, + resultsDir: string, + report: E2EReport | undefined, +): SideResult { // The report also lists the tests the run did not select, with no attempts. const result = report?.run.results.find((candidate) => candidate.attempts.length > 0); const attempt = result?.attempts.at(-1); - if (report === undefined || result === undefined || attempt === undefined) { + if (result === undefined || attempt === undefined) { const message = "e2e wrote no report: the app did not start, or no test was selected"; return { sha, status: "error", title: "", message, steps: [], captures: [], videos: [] }; } const files = (kind: string) => (attempt.artifacts ?? []).flatMap((artifact) => artifact.kind === kind && artifact.path !== undefined - ? [NodePath.join(outDir, "results", artifact.path)] + ? [`${resultsDir}/${artifact.path}`] : [], ); const error = attempt.error; return { sha, + // e2e exits 0 for a skipped test too, and a test that did not run proves nothing. status: - report.run.exitCode === 0 + attempt.status === "passed" ? "passed" : error?.code === "ASSERTION_FAILED" ? "failed" : "error", title: result.titlePath.join(" > "), - ...(error === undefined ? {} : { message: `${error.code}: ${error.message}` }), + ...(error === undefined + ? attempt.status === "passed" + ? {} + : { message: `the test ended ${attempt.status ?? "without a status"}` } + : { message: `${error.code}: ${error.message}` }), steps: attempt.steps.map((step) => ({ // The sign-in step's label holds the run's pairing token. step: `${step.api} ${step.label?.includes("token=") ? "" : (step.label ?? "")}`, @@ -75,7 +106,10 @@ export function readSide(sha: string, outDir: string, report: E2EReport | undefi const short = (sha: string) => sha.slice(0, 10); const captureLabel = (path: string) => - NodePath.basename(path, ".png").replace(/^\d+-/, "").replaceAll("-", " "); + (path.split("/").at(-1) ?? "") + .replace(/\.png$/, "") + .replace(/^\d+-/, "") + .replaceAll("-", " "); /** `link` maps a capture path to what the Markdown should point at, such as an uploaded URL. */ export function buildProof(base: SideResult, head: SideResult, link: (path: string) => string) { @@ -133,102 +167,154 @@ export function buildProof(base: SideResult, head: SideResult, link: (path: stri }; } -const git = (args: ReadonlyArray) => - NodeChildProcess.execFileSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" }).trim(); +const roots = Effect.gen(function* () { + const path = yield* Path.Path; + const scripts = yield* path.fromFileUrl(new URL(".", import.meta.url)); + return { scripts, repo: path.dirname(path.resolve(scripts)) }; +}); -/** Runs `run` in a checkout of `sha`: this one when it already is that commit, else a worktree. */ -function withCheckout(sha: string, run: (appDir: string) => A): A { - const clean = git(["status", "--porcelain", "--untracked-files=no"]).length === 0; - if (clean && git(["rev-parse", "HEAD"]) === sha) return run(REPO_ROOT); +const git = Effect.fn("prove.git")(function* (args: ReadonlyArray) { + return (yield* run("git", args, { cwd: (yield* roots).repo })).stdout.trim(); +}); + +/** Runs `use` with a checkout of `sha`: this one when it already is that commit, else a worktree. */ +const withCheckout = Effect.fn("prove.withCheckout")(function* ( + sha: string, + use: (appDir: string) => Effect.Effect, +) { + const { repo } = yield* roots; + const clean = (yield* git(["status", "--porcelain", "--untracked-files=no"])).length === 0; + if (clean && (yield* git(["rev-parse", "HEAD"])) === sha) return yield* use(repo); // Beside this checkout, not in the temp directory, so tooling resolves as it does here. - const worktree = `${REPO_ROOT}-scenario-${short(sha)}`; - git(["worktree", "add", "--quiet", "--detach", "--force", worktree, sha]); - try { - NodeChildProcess.execFileSync("vp", ["i"], { cwd: worktree, stdio: ["ignore", 2, 2] }); - return run(worktree); - } finally { - git(["worktree", "remove", "--force", worktree]); - } -} + const worktree = `${repo}-scenario-${short(sha)}`; + return yield* Effect.acquireUseRelease( + git(["worktree", "add", "--quiet", "--detach", "--force", worktree, sha]), + () => run("vp", ["i"], { cwd: worktree, echo: true }).pipe(Effect.andThen(use(worktree))), + () => git(["worktree", "remove", "--force", worktree]).pipe(Effect.ignore), + ); +}); -if (import.meta.main) { - const { values, positionals } = NodeUtil.parseArgs({ - allowPositionals: true, - options: { - base: { type: "string" }, - head: { type: "string", default: "HEAD" }, - "upload-command": { type: "string" }, - }, - }); - const [scenario] = positionals; - if (scenario === undefined || values.base === undefined) { - console.error( - 'Usage: node scripts/prove-scenario.ts --base [--head ] [--upload-command ""]', - ); - process.exit(2); - } - const scenarioPath = NodePath.resolve(scenario); - // A proof left by an earlier run must not be read as this one's. - for (const stale of ["proof.md", "proof.json"]) { - NodeFS.rmSync(NodePath.join(SCRIPTS, ".e2e/prove", stale), { force: true }); - } - const sides = (["base", "head"] as const).map((side) => { - const sha = git([ - "rev-parse", - "--verify", - `${side === "base" ? values.base : values.head}^{commit}`, - ]); - const outDir = NodePath.join(SCRIPTS, ".e2e/prove", side); - NodeFS.rmSync(outDir, { recursive: true, force: true }); - withCheckout(sha, (appDir) => - // The scenario and e2e come from this checkout; only the app comes from `appDir`. - NodeChildProcess.spawnSync( - NodePath.join(SCRIPTS, "node_modules/.bin/e2e"), - ["run", scenarioPath, "--output", NodePath.relative(SCRIPTS, outDir)], - { - cwd: SCRIPTS, - stdio: ["ignore", 2, 2], - env: { ...process.env, T3_SCENARIO_APP_DIR: appDir, E2E_TELEMETRY_DISABLED: "1" }, - }, +/** Runs the scenario with `e2e run` against the app at `sha` and reads the report it wrote. */ +const runSide = Effect.fn("prove.runSide")(function* ( + side: "base" | "head", + rev: string, + scenarioPath: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { scripts } = yield* roots; + const sha = yield* git(["rev-parse", "--verify", `${rev}^{commit}`]); + const outDir = path.join(scripts, ".e2e/prove", side); + yield* fs.remove(outDir, { recursive: true, force: true }); + const environment = yield* HostProcess.Environment; + yield* withCheckout(sha, (appDir) => + // The scenario and e2e come from this checkout; only the app comes from `appDir`. + run( + path.join(scripts, "node_modules/.bin/e2e"), + ["run", scenarioPath, "--output", path.relative(scripts, outDir)], + { + cwd: scripts, + env: { ...environment, T3_SCENARIO_APP_DIR: appDir, E2E_TELEMETRY_DISABLED: "1" }, + // The report says how the run went; e2e's exit code only repeats it. + allowExit: [0, 1, 2, 3, 4], + echo: true, + }, + ), + ); + const report = yield* fs.readFileString(path.join(outDir, "report.json")).pipe( + Effect.flatMap(decodeReport), + Effect.orElseSucceed(() => undefined), + ); + return readSide(sha, path.join(outDir, "results"), report); +}); + +/** Uploads the captures and maps each to its URL; a failed upload leaves the map empty. */ +const upload = Effect.fn("prove.upload")(function* ( + uploadCommand: string, + captures: ReadonlyArray, +) { + const [command = "", ...args] = uploadCommand.split(/\s+/).filter(Boolean); + if (captures.length === 0) return new Map(); + return yield* run(command, [...args, ...captures]).pipe( + Effect.map((result) => result.stdout.trim().split("\n")), + // Lines pair with captures by position, so a different count would mislabel every image. + Effect.filterOrFail( + (urls) => urls.length === captures.length, + () => new ScenarioError({ message: "The upload command printed an unexpected line count." }), + ), + Effect.map((urls) => new Map(captures.map((capture, index) => [capture, urls[index] ?? ""]))), + // Two finished runs are worth more than their links: the proof then names local files. + Effect.catch((error) => + Console.error(`Upload failed, linking local captures: ${error.message}`).pipe( + Effect.as(new Map()), ), - ); - const reportPath = NodePath.join(outDir, "report.json"); - const report = NodeFS.existsSync(reportPath) - ? (JSON.parse(NodeFS.readFileSync(reportPath, "utf8")) as E2EReport) - : undefined; - return readSide(sha, outDir, report); - }); - const [base, head] = sides as [SideResult, SideResult]; - const captures = [...base.captures, ...head.captures]; - const links = new Map(); - if (values["upload-command"] !== undefined && captures.length > 0) { - const [command = "", ...args] = values["upload-command"].split(/\s+/).filter(Boolean); - // A failed upload must not lose two finished runs: the proof then points at local files. - try { - const urls = NodeChildProcess.execFileSync(command, [...args, ...captures], { - encoding: "utf8", - stdio: ["ignore", "pipe", "inherit"], - }); - for (const [index, url] of urls.trim().split("\n").entries()) { - const capture = captures[index]; - if (capture !== undefined) links.set(capture, url); - } - } catch { - console.error("The upload command failed; the proof links local capture files."); - } - } - const proveDir = NodePath.join(SCRIPTS, ".e2e/prove"); + ), + ); +}); + +const prove = Effect.fn("prove")(function* (input: { + readonly scenario: string; + readonly base: string; + readonly head: string; + readonly uploadCommand: Option.Option; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { scripts } = yield* roots; + const proveDir = path.join(scripts, ".e2e/prove"); + const scenarioPath = path.resolve(yield* HostProcess.WorkingDirectory, input.scenario); + // A proof left by an earlier run must not be read as this one's. + yield* fs.remove(proveDir, { recursive: true, force: true }); + yield* fs.makeDirectory(proveDir, { recursive: true }); + const base = yield* runSide("base", input.base, scenarioPath); + const head = yield* runSide("head", input.head, scenarioPath); + const links = Option.isSome(input.uploadCommand) + ? yield* upload(input.uploadCommand.value, [...base.captures, ...head.captures]) + : new Map(); const proof = buildProof( base, head, - (path) => links.get(path) ?? NodePath.relative(proveDir, path), + (capture) => links.get(capture) ?? path.relative(proveDir, capture), ); - NodeFS.writeFileSync(NodePath.join(proveDir, "proof.md"), proof.markdown); - NodeFS.writeFileSync( - NodePath.join(proveDir, "proof.json"), - `${JSON.stringify(proof, null, 2)}\n`, - ); - console.log(JSON.stringify(proof, null, 2)); + const json = yield* encodeProof(proof); + yield* fs.writeFileString(path.join(proveDir, "proof.md"), proof.markdown); + yield* fs.writeFileString(path.join(proveDir, "proof.json"), `${json}\n`); + yield* Console.log(json); const couldNotRun = base.status === "error" || head.status === "error"; - process.exit(proof.failsOnBase && proof.passesOnHead ? 0 : couldNotRun ? 2 : 1); + return proof.failsOnBase && proof.passesOnHead ? 0 : couldNotRun ? 2 : 1; +}); + +const command = Command.make( + "prove-scenario", + { + scenario: Argument.String("scenario").pipe( + Argument.withDescription("Scenario file, such as scripts/scenarios/name.e2e.ts."), + ), + base: Flag.String("base").pipe(Flag.withDescription("Commit the bug shows on.")), + head: Flag.String("head").pipe( + Flag.withDescription("Commit with the fix."), + Flag.withDefault("HEAD"), + ), + uploadCommand: Flag.String("upload-command").pipe( + Flag.withDescription("Command that takes capture paths and prints one URL per line."), + Flag.optional, + ), + }, + (input) => + prove(input).pipe( + // A bad revision or a failed install is "could not run" (2), not "did not prove" (1). + Effect.catch((error) => Console.error(String(error)).pipe(Effect.as(2))), + Effect.flatMap((exitCode) => + Effect.sync(() => { + process.exitCode = exitCode; + }), + ), + ), +); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); } diff --git a/scripts/scenarios/shell.ts b/scripts/scenarios/shell.ts new file mode 100644 index 0000000000000..40e3b224fa694 --- /dev/null +++ b/scripts/scenarios/shell.ts @@ -0,0 +1,64 @@ +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +export class ScenarioError extends Schema.TaggedError()("ScenarioError", { + message: Schema.String, +}) {} + +interface RunOptions { + readonly cwd?: string; + /** Replaces the inherited environment when given. */ + readonly env?: NodeJS.ProcessEnv; + /** Exit codes that are answers, not failures. Defaults to 0 only. */ + readonly allowExit?: ReadonlyArray; + /** Prints the command's output to stderr as it arrives, keeping stdout for results. */ + readonly echo?: boolean; +} + +const collect = (stream: Stream.Stream, echo: boolean) => + echo + ? stream.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.tap((line) => Console.error(line)), + Stream.runCollect, + Effect.map((lines) => [...lines].join("\n")), + ) + : Stream.mkString(Stream.decodeText(stream)); + +/** Runs a command to its end and returns what it printed. */ +export const run = Effect.fn("scenario.run")( + function* (program: string, args: ReadonlyArray, options: RunOptions = {}) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(program, [...args], { + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + ...(options.env === undefined ? {} : { env: options.env, extendEnv: false }), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collect(child.stdout, options.echo === true), + collect(child.stderr, options.echo === true), + child.exitCode, + ], + { concurrency: "unbounded" }, + ); + if (!(options.allowExit ?? [0]).includes(exitCode)) { + return yield* new ScenarioError({ + message: `${program} ${args.slice(0, 3).join(" ")} exited with ${exitCode}: ${stderr.trim().slice(-600)}`, + }); + } + return { stdout, stderr, exitCode: Number(exitCode) }; + }, + Effect.scoped, + Effect.mapError((error) => + error._tag === "ScenarioError" ? error : new ScenarioError({ message: String(error) }), + ), +); diff --git a/scripts/scenarios/start-app.ts b/scripts/scenarios/start-app.ts index 1ca2fad752b5a..5eabe224b9778 100644 --- a/scripts/scenarios/start-app.ts +++ b/scripts/scenarios/start-app.ts @@ -1,25 +1,42 @@ -// @effect-diagnostics nodeBuiltinImport:off globalTimers:off -// The app command of e2e.config.ts: starts this checkout's dev stack on a fresh T3 home. -// Run from the checkout whose app should start. -import * as NodeChildProcess from "node:child_process"; -import * as NodeCrypto from "node:crypto"; -import * as NodeFS from "node:fs"; -import * as NodeNet from "node:net"; -import * as NodePath from "node:path"; -import * as NodeReadline from "node:readline"; -import * as NodeTimersPromises from "node:timers/promises"; +#!/usr/bin/env node +// The app command of e2e.config.ts: starts the dev stack of the checkout it runs in on a fresh +// T3 home. e2e stops it with a signal, which ends the scope and the processes it started. +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Argument, Command } from "effect/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; -const [surface, home, ...ports] = process.argv.slice(2); -if ((surface !== "web" && surface !== "mobile") || home === undefined || ports.length === 0) { - throw new Error( - "Usage: start-app.ts web | mobile ", - ); -} +import { ScenarioError } from "./shell.ts"; // The dev runner's web port is 5733 plus an offset. Mirror of BASE_WEB_PORT in dev-runner.ts. const BASE_WEB_PORT = 5733; // The replay agent `vp run dev` registers: free, recorded turns through the real adapter. const REPLAY = { instanceId: "devReplay", model: "showcase" }; +// Generated text (titles) cannot go to the replay agent: the server swaps an ACP agent for the +// first enabled real provider, else its default, Codex. So every provider it could fall back to +// is off and Codex is a command that only fails: no run reaches a paid model. +// Mirror of TEXT_GENERATION_FALLBACK_DRIVERS in shared/serverSettings. +const REAL_PROVIDERS = [ + "codex", + "claudeAgent", + "cursor", + "grok", + "muse", + "pi", + "opencode", + "antigravity", +]; // The server labels its environment with the host's name, probed through these commands. // Captures must not depend on, or show, the machine that made them. @@ -33,101 +50,186 @@ case "$*" in esac `; -NodeFS.rmSync(home, { recursive: true, force: true, maxRetries: 5 }); -NodeFS.mkdirSync(NodePath.join(home, "userdata"), { recursive: true }); -NodeFS.mkdirSync(NodePath.join(home, "bin")); -for (const command of ["hostnamectl", "scutil"]) { - NodeFS.writeFileSync(NodePath.join(home, "bin", command), HOST_LABEL_SHIM, { mode: 0o755 }); -} -// New threads and generated text (titles) go to the replay agent, so no run reaches a paid model. -NodeFS.writeFileSync( - NodePath.join(home, "userdata/settings.json"), - JSON.stringify({ defaultModelSelection: REPLAY, textGenerationModelSelection: REPLAY }), -); +const encodeSettings = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); -const env = { - ...process.env, - PATH: `${NodePath.join(home, "bin")}${NodePath.delimiter}${process.env.PATH ?? ""}`, - T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "0", -}; -const children: Array = []; -const start = ( - cwd: string, - command: string, - args: ReadonlyArray, - extra: NodeJS.ProcessEnv, -) => { - const child = NodeChildProcess.spawn(command, args, { - cwd, - stdio: ["ignore", "pipe", "inherit"], - env: { ...env, ...extra }, - }); - children.push(child); - child.on("exit", (code) => process.exit(code ?? 1)); +const prepareHome = Effect.fn("startApp.prepareHome")(function* (home: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.remove(home, { recursive: true, force: true }); + yield* fs.makeDirectory(path.join(home, "userdata"), { recursive: true }); + yield* fs.makeDirectory(path.join(home, "bin")); + for (const command of ["hostnamectl", "scutil"]) { + const shim = path.join(home, "bin", command); + yield* fs.writeFileString(shim, HOST_LABEL_SHIM); + yield* fs.chmod(shim, 0o755); + } + const noModel = path.join(home, "bin/no-model"); + yield* fs.writeFileString(noModel, "#!/bin/sh\necho 'scenarios run no model' >&2\nexit 1\n"); + yield* fs.chmod(noModel, 0o755); + yield* fs.writeFileString( + path.join(home, "userdata/settings.json"), + yield* encodeSettings({ + defaultModelSelection: REPLAY, + providerInstances: Object.fromEntries( + REAL_PROVIDERS.map((driver) => [ + driver, + { + driver, + enabled: false, + ...(driver === "codex" ? { config: { binaryPath: noModel } } : {}), + }, + ]), + ), + }), + ); +}); + +/** Starts a process in the caller's scope and hands each line it prints to `onLine`. */ +const start = Effect.fn("startApp.start")(function* (input: { + readonly cwd: string; + readonly command: string; + readonly args: ReadonlyArray; + readonly env: NodeJS.ProcessEnv; + readonly onLine: (line: string) => Effect.Effect; +}) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(input.command, [...input.args], { + cwd: input.cwd, + env: input.env, + extendEnv: false, + stdin: "ignore", + stdout: "pipe", + stderr: "inherit", + forceKillAfter: "5 seconds", + }), + ); + yield* child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.runForEach(input.onLine), + Effect.ignore, + Effect.forkScoped, + ); return child; -}; -for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.on(signal, () => children.forEach((child) => child.kill(signal))); -} -const listening = (port: number) => - new Promise((resolve) => { - const socket = NodeNet.connect(port, "127.0.0.1"); - socket.once("connect", () => (socket.destroy(), resolve(true))); - socket.once("error", () => resolve(false)); - }); +}); + +const startApp = Effect.fn("startApp")(function* (input: { + readonly surface: "web" | "mobile"; + readonly home: string; + readonly ports: ReadonlyArray; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const net = yield* NetService.NetService; + const node = yield* HostProcess.ExecutablePath; + const checkout = yield* HostProcess.WorkingDirectory; + const hostEnvironment = yield* HostProcess.Environment; + yield* prepareHome(input.home); + const env = { + ...hostEnvironment, + PATH: `${path.join(input.home, "bin")}:${hostEnvironment.PATH ?? ""}`, + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "0", + }; + const devRunner = (mode: string, args: ReadonlyArray) => [ + "scripts/dev-runner.ts", + mode, + "--home-dir", + input.home, + "--no-seed", + ...args, + ]; -if (surface === "web") { - const [webPort] = ports; - const serverPort = await new Promise((resolve) => { - const probe = NodeNet.createServer().listen(0, "127.0.0.1", () => { - const { port } = probe.address() as NodeNet.AddressInfo; - probe.close(() => resolve(port)); + if (input.surface === "web") { + const [webPort = ""] = input.ports; + const serverPort = yield* net.reserveLoopbackPort(); + const token = yield* (yield* SecureRandom.SecureRandom).uuidV4(); + const runner = yield* start({ + cwd: checkout, + command: node, + args: devRunner("dev", ["--port", String(serverPort)]), + env: { + ...env, + T3CODE_PORT_OFFSET: String(Number(webPort) - BASE_WEB_PORT), + T3CODE_DEV_ALLOWED_ORIGINS: `http://127.0.0.1:${webPort}`, + // Makes the startup pairing link reusable, so every test can sign in with it. + T3CODE_DEV_AUTH_TOKEN: `${token}${token}`.replaceAll("-", ""), + }, + onLine: (line) => { + const url = /pairingUrl:\s*(\S+)/.exec(line)?.[1]; + if (url === undefined) return Console.log(line); + // The link is a credential: it goes to a file the tests read, never to the log. + const { pathname, search, hash } = new URL(url); + return fs + .writeFileString(path.join(input.home, "pairing-path"), `${pathname}${search}${hash}`) + .pipe(Effect.ignore); + }, }); - }); - const runner = start( - process.cwd(), - process.execPath, - ["scripts/dev-runner.ts", "dev", "--home-dir", home, "--no-seed", "--port", String(serverPort)], - { - T3CODE_PORT_OFFSET: String(Number(webPort) - BASE_WEB_PORT), - T3CODE_DEV_ALLOWED_ORIGINS: `http://127.0.0.1:${webPort}`, - // Makes the startup pairing link reusable, so every test can sign in with it. - T3CODE_DEV_AUTH_TOKEN: NodeCrypto.randomBytes(32).toString("hex"), - }, - ); - NodeReadline.createInterface({ input: runner.stdout! }).on("line", (line) => { - const url = /pairingUrl:\s*(\S+)/.exec(line)?.[1]; - if (url === undefined) return void process.stdout.write(`${line}\n`); - // The link is a credential: it goes to a file the tests read, never to the log. - const { pathname, search, hash } = new URL(url); - NodeFS.writeFileSync(NodePath.join(home, "pairing-path"), `${pathname}${search}${hash}`); - }); -} else { + return yield* runner.exitCode; + } + // The device is another machine: the server listens on every interface, and Metro serves the // dev client its JavaScript. Tests mint their own pairing links, so the startup one is dropped. - const [serverPort, metroPort, host] = ports as [string, string, string]; - const listen = ["--host", "0.0.0.0", "--port", serverPort]; - const server = start( - process.cwd(), - process.execPath, - ["scripts/dev-runner.ts", "dev:server", "--home-dir", home, "--no-seed", ...listen], - {}, - ); - NodeReadline.createInterface({ input: server.stdout! }).on("line", (line) => { - if (!line.includes("pairingUrl:")) process.stdout.write(`${line}\n`); + const [serverPort = "", metroPort = "", host = ""] = input.ports; + const server = yield* start({ + cwd: checkout, + command: node, + args: devRunner("dev:server", ["--host", "0.0.0.0", "--port", serverPort]), + env, + onLine: (line) => (line.includes("pairingUrl:") ? Effect.void : Console.log(line)), }); - while (!(await listening(Number(serverPort)))) await NodeTimersPromises.setTimeout(500); - const metro = start( - NodePath.join(process.cwd(), "apps/mobile"), - "npx", - ["expo", "start", "--dev-client", "--scheme", "t3code-dev", "--lan", "--port", metroPort], - { + yield* net.hasListenerOnHost(Number(serverPort), "127.0.0.1").pipe( + Effect.filterOrFail( + (listening) => listening, + () => new ScenarioError({ message: "The server is not listening yet." }), + ), + Effect.retry({ schedule: Schedule.spaced("500 millis"), times: 600 }), + ); + const metro = yield* start({ + cwd: path.join(checkout, "apps/mobile"), + command: "npx", + args: ["expo", "start", "--dev-client", "--scheme", "t3code-dev", "--lan", "--port", metroPort], + env: { + ...env, APP_VARIANT: "development", CI: "1", // Lazily fetched chunks sometimes arrive broken on pool devices. EXPO_NO_METRO_LAZY: "1", REACT_NATIVE_PACKAGER_HOSTNAME: host, }, + onLine: Console.log, + }); + return yield* Effect.raceFirst(server.exitCode, metro.exitCode); +}); + +const command = Command.make( + "start-app", + { + surface: Argument.Literals("surface", ["web", "mobile"]).pipe( + Argument.withDescription( + "web: server and Vite. mobile: server on every interface and Metro.", + ), + ), + home: Argument.String("t3-home").pipe(Argument.withDescription("T3 home to recreate.")), + ports: Argument.String("port").pipe( + Argument.withDescription("web: . mobile: ."), + Argument.variadic(), + ), + }, + (input) => + startApp(input).pipe( + Effect.flatMap((exitCode) => + Effect.sync(() => { + process.exitCode = Number(exitCode) || 1; + }), + ), + ), +); + +if (import.meta.main) { + Command.run(command, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide([NodeServices.layer, NodeCrypto.layer, NetService.layer]), + NodeRuntime.runMain, ); - metro.stdout!.pipe(process.stdout); } diff --git a/scripts/scenarios/t3-mobile.ts b/scripts/scenarios/t3-mobile.ts index 6c99de562f157..154030dd7abe6 100644 --- a/scripts/scenarios/t3-mobile.ts +++ b/scripts/scenarios/t3-mobile.ts @@ -1,15 +1,15 @@ -// @effect-diagnostics nodeBuiltinImport:off // `test` for iOS scenarios: e2e's mobile test plus `t3`, which seeds state like the web one and // opens the dev client paired with the scenario's server. -import * as NodeChildProcess from "node:child_process"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; - import { test as base } from "@e2e-dev/mobile"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { expect } from "e2e"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { APP_DIR, MOBILE, MOBILE_T3_HOME } from "../e2e.config.ts"; -import { makeState, type T3State } from "./t3.ts"; +import { run, ScenarioError } from "./shell.ts"; +import { makeState, runPromise, type T3State } from "./t3.ts"; const APP_ID = "com.t3tools.t3code.dev"; const SCHEME = "t3code-dev"; @@ -19,24 +19,26 @@ export interface MobileT3 extends T3State { readonly open: () => Promise; } +const decodePrepared = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ fingerprint: Schema.String })), +); + /** The dev client built for this checkout's native code, from the shared cache. */ -function devClientPath(): string { - const stdout = NodeChildProcess.execFileSync( - process.execPath, +const devClientPath = Effect.gen(function* () { + const path = yield* Path.Path; + const prepared = yield* run( + yield* HostProcess.ExecutablePath, ["scripts/mobile-native-client.ts", "ensure", "ios", "--prepare"], - { cwd: APP_DIR, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] }, + { cwd: APP_DIR }, ); - const { fingerprint } = JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}") as { - fingerprint?: string; - }; - if (fingerprint === undefined) throw new Error("mobile-native-client printed no fingerprint"); - return NodePath.join( - NodeOS.homedir(), + const { fingerprint } = yield* decodePrepared(prepared.stdout.trim().split("\n").at(-1) ?? ""); + return path.join( + yield* HostProcess.HomeDirectory, ".cache/t3code/native-clients/artifacts/ios/development", fingerprint, "T3CodeDev.app", ); -} +}); // A leased device is wiped when it returns to the pool, so one install and pairing last the run. let installed = false; @@ -47,11 +49,18 @@ export const test = base.extend<{ t3: MobileT3 }>({ const { state, cli, cleanup } = makeState(MOBILE_T3_HOME, { T3CODE_PORT: String(MOBILE.serverPort), }); + const pairingUrl = Effect.gen(function* () { + const origin = `http://${MOBILE.host}:${MOBILE.serverPort}`; + const pairing = ["auth", "pairing", "create", "--ttl", "15m", "--label", "scenario"]; + const created = yield* cli([...pairing, "--base-url", origin]); + const url = /Pair URL:\s*(\S+)/.exec(`${created.stdout}\n${created.stderr}`)?.[1]; + return url ?? (yield* new ScenarioError({ message: "The server printed no pairing URL." })); + }); await use({ ...state, open: async () => { if (!installed) { - await device.installApp(devClientPath()); + await device.installApp(await runPromise(devClientPath)); installed = true; } // iOS asks before a link opens another app. @@ -68,17 +77,15 @@ export const test = base.extend<{ t3: MobileT3 }>({ await expect(screen.getByRole("button", "Add environment")).toBeVisible({ timeout: 240_000, }); - const origin = `http://${MOBILE.host}:${MOBILE.serverPort}`; - const pairing = ["auth", "pairing", "create", "--ttl", "15m", "--label", "scenario"]; - const created = await cli([...pairing, "--base-url", origin]); - const pairingUrl = /Pair URL:\s*(\S+)/.exec(`${created.stdout}\n${created.stderr}`)?.[1]; - if (pairingUrl === undefined) throw new Error("The server printed no pairing URL"); - const query = new URLSearchParams({ pairingUrl, autoConnect: "1" }); + const query = new URLSearchParams({ + pairingUrl: await runPromise(pairingUrl), + autoConnect: "1", + }); await openLink(`${SCHEME}://connections/new?${query.toString()}`); paired = true; }, }); - await cleanup(); + await runPromise(cleanup); }, }); diff --git a/scripts/scenarios/t3.ts b/scripts/scenarios/t3.ts index 2455671cfc7a1..4df481ace8cf2 100644 --- a/scripts/scenarios/t3.ts +++ b/scripts/scenarios/t3.ts @@ -1,16 +1,15 @@ -// @effect-diagnostics nodeBuiltinImport:off // `test` for web scenarios: e2e's web test plus `t3`, which seeds state and opens the signed-in // app. `t3-mobile.ts` is the same for the iOS app. -import * as NodeChildProcess from "node:child_process"; -import * as NodeFS from "node:fs"; -import * as NodePath from "node:path"; -import * as NodeUtil from "node:util"; - +import * as NodeServices from "@effect/platform-node/NodeServices"; import { test as base } from "@e2e-dev/web"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { APP_DIR, T3_HOME } from "../e2e.config.ts"; - -const execFile = NodeUtil.promisify(NodeChildProcess.execFile); +import { run } from "./shell.ts"; export interface T3State { /** Creates a git repository holding `files` and adds it as a project. Returns its path. */ @@ -32,70 +31,108 @@ export interface T3 extends T3State { readonly open: (path?: string) => Promise; } +/** e2e's fixtures are promise functions; this is the one place an Effect becomes a promise. */ +export const runPromise = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer))); + +const decodeLaunched = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ threadId: Schema.String })), +); + /** State for the server on `home`, made through the app's own CLI: real events, not inserted rows. */ export function makeState(home: string, env: NodeJS.ProcessEnv = {}) { - const cli = (args: ReadonlyArray) => - execFile(process.execPath, ["apps/server/src/bin.ts", ...args, "--base-dir", home], { - cwd: APP_DIR, - env: { PATH: process.env.PATH, HOME: process.env.HOME, ...env }, - }); + const cli = Effect.fn("scenario.cli")(function* ( + args: ReadonlyArray, + allowExit: ReadonlyArray = [0], + ) { + const hostEnvironment = yield* HostProcess.Environment; + return yield* run( + yield* HostProcess.ExecutablePath, + ["apps/server/src/bin.ts", ...args, "--base-dir", home], + { + cwd: APP_DIR, + env: { PATH: hostEnvironment.PATH, HOME: hostEnvironment.HOME, ...env }, + allowExit, + }, + ); + }); const projects: Array = []; - const state: T3State = { - project: async (title, files = {}) => { - // Its own folder per test: the server and this home outlive one test. - NodeFS.mkdirSync(NodePath.join(home, "workspaces"), { recursive: true }); - const parent = NodeFS.mkdtempSync(NodePath.join(home, "workspaces", "w-")); - const workspace = NodePath.join(parent, title); - for (const [file, content] of Object.entries({ "README.md": "", ...files })) { - NodeFS.mkdirSync(NodePath.dirname(NodePath.join(workspace, file)), { recursive: true }); - NodeFS.writeFileSync(NodePath.join(workspace, file), content); - } - const git = (args: ReadonlyArray) => execFile("git", args, { cwd: workspace }); - const author = ["-c", "user.name=Scenario", "-c", "user.email=scenario@example.invalid"]; - await git(["init", "--quiet", "--initial-branch=main"]); - await git(["add", "--all"]); - await git([...author, "commit", "--quiet", "--message", "Initial commit"]); - await cli(["project", "add", workspace, "--title", title]); - projects.push(workspace); - return workspace; - }, - thread: async ({ project, title, messages, model = "multi-turn" }) => { - // Exit code 2 means the turn waits for an answer, which a scenario may want to start from. - const turn = (args: ReadonlyArray) => - cli(["thread", ...args, "--wait", "--json"]).catch( - (error: { code?: unknown; stdout?: string }) => { - if (error.code !== 2 || error.stdout === undefined) throw error; - return { stdout: error.stdout }; - }, - ); - const [first = "Start", ...rest] = messages; - const replay = ["--provider", "devReplay", "--model", model]; - const started = await turn(["start", project, first, "--title", title, ...replay]); - const { threadId } = JSON.parse(started.stdout) as { threadId: string }; - for (const message of rest) await turn(["send", threadId, message]); - }, - }; + + const project = Effect.fn("scenario.project")(function* ( + title: string, + files: Readonly>, + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Its own folder per test: the server and this home outlive one test. + yield* fs.makeDirectory(path.join(home, "workspaces"), { recursive: true }); + const parent = yield* fs.makeTempDirectory({ + directory: path.join(home, "workspaces"), + prefix: "w-", + }); + const workspace = path.join(parent, title); + for (const [file, content] of Object.entries({ "README.md": "", ...files })) { + yield* fs.makeDirectory(path.dirname(path.join(workspace, file)), { recursive: true }); + yield* fs.writeFileString(path.join(workspace, file), content); + } + const git = (args: ReadonlyArray) => run("git", args, { cwd: workspace }); + const author = ["-c", "user.name=Scenario", "-c", "user.email=scenario@example.invalid"]; + yield* git(["init", "--quiet", "--initial-branch=main"]); + yield* git(["add", "--all"]); + yield* git([...author, "commit", "--quiet", "--message", "Initial commit"]); + yield* cli(["project", "add", workspace, "--title", title]); + projects.push(workspace); + return workspace; + }); + + const thread = Effect.fn("scenario.thread")(function* (input: { + readonly project: string; + readonly title: string; + readonly messages: ReadonlyArray; + readonly model?: string; + }) { + // Exit code 2 means the turn waits for an answer, which a scenario may want to start from. + const turn = (args: ReadonlyArray) => + cli(["thread", ...args, "--wait", "--json"], [0, 2]); + const [first = "Start", ...rest] = input.messages; + const replay = ["--provider", "devReplay", "--model", input.model ?? "multi-turn"]; + const started = yield* turn(["start", input.project, first, "--title", input.title, ...replay]); + const { threadId } = yield* decodeLaunched(started.stdout); + for (const message of rest) yield* turn(["send", threadId, message]); + }); + return { - state, cli, + state: { + project: (title, files = {}) => runPromise(project(title, files)), + thread: (input) => runPromise(thread(input)), + } satisfies T3State, // The server outlives the test, so the next one must not see this one's projects. - cleanup: async () => { - for (const workspace of projects) await cli(["project", "remove", workspace, "--force"]); - }, + cleanup: Effect.forEach( + projects, + (workspace) => cli(["project", "remove", workspace, "--force"]), + { discard: true }, + ), }; } +const readPairingPath = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fs.readFileString(path.join(T3_HOME, "pairing-path")); +}); + export const test = base.extend<{ t3: T3 }>({ t3: async ({ app }, use) => { const { state, cleanup } = makeState(T3_HOME); await use({ ...state, open: async (path = "/") => { - await app.open(NodeFS.readFileSync(NodePath.join(T3_HOME, "pairing-path"), "utf8")); + await app.open(await runPromise(readPairingPath)); if (path !== "/") await app.open(path); }, }); - await cleanup(); + await runPromise(cleanup); }, }); From 00bd944d93489ac7c926e767b15381b482600024 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 15:00:43 -0800 Subject: [PATCH 6/6] fix(scripts): a proof needs exactly one test in the scenario file Reading only the first executed test could report a fix as proven while another test in the same file failed. Co-Authored-By: Claude Fable 5.2 --- docs/operations/scenarios.md | 2 ++ scripts/prove-scenario.test.ts | 9 +++++++++ scripts/prove-scenario.ts | 9 +++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/operations/scenarios.md b/docs/operations/scenarios.md index aeb1fd03608bd..c7a753f8ae2b5 100644 --- a/docs/operations/scenarios.md +++ b/docs/operations/scenarios.md @@ -49,6 +49,8 @@ whose schema differs. New threads default to the replay agent's `showcase` recor message and send it. To play something else, add a recording to the [replay agent](development.md#replay-provider). +Write one test per file: a proof runs the file and needs exactly one test in it. + Take an `app.screenshot("label")` where the bug shows. The proof pairs base and head captures by label. e2e adds a capture at the point of failure itself. diff --git a/scripts/prove-scenario.test.ts b/scripts/prove-scenario.test.ts index bfadf91bea9cc..51fc2f3ece7d0 100644 --- a/scripts/prove-scenario.test.ts +++ b/scripts/prove-scenario.test.ts @@ -35,6 +35,8 @@ const passed = report(0); const failed = report(1, { code: "ASSERTION_FAILED", message: "expected: focused" }); // The control the scenario clicks is missing: the scenario is broken, the bug is not shown. const brokenScenario = report(1, { code: "LOCATOR_NOT_FOUND", message: "no node" }); +// A file with two tests: the first passes, so reading only it would hide the second. +const twoTests = { run: { results: [...passed.run.results, ...failed.run.results] } }; // e2e exits 0 when the test skips itself at runtime. const skipped = report(0, undefined, "skipped"); @@ -64,6 +66,13 @@ describe("prove-scenario", () => { passesOnHead: false, verdict: /could not run on head/, }, + { + base: failed, + head: twoTests, + failsOnBase: true, + passesOnHead: false, + verdict: /could not run on head .*exactly one/, + }, // e2e writes no report when the app does not start. { base: undefined, diff --git a/scripts/prove-scenario.ts b/scripts/prove-scenario.ts index 20921009ec55a..6b0bb9c1a44f6 100644 --- a/scripts/prove-scenario.ts +++ b/scripts/prove-scenario.ts @@ -66,10 +66,15 @@ export function readSide( report: E2EReport | undefined, ): SideResult { // The report also lists the tests the run did not select, with no attempts. - const result = report?.run.results.find((candidate) => candidate.attempts.length > 0); + const executed = report?.run.results.filter((candidate) => candidate.attempts.length > 0) ?? []; + // One scenario is one test: with several, a pass in the first could hide a failure in another. + const result = executed.length === 1 ? executed[0] : undefined; const attempt = result?.attempts.at(-1); if (result === undefined || attempt === undefined) { - const message = "e2e wrote no report: the app did not start, or no test was selected"; + const message = + executed.length > 1 + ? `the scenario file ran ${executed.length} tests; a proof needs exactly one` + : "e2e wrote no report: the app did not start, or no test was selected"; return { sha, status: "error", title: "", message, steps: [], captures: [], videos: [] }; } const files = (kind: string) =>