From 2ab67e72d3333403d144241fc4a6d0e28371a46f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 09:53:56 +0200 Subject: [PATCH 1/9] fix(client): waitForSession misses a claim that lands before it subscribes --- CHANGELOG.md | 2 ++ .../appduct/src/__tests__/rpc-client.test.ts | 30 +++++++++++++++++++ packages/appduct/src/client/bootstrap.ts | 26 ++++++++-------- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 131e4b79..461dcb45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ section into a versioned heading. ## Unreleased +- **Fix: `waitForSession()` from `appduct/client` no longer sometimes times out when the app claims the session right after the wait starts.** + ## 0.15.1 (2026-10-15) - **Fix: a React Native build that leaves out Appduct's native module no longer crashes when the app registers a tool.** `registerTool`, `useAppductTool` and the rest of the API now do nothing in that build, as they should, instead of raising a fatal error in Release. diff --git a/packages/appduct/src/__tests__/rpc-client.test.ts b/packages/appduct/src/__tests__/rpc-client.test.ts index f11ec9cf..4bdb46ed 100644 --- a/packages/appduct/src/__tests__/rpc-client.test.ts +++ b/packages/appduct/src/__tests__/rpc-client.test.ts @@ -15,6 +15,7 @@ import { randomUUID } from "node:crypto"; import { afterEach, describe, expect, test } from "vitest"; +import { waitForSession } from "../client/index.js"; import { getStateDirPaths } from "../daemon/state-dir.js"; import { callDaemon, @@ -83,6 +84,8 @@ const startFakeDaemon = async ( pendingLinks?: number; /** Accept the connection but never answer `daemon.status` — an alive-but-wedged daemon. */ stallStatus?: boolean; + /** The reply to any other method: its `result` or its `error`. */ + answer?: (method: string) => { result: unknown } | { error: { code: number; message: string; data?: unknown } }; }, ): Promise => { const paths = getStateDirPaths(stateDir); @@ -163,6 +166,9 @@ const startFakeDaemon = async ( } else if (request.method === "daemon.shutdown") { shutdownCalls += 1; result = { ok: true }; + } else if (options.answer) { + socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: request.id, ...options.answer(request.method) })}\n`); + continue; } else { result = { answeredBy: id }; } @@ -820,3 +826,27 @@ describe("daemon version check: a contended lock is not an ineffective restart ( }); }); + +describe("waitForSession", () => { + test("resolves for a session claimed just before its subscription took effect", async () => { + const stateDir = await makeTempStateDir(); + // The app claims the session as the subscription arrives, too early for the subscription to + // carry a `session_claimed` notification. + let claimed = false; + await startFakeDaemon(stateDir, { + version: "9.9.9", + answer: (method) => { + if (method === "events.subscribe") claimed = true; + if (method !== "sessions.describe") return { result: {} }; + return claimed + ? { result: { sessionId: "s1" } } + : { error: { code: -32000, message: "unknown", data: { type: "unknown_session" } } }; + }, + }); + + const app = await waitForSession("s1", { stateDir, autoSpawn: false, timeoutMs: 1_000 }); + + expect(app.sessionId).toBe("s1"); + app.close(); + }); +}); diff --git a/packages/appduct/src/client/bootstrap.ts b/packages/appduct/src/client/bootstrap.ts index cf395667..94aabd47 100644 --- a/packages/appduct/src/client/bootstrap.ts +++ b/packages/appduct/src/client/bootstrap.ts @@ -131,17 +131,6 @@ export const waitForSession = async ( openDaemonStream({ stateDir, spawn: options.spawn, autoSpawn: options.autoSpawn }); try { - // The session may already be claimed by the time this runs — resolve immediately rather than - // waiting for an event that already happened. - try { - await stream.call(RPC_METHODS.sessionsDescribe, { selector: sessionId }); - return makeAppClient(stream, sessionId, openStream); - } catch (error) { - if (!(error instanceof DaemonRpcError) || error.data?.type !== "unknown_session") { - throw error; - } - } - await new Promise((resolve, reject) => { let settled = false; @@ -196,11 +185,20 @@ export const waitForSession = async ( } }); + // Looks for the session only once subscribed: a claim before the subscription sent no + // notification this stream can see, so it must be found by looking, and a claim after it + // is seen by the listener above. stream .call(RPC_METHODS.eventsSubscribe, { sessionSelector: sessionId, kinds: ["session_claimed"] }) - .catch((error) => { - settle(() => reject(toAppductError(error))); - }); + .then(() => stream.call(RPC_METHODS.sessionsDescribe, { selector: sessionId })) + .then( + () => settle(resolve), + (error) => { + if (!(error instanceof DaemonRpcError) || error.data?.type !== "unknown_session") { + settle(() => reject(toAppductError(error))); + } + }, + ); }); return makeAppClient(stream, sessionId, openStream); From 0cdb7b29c9c9d0b6060a63c3d7d1a5ce102db820 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 10:22:01 +0200 Subject: [PATCH 2/9] feat(e2e): add @appduct/e2e fixture and agent tools for the e2e runner --- AGENTS.md | 1 + CHANGELOG.md | 1 + eslint.config.mjs | 2 +- packages/e2e/README.md | 79 +++ packages/e2e/e2e/.appduct/config.json | 7 + packages/e2e/e2e/.gitignore | 1 + packages/e2e/e2e/README.md | 18 + packages/e2e/e2e/e2e.config.ts | 15 + packages/e2e/e2e/scripted-model.ts | 39 ++ packages/e2e/e2e/tests/tools.e2e.ts | 31 + packages/e2e/package.json | 52 ++ packages/e2e/src/__tests__/session.test.ts | 147 +++++ packages/e2e/src/agent-tools.ts | 53 ++ packages/e2e/src/index.ts | 50 ++ packages/e2e/src/pairing.ts | 63 ++ packages/e2e/src/session.ts | 91 +++ packages/e2e/tsconfig.build.json | 8 + packages/e2e/tsconfig.json | 20 + pnpm-lock.yaml | 735 ++++++++++++++++++++- 19 files changed, 1410 insertions(+), 3 deletions(-) create mode 100644 packages/e2e/README.md create mode 100644 packages/e2e/e2e/.appduct/config.json create mode 100644 packages/e2e/e2e/.gitignore create mode 100644 packages/e2e/e2e/README.md create mode 100644 packages/e2e/e2e/e2e.config.ts create mode 100644 packages/e2e/e2e/scripted-model.ts create mode 100644 packages/e2e/e2e/tests/tools.e2e.ts create mode 100644 packages/e2e/package.json create mode 100644 packages/e2e/src/__tests__/session.test.ts create mode 100644 packages/e2e/src/agent-tools.ts create mode 100644 packages/e2e/src/index.ts create mode 100644 packages/e2e/src/pairing.ts create mode 100644 packages/e2e/src/session.ts create mode 100644 packages/e2e/tsconfig.build.json create mode 100644 packages/e2e/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 338065af..a4172ecf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ cards, slides, diagrams, videos), follow [docs/DESIGN.md](docs/DESIGN.md). | `packages/appduct` | CLI, daemon, MCP server, `appduct/client` (TypeScript) | | `packages/shared` | Wire protocol and domain types shared by CLI and SDK, plus the TypeScript SDK layer (`/sdk`: client, schema conversion, tool groups; `/react`: the hook; `/inert`: the only entry a noop entry may import, no client code) | | `packages/react-native` | React Native SDK, Expo config plugin, Metro helper | +| `packages/e2e` | `@appduct/e2e`: the `appduct` fixture and agent tools for the e2e test runner; `e2e/` is its suite against the playgrounds | | `packages/native` | Framework-free iOS (Swift) and Android (Kotlin) core | | `packages/flutter` | Flutter plugin `appduct`, outside the pnpm workspace (no `package.json`); `lib/src/core/` is a pure-Dart wire codec checked against `packages/native/fixtures` | | `playground`, `playground-native`, `playground-flutter` | Expo app, native apps and a Flutter app (outside the pnpm workspace) registering the same five demo tools | diff --git a/CHANGELOG.md b/CHANGELOG.md index 461dcb45..ae04e93c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ section into a versioned heading. ## Unreleased +- **New: `@appduct/e2e` lets [e2e](https://github.com/tester-army/e2e) tests and their agent call your app's tools.** Write tests with its `test` to get an `appduct` fixture, and add `appductTools()` to an agent; see the [package README](https://github.com/callstackincubator/appduct/tree/main/packages/e2e). - **Fix: `waitForSession()` from `appduct/client` no longer sometimes times out when the app claims the session right after the wait starts.** ## 0.15.1 (2026-10-15) diff --git a/eslint.config.mjs b/eslint.config.mjs index 0b96afe2..01a5bc4f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import tseslint from "typescript-eslint"; const root = path.dirname(fileURLToPath(import.meta.url)); -const PACKAGES = ["packages/appduct/src", "packages/shared/src", "packages/web/src"]; +const PACKAGES = ["packages/appduct/src", "packages/shared/src", "packages/web/src", "packages/e2e/src"]; const SOURCE = PACKAGES.map((p) => `${p}/**/*.ts`); // The device suite is test code throughout: it may reach the OS, but never mock a module. const TESTS = [...PACKAGES.map((p) => `${p}/__tests__/**/*.ts`), "e2e-device/**/*.ts"]; diff --git a/packages/e2e/README.md b/packages/e2e/README.md new file mode 100644 index 00000000..710f9aeb --- /dev/null +++ b/packages/e2e/README.md @@ -0,0 +1,79 @@ +Appduct by Callstack. Your app is an MCP server now: your agent calls its functions over MCP, on iOS, Android, React Native, Flutter and web. + +# Appduct for e2e + +Call your app's Appduct tools from [e2e](https://github.com/tester-army/e2e) tests, and let e2e's agent call them during agent steps. Set up state without tapping through the UI, and check what the app holds instead of only what it shows. + +## Requirements + +- An app that registers tools with an Appduct SDK, in a debug build. Web pages load `@appduct/web` from a development build. +- e2e 0.21 or newer, with its web or mobile engine. +- Web, iOS simulator or Android emulator targets. +- For iOS and Android, the app's URL scheme in `.appduct/config.json` at your e2e project root, as the `appduct` CLI reads it. + +## Getting started + +```bash +npm install -D @appduct/e2e +``` + +Write tests with `test` from `@appduct/e2e` instead of `e2e`. Each test gets an `appduct` fixture: + +```ts +// tests/cart.e2e.ts +import { expect } from "e2e"; +import { test } from "@appduct/e2e"; + +test("a seeded cart checks out", async ({ app, appduct, screen }) => { + await app.open(); + await appduct.call("seed_cart", { items: 3 }); + + await screen.getByRole("button", { name: "Check out" }).tap(); + + expect(await appduct.call("order_status")).toEqual({ status: "paid" }); +}); +``` + +The first `appduct` call connects to the app that the test opened. A test that never uses `appduct` doesn't connect. + +## Let the agent call your tools + +Add `appductTools()` to an agent's `tools` in `e2e.config.ts`: + +```ts +import type { E2EConfig } from "e2e"; +import { appductTools } from "@appduct/e2e"; + +export default { + // targets: … + agents: { default: { model, tools: appductTools() } }, +} satisfies E2EConfig; +``` + +The agent gets `appduct_tools`, which lists the app's tools, and `appduct_call`, which calls one. They work in tests written with `test` from `@appduct/e2e`. + +## The `appduct` fixture + +| Member | What it does | +| --- | --- | +| `call(name, args?, options?)` | Calls a tool and returns its result. Waits up to 10 seconds for a tool the app hasn't registered yet. | +| `tools()` | Lists the app's tools. Waits up to 10 seconds for the app to register its first. | +| `waitForEvent(name, options?)` | Resolves with the next event the app posts under `name`. | + +Errors are `AppductError`s from `appduct/client`, with the same `type` the CLI reports. + +## Things to know + +- **A relaunched app connects again by itself.** After `app.open()`, `app.restart()` or a reload, the next call connects to the new app process. +- **iOS asks before opening a link when several simulators are booted.** With one booted simulator, the link goes straight to the app. With several, or a phone, e2e opens the link and accepts iOS's "Open in …?" question, which adds a few seconds and shows a failed `device.alert` step in the report even though the link opened. +- **On an iPhone, shut down any booted simulator.** With exactly one simulator booted, the link goes to that simulator, and the test times out waiting for the phone. +- **Agent steps that call `appduct_call` run live.** e2e can't replay a call that may change app state from its cache. +- **Tests that run on a device pool connect only through e2e.** Each connection still goes to the device the test runs on, at the slower speed above. + +## Made with ❤️ at Callstack + +`appduct` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi! + +Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥 + +[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=appduct&utm_term=readme-with-love diff --git a/packages/e2e/e2e/.appduct/config.json b/packages/e2e/e2e/.appduct/config.json new file mode 100644 index 00000000..40f6435a --- /dev/null +++ b/packages/e2e/e2e/.appduct/config.json @@ -0,0 +1,7 @@ +{ + "scheme": "appduct-native", + "appId": { + "ios": "com.callstack.appduct.playgroundnative", + "android": "com.callstack.appduct.playground" + } +} diff --git a/packages/e2e/e2e/.gitignore b/packages/e2e/e2e/.gitignore new file mode 100644 index 00000000..8bf17f82 --- /dev/null +++ b/packages/e2e/e2e/.gitignore @@ -0,0 +1 @@ +.e2e/ diff --git a/packages/e2e/e2e/README.md b/packages/e2e/e2e/README.md new file mode 100644 index 00000000..7c9fe16a --- /dev/null +++ b/packages/e2e/e2e/README.md @@ -0,0 +1,18 @@ +# @appduct/e2e suite + +Runs `@appduct/e2e` through the e2e CLI against the playgrounds, the way a user's suite would. +`scripted-model.ts` stands in for a model, so the agent-tool test needs no provider. Not part of +`pnpm test`: it needs a browser, a running playground and, for mobile, a simulator or emulator. + +```bash +pnpm build +pnpm --filter appduct exec playwright-core install chromium # once +pnpm playground:web -- --port 5199 # in another terminal +cd packages/e2e/e2e +../node_modules/.bin/e2e run --config ./e2e.config.ts --target web +``` + +For `--target ios` or `--target android`, install `playground-native` on the device first (see +`e2e-device/README.md`) and pin the device with `IOS_DEVICE=` or `ANDROID_DEVICE=`. +Set `APPDUCT_STATE_DIR` to a scratch directory holding `{"wssPort":0}` in `config.json` to keep the +suite's daemon away from yours. diff --git a/packages/e2e/e2e/e2e.config.ts b/packages/e2e/e2e/e2e.config.ts new file mode 100644 index 00000000..d31764ea --- /dev/null +++ b/packages/e2e/e2e/e2e.config.ts @@ -0,0 +1,15 @@ +import type { E2EConfig } from "e2e"; +import { mobile } from "@e2e-dev/mobile"; +import { web } from "@e2e-dev/web"; +import { appductTools } from "@appduct/e2e"; +import { scriptedModel } from "./scripted-model.ts"; + +export default { + targets: [ + { name: "web", engine: web(), app: { url: process.env.APP_URL ?? "http://localhost:5199" } }, + { name: "android", engine: mobile({ platform: "android", device: process.env.ANDROID_DEVICE }), app: { bundleId: "com.callstack.appduct.playground" } }, + { name: "ios", engine: mobile({ platform: "ios", device: process.env.IOS_DEVICE }), app: { bundleId: "com.callstack.appduct.playgroundnative" } }, + ], + agents: { default: { model: scriptedModel(), tools: appductTools() } }, + workers: 1, +} satisfies E2EConfig; diff --git a/packages/e2e/e2e/scripted-model.ts b/packages/e2e/e2e/scripted-model.ts new file mode 100644 index 00000000..8da846f6 --- /dev/null +++ b/packages/e2e/e2e/scripted-model.ts @@ -0,0 +1,39 @@ +// A stand-in model that plays a fixed script of tool calls, so the agent-tool path runs without a +// model provider. It lists the app's tools, calls `sum`, then concludes the step. +import { MockLanguageModelV4 } from "ai/test"; + +const SCRIPT = [ + { toolName: "appduct_tools", input: {} }, + { toolName: "appduct_call", input: { name: "sum", args: { a: 2, b: 3 } } }, + { toolName: "complete_step", input: { status: "passed", summary: "Called sum through Appduct." } }, +]; + +const usage = { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, +}; + +const nextCall = (prompt: { role: string }[]) => { + const step = SCRIPT[Math.min(prompt.filter((m) => m.role === "tool").length, SCRIPT.length - 1)]!; + return { type: "tool-call" as const, toolCallId: `call-${Math.random()}`, toolName: step.toolName, input: JSON.stringify(step.input) }; +}; + +export const scriptedModel = () => + new MockLanguageModelV4({ + doGenerate: async ({ prompt }) => ({ + content: [nextCall(prompt)], + finishReason: { unified: "tool-calls", raw: undefined }, + usage, + warnings: [], + }), + doStream: async ({ prompt }) => ({ + stream: new ReadableStream({ + start(controller) { + controller.enqueue({ type: "stream-start", warnings: [] }); + controller.enqueue(nextCall(prompt)); + controller.enqueue({ type: "finish", finishReason: { unified: "tool-calls", raw: undefined }, usage }); + controller.close(); + }, + }), + }), + }); diff --git a/packages/e2e/e2e/tests/tools.e2e.ts b/packages/e2e/e2e/tests/tools.e2e.ts new file mode 100644 index 00000000..833a67c7 --- /dev/null +++ b/packages/e2e/e2e/tests/tools.e2e.ts @@ -0,0 +1,31 @@ +import { expect } from "e2e"; +import { test } from "@appduct/e2e"; + +test("calls a tool in the running app", async ({ app, appduct }) => { + await app.open(); + const result = await appduct.call("sum", { a: 2, b: 3 }); + expect(result).toEqual({ total: 5 }); +}); + +test("a tool call changes what the screen shows", async ({ app, appduct, screen, platform }) => { + await app.open(); + await appduct.call("reset_counter"); + await appduct.call("sum", { a: 1, b: 1 }); + await appduct.call("sum", { a: 1, b: 1 }); + expect(await appduct.call("call_count")).toEqual({ count: 2 }); + if (platform === "web") await expect(screen.getByText("Counted calls: 2")).toBeVisible(); + else await expect(screen.getByTestId("call-count")).toHaveText("2"); +}); + +test("lists the registered tools", async ({ app, appduct }) => { + await app.open(); + const names = (await appduct.tools()).map((t) => t.name); + expect(names).toEqual(expect.arrayContaining(["sum", "call_count", "reset_counter"])); +}); + +test("the agent calls the app's tools", async ({ app, appduct, agent }) => { + await app.open(); + await appduct.call("reset_counter"); + await agent.act("add 2 and 3 with the app's sum tool"); + expect(await appduct.call("call_count")).toEqual({ count: 1 }); +}); diff --git a/packages/e2e/package.json b/packages/e2e/package.json new file mode 100644 index 00000000..593a7e35 --- /dev/null +++ b/packages/e2e/package.json @@ -0,0 +1,52 @@ +{ + "name": "@appduct/e2e", + "version": "0.15.1", + "description": "Use Appduct in e2e tests: call your app's tools from a test or let e2e's agent call them.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "clean": "rm -rf dist", + "lint": "eslint src", + "test": "vitest run --config ../../vitest.config.ts src", + "typecheck": "tsc -p tsconfig.json" + }, + "repository": "https://github.com/callstackincubator/appduct", + "bugs": { + "url": "https://github.com/callstackincubator/appduct/issues" + }, + "author": "Szymon Chmal (V3RON)", + "license": "MIT", + "dependencies": { + "appduct": "workspace:*" + }, + "peerDependencies": { + "ai": "^7.0.0", + "e2e": ">=0.21.0 <1" + }, + "devDependencies": { + "@e2e-dev/mobile": "0.12.3", + "@e2e-dev/web": "0.15.2", + "@types/node": "^22.10.0", + "ai": "^7.0.0", + "e2e": "0.21.2", + "typescript": "~5.9.2" + }, + "homepage": "https://github.com/callstackincubator/appduct/tree/main/packages/e2e#readme", + "files": [ + "dist" + ], + "keywords": [ + "appduct", + "e2e", + "testing" + ] +} diff --git a/packages/e2e/src/__tests__/session.test.ts b/packages/e2e/src/__tests__/session.test.ts new file mode 100644 index 00000000..639f4163 --- /dev/null +++ b/packages/e2e/src/__tests__/session.test.ts @@ -0,0 +1,147 @@ +import { AppductError, type AppClient, type ListedToolDescriptor } from "appduct/client"; +import { describe, expect, it } from "vitest"; + +import { createSession, type Clock } from "../session.js"; + +/** A clock whose time moves only when the code under test sleeps. */ +const fakeClock = (): Clock & { elapsed(): number } => { + let now = 0; + return { now: () => now, sleep: async (ms) => void (now += ms), elapsed: () => now }; +}; + +type FakeApp = { + call?: (name: string, args: Record) => Promise; + tools?: () => Promise; +}; + +/** An app client whose answers the test scripts, recording whether it was closed. */ +const fakeClient = (app: FakeApp = {}): AppClient & { closed: boolean } => { + const client = { + sessionId: "s", + closed: false, + call: app.call ?? (async () => ({ ok: true })), + tools: app.tools ?? (async () => [{ name: "sum" } as ListedToolDescriptor]), + events: async () => ({ events: [], cursor: 0, dropped: 0, remaining: 0 }), + waitForEvent: async (name: string) => ({ name }), + close: () => void (client.closed = true), + }; + return client as unknown as AppClient & { closed: boolean }; +}; + +/** A pairing that hands out `clients` in order and counts how often it ran. */ +const pairings = (...clients: AppClient[]) => { + let count = 0; + const pair = async () => { + const client = clients[count++]; + if (!client) throw new Error("paired more often than expected"); + return client; + }; + return { pair, count: () => count }; +}; + +const gone = () => new AppductError("session_suspended", "The app went away."); +const missing = () => new AppductError("tool_not_found", 'Tool "sum" is not registered.'); + +describe("createSession", () => { + it("does not pair until the test uses Appduct", () => { + const { pair, count } = pairings(); + createSession(pair, fakeClock()).close(); + expect(count()).toBe(0); + }); + + it("pairs once for every call in a test", async () => { + const { pair, count } = pairings(fakeClient({ call: async (_name, args) => args })); + const session = createSession(pair, fakeClock()); + + const results = await Promise.all([session.call("sum", { a: 1 }), session.call("sum", { a: 2 })]); + await session.tools(); + + expect(results).toEqual([{ a: 1 }, { a: 2 }]); + expect(count()).toBe(1); + }); + + it("pairs again and repeats the call when the app's session has gone", async () => { + const first = fakeClient({ call: () => Promise.reject(gone()) }); + const second = fakeClient({ call: async () => ({ total: 3 }) }); + const { pair, count } = pairings(first, second); + const session = createSession(pair, fakeClock()); + + expect(await session.call("sum", { a: 1, b: 2 })).toEqual({ total: 3 }); + expect(count()).toBe(2); + expect(first.closed).toBe(true); + }); + + it("pairs again only once when the new session has gone too", async () => { + const lost = () => fakeClient({ call: () => Promise.reject(gone()) }); + const { pair, count } = pairings(lost(), lost(), lost()); + const session = createSession(pair, fakeClock()); + + await expect(session.call("sum")).rejects.toMatchObject({ type: "session_suspended" }); + expect(count()).toBe(2); + }); + + it("pairs again on the next use after a pairing fails", async () => { + let attempts = 0; + const session = createSession(async () => { + attempts += 1; + if (attempts === 1) throw new AppductError("timeout", "No claim."); + return fakeClient(); + }, fakeClock()); + + await expect(session.call("sum")).rejects.toMatchObject({ type: "timeout" }); + await expect(session.call("sum")).resolves.toEqual({ ok: true }); + }); + + it("waits for a tool the app registers after the call is made", async () => { + let calls = 0; + const app = fakeClient({ call: async () => (++calls < 3 ? Promise.reject(missing()) : { total: 3 }) }); + const session = createSession(pairings(app).pair, fakeClock()); + + expect(await session.call("sum")).toEqual({ total: 3 }); + }); + + it("fails with tool_not_found when the tool is still missing after ten seconds", async () => { + const clock = fakeClock(); + const session = createSession(pairings(fakeClient({ call: () => Promise.reject(missing()) })).pair, clock); + + await expect(session.call("sum")).rejects.toMatchObject({ type: "tool_not_found" }); + expect(clock.elapsed()).toBeGreaterThanOrEqual(10_000); + expect(clock.elapsed()).toBeLessThan(10_100); + }); + + it("does not wait on a tool that fails", async () => { + const clock = fakeClock(); + const failing = fakeClient({ call: () => Promise.reject(new AppductError("tool_execution_error", "Boom.")) }); + const session = createSession(pairings(failing).pair, clock); + + await expect(session.call("sum")).rejects.toMatchObject({ type: "tool_execution_error" }); + expect(clock.elapsed()).toBe(0); + }); + + it("lists tools once the app has registered its first", async () => { + let lists = 0; + const app = fakeClient({ tools: async () => (++lists < 3 ? [] : [{ name: "sum" } as ListedToolDescriptor]) }); + const session = createSession(pairings(app).pair, fakeClock()); + + expect((await session.tools()).map((tool) => tool.name)).toEqual(["sum"]); + }); + + it("lists no tools for an app that registers none in ten seconds", async () => { + const clock = fakeClock(); + const session = createSession(pairings(fakeClient({ tools: async () => [] })).pair, clock); + + expect(await session.tools()).toEqual([]); + expect(clock.elapsed()).toBeGreaterThanOrEqual(10_000); + }); + + it("closes the paired client when the test ends", async () => { + const app = fakeClient(); + const session = createSession(pairings(app).pair, fakeClock()); + await session.call("sum"); + + session.close(); + await Promise.resolve(); + + expect(app.closed).toBe(true); + }); +}); diff --git a/packages/e2e/src/agent-tools.ts b/packages/e2e/src/agent-tools.ts new file mode 100644 index 00000000..cfd87381 --- /dev/null +++ b/packages/e2e/src/agent-tools.ts @@ -0,0 +1,53 @@ +import { jsonSchema, tool } from "ai"; +import { defineTool } from "e2e/agent"; + +import type { Appduct } from "./session.js"; + +/** The session of the running test. e2e runs one test at a time in a worker. */ +let current: Appduct | undefined; + +/** Points the agent tools at `session` until the returned function is called. */ +export const offerToAgent = (session: Appduct): (() => void) => { + current = session; + return () => { + if (current === session) current = undefined; + }; +}; + +const session = (): Appduct => { + if (!current) throw new Error("No Appduct session in this test. Write the test with `test` from '@appduct/e2e'."); + return current; +}; + +/** + * Tools for e2e's agent that call the tools the app registered with Appduct. Add them to an + * agent's `tools` in `e2e.config.ts`; they work in tests written with `test` from `@appduct/e2e`. + */ +export const appductTools = () => ({ + appduct_tools: defineTool( + tool({ + description: + "List the tools the app under test registered with Appduct: what each one does and the arguments it takes. Use one with appduct_call to read or set app state directly instead of through the screen.", + inputSchema: jsonSchema>({ type: "object", properties: {}, additionalProperties: false }), + execute: () => session().tools(), + }), + { mutates: false }, + ), + appduct_call: defineTool( + tool({ + description: "Call a tool the app under test registered with Appduct and get its result. List the tools with appduct_tools first.", + inputSchema: jsonSchema<{ name: string; args?: Record }>({ + type: "object", + properties: { + name: { type: "string", description: "The tool's name, as appduct_tools lists it." }, + args: { type: "object", description: "The tool's arguments, matching its input schema." }, + }, + required: ["name"], + additionalProperties: false, + }), + execute: ({ name, args }) => session().call(name, args), + }), + // A tool may change app state, which e2e cannot replay from its cache. + { mutates: true }, + ), +}); diff --git a/packages/e2e/src/index.ts b/packages/e2e/src/index.ts new file mode 100644 index 00000000..0e2519de --- /dev/null +++ b/packages/e2e/src/index.ts @@ -0,0 +1,50 @@ +/** + * `@appduct/e2e`: Appduct in e2e (https://github.com/tester-army/e2e) tests. `test` gives every + * test an `appduct` fixture that calls the app's tools, and `appductTools()` gives e2e's agent the + * same reach. + * + * ```ts + * import { test } from "@appduct/e2e"; + * + * test("checks out", async ({ app, appduct, agent }) => { + * await app.open(); + * await appduct.call("seed_cart", { sku: "SHIRT" }); + * await agent.act("check out"); + * expect(await appduct.call("order_status")).toEqual({ status: "paid" }); + * }); + * ``` + */ +import { test as base, type TestFixtures } from "e2e"; + +import { offerToAgent } from "./agent-tools.js"; +import { pairWith, type PairingBrowser, type PairingDevice, type PairingTarget } from "./pairing.js"; +import { createSession, type Appduct, type Clock } from "./session.js"; + +export { appductTools } from "./agent-tools.js"; +export type { Appduct, EventWait } from "./session.js"; + +const clock: Clock = { + now: () => Date.now(), + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), +}; + +type EngineFixtures = TestFixtures & { readonly browser: PairingBrowser; readonly device: PairingDevice }; + +/** Reads the engine's fixture for the platform only when pairing, since e2e acquires fixtures on access. */ +const targetOf = (fixtures: EngineFixtures): PairingTarget => { + const { platform } = fixtures; + if (platform === "web") return { platform, browser: fixtures.browser }; + if (platform === "ios" || platform === "android") return { platform, device: fixtures.device }; + throw new Error(`Appduct pairs with web, iOS and Android targets, not "${platform}".`); +}; + +/** e2e's `test` with an `appduct` fixture: the app's Appduct session, paired on first use. */ +export const test = base.extend<{ appduct: Appduct }>({ + appduct: async (fixtures, use) => { + const session = createSession(() => pairWith(targetOf(fixtures as EngineFixtures)), clock); + const withdraw = offerToAgent(session); + await use(session); + withdraw(); + session.close(); + }, +}); diff --git a/packages/e2e/src/pairing.ts b/packages/e2e/src/pairing.ts new file mode 100644 index 00000000..bf061cc6 --- /dev/null +++ b/packages/e2e/src/pairing.ts @@ -0,0 +1,63 @@ +import { AppductError, link, waitForSession, type AppClient } from "appduct/client"; + +/** The part of e2e's `browser` fixture pairing uses. */ +export type PairingBrowser = { + url(): Promise; + evaluate(script: string): Promise; +}; + +/** The part of e2e's `device` fixture pairing uses. */ +export type PairingDevice = { + openLink(url: string): Promise; + alert(action: "accept"): Promise; +}; + +export type PairingTarget = + | { platform: "web"; browser: PairingBrowser } + | { platform: "ios" | "android"; device: PairingDevice }; + +/** A web page claims at once; a device may still be launching the app. */ +const CLAIM_TIMEOUT_MS = 60_000; + +/** Connects the page in the test's browser, which must load `@appduct/web`. */ +const linkPage = async (browser: PairingBrowser): Promise => { + const { sessionId, script } = await link({ target: "web", url: await browser.url() }); + await browser.evaluate(`(${script}, null)`); + return sessionId; +}; + +/** Has e2e open the app's deep link, launching the app if it is not running. */ +const openDeepLink = async (device: PairingDevice): Promise => { + const { sessionId, deepLink } = await link(); + await device.openLink(deepLink); + return sessionId; +}; + +/** + * Opens the link on the simulator itself when exactly one is booted, which is quick and asks + * nothing. A link e2e opens for the test makes iOS ask "Open in …?", so that path, taken when + * several simulators are booted or the device is a phone, accepts the question as well. + */ +const linkIosApp = async (device: PairingDevice): Promise => { + try { + return (await link({ target: "ios-sim" })).sessionId; + } catch (error) { + if (!(error instanceof AppductError && error.type === "client_error")) throw error; + } + const sessionId = await openDeepLink(device); + // agent-device reports a failure for an accepted question too, after it closes, so the outcome is + // read from the claim instead. + await device.alert("accept").catch(() => undefined); + return sessionId; +}; + +/** Hands the app under test a fresh link and resolves once the app has claimed it. */ +export const pairWith = async (target: PairingTarget): Promise => { + const sessionId = + target.platform === "web" + ? await linkPage(target.browser) + : target.platform === "ios" + ? await linkIosApp(target.device) + : await openDeepLink(target.device); + return waitForSession(sessionId, { timeoutMs: CLAIM_TIMEOUT_MS }); +}; diff --git a/packages/e2e/src/session.ts b/packages/e2e/src/session.ts new file mode 100644 index 00000000..b6ce4a64 --- /dev/null +++ b/packages/e2e/src/session.ts @@ -0,0 +1,91 @@ +import { AppductError, type AppClient, type CallOptions, type FullAppEvent, type ListedToolDescriptor, type WaitForEventOptions } from "appduct/client"; + +/** The app's Appduct session in one e2e test, paired the first time a test or agent step uses it. */ +export type Appduct = { + /** Calls a tool the app registered, waiting up to 10 s for a tool the app has not registered yet. */ + call(name: string, args?: Record, options?: CallOptions): Promise; + /** The tools the app registered, waiting up to 10 s for the app to register its first. */ + tools(): Promise; + /** Resolves with the next event the app posts under `name`. */ + waitForEvent(name: string, options?: EventWait): Promise>; +}; + +/** How long to wait and which events count; the payload always arrives in full. */ +export type EventWait = Omit; + +export type Clock = { + now(): number; + sleep(ms: number): Promise; +}; + +/** An app registers its tools just after it claims a session, and a screen may register more as it opens. */ +const TOOL_WAIT_MS = 10_000; +const POLL_MS = 50; + +const isGone = (error: unknown): boolean => + error instanceof AppductError && + (error.type === "session_suspended" || error.type === "session_not_active" || error.type === "unknown_session"); + +/** + * Pairs with `pair` on first use and again whenever the session has gone, as it does when the + * test relaunches the app or reloads the page. + */ +export const createSession = (pair: () => Promise, clock: Clock): Appduct & { close(): void } => { + let pairing: Promise | undefined; + + const forget = (stale: Promise) => { + if (pairing === stale) pairing = undefined; + }; + + const paired = (): Promise => { + if (!pairing) { + const attempt = pair(); + attempt.catch(() => forget(attempt)); // so the next use pairs again + pairing = attempt; + } + return pairing; + }; + + /** Runs `use` on the paired client, pairing once more if the session has gone. */ + const withClient = async (use: (client: AppClient) => Promise): Promise => { + const current = paired(); + const client = await current; + try { + return await use(client); + } catch (error) { + if (!isGone(error)) throw error; + client.close(); + forget(current); + return use(await paired()); + } + }; + + return { + call: async (name, args = {}, options) => { + const deadline = clock.now() + TOOL_WAIT_MS; + for (;;) { + try { + return await withClient((client) => client.call(name, args, options)); + } catch (error) { + const missing = error instanceof AppductError && error.type === "tool_not_found"; + if (!missing || clock.now() >= deadline) throw error; + await clock.sleep(POLL_MS); + } + } + }, + tools: async () => { + const deadline = clock.now() + TOOL_WAIT_MS; + for (;;) { + const tools = await withClient((client) => client.tools()); + if (tools.length > 0 || clock.now() >= deadline) return tools; + await clock.sleep(POLL_MS); + } + }, + waitForEvent: (name: string, options?: EventWait) => + withClient((client) => client.waitForEvent(name, options)), + close: () => { + pairing?.then((client) => client.close(), () => undefined); + pairing = undefined; + }, + }; +}; diff --git a/packages/e2e/tsconfig.build.json b/packages/e2e/tsconfig.build.json new file mode 100644 index 00000000..4696c6db --- /dev/null +++ b/packages/e2e/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declarationMap": false + }, + "exclude": ["src/**/__tests__/**"] +} diff --git a/packages/e2e/tsconfig.json b/packages/e2e/tsconfig.json new file mode 100644 index 00000000..31b2d303 --- /dev/null +++ b/packages/e2e/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "strict": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eabe2228..50a205b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: 22.20.1 agent-device: specifier: 0.21.23 - version: 0.21.23 + version: 0.21.23(ai@7.0.146(zod@4.6.5)) appduct: specifier: workspace:* version: link:../packages/appduct @@ -113,6 +113,31 @@ importers: specifier: ^5.111.1 version: 5.111.1(esbuild@0.25.12) + packages/e2e: + dependencies: + appduct: + specifier: workspace:* + version: link:../appduct + devDependencies: + '@e2e-dev/mobile': + specifier: 0.12.3 + version: 0.12.3(ai@7.0.146(zod@4.6.5))(e2e@0.21.2(ai@7.0.146(zod@4.6.5))) + '@e2e-dev/web': + specifier: 0.15.2 + version: 0.15.2(e2e@0.21.2(ai@7.0.146(zod@4.6.5))) + '@types/node': + specifier: ^22.10.0 + version: 22.20.1 + ai: + specifier: ^7.0.0 + version: 7.0.146(zod@4.6.5) + e2e: + specifier: 0.21.2 + version: 0.21.2(ai@7.0.146(zod@4.6.5)) + typescript: + specifier: ~5.9.2 + version: 5.9.3 + packages/react-native: dependencies: '@appduct/shared': @@ -374,6 +399,26 @@ packages: graphql: optional: true + '@ai-sdk/gateway@4.0.119': + resolution: {integrity: sha512-CJczvdtOAW9URKHY74TAup1CPc+HTB0lbcbd0mPTweiJ64tk6T3BFxREixBwfNDP6d0XIzdBWucJnDL47gW0XA==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.64': + resolution: {integrity: sha512-BDU8rDR6mBR+na5U/Dj4B33LX980x200lqhBGRIOxxx1NFunoixuvvweqIB+sCsCoFC8cGCsa82Ce2fKoAPOqw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@4.0.26': + resolution: {integrity: sha512-BZ443uBEq6HuMA+Z4b7Hpm65GpZSo7pWqnYqpf/7tAO3C48JbtvAhOE2JnODFj+jodsZzfRHkAyQb64xuD2tZQ==} + engines: {node: '>=22'} + + '@ai-sdk/provider@4.0.31': + resolution: {integrity: sha512-3wJnwVTFg8SwOyoZCv+ov7Re2GB+7IQnU5a5MYJACrtiWDDUHgqTBeeesq2MB2ddgojzL8hgWleltwpAgtUqfg==} + engines: {node: '>=22'} + '@astrojs/astro2tsx@0.1.0': resolution: {integrity: sha512-tgprkax8mcF+BiHJQBdoQ+REqtvvnLlYzV0yCeLMdcv3pJIutdlinWNHUzN1jMOT5VRlYTtMmDdaoPrtd3cBAQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1084,6 +1129,23 @@ packages: resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} engines: {node: '>=14'} + '@e2e-dev/mobile@0.12.3': + resolution: {integrity: sha512-qlR3S0yMmwezeN3adrW5CUyHjjNYM1D/8SkxYC+tmXhtiNm6ZfICJbW5niFa6HVye0pWuv0PPSELveqA8z6JWA==} + engines: {node: ^22.22.3 || >=24.8.0} + peerDependencies: + ai: ^7.0.0 + e2e: '>=0.15.0 <1' + peerDependenciesMeta: + ai: + optional: true + + '@e2e-dev/web@0.15.2': + resolution: {integrity: sha512-XidBXbpiEeJQqPsXcEwMOon5KDk8PEaaXR65rDu5P1u115bW+2KK7DlWqYvLg/RpjK/pnOa5iNuD9JthqODLVQ==} + engines: {node: ^22.22.3 || >=24.8.0} + hasBin: true + peerDependencies: + e2e: '>=0.18.0 <1' + '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} @@ -1902,6 +1964,10 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@modelcontextprotocol/core@2.3.1': + resolution: {integrity: sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==} + engines: {node: '>=20'} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -1912,6 +1978,10 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/server@2.3.1': + resolution: {integrity: sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==} + engines: {node: '>=20'} + '@napi-rs/wasm-runtime@1.2.4': resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1938,9 +2008,260 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@oxc-parser/binding-android-arm-eabi@0.153.0': + resolution: {integrity: sha512-BcWu25dMwMjHpaDCEidmpAi7rGg1qZoihJeUUH9bD55pVZ0z4Wa5CQgqSTo0R41H81oozisQKW3WqvAN2m77IQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.153.0': + resolution: {integrity: sha512-9l5rdp2DhRqVriBHsNpu5cEf+hizZ4B58rt93HzyQEBUgZiC6qe2oFpaAAnXGCxHGnQExI1s7S2G0QhFacUZ6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.153.0': + resolution: {integrity: sha512-EcpsyRXHhENDpekpG4QNaSZj2F/BAz4Gh4o89zZwNss86R8KyRSQ6sXbAj74Xyeb4z3j/yBZZ6joQZhraIWyfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.153.0': + resolution: {integrity: sha512-hlj80HUQy07/2lYp6rmxH+3u+H2ik3rjLR52CUdPApxmZCNxASDUf/+ykTzAHS/fehftmVMATKvPaWmuLHuasg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.153.0': + resolution: {integrity: sha512-fWRcv2pMSh1qtez46MOu8vuUJ5fiaQZPHSphbWulbL7SHBMKfRgZl2PzjXZYQJbq7NEXyXbgIuIuUm+/Rmkv8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.153.0': + resolution: {integrity: sha512-paZeUK1GXShl/bOJQET1/8AQxayFeuN4SIWAkgTRxE7ZQwv9uYSB2n0648smygP22N7jcI3zxAm0Z+ALNiIKxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.153.0': + resolution: {integrity: sha512-qFPp5NwMf4K1C0O+DCVGmcbOaYyYAq98Rvv9p4VSfb777iUwlJCqMUxf6QG9N5f5ls4TEX6LitOZHi+6GL6rYQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.153.0': + resolution: {integrity: sha512-oufEhZCX7saeo27Logra8cvBR1jQ/yw2zUI2hHmWw3qA/XYqEW31FOvLQBv7DWN32NjVdlcaP11WBBZTJo4k9g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.153.0': + resolution: {integrity: sha512-zXYzuAqng85h6+LI3iRm0zeMLX1/K2z41ZAMWxVRgt0D10VvZUaV5q+TEB46KDhoKBpdexoTLC6QoB/EiP2oqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.153.0': + resolution: {integrity: sha512-J7+NLwLEzdkKM9cKyPHH/AjXXstWOs98KYgLrPPnO4N9MO8EZRBfPNnBcS3S67uMOBkRpUmCl2FhRnWdQ5CbBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.153.0': + resolution: {integrity: sha512-sD0K11GQTLWylln54nPMMTtgmBbQrR5It/vMpZpy/fogiw8O2AvIE+9LuWUXddrWk9zvFIHGRcSgFn4IHOfoYg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.153.0': + resolution: {integrity: sha512-7L66uql2BhvSz51LwfExjOdn6et/ydTiQxl23VApdKzJhTc3TczA1Qt6VR/UcvlQrL2ZbytIJb447vh7p5wmUw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.153.0': + resolution: {integrity: sha512-hXV3T7mNyrPR19rbPYkglkQJtoM/UNo/xwbZeeqdAxEg8PXgb4LXXuxPpGgI4bzRRCWwLUxdDSS58xIyC2u1TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.153.0': + resolution: {integrity: sha512-Da8Vqs1xdtoDYtEjmozD7wGgJeLBQkMbR0WUBECOSxodyX5DXy8ySlvJWbukyO2Q0+TM9EK+4LT6tbq0jCg4hQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.153.0': + resolution: {integrity: sha512-zGAPxNTIIw6X5WyTRscIFE/oDjpdNtrrl32ing6NOXdGjzRucadnxBilexLdeYQ9YxixF93GujpXyQ0jw7ddSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.153.0': + resolution: {integrity: sha512-GoHzpqP5XyPIp9DHokoEdCp+K4UPl/h1neFj1uXnUB3pJYyOQ7NOhFVOttNUgCGcj2eywAvu5cRivTOSq4S10g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.153.0': + resolution: {integrity: sha512-xGcy12tO9k7zFbeyQzEuilfWfQpyRq23/fLPfN3wjSWuWv58k46fQT1hsO1KFNykZuVGW9xSJE/z592TmAoSDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.153.0': + resolution: {integrity: sha512-o1ykUjWncvXX6N5a4KebgLjtD8zRui+f1llMhSGoSC9oP2KOqapRpdwmZ3NcNG0axFouwVKmhGBjd+IhA8BksA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.153.0': + resolution: {integrity: sha512-gxQz7G96Up136fd+2zWrP+o6FYGs9NO4Dyv55IoeJFJUDwLFHcLYY11hhA5wQKs0HoM57Q8esQ3nEVhnCdsjAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/runtime@0.153.0': + resolution: {integrity: sha512-rXJvtlsVG4xJNq64hiaUTIatE7jbBgpa6l4QgmEa2jLgT3ULH9scUBkpsC9N1DfmqixLEyT6DuK6OSXozziIlw==} + engines: {node: ^20.19.0 || >=22.12.0} + '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + + '@oxc-transform/binding-android-arm-eabi@0.153.0': + resolution: {integrity: sha512-EUYebwgzuoDXutZJYbMdL6vzwnQoJyO3fRWGuo5AXSeKRO2YBySW1u2gcVwAA2lTcFXMPJh2Fw1OM23ZfFQF1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-transform/binding-android-arm64@0.153.0': + resolution: {integrity: sha512-UwwX/zjbEFbLuUdV1+9+SKWhjqwO9tnHqKNQYN4dR3ypK61MFa7s0C2z+vASSSZt7CmcAbl5ArGPcykFiga38A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-transform/binding-darwin-arm64@0.153.0': + resolution: {integrity: sha512-J6r1FDN0eqhrddJ8Fxe01DMABxsRTGzrn0d14eDXT2a6gqj5sdtEOiEksN7FimuqDPrMsX1QGjEacSNAuzboGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-transform/binding-darwin-x64@0.153.0': + resolution: {integrity: sha512-6ad61F1FuyozXDGkPl9VHi/aSOCNUS4ebqXhUqc2JsGXG2S1CFVOsN11u5haNWCLdctbognhzKexK4zpFAsLXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-transform/binding-freebsd-x64@0.153.0': + resolution: {integrity: sha512-eVcvny0FJzAV7Rr49JkWupfQxoaghJ189nwiwQBv//wI52GtDj+IWkrpPEd7x5bCf1GRX8hoBJmHk/QvKS5S/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-transform/binding-linux-arm-gnueabihf@0.153.0': + resolution: {integrity: sha512-8DiPMKx18GaMvt4KvHxHb7kHbOnZL9D0Xjxofc+N67RgeWNDdXiqLaUXVmptpOXmIK+sG4+RtQtHGrAt7fnyDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform/binding-linux-arm-musleabihf@0.153.0': + resolution: {integrity: sha512-k1MDve56Sml/O8f9EeeRecZtMdzAQZYtq8mqShtzyJ/LvLQ5KKXnyzlZxQYjoMsRJwzsdkWNHE2svNQxtpyBaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-transform/binding-linux-arm64-gnu@0.153.0': + resolution: {integrity: sha512-N8ZvBN7uhlqRgxiqwhgpF7KAaIj5qT/4cGMscxleipj90tbtYOO3rAqxiBuABCrvoosrLX4HbJ39gQX9verUiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-arm64-musl@0.153.0': + resolution: {integrity: sha512-OIRZqffxi0yam6aMzIV0lAV5JsMeGs1cQRTwco0qzeAFTKkVaFhpUWXdw1j4BXDDTqbJ3PWi+oQaYzLcGNiaFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-transform/binding-linux-ppc64-gnu@0.153.0': + resolution: {integrity: sha512-KLf7dDN7zU6MFhka9zUHmJw8fvNRSxxGgLDelZwKh0iuaRjUSibKFQ4ZQd0/Rk5ybZbJgqDNFH5xeVU4Cd6ZfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-riscv64-gnu@0.153.0': + resolution: {integrity: sha512-WByPPuG7ndXq0tb12P6eHzzolS9Q+ioBmUF99x1FfOiya5nkuc6bjjixq7ZgYezNiSxkw7UsfCgcBZwtNYajkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-riscv64-musl@0.153.0': + resolution: {integrity: sha512-yXM0cc0mtvIH9keD/9oULanLhVXN8Rf8cvSO5OO2vVrGYs5b0DwkTStztvwn1JQc1eQxPru9iKpZSJq6R4kURA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-transform/binding-linux-s390x-gnu@0.153.0': + resolution: {integrity: sha512-fqwaM2IMmOI0L7DEUWxWtJzGPyvr9ciIOXhB1vp8gkQ97j6xUCddJrEeUJTOAyJyYvHeT0rf03rWciASK4FqUw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-x64-gnu@0.153.0': + resolution: {integrity: sha512-q0yrehYszYqbJfBW0LeUeYvI+NUMwD5p/6skX05EHVQsOJCUTdYKI2wEN2b3nBRUVYxf030pFdOtXJgpZatf/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-transform/binding-linux-x64-musl@0.153.0': + resolution: {integrity: sha512-+e0dMTEE7azbGjnJSG97BIQ/CtHM2FbudIlaMmq0Dx5BMaeZaynvXfOg4X2uAYWHEIJva9pC+o6JByuIhUrMOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-transform/binding-openharmony-arm64@0.153.0': + resolution: {integrity: sha512-yQu5Ykk76Nu0g82B6q5nMgyuqgntdmv16LXQPBOVIl2CvwbSwx2jrigfTztNDHk708/hCjiPp6/J7AnNpxO/kg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-transform/binding-win32-arm64-msvc@0.153.0': + resolution: {integrity: sha512-6hXONV9UIT0wqfSOtuXlRZSZofHF8orlhyYCNwZJY+xuu/HjFI6D/+T6BbuTnCrhbutynMT5ZUIeVScqsELxDw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-transform/binding-win32-ia32-msvc@0.153.0': + resolution: {integrity: sha512-i0tleWz872L9t0SAYs6bcY2RLfN/3XE3i0h0UDuTCxMT7wYj22Nd3IFF+4CFZQgF9Eb/Jh92OFrXQHFlwfhA0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-transform/binding-win32-x64-msvc@0.153.0': + resolution: {integrity: sha512-DC1juC+WQbUmGf3qJ7x+w4K0U9SUAANizzgGU3J+Y0WrMNZZryXWq0zzHAKTfvFpY2fORgz+J1IXlZ/wf6mRGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@pagefind/darwin-arm64@1.5.2': resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} cpu: [arm64] @@ -2905,9 +3226,16 @@ packages: peerDependencies: '@urql/core': ^5.0.0 + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@5.0.3': + resolution: {integrity: sha512-N/VGY122AqBx25OB4EatqlVu8PBayvm9dQzpJ8kVq/pOvqAhXTE5MAmoSMzdB8klDU9wcgSEUliwgMNMtxPa1w==} + '@vitest/mocker@4.1.10': resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: @@ -2922,6 +3250,9 @@ packages: '@vitest/pretty-format@4.1.10': resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@5.0.3': + resolution: {integrity: sha512-3hr5eV5z2ff0l2Q1vaKQbB4j3M3tElw8stDokEOTqXJB3Es5VrT8gBRIMY59gGuFp8Y61sWlWFK1aNUH5dHDYw==} + '@vitest/runner@4.1.10': resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} @@ -2931,9 +3262,15 @@ packages: '@vitest/spy@4.1.10': resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@5.0.3': + resolution: {integrity: sha512-77fDTZv8eu1CzlIfgPU2yrl5MXSUaUAOvgdZEeVm3X3WIxUubIX2SforqS6176lT32HCF6uBVMYVqfHxw7o6eg==} + '@volar/kit@2.4.28': resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} peerDependencies: @@ -3005,6 +3342,9 @@ packages: '@webassemblyjs/wast-printer@1.14.1': resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} @@ -3057,6 +3397,22 @@ packages: ai: optional: true + agent-device@0.21.28: + resolution: {integrity: sha512-uGEENVhPzSpIACrVlt16O1mloODWJyAemTCT0boDb4L006JXN2rG747u1TRoQJudOpdP/Pm2urxNRHeECLMLfg==} + engines: {node: '>=22.12'} + hasBin: true + peerDependencies: + ai: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + ai: + optional: true + + ai@7.0.146: + resolution: {integrity: sha512-dNeZs+zlp47upGQKuw2/o8zNbuzEdTlD+0uWi3DTeDU3DH38iWkaNMy8fAl4z3IsgKvsM9x+dzUF5VNYR9CzEw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -3539,6 +3895,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -3795,6 +4155,31 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + e2e@0.21.2: + resolution: {integrity: sha512-w3NNJLbu0wkUhGy44O3elTXhDNdwetrvEhVvqA2ZjUx+l+ccchIVADodrOXaP2FaB15Y/lnZMbQrOdfVOheJIA==} + 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==} @@ -4358,6 +4743,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@8.0.0: + resolution: {integrity: sha512-gCysNasJ8KEMgfdYIKd/wTDo6ENK1PWT0RJO7O+0pgmuHPw2O6tA1WvdxFRJoLf9V8yFYpG0FA1YgI8X97OhJA==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -4391,6 +4779,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@11.1.5: resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} @@ -4530,6 +4921,9 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + get-tsconfig@5.0.0-beta.4: resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} engines: {node: '>=20.20.0'} @@ -4810,6 +5204,9 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + iobuffer@6.0.1: + resolution: {integrity: sha512-SZWYkWNfjIXIBYSDpXDYIgshqtbOPsi4lviawAEceR1Kqk+sHDlcQjWrzNQsii80AyBY0q5c8HCTNjqo74ul+Q==} + ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -5071,6 +5468,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -5869,6 +6269,14 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + oxc-parser@0.153.0: + resolution: {integrity: sha512-cEnpyyV1OJRXv++SFNw0z8i+2o/cr88rVLaOYJafELDoHM5yHvCAbwGRLvxuany6SNLcbblSMUyZaGzH+quSYg==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-transform@0.153.0: + resolution: {integrity: sha512-QNLqPkK0IeFF8QWB5KwieTLOZAo6v2hEHrXtmgPEINznmNy/nRi/fce/rm51RPKHPBkrEsxhePwYjGKp6wvfHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -5975,6 +6383,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + 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'} @@ -5984,6 +6396,11 @@ packages: engines: {node: '>=18'} hasBin: true + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + plist@3.1.1: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} @@ -5992,6 +6409,10 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -6879,6 +7300,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -7021,6 +7446,10 @@ packages: resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} engines: {node: '>=18.17'} + undici@7.30.1: + resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==} + engines: {node: '>=20.18.1'} + undici@8.10.2: resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} engines: {node: '>=22.19.0'} @@ -7643,6 +8072,9 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.1: + resolution: {integrity: sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==} + zod@4.6.5: resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} @@ -7653,6 +8085,30 @@ snapshots: '@0no-co/graphql.web@1.3.2': {} + '@ai-sdk/gateway@4.0.119(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.64(zod@4.6.5) + '@vercel/oidc': 3.2.0 + zod: 4.6.5 + + '@ai-sdk/provider-utils@5.0.64(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.31 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.0 + undici: 7.30.1 + zod: 4.6.5 + + '@ai-sdk/provider@4.0.26': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/provider@4.0.31': + dependencies: + json-schema: 0.4.0 + '@astrojs/astro2tsx@0.1.0(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.11.3 @@ -8537,6 +8993,20 @@ snapshots: '@ctrl/tinycolor@4.2.1': {} + '@e2e-dev/mobile@0.12.3(ai@7.0.146(zod@4.6.5))(e2e@0.21.2(ai@7.0.146(zod@4.6.5)))': + dependencies: + agent-device: 0.21.28(ai@7.0.146(zod@4.6.5)) + e2e: 0.21.2(ai@7.0.146(zod@4.6.5)) + pngjs: 7.0.0 + zod: 4.6.1 + optionalDependencies: + ai: 7.0.146(zod@4.6.5) + + '@e2e-dev/web@0.15.2(e2e@0.21.2(ai@7.0.146(zod@4.6.5)))': + dependencies: + e2e: 0.21.2(ai@7.0.146(zod@4.6.5)) + playwright-core: 1.64.0 + '@egjs/hammerjs@2.0.17': dependencies: '@types/hammerjs': 2.0.46 @@ -9432,6 +9902,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/core@2.3.1': + dependencies: + zod: 4.6.5 + '@modelcontextprotocol/sdk@1.29.0(zod@4.6.5)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.30) @@ -9454,6 +9928,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/server@2.3.1': + dependencies: + '@modelcontextprotocol/core': 2.3.1 + zod: 4.6.5 + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -9490,8 +9969,126 @@ snapshots: '@oslojs/encoding@1.1.0': {} + '@oxc-parser/binding-android-arm-eabi@0.153.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.153.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.153.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.153.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.153.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.153.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.153.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.153.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.153.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.153.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.153.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.153.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.153.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.153.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.153.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.153.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.153.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.153.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.153.0': + optional: true + + '@oxc-project/runtime@0.153.0': {} + '@oxc-project/types@0.139.0': {} + '@oxc-project/types@0.153.0': {} + + '@oxc-transform/binding-android-arm-eabi@0.153.0': + optional: true + + '@oxc-transform/binding-android-arm64@0.153.0': + optional: true + + '@oxc-transform/binding-darwin-arm64@0.153.0': + optional: true + + '@oxc-transform/binding-darwin-x64@0.153.0': + optional: true + + '@oxc-transform/binding-freebsd-x64@0.153.0': + optional: true + + '@oxc-transform/binding-linux-arm-gnueabihf@0.153.0': + optional: true + + '@oxc-transform/binding-linux-arm-musleabihf@0.153.0': + optional: true + + '@oxc-transform/binding-linux-arm64-gnu@0.153.0': + optional: true + + '@oxc-transform/binding-linux-arm64-musl@0.153.0': + optional: true + + '@oxc-transform/binding-linux-ppc64-gnu@0.153.0': + optional: true + + '@oxc-transform/binding-linux-riscv64-gnu@0.153.0': + optional: true + + '@oxc-transform/binding-linux-riscv64-musl@0.153.0': + optional: true + + '@oxc-transform/binding-linux-s390x-gnu@0.153.0': + optional: true + + '@oxc-transform/binding-linux-x64-gnu@0.153.0': + optional: true + + '@oxc-transform/binding-linux-x64-musl@0.153.0': + optional: true + + '@oxc-transform/binding-openharmony-arm64@0.153.0': + optional: true + + '@oxc-transform/binding-win32-arm64-msvc@0.153.0': + optional: true + + '@oxc-transform/binding-win32-ia32-msvc@0.153.0': + optional: true + + '@oxc-transform/binding-win32-x64-msvc@0.153.0': + optional: true + '@pagefind/darwin-arm64@1.5.2': optional: true @@ -10517,6 +11114,8 @@ snapshots: '@urql/core': 5.2.0 wonka: 6.3.6 + '@vercel/oidc@3.2.0': {} + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -10526,6 +11125,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/expect@5.0.3': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 5.0.3 + '@vitest/utils': 5.0.3 + chai: 6.2.2 + tinyrainbow: 3.2.0 + '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@24.13.6)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 @@ -10538,6 +11146,10 @@ snapshots: dependencies: tinyrainbow: 3.1.0 + '@vitest/pretty-format@5.0.3': + dependencies: + tinyrainbow: 3.2.0 + '@vitest/runner@4.1.10': dependencies: '@vitest/utils': 4.1.10 @@ -10552,12 +11164,20 @@ snapshots: '@vitest/spy@4.1.10': {} + '@vitest/spy@5.0.3': {} + '@vitest/utils@4.1.10': dependencies: '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vitest/utils@5.0.3': + dependencies: + '@vitest/pretty-format': 5.0.3 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 + '@volar/kit@2.4.28(typescript@5.9.3)': dependencies: '@volar/language-service': 2.4.28 @@ -10684,6 +11304,8 @@ snapshots: '@webassemblyjs/ast': 1.14.1 '@xtuc/long': 4.2.2 + '@workflow/serde@4.1.0': {} + '@xmldom/xmldom@0.8.13': {} '@xmldom/xmldom@0.9.10': {} @@ -10714,7 +11336,20 @@ snapshots: agent-base@7.1.4: {} - agent-device@0.21.23: {} + agent-device@0.21.23(ai@7.0.146(zod@4.6.5)): + optionalDependencies: + ai: 7.0.146(zod@4.6.5) + + agent-device@0.21.28(ai@7.0.146(zod@4.6.5)): + optionalDependencies: + ai: 7.0.146(zod@4.6.5) + + ai@7.0.146(zod@4.6.5): + dependencies: + '@ai-sdk/gateway': 4.0.119(zod@4.6.5) + '@ai-sdk/provider': 4.0.31 + '@ai-sdk/provider-utils': 5.0.64(zod@4.6.5) + zod: 4.6.5 ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: @@ -11356,6 +11991,8 @@ snapshots: commander@12.1.0: {} + commander@15.0.0: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -11590,6 +12227,25 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + e2e@0.21.2(ai@7.0.146(zod@4.6.5)): + dependencies: + '@ai-sdk/provider': 4.0.26 + '@clack/prompts': 1.8.1 + '@jridgewell/remapping': 2.3.5 + '@modelcontextprotocol/server': 2.3.1 + '@oxc-project/runtime': 0.153.0 + '@vitest/expect': 5.0.3 + commander: 15.0.0 + fast-png: 8.0.0 + get-tsconfig: 4.14.3 + oxc-parser: 0.153.0 + oxc-transform: 0.153.0 + picocolors: 1.1.1 + pixelmatch: 8.0.0 + zod: 4.6.1 + optionalDependencies: + ai: 7.0.146(zod@4.6.5) + ee-first@1.1.1: {} electron-to-chromium@1.5.392: {} @@ -12452,6 +13108,11 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@8.0.0: + dependencies: + fflate: 0.8.3 + iobuffer: 6.0.1 + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -12490,6 +13151,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + file-entry-cache@11.1.5: dependencies: flat-cache: 6.1.23 @@ -12640,6 +13303,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + get-tsconfig@5.0.0-beta.4: dependencies: resolve-pkg-maps: 1.0.0 @@ -13049,6 +13716,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + iobuffer@6.0.1: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -13332,6 +14001,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -14554,6 +15225,52 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + oxc-parser@0.153.0: + dependencies: + '@oxc-project/types': 0.153.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.153.0 + '@oxc-parser/binding-android-arm64': 0.153.0 + '@oxc-parser/binding-darwin-arm64': 0.153.0 + '@oxc-parser/binding-darwin-x64': 0.153.0 + '@oxc-parser/binding-freebsd-x64': 0.153.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.153.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.153.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.153.0 + '@oxc-parser/binding-linux-arm64-musl': 0.153.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.153.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.153.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.153.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.153.0 + '@oxc-parser/binding-linux-x64-gnu': 0.153.0 + '@oxc-parser/binding-linux-x64-musl': 0.153.0 + '@oxc-parser/binding-openharmony-arm64': 0.153.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.153.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.153.0 + '@oxc-parser/binding-win32-x64-msvc': 0.153.0 + + oxc-transform@0.153.0: + optionalDependencies: + '@oxc-transform/binding-android-arm-eabi': 0.153.0 + '@oxc-transform/binding-android-arm64': 0.153.0 + '@oxc-transform/binding-darwin-arm64': 0.153.0 + '@oxc-transform/binding-darwin-x64': 0.153.0 + '@oxc-transform/binding-freebsd-x64': 0.153.0 + '@oxc-transform/binding-linux-arm-gnueabihf': 0.153.0 + '@oxc-transform/binding-linux-arm-musleabihf': 0.153.0 + '@oxc-transform/binding-linux-arm64-gnu': 0.153.0 + '@oxc-transform/binding-linux-arm64-musl': 0.153.0 + '@oxc-transform/binding-linux-ppc64-gnu': 0.153.0 + '@oxc-transform/binding-linux-riscv64-gnu': 0.153.0 + '@oxc-transform/binding-linux-riscv64-musl': 0.153.0 + '@oxc-transform/binding-linux-s390x-gnu': 0.153.0 + '@oxc-transform/binding-linux-x64-gnu': 0.153.0 + '@oxc-transform/binding-linux-x64-musl': 0.153.0 + '@oxc-transform/binding-openharmony-arm64': 0.153.0 + '@oxc-transform/binding-win32-arm64-msvc': 0.153.0 + '@oxc-transform/binding-win32-ia32-msvc': 0.153.0 + '@oxc-transform/binding-win32-x64-msvc': 0.153.0 + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -14657,10 +15374,16 @@ snapshots: pirates@4.0.7: {} + pixelmatch@8.0.0: + dependencies: + pngjs: 7.0.0 + pkce-challenge@5.0.1: {} playwright-core@1.56.1: {} + playwright-core@1.64.0: {} + plist@3.1.1: dependencies: '@xmldom/xmldom': 0.9.10 @@ -14669,6 +15392,8 @@ snapshots: pngjs@3.4.0: {} + pngjs@7.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-nested@6.2.0(postcss@8.5.19): @@ -15833,6 +16558,8 @@ snapshots: tinyrainbow@3.1.0: {} + tinyrainbow@3.2.0: {} + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -15977,6 +16704,8 @@ snapshots: undici@6.27.0: {} + undici@7.30.1: {} + undici@8.10.2: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -16573,6 +17302,8 @@ snapshots: zod@4.4.3: {} + zod@4.6.1: {} + zod@4.6.5: {} zwitch@2.0.4: {} From 6d60f0b00fd38f60592caf7d6cd2da9a7b658f38 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 10:27:34 +0200 Subject: [PATCH 3/9] refactor(e2e): export withAppduct(base) instead of a ready-made test --- CHANGELOG.md | 2 +- packages/e2e/README.md | 16 ++++++++++++--- packages/e2e/e2e/tests/fixtures.ts | 4 ++++ packages/e2e/e2e/tests/tools.e2e.ts | 2 +- packages/e2e/src/agent-tools.ts | 4 ++-- packages/e2e/src/index.ts | 31 +++++++++++++++++++---------- 6 files changed, 41 insertions(+), 18 deletions(-) create mode 100644 packages/e2e/e2e/tests/fixtures.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae04e93c..89ef8ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ section into a versioned heading. ## Unreleased -- **New: `@appduct/e2e` lets [e2e](https://github.com/tester-army/e2e) tests and their agent call your app's tools.** Write tests with its `test` to get an `appduct` fixture, and add `appductTools()` to an agent; see the [package README](https://github.com/callstackincubator/appduct/tree/main/packages/e2e). +- **New: `@appduct/e2e` lets [e2e](https://github.com/tester-army/e2e) tests and their agent call your app's tools.** Wrap your `test` with `withAppduct` to get an `appduct` fixture, and add `appductTools()` to an agent; see the [package README](https://github.com/callstackincubator/appduct/tree/main/packages/e2e). - **Fix: `waitForSession()` from `appduct/client` no longer sometimes times out when the app claims the session right after the wait starts.** ## 0.15.1 (2026-10-15) diff --git a/packages/e2e/README.md b/packages/e2e/README.md index 710f9aeb..f27188e8 100644 --- a/packages/e2e/README.md +++ b/packages/e2e/README.md @@ -17,12 +17,22 @@ Call your app's Appduct tools from [e2e](https://github.com/tester-army/e2e) tes npm install -D @appduct/e2e ``` -Write tests with `test` from `@appduct/e2e` instead of `e2e`. Each test gets an `appduct` fixture: +Add the `appduct` fixture to your `test` in one place: + +```ts +// tests/fixtures.ts +import { test as base } from "e2e"; +import { withAppduct } from "@appduct/e2e"; + +export const test = withAppduct(base); +``` + +`base` can also be an engine's `test`, such as the one from `@e2e-dev/mobile`, or a `test` you already extended with fixtures of your own. Then write tests with it: ```ts // tests/cart.e2e.ts import { expect } from "e2e"; -import { test } from "@appduct/e2e"; +import { test } from "./fixtures.ts"; test("a seeded cart checks out", async ({ app, appduct, screen }) => { await app.open(); @@ -50,7 +60,7 @@ export default { } satisfies E2EConfig; ``` -The agent gets `appduct_tools`, which lists the app's tools, and `appduct_call`, which calls one. They work in tests written with `test` from `@appduct/e2e`. +The agent gets `appduct_tools`, which lists the app's tools, and `appduct_call`, which calls one. They work in tests written with a `test` from `withAppduct`. ## The `appduct` fixture diff --git a/packages/e2e/e2e/tests/fixtures.ts b/packages/e2e/e2e/tests/fixtures.ts new file mode 100644 index 00000000..9a38f7dd --- /dev/null +++ b/packages/e2e/e2e/tests/fixtures.ts @@ -0,0 +1,4 @@ +import { test as base } from "e2e"; +import { withAppduct } from "@appduct/e2e"; + +export const test = withAppduct(base); diff --git a/packages/e2e/e2e/tests/tools.e2e.ts b/packages/e2e/e2e/tests/tools.e2e.ts index 833a67c7..0902da94 100644 --- a/packages/e2e/e2e/tests/tools.e2e.ts +++ b/packages/e2e/e2e/tests/tools.e2e.ts @@ -1,5 +1,5 @@ import { expect } from "e2e"; -import { test } from "@appduct/e2e"; +import { test } from "./fixtures.ts"; test("calls a tool in the running app", async ({ app, appduct }) => { await app.open(); diff --git a/packages/e2e/src/agent-tools.ts b/packages/e2e/src/agent-tools.ts index cfd87381..c585f479 100644 --- a/packages/e2e/src/agent-tools.ts +++ b/packages/e2e/src/agent-tools.ts @@ -15,13 +15,13 @@ export const offerToAgent = (session: Appduct): (() => void) => { }; const session = (): Appduct => { - if (!current) throw new Error("No Appduct session in this test. Write the test with `test` from '@appduct/e2e'."); + if (!current) throw new Error("No Appduct session in this test. Write the test with a `test` from `withAppduct`."); return current; }; /** * Tools for e2e's agent that call the tools the app registered with Appduct. Add them to an - * agent's `tools` in `e2e.config.ts`; they work in tests written with `test` from `@appduct/e2e`. + * agent's `tools` in `e2e.config.ts`; they work in tests written with a `test` from `withAppduct`. */ export const appductTools = () => ({ appduct_tools: defineTool( diff --git a/packages/e2e/src/index.ts b/packages/e2e/src/index.ts index 0e2519de..80557ba4 100644 --- a/packages/e2e/src/index.ts +++ b/packages/e2e/src/index.ts @@ -1,10 +1,13 @@ /** - * `@appduct/e2e`: Appduct in e2e (https://github.com/tester-army/e2e) tests. `test` gives every - * test an `appduct` fixture that calls the app's tools, and `appductTools()` gives e2e's agent the - * same reach. + * `@appduct/e2e`: Appduct in e2e (https://github.com/tester-army/e2e) tests. `withAppduct(test)` + * gives every test an `appduct` fixture that calls the app's tools, and `appductTools()` gives + * e2e's agent the same reach. * * ```ts - * import { test } from "@appduct/e2e"; + * import { test as base } from "e2e"; + * import { withAppduct } from "@appduct/e2e"; + * + * const test = withAppduct(base); * * test("checks out", async ({ app, appduct, agent }) => { * await app.open(); @@ -14,7 +17,7 @@ * }); * ``` */ -import { test as base, type TestFixtures } from "e2e"; +import type { FixtureDefinitions, FixtureFn, TestAPI, TestFixtures } from "e2e"; import { offerToAgent } from "./agent-tools.js"; import { pairWith, type PairingBrowser, type PairingDevice, type PairingTarget } from "./pairing.js"; @@ -38,13 +41,19 @@ const targetOf = (fixtures: EngineFixtures): PairingTarget => { throw new Error(`Appduct pairs with web, iOS and Android targets, not "${platform}".`); }; -/** e2e's `test` with an `appduct` fixture: the app's Appduct session, paired on first use. */ -export const test = base.extend<{ appduct: Appduct }>({ - appduct: async (fixtures, use) => { - const session = createSession(() => pairWith(targetOf(fixtures as EngineFixtures)), clock); +/** + * Adds an `appduct` fixture to `base`: the app's Appduct session, paired on first use. `base` is + * e2e's `test`, an engine's typed `test`, or one you already extended. + */ +export const withAppduct = (base: TestAPI): TestAPI => { + const appduct: FixtureFn = async (fixtures, use) => { + const session = createSession(() => pairWith(targetOf(fixtures as unknown as EngineFixtures)), clock); const withdraw = offerToAgent(session); await use(session); withdraw(); session.close(); - }, -}); + }; + // The mapped definition type cannot tell that `appduct` is absent from a generic `Fixtures`; a + // base that has one fails at import with e2e's own COLLECTION_ERROR. + return base.extend({ appduct } as FixtureDefinitions); +}; From e13726d57812a2e3bd7901bdd257413c96366688 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 11:02:53 +0200 Subject: [PATCH 4/9] feat(client): add AppClient.state() and revoke() --- CHANGELOG.md | 1 + .../src/__tests__/e2e/client.e2e.test.ts | 36 +++++++++++++++++++ packages/appduct/src/client/app-client.ts | 27 ++++++++++++++ packages/appduct/src/client/index.ts | 2 +- 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ef8ab4..9c04c7c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ section into a versioned heading. ## Unreleased +- **New: an `AppClient` from `appduct/client` has `state()` and `revoke()`.** `state()` tells whether the app is still connected, and `revoke()` ends the session. - **New: `@appduct/e2e` lets [e2e](https://github.com/tester-army/e2e) tests and their agent call your app's tools.** Wrap your `test` with `withAppduct` to get an `appduct` fixture, and add `appductTools()` to an agent; see the [package README](https://github.com/callstackincubator/appduct/tree/main/packages/e2e). - **Fix: `waitForSession()` from `appduct/client` no longer sometimes times out when the app claims the session right after the wait starts.** diff --git a/packages/appduct/src/__tests__/e2e/client.e2e.test.ts b/packages/appduct/src/__tests__/e2e/client.e2e.test.ts index ec0a8931..b801456a 100644 --- a/packages/appduct/src/__tests__/e2e/client.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/client.e2e.test.ts @@ -315,6 +315,42 @@ describe("e2e: appduct/client", () => { 15_000, ); + test("state() reports an active session, and suspended once the app's connection drops", async () => { + const { stateDir } = await makeTempStateDir(); + await ensureDaemon(stateDir); + const fakeApp = new FakeAppClient(await daemonWssPort(stateDir), await fetchPinnedKeys(stateDir)); + const link = await mintLink(stateDir); + await fakeApp.claim(link); + const app = await connect({ stateDir, selector: link.sessionId }); + + expect(await app.state()).toBe("active"); + + const events = await subscribeToEvents(stateDir); + const suspended = events.waitFor("session_suspended"); + fakeApp.dropSocket(); + await suspended; + events.close(); + + expect(await app.state()).toBe("suspended"); + app.close(); + }); + + test("revoke() ends the session, so it no longer exists and the app is disconnected", async () => { + const { stateDir } = await makeTempStateDir(); + await ensureDaemon(stateDir); + const fakeApp = new FakeAppClient(await daemonWssPort(stateDir), await fetchPinnedKeys(stateDir)); + const link = await mintLink(stateDir); + await fakeApp.claim(link); + const app = await connect({ stateDir, selector: link.sessionId }); + const disconnected = fakeApp.waitForClose(); + + await app.revoke(); + + expect(await disconnected).toMatchObject({ code: 1000 }); + await expect(app.state()).rejects.toMatchObject({ type: "unknown_session" }); + app.close(); + }); + test("connect() rejects with a connection_error AppductError when the daemon is unreachable and auto-spawn is disabled", async () => { const { stateDir } = await makeTempStateDir(); diff --git a/packages/appduct/src/client/app-client.ts b/packages/appduct/src/client/app-client.ts index 9e4570dd..518ef4d4 100644 --- a/packages/appduct/src/client/app-client.ts +++ b/packages/appduct/src/client/app-client.ts @@ -17,6 +17,8 @@ import { type EventsSinceResult, type FullAppEvent, type ListedToolDescriptor, + type SessionsDescribeResult, + type SessionState, type ToolsCallResult, type ToolsListResult, } from "@appduct/shared"; @@ -115,6 +117,15 @@ export type { AppEvent, FullAppEvent } from "@appduct/shared"; export type AppClient = { readonly sessionId: string; + /** The session's state now: `active` while the app is connected, `suspended` while it is away + * (its process died, it was backgrounded or a page closed) and may still come back. Rejects with + * `"unknown_session"` once the session is gone. */ + state(): Promise; + + /** Ends the session: the daemon disconnects the app and forgets the session, so it can never + * resume. Works in any state. */ + revoke(): Promise; + /** `tools.list` for this session. An entry spells an ungrouped tool's `group` as `null`, * where a registration omits it. */ tools(): Promise; @@ -243,6 +254,22 @@ export const makeAppClient = ( const client = { sessionId, + state: async (): Promise => { + try { + return (await stream.call(RPC_METHODS.sessionsDescribe, { selector: sessionId })).state; + } catch (error) { + throw toAppductError(error); + } + }, + + revoke: async (): Promise => { + try { + await stream.call(RPC_METHODS.sessionsRevoke, { selector: sessionId }); + } catch (error) { + throw toAppductError(error); + } + }, + tools: async (): Promise => { try { // No `filter`/`limit`/`offset`: this client's public `tools()` contract is "every tool on diff --git a/packages/appduct/src/client/index.ts b/packages/appduct/src/client/index.ts index 2cc8f671..b7089300 100644 --- a/packages/appduct/src/client/index.ts +++ b/packages/appduct/src/client/index.ts @@ -35,4 +35,4 @@ export type { WaitForEventOptions, } from "./app-client.js"; export type { TruncatedAppEvent } from "@appduct/shared"; -export type { AgentEndpoint, ErrorType, ListedToolDescriptor, ToolDescriptor } from "@appduct/shared"; +export type { AgentEndpoint, ErrorType, ListedToolDescriptor, SessionState, ToolDescriptor } from "@appduct/shared"; From ce16c943b145cfdd9d74f2d315f3ce0aa6d1afc9 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 11:07:13 +0200 Subject: [PATCH 5/9] feat(e2e): reuse a connected session across tests, revoke dead ones --- packages/e2e/README.md | 4 +- packages/e2e/e2e/tests/sessions.e2e.ts | 15 +++ packages/e2e/src/__tests__/session.test.ts | 128 +++++++++++++++------ packages/e2e/src/index.ts | 11 +- packages/e2e/src/session.ts | 124 +++++++++++--------- 5 files changed, 192 insertions(+), 90 deletions(-) create mode 100644 packages/e2e/e2e/tests/sessions.e2e.ts diff --git a/packages/e2e/README.md b/packages/e2e/README.md index f27188e8..0d39f246 100644 --- a/packages/e2e/README.md +++ b/packages/e2e/README.md @@ -44,7 +44,7 @@ test("a seeded cart checks out", async ({ app, appduct, screen }) => { }); ``` -The first `appduct` call connects to the app that the test opened. A test that never uses `appduct` doesn't connect. +The first `appduct` call in a test connects to the app the test opened. A test that never uses `appduct` doesn't connect. ## Let the agent call your tools @@ -74,7 +74,7 @@ Errors are `AppductError`s from `appduct/client`, with the same `type` the CLI r ## Things to know -- **A relaunched app connects again by itself.** After `app.open()`, `app.restart()` or a reload, the next call connects to the new app process. +- **The connection lasts as long as the app does.** A test that keeps the app running, such as the next test in a `serial` group, reuses the previous test's connection. After `app.open()`, `app.restart()` or a reload, the next call connects to the new app and ends the old connection, so `appduct sessions ls` doesn't fill up with dead sessions. - **iOS asks before opening a link when several simulators are booted.** With one booted simulator, the link goes straight to the app. With several, or a phone, e2e opens the link and accepts iOS's "Open in …?" question, which adds a few seconds and shows a failed `device.alert` step in the report even though the link opened. - **On an iPhone, shut down any booted simulator.** With exactly one simulator booted, the link goes to that simulator, and the test times out waiting for the phone. - **Agent steps that call `appduct_call` run live.** e2e can't replay a call that may change app state from its cache. diff --git a/packages/e2e/e2e/tests/sessions.e2e.ts b/packages/e2e/e2e/tests/sessions.e2e.ts new file mode 100644 index 00000000..d9c0059f --- /dev/null +++ b/packages/e2e/e2e/tests/sessions.e2e.ts @@ -0,0 +1,15 @@ +import { describe, expect } from "e2e"; + +import { test } from "./fixtures.ts"; + +describe("a serial group", { serial: true }, () => { + test("counts a call", async ({ app, appduct }) => { + await app.open(); + await appduct.call("reset_counter"); + await appduct.call("sum", { a: 1, b: 1 }); + }); + + test("still sees the count in the same app", async ({ appduct }) => { + expect(await appduct.call("call_count")).toEqual({ count: 1 }); + }); +}); diff --git a/packages/e2e/src/__tests__/session.test.ts b/packages/e2e/src/__tests__/session.test.ts index 639f4163..f58379bb 100644 --- a/packages/e2e/src/__tests__/session.test.ts +++ b/packages/e2e/src/__tests__/session.test.ts @@ -1,7 +1,7 @@ -import { AppductError, type AppClient, type ListedToolDescriptor } from "appduct/client"; +import { AppductError, type AppClient, type ListedToolDescriptor, type SessionState } from "appduct/client"; import { describe, expect, it } from "vitest"; -import { createSession, type Clock } from "../session.js"; +import { createSessions, type Clock } from "../session.js"; /** A clock whose time moves only when the code under test sleeps. */ const fakeClock = (): Clock & { elapsed(): number } => { @@ -12,20 +12,28 @@ const fakeClock = (): Clock & { elapsed(): number } => { type FakeApp = { call?: (name: string, args: Record) => Promise; tools?: () => Promise; + /** What the daemon reports for the session; `undefined` means it no longer exists. */ + state?: SessionState; }; -/** An app client whose answers the test scripts, recording whether it was closed. */ -const fakeClient = (app: FakeApp = {}): AppClient & { closed: boolean } => { +type Fake = AppClient & { closed: boolean; revoked: boolean; app: FakeApp }; + +/** An app client whose answers the test scripts, recording whether it was closed or revoked. */ +const fakeClient = (app: FakeApp = { state: "active" }): Fake => { const client = { sessionId: "s", + app, closed: false, + revoked: false, + state: async () => app.state ?? Promise.reject(new AppductError("unknown_session", "No such session.")), + revoke: async () => void (client.revoked = true), call: app.call ?? (async () => ({ ok: true })), tools: app.tools ?? (async () => [{ name: "sum" } as ListedToolDescriptor]), events: async () => ({ events: [], cursor: 0, dropped: 0, remaining: 0 }), waitForEvent: async (name: string) => ({ name }), close: () => void (client.closed = true), }; - return client as unknown as AppClient & { closed: boolean }; + return client as unknown as Fake; }; /** A pairing that hands out `clients` in order and counts how often it ran. */ @@ -42,16 +50,16 @@ const pairings = (...clients: AppClient[]) => { const gone = () => new AppductError("session_suspended", "The app went away."); const missing = () => new AppductError("tool_not_found", 'Tool "sum" is not registered.'); -describe("createSession", () => { +describe("one test", () => { it("does not pair until the test uses Appduct", () => { const { pair, count } = pairings(); - createSession(pair, fakeClock()).close(); + createSessions(fakeClock()).forTest(pair); expect(count()).toBe(0); }); it("pairs once for every call in a test", async () => { - const { pair, count } = pairings(fakeClient({ call: async (_name, args) => args })); - const session = createSession(pair, fakeClock()); + const { pair, count } = pairings(fakeClient({ state: "active", call: async (_name, args) => args })); + const session = createSessions(fakeClock()).forTest(pair); const results = await Promise.all([session.call("sum", { a: 1 }), session.call("sum", { a: 2 })]); await session.tools(); @@ -60,21 +68,32 @@ describe("createSession", () => { expect(count()).toBe(1); }); - it("pairs again and repeats the call when the app's session has gone", async () => { - const first = fakeClient({ call: () => Promise.reject(gone()) }); - const second = fakeClient({ call: async () => ({ total: 3 }) }); + it("revokes a session that has gone, pairs again and repeats the call", async () => { + const first = fakeClient({ state: "suspended", call: () => Promise.reject(gone()) }); + const second = fakeClient({ state: "active", call: async () => ({ total: 3 }) }); const { pair, count } = pairings(first, second); - const session = createSession(pair, fakeClock()); + const session = createSessions(fakeClock()).forTest(pair); expect(await session.call("sum", { a: 1, b: 2 })).toEqual({ total: 3 }); expect(count()).toBe(2); + expect(first.revoked).toBe(true); expect(first.closed).toBe(true); }); + it("pairs again once when concurrent calls find the session gone", async () => { + const first = fakeClient({ state: "suspended", call: () => Promise.reject(gone()) }); + const { pair, count } = pairings(first, fakeClient()); + const session = createSessions(fakeClock()).forTest(pair); + + await Promise.all([session.call("sum"), session.call("sum")]); + + expect(count()).toBe(2); + }); + it("pairs again only once when the new session has gone too", async () => { - const lost = () => fakeClient({ call: () => Promise.reject(gone()) }); + const lost = () => fakeClient({ state: "suspended", call: () => Promise.reject(gone()) }); const { pair, count } = pairings(lost(), lost(), lost()); - const session = createSession(pair, fakeClock()); + const session = createSessions(fakeClock()).forTest(pair); await expect(session.call("sum")).rejects.toMatchObject({ type: "session_suspended" }); expect(count()).toBe(2); @@ -82,11 +101,11 @@ describe("createSession", () => { it("pairs again on the next use after a pairing fails", async () => { let attempts = 0; - const session = createSession(async () => { + const session = createSessions(fakeClock()).forTest(async () => { attempts += 1; if (attempts === 1) throw new AppductError("timeout", "No claim."); return fakeClient(); - }, fakeClock()); + }); await expect(session.call("sum")).rejects.toMatchObject({ type: "timeout" }); await expect(session.call("sum")).resolves.toEqual({ ok: true }); @@ -94,15 +113,16 @@ describe("createSession", () => { it("waits for a tool the app registers after the call is made", async () => { let calls = 0; - const app = fakeClient({ call: async () => (++calls < 3 ? Promise.reject(missing()) : { total: 3 }) }); - const session = createSession(pairings(app).pair, fakeClock()); + const app = fakeClient({ state: "active", call: async () => (++calls < 3 ? Promise.reject(missing()) : { total: 3 }) }); + const session = createSessions(fakeClock()).forTest(pairings(app).pair); expect(await session.call("sum")).toEqual({ total: 3 }); }); it("fails with tool_not_found when the tool is still missing after ten seconds", async () => { const clock = fakeClock(); - const session = createSession(pairings(fakeClient({ call: () => Promise.reject(missing()) })).pair, clock); + const app = fakeClient({ state: "active", call: () => Promise.reject(missing()) }); + const session = createSessions(clock).forTest(pairings(app).pair); await expect(session.call("sum")).rejects.toMatchObject({ type: "tool_not_found" }); expect(clock.elapsed()).toBeGreaterThanOrEqual(10_000); @@ -111,8 +131,8 @@ describe("createSession", () => { it("does not wait on a tool that fails", async () => { const clock = fakeClock(); - const failing = fakeClient({ call: () => Promise.reject(new AppductError("tool_execution_error", "Boom.")) }); - const session = createSession(pairings(failing).pair, clock); + const app = fakeClient({ state: "active", call: () => Promise.reject(new AppductError("tool_execution_error", "Boom.")) }); + const session = createSessions(clock).forTest(pairings(app).pair); await expect(session.call("sum")).rejects.toMatchObject({ type: "tool_execution_error" }); expect(clock.elapsed()).toBe(0); @@ -120,28 +140,72 @@ describe("createSession", () => { it("lists tools once the app has registered its first", async () => { let lists = 0; - const app = fakeClient({ tools: async () => (++lists < 3 ? [] : [{ name: "sum" } as ListedToolDescriptor]) }); - const session = createSession(pairings(app).pair, fakeClock()); + const app = fakeClient({ state: "active", tools: async () => (++lists < 3 ? [] : [{ name: "sum" } as ListedToolDescriptor]) }); + const session = createSessions(fakeClock()).forTest(pairings(app).pair); expect((await session.tools()).map((tool) => tool.name)).toEqual(["sum"]); }); it("lists no tools for an app that registers none in ten seconds", async () => { const clock = fakeClock(); - const session = createSession(pairings(fakeClient({ tools: async () => [] })).pair, clock); + const session = createSessions(clock).forTest(pairings(fakeClient({ state: "active", tools: async () => [] })).pair); expect(await session.tools()).toEqual([]); expect(clock.elapsed()).toBeGreaterThanOrEqual(10_000); }); +}); + +describe("across tests in a worker", () => { + it("reuses the previous test's session while the app is still connected", async () => { + const app = fakeClient({ state: "active" }); + const { pair, count } = pairings(app); + const sessions = createSessions(fakeClock()); + + await sessions.forTest(pair).call("sum"); + await sessions.forTest(pair).call("sum"); + + expect(count()).toBe(1); + expect(app.revoked).toBe(false); + expect(app.closed).toBe(false); + }); - it("closes the paired client when the test ends", async () => { - const app = fakeClient(); - const session = createSession(pairings(app).pair, fakeClock()); - await session.call("sum"); + it("revokes the previous test's session and pairs again once the app has gone", async () => { + const relaunched = fakeClient({ state: "active" }); + const fresh = fakeClient({ state: "active" }); + const { pair, count } = pairings(relaunched, fresh); + const sessions = createSessions(fakeClock()); + await sessions.forTest(pair).call("sum"); - session.close(); - await Promise.resolve(); + relaunched.app.state = "suspended"; // the next test relaunched the app + await sessions.forTest(pair).call("sum"); - expect(app.closed).toBe(true); + expect(count()).toBe(2); + expect(relaunched.revoked).toBe(true); + expect(relaunched.closed).toBe(true); + }); + + it("pairs again when the previous test's session no longer exists", async () => { + const forgotten = fakeClient({ state: "active" }); + const { pair, count } = pairings(forgotten, fakeClient()); + const sessions = createSessions(fakeClock()); + await sessions.forTest(pair).call("sum"); + + forgotten.app.state = undefined; // its grace period ran out + await sessions.forTest(pair).call("sum"); + + expect(count()).toBe(2); + }); + + it("does not check or pair for a test that never uses Appduct", async () => { + const app = fakeClient({ state: "active" }); + const { pair, count } = pairings(app); + const sessions = createSessions(fakeClock()); + await sessions.forTest(pair).call("sum"); + + app.app.state = "suspended"; + sessions.forTest(pair); + + expect(count()).toBe(1); + expect(app.revoked).toBe(false); }); }); diff --git a/packages/e2e/src/index.ts b/packages/e2e/src/index.ts index 80557ba4..b0233b2f 100644 --- a/packages/e2e/src/index.ts +++ b/packages/e2e/src/index.ts @@ -21,7 +21,7 @@ import type { FixtureDefinitions, FixtureFn, TestAPI, TestFixtures } from "e2e"; import { offerToAgent } from "./agent-tools.js"; import { pairWith, type PairingBrowser, type PairingDevice, type PairingTarget } from "./pairing.js"; -import { createSession, type Appduct, type Clock } from "./session.js"; +import { createSessions, type Appduct, type Clock } from "./session.js"; export { appductTools } from "./agent-tools.js"; export type { Appduct, EventWait } from "./session.js"; @@ -31,6 +31,9 @@ const clock: Clock = { sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), }; +/** One per worker process, shared by every `withAppduct` test in it. */ +const sessions = createSessions(clock); + type EngineFixtures = TestFixtures & { readonly browser: PairingBrowser; readonly device: PairingDevice }; /** Reads the engine's fixture for the platform only when pairing, since e2e acquires fixtures on access. */ @@ -42,16 +45,16 @@ const targetOf = (fixtures: EngineFixtures): PairingTarget => { }; /** - * Adds an `appduct` fixture to `base`: the app's Appduct session, paired on first use. `base` is + * Adds an `appduct` fixture to `base`: the app's Appduct session, connected on first use and kept + * for the next test while the app stays connected. `base` is * e2e's `test`, an engine's typed `test`, or one you already extended. */ export const withAppduct = (base: TestAPI): TestAPI => { const appduct: FixtureFn = async (fixtures, use) => { - const session = createSession(() => pairWith(targetOf(fixtures as unknown as EngineFixtures)), clock); + const session = sessions.forTest(() => pairWith(targetOf(fixtures as unknown as EngineFixtures))); const withdraw = offerToAgent(session); await use(session); withdraw(); - session.close(); }; // The mapped definition type cannot tell that `appduct` is absent from a generic `Fixtures`; a // base that has one fails at import with e2e's own COLLECTION_ERROR. diff --git a/packages/e2e/src/session.ts b/packages/e2e/src/session.ts index b6ce4a64..2dcf2668 100644 --- a/packages/e2e/src/session.ts +++ b/packages/e2e/src/session.ts @@ -26,66 +26,86 @@ const isGone = (error: unknown): boolean => error instanceof AppductError && (error.type === "session_suspended" || error.type === "session_not_active" || error.type === "unknown_session"); +/** Ends a session that is no longer worth keeping. The daemon may have forgotten it already. */ +const discard = (client: AppClient): void => { + void client.revoke().catch(() => undefined); + client.close(); +}; + /** - * Pairs with `pair` on first use and again whenever the session has gone, as it does when the - * test relaunches the app or reloads the page. + * The worker's Appduct session, kept across tests: e2e runs one test at a time in a worker, and a + * test that leaves the app running hands the next one an app that is still connected. */ -export const createSession = (pair: () => Promise, clock: Clock): Appduct & { close(): void } => { - let pairing: Promise | undefined; - - const forget = (stale: Promise) => { - if (pairing === stale) pairing = undefined; - }; +export const createSessions = (clock: Clock) => { + let kept: AppClient | undefined; - const paired = (): Promise => { - if (!pairing) { - const attempt = pair(); - attempt.catch(() => forget(attempt)); // so the next use pairs again - pairing = attempt; + /** The kept session while the app is still connected to it, else a newly paired one. */ + const resume = async (pair: () => Promise): Promise => { + const previous = kept; + kept = undefined; + if (previous) { + // Anything but `active` means the app went away: relaunched, reloaded or closed. + if ((await previous.state().catch(() => undefined)) === "active") return (kept = previous); + discard(previous); } - return pairing; + return (kept = await pair()); }; - /** Runs `use` on the paired client, pairing once more if the session has gone. */ - const withClient = async (use: (client: AppClient) => Promise): Promise => { - const current = paired(); - const client = await current; - try { - return await use(client); - } catch (error) { - if (!isGone(error)) throw error; - client.close(); - forget(current); - return use(await paired()); - } - }; + /** + * One test's view of the session. It connects on first use, reusing the kept session when it can, + * and pairs again once if the session goes away during the test. + */ + const forTest = (pair: () => Promise): Appduct => { + let current: Promise | undefined; - return { - call: async (name, args = {}, options) => { - const deadline = clock.now() + TOOL_WAIT_MS; - for (;;) { - try { - return await withClient((client) => client.call(name, args, options)); - } catch (error) { - const missing = error instanceof AppductError && error.type === "tool_not_found"; - if (!missing || clock.now() >= deadline) throw error; - await clock.sleep(POLL_MS); - } + const connected = (): Promise => { + if (!current) { + const attempt = resume(pair); + attempt.catch(() => { + if (current === attempt) current = undefined; // so the next use tries again + }); + current = attempt; } - }, - tools: async () => { - const deadline = clock.now() + TOOL_WAIT_MS; - for (;;) { - const tools = await withClient((client) => client.tools()); - if (tools.length > 0 || clock.now() >= deadline) return tools; - await clock.sleep(POLL_MS); + return current; + }; + + const withClient = async (use: (client: AppClient) => Promise): Promise => { + const attempt = connected(); + const client = await attempt; + try { + return await use(client); + } catch (error) { + if (!isGone(error)) throw error; + if (current === attempt) current = undefined; // a concurrent call may have reconnected already + return use(await connected()); } - }, - waitForEvent: (name: string, options?: EventWait) => - withClient((client) => client.waitForEvent(name, options)), - close: () => { - pairing?.then((client) => client.close(), () => undefined); - pairing = undefined; - }, + }; + + return { + call: async (name, args = {}, options) => { + const deadline = clock.now() + TOOL_WAIT_MS; + for (;;) { + try { + return await withClient((client) => client.call(name, args, options)); + } catch (error) { + const missing = error instanceof AppductError && error.type === "tool_not_found"; + if (!missing || clock.now() >= deadline) throw error; + await clock.sleep(POLL_MS); + } + } + }, + tools: async () => { + const deadline = clock.now() + TOOL_WAIT_MS; + for (;;) { + const tools = await withClient((client) => client.tools()); + if (tools.length > 0 || clock.now() >= deadline) return tools; + await clock.sleep(POLL_MS); + } + }, + waitForEvent: (name: string, options?: EventWait) => + withClient((client) => client.waitForEvent(name, options)), + }; }; + + return { forTest }; }; From 1f1ccd5a450fe3f11c3f9588482052646e5c1ffb Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 11:44:05 +0200 Subject: [PATCH 6/9] fix(e2e): open device links through e2e, accept the iOS prompt if needed --- packages/e2e/README.md | 4 +-- packages/e2e/src/index.ts | 2 +- packages/e2e/src/pairing.ts | 60 +++++++++++++++++-------------------- 3 files changed, 30 insertions(+), 36 deletions(-) diff --git a/packages/e2e/README.md b/packages/e2e/README.md index 0d39f246..2581b53c 100644 --- a/packages/e2e/README.md +++ b/packages/e2e/README.md @@ -75,10 +75,8 @@ Errors are `AppductError`s from `appduct/client`, with the same `type` the CLI r ## Things to know - **The connection lasts as long as the app does.** A test that keeps the app running, such as the next test in a `serial` group, reuses the previous test's connection. After `app.open()`, `app.restart()` or a reload, the next call connects to the new app and ends the old connection, so `appduct sessions ls` doesn't fill up with dead sessions. -- **iOS asks before opening a link when several simulators are booted.** With one booted simulator, the link goes straight to the app. With several, or a phone, e2e opens the link and accepts iOS's "Open in …?" question, which adds a few seconds and shows a failed `device.alert` step in the report even though the link opened. -- **On an iPhone, shut down any booted simulator.** With exactly one simulator booted, the link goes to that simulator, and the test times out waiting for the phone. +- **A new iOS simulator asks once before the first link opens the app.** The fixture accepts iOS's "Open in …?" question for you, which shows as a `device.alert` step in that test's report. The simulator doesn't ask again. - **Agent steps that call `appduct_call` run live.** e2e can't replay a call that may change app state from its cache. -- **Tests that run on a device pool connect only through e2e.** Each connection still goes to the device the test runs on, at the slower speed above. ## Made with ❤️ at Callstack diff --git a/packages/e2e/src/index.ts b/packages/e2e/src/index.ts index b0233b2f..1152c5cf 100644 --- a/packages/e2e/src/index.ts +++ b/packages/e2e/src/index.ts @@ -51,7 +51,7 @@ const targetOf = (fixtures: EngineFixtures): PairingTarget => { */ export const withAppduct = (base: TestAPI): TestAPI => { const appduct: FixtureFn = async (fixtures, use) => { - const session = sessions.forTest(() => pairWith(targetOf(fixtures as unknown as EngineFixtures))); + const session = sessions.forTest(() => pairWith(targetOf(fixtures as unknown as EngineFixtures), clock)); const withdraw = offerToAgent(session); await use(session); withdraw(); diff --git a/packages/e2e/src/pairing.ts b/packages/e2e/src/pairing.ts index bf061cc6..2c7d777e 100644 --- a/packages/e2e/src/pairing.ts +++ b/packages/e2e/src/pairing.ts @@ -1,4 +1,6 @@ -import { AppductError, link, waitForSession, type AppClient } from "appduct/client"; +import { link, waitForSession, type AppClient } from "appduct/client"; + +import type { Clock } from "./session.js"; /** The part of e2e's `browser` fixture pairing uses. */ export type PairingBrowser = { @@ -18,46 +20,40 @@ export type PairingTarget = /** A web page claims at once; a device may still be launching the app. */ const CLAIM_TIMEOUT_MS = 60_000; +/** How long an iOS app gets to claim before pairing looks for iOS's "Open in …?" question. */ +const PROMPT_WAIT_MS = 2_000; + +const claim = (sessionId: string): Promise => waitForSession(sessionId, { timeoutMs: CLAIM_TIMEOUT_MS }); /** Connects the page in the test's browser, which must load `@appduct/web`. */ -const linkPage = async (browser: PairingBrowser): Promise => { +const linkPage = async (browser: PairingBrowser): Promise => { const { sessionId, script } = await link({ target: "web", url: await browser.url() }); await browser.evaluate(`(${script}, null)`); - return sessionId; -}; - -/** Has e2e open the app's deep link, launching the app if it is not running. */ -const openDeepLink = async (device: PairingDevice): Promise => { - const { sessionId, deepLink } = await link(); - await device.openLink(deepLink); - return sessionId; + return claim(sessionId); }; /** - * Opens the link on the simulator itself when exactly one is booted, which is quick and asks - * nothing. A link e2e opens for the test makes iOS ask "Open in …?", so that path, taken when - * several simulators are booted or the device is a phone, accepts the question as well. + * Has e2e open the app's deep link, which launches the app if it is not running. A simulator asks + * "Open in …?" for the first link it opens into an app and not again once accepted, so the question + * is accepted only when the app has not claimed in time. */ -const linkIosApp = async (device: PairingDevice): Promise => { - try { - return (await link({ target: "ios-sim" })).sessionId; - } catch (error) { - if (!(error instanceof AppductError && error.type === "client_error")) throw error; +const linkApp = async (target: Extract, clock: Clock): Promise => { + const { sessionId, deepLink } = await link(); + await target.device.openLink(deepLink); + const claimed = claim(sessionId); + if (target.platform === "ios") { + const settled = claimed.then( + () => true, + () => true, + ); + if (!(await Promise.race([settled, clock.sleep(PROMPT_WAIT_MS).then(() => false)]))) { + // agent-device can report a failure for a question it did accept, so the claim decides. + await target.device.alert("accept").catch(() => undefined); + } } - const sessionId = await openDeepLink(device); - // agent-device reports a failure for an accepted question too, after it closes, so the outcome is - // read from the claim instead. - await device.alert("accept").catch(() => undefined); - return sessionId; + return claimed; }; /** Hands the app under test a fresh link and resolves once the app has claimed it. */ -export const pairWith = async (target: PairingTarget): Promise => { - const sessionId = - target.platform === "web" - ? await linkPage(target.browser) - : target.platform === "ios" - ? await linkIosApp(target.device) - : await openDeepLink(target.device); - return waitForSession(sessionId, { timeoutMs: CLAIM_TIMEOUT_MS }); -}; +export const pairWith = (target: PairingTarget, clock: Clock): Promise => + target.platform === "web" ? linkPage(target.browser) : linkApp(target, clock); From 3018ae95bda14dcb01b1e6f6ab55d2a5bc7e83d0 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 12:03:03 +0200 Subject: [PATCH 7/9] ci(release): version, pack and publish @appduct/e2e with the others --- .claude/skills/cut-release/SKILL.md | 12 +++---- .github/workflows/deploy.yaml | 35 ++++++++++++++++--- .../src/__tests__/release-version.e2e.test.ts | 13 ++++++- scripts/release-version.mjs | 4 +-- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/.claude/skills/cut-release/SKILL.md b/.claude/skills/cut-release/SKILL.md index 965d6579..6116cdfc 100644 --- a/.claude/skills/cut-release/SKILL.md +++ b/.claude/skills/cut-release/SKILL.md @@ -1,18 +1,18 @@ --- name: cut-release -description: Cut a release of the four npm packages and the Flutter package - propose the version from the Unreleased changelog, bump versions in lockstep, open the release PR, and after it merges create the GitHub release that triggers publishing, only on an explicit yes. Use when asked to release, cut a version, tag or publish. +description: Cut a release of the five npm packages and the Flutter package - propose the version from the Unreleased changelog, bump versions in lockstep, open the release PR, and after it merges create the GitHub release that triggers publishing, only on an explicit yes. Use when asked to release, cut a version, tag or publish. model: sonnet effort: low --- # Cut a release -The four npm packages (`@appduct/shared`, `appduct`, `@appduct/react-native`, `@appduct/web`) and the Flutter +The five npm packages (`@appduct/shared`, `appduct`, `@appduct/react-native`, `@appduct/web`, `@appduct/e2e`) and the Flutter package (`appduct` on pub.dev) share one version. Podspecs and Gradle read it from `package.json`, and the Flutter package carries it in `pubspec.yaml`, `darwin/appduct.podspec` and -`android/build.gradle.kts`, so the version lives in exactly seven files. Publishing is +`android/build.gradle.kts`, so the version lives in exactly eight files. Publishing is `.github/workflows/deploy.yaml`, triggered by a published GitHub release whose tag is -`v`; it refuses to run if the seven files disagree +`v`; it refuses to run if the eight files disagree (`node scripts/release-version.mjs`). Two phases. Never run phase 2 inside a `work-issue` loop or without the explicit yes. @@ -38,7 +38,7 @@ Read the `cut-release` section of `.agents/memory/LESSONS.md` before starting, p ```bash v= - for p in shared appduct react-native web; do + for p in shared appduct react-native web e2e; do node -e "const f='packages/$p/package.json';const fs=require('fs');const j=JSON.parse(fs.readFileSync(f));j.version='$v';fs.writeFileSync(f,JSON.stringify(j,null,2)+'\n')" done ``` @@ -52,7 +52,7 @@ Read the `cut-release` section of `.agents/memory/LESSONS.md` before starting, p sed -i.bak -E "s/(s\.version +=) '[^']*'/\1 '$v'/" packages/flutter/darwin/appduct.podspec sed -i.bak -E "s/^version = \"[^\"]*\"/version = \"$v\"/" packages/flutter/android/build.gradle.kts rm packages/flutter/pubspec.yaml.bak packages/flutter/darwin/appduct.podspec.bak packages/flutter/android/build.gradle.kts.bak - node scripts/release-version.mjs # prints $v when all seven files agree + node scripts/release-version.mjs # prints $v when all eight files agree (cd packages/flutter && dart pub publish --dry-run) ``` diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 8722d872..337ac1b8 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -41,7 +41,7 @@ jobs: # restoring a pnpm store from a prior run's cache would let a poisoned cache entry slip # dependencies into a published package. Every run resolves and installs cleanly. - name: Validate production release version - # The four npm packages and the Flutter package must carry one version, and it must be + # The five npm packages and the Flutter package must carry one version, and it must be # the release tag's. env: RELEASE_TAG: ${{ github.event.release.tag_name }} @@ -57,6 +57,7 @@ jobs: pnpm --filter appduct pack --pack-destination release pnpm --filter @appduct/react-native pack --pack-destination release pnpm --filter @appduct/web pack --pack-destination release + pnpm --filter @appduct/e2e pack --pack-destination release - name: Upload release tarballs uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -165,13 +166,39 @@ jobs: - name: Publish web package run: npm publish ./release/appduct-web-*.tgz --access public --provenance --tag latest + publish-e2e: + # Depends on the `appduct` package, so it goes out after the CLI. + needs: publish-cli + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: npm + permissions: + id-token: write + steps: + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - name: Verify npm version + run: >- + node -e 'const [major, minor, patch] = process.argv[1].split(".").map(Number); + if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) process.exit(1);' + "$(npm --version)" + - name: Download release tarballs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: npm-packages + path: release + - name: Publish e2e package + run: npm publish ./release/appduct-e2e-*.tgz --access public --provenance --tag latest + publish-cocoapods: # Deliberately last, after every npm publish has succeeded. A CocoaPods trunk version is # permanent -- `pod trunk` has no unpublish, and a version can never be replaced or reused -- # whereas npm allows `npm unpublish` within 72 hours. So the irreversible half of the release # only runs once the reversible half is known good; a trunk push that lands next to a failed # npm publish would pin the repo to a version number it can never ship again. - needs: [publish-cli, publish-react-native, publish-web] + needs: [publish-cli, publish-react-native, publish-web, publish-e2e] runs-on: macos-latest timeout-minutes: 45 # Holds COCOAPODS_TRUNK_TOKEN (from `pod trunk register`). CocoaPods has no OIDC/trusted- @@ -251,7 +278,7 @@ jobs: # # Re-run safe, like publish-cocoapods: an already-published version is skipped, and a deployment # uploaded by an earlier attempt of this run is picked back up instead of being uploaded twice. - needs: [publish-cli, publish-react-native, publish-web] + needs: [publish-cli, publish-react-native, publish-web, publish-e2e] runs-on: ubuntu-latest timeout-minutes: 30 # Holds CENTRAL_TOKEN_USERNAME/PASSWORD (Central Portal user token) and the GPG key pair. @@ -476,7 +503,7 @@ jobs: # npm publishes. Nothing needs this job, so a pub.dev problem never fails the npm release; the # publish run shows its own result under Actions > Publish to pub.dev and can be started again # by hand on the tag. - needs: [publish-cli, publish-react-native, publish-web] + needs: [publish-cli, publish-react-native, publish-web, publish-e2e] runs-on: ubuntu-latest timeout-minutes: 5 permissions: diff --git a/packages/appduct/src/__tests__/release-version.e2e.test.ts b/packages/appduct/src/__tests__/release-version.e2e.test.ts index 3631edd1..1e3691e8 100644 --- a/packages/appduct/src/__tests__/release-version.e2e.test.ts +++ b/packages/appduct/src/__tests__/release-version.e2e.test.ts @@ -34,7 +34,7 @@ function fixture({ }: Versions = {}): string { const root = mkdtempSync(join(tmpdir(), "release-version-")); roots.push(root); - for (const name of ["shared", "appduct", "react-native", "web"]) { + for (const name of ["shared", "appduct", "react-native", "web", "e2e"]) { mkdirSync(join(root, "packages", name), { recursive: true }); writeFileSync( join(root, "packages", name, "package.json"), @@ -112,6 +112,17 @@ describe("release version check", () => { expect(result.err).toContain("packages/web/package.json"); }); + it("rejects an e2e package whose version differs from the other npm packages", () => { + const root = fixture(); + writeFileSync( + join(root, "packages", "e2e", "package.json"), + JSON.stringify({ version: "0.14.0" }), + ); + const result = run(root); + expect(result.code).toBe(1); + expect(result.err).toContain("packages/e2e/package.json"); + }); + it("rejects a prerelease version", () => { const result = run( fixture({ diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index 854006de..3a26be32 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node /** - * Prints the release version and fails unless the four npm packages and the Flutter package carry + * Prints the release version and fails unless the five npm packages and the Flutter package carry * the same, non-prerelease version (the Flutter package is its `pubspec.yaml` its podspec and its Gradle build). * * Usage: node scripts/release-version.mjs [--root ] [--tag ] @@ -22,7 +22,7 @@ const root = resolve( const tag = option("--tag"); const versions = new Map(); -for (const name of ["shared", "appduct", "react-native", "web"]) { +for (const name of ["shared", "appduct", "react-native", "web", "e2e"]) { const file = `packages/${name}/package.json`; versions.set( file, From 0c5685c4c776743d5e9e656ab42b359e71811710 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 12:03:03 +0200 Subject: [PATCH 8/9] fix(client): waitForSession rejects at once when subscribing fails --- .../appduct/src/__tests__/rpc-client.test.ts | 15 +++++++++++++++ packages/appduct/src/client/bootstrap.ts | 19 ++++++++++--------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/appduct/src/__tests__/rpc-client.test.ts b/packages/appduct/src/__tests__/rpc-client.test.ts index 4bdb46ed..169fc332 100644 --- a/packages/appduct/src/__tests__/rpc-client.test.ts +++ b/packages/appduct/src/__tests__/rpc-client.test.ts @@ -849,4 +849,19 @@ describe("waitForSession", () => { expect(app.sessionId).toBe("s1"); app.close(); }); + + test("rejects at once when the daemon refuses the subscription", async () => { + const stateDir = await makeTempStateDir(); + await startFakeDaemon(stateDir, { + version: "9.9.9", + answer: (method) => + method === "events.subscribe" + ? { error: { code: -32000, message: "No such session.", data: { type: "unknown_session" } } } + : { error: { code: -32000, message: "unknown", data: { type: "unknown_session" } } }, + }); + + await expect(waitForSession("s1", { stateDir, autoSpawn: false, timeoutMs: 5_000 })).rejects.toMatchObject({ + type: "unknown_session", + }); + }); }); diff --git a/packages/appduct/src/client/bootstrap.ts b/packages/appduct/src/client/bootstrap.ts index 94aabd47..30a8e3ab 100644 --- a/packages/appduct/src/client/bootstrap.ts +++ b/packages/appduct/src/client/bootstrap.ts @@ -188,17 +188,18 @@ export const waitForSession = async ( // Looks for the session only once subscribed: a claim before the subscription sent no // notification this stream can see, so it must be found by looking, and a claim after it // is seen by the listener above. + const fail = (error: unknown) => settle(() => reject(toAppductError(error))); stream .call(RPC_METHODS.eventsSubscribe, { sessionSelector: sessionId, kinds: ["session_claimed"] }) - .then(() => stream.call(RPC_METHODS.sessionsDescribe, { selector: sessionId })) - .then( - () => settle(resolve), - (error) => { - if (!(error instanceof DaemonRpcError) || error.data?.type !== "unknown_session") { - settle(() => reject(toAppductError(error))); - } - }, - ); + .then(() => { + stream.call(RPC_METHODS.sessionsDescribe, { selector: sessionId }).then( + () => settle(resolve), + // Not claimed yet: the subscription reports the claim when it comes. + (error) => { + if (!(error instanceof DaemonRpcError) || error.data?.type !== "unknown_session") fail(error); + }, + ); + }, fail); }); return makeAppClient(stream, sessionId, openStream); From c68af178c583009a92b2490f03be24f6f203e958 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 21 Oct 2026 12:03:03 +0200 Subject: [PATCH 9/9] fix(e2e): address review findings in pairing, sessions and agent tools --- .../e2e/src/__tests__/agent-tools.test.ts | 40 ++++++ packages/e2e/src/__tests__/pairing.test.ts | 116 ++++++++++++++++++ packages/e2e/src/__tests__/session.test.ts | 35 +++++- packages/e2e/src/agent-tools.ts | 18 ++- packages/e2e/src/daemon.ts | 18 +++ packages/e2e/src/index.ts | 10 +- packages/e2e/src/memory-daemon.ts | 54 ++++++++ packages/e2e/src/pairing.ts | 39 +++--- packages/e2e/src/session.ts | 19 +-- 9 files changed, 316 insertions(+), 33 deletions(-) create mode 100644 packages/e2e/src/__tests__/agent-tools.test.ts create mode 100644 packages/e2e/src/__tests__/pairing.test.ts create mode 100644 packages/e2e/src/daemon.ts create mode 100644 packages/e2e/src/memory-daemon.ts diff --git a/packages/e2e/src/__tests__/agent-tools.test.ts b/packages/e2e/src/__tests__/agent-tools.test.ts new file mode 100644 index 00000000..b781fe3f --- /dev/null +++ b/packages/e2e/src/__tests__/agent-tools.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { appductTools, offerToAgent } from "../agent-tools.js"; +import type { Appduct } from "../session.js"; + +const session = (calls: unknown[]): Appduct => ({ + call: async (name, args) => { + calls.push([name, args]); + return { total: 3 }; + }, + tools: async () => [], + waitForEvent: async () => { + throw new Error("unused"); + }, +}); + +const run = (name: "appduct_call" | "appduct_tools", input: unknown) => + appductTools()[name].tool.execute!(input as never, { toolCallId: "1", messages: [], context: {} }); + +describe("appductTools", () => { + it("calls the app's tool in the running test's session", async () => { + const calls: unknown[] = []; + const withdraw = offerToAgent(session(calls)); + + expect(await run("appduct_call", { name: "sum", args: { a: 1, b: 2 } })).toEqual({ total: 3 }); + expect(calls).toEqual([["sum", { a: 1, b: 2 }]]); + withdraw(); + }); + + it("refuses once the test has ended", async () => { + offerToAgent(session([]))(); + + await expect(run("appduct_tools", {})).rejects.toThrow("No Appduct session"); + }); + + it("marks calling a tool as changing app state and listing them as not", () => { + expect(appductTools().appduct_call.annotations.mutates).toBe(true); + expect(appductTools().appduct_tools.annotations.mutates).toBe(false); + }); +}); diff --git a/packages/e2e/src/__tests__/pairing.test.ts b/packages/e2e/src/__tests__/pairing.test.ts new file mode 100644 index 00000000..a8337c88 --- /dev/null +++ b/packages/e2e/src/__tests__/pairing.test.ts @@ -0,0 +1,116 @@ +import { AppductError, type AppClient } from "appduct/client"; +import { describe, expect, it } from "vitest"; + +import { createMemoryDaemon } from "../memory-daemon.js"; +import { pairWith, type PairingDevice } from "../pairing.js"; +import type { Clock } from "../session.js"; + +const clock: Clock = { now: () => 0, sleep: async () => undefined }; +const client = { sessionId: "paired" } as AppClient; +const sessionOf = (deepLink: string) => new URL(deepLink).searchParams.get("appduct")!; + +/** A device that records what it was asked to do and runs `onOpen` and `onAlert` like an app would. */ +const fakeDevice = (onOpen: (sessionId: string) => void = () => {}, onAlert: () => void = () => {}) => { + const device = { + opened: [] as string[], + alerts: 0, + openLink: async (url: string) => { + device.opened.push(url); + onOpen(sessionOf(url)); + }, + alert: async () => { + device.alerts += 1; + onAlert(); + }, + }; + return device satisfies PairingDevice; +}; + +describe("pairing a device", () => { + it("opens the link on the device and resolves once the app claims it", async () => { + const fake = createMemoryDaemon(); + const device = fakeDevice((sessionId) => fake.appClaims(sessionId, client)); + + expect(await pairWith({ platform: "android", device }, fake.daemon, clock)).toBe(client); + expect(device.opened).toEqual(["myapp:///?appduct=s1"]); + }); + + it("mints a link that lasts as long as the wait for its claim", async () => { + const fake = createMemoryDaemon(); + const device = fakeDevice((sessionId) => fake.appClaims(sessionId, client)); + + await pairWith({ platform: "android", device }, fake.daemon, clock); + + expect(fake.links[0]?.ttlSeconds).toBe(60); + expect(fake.claimTimeouts.get("s1")).toBe(60_000); + }); + + it("does not look for iOS's question when the app claims in time", async () => { + const fake = createMemoryDaemon(); + const device = fakeDevice((sessionId) => fake.appClaims(sessionId, client)); + + await pairWith({ platform: "ios", device }, fake.daemon, clock); + + expect(device.alerts).toBe(0); + }); + + it("accepts iOS's question when the app has not claimed in time", async () => { + const fake = createMemoryDaemon(); + const device = fakeDevice(undefined, () => fake.appClaims("s1", client)); + + expect(await pairWith({ platform: "ios", device }, fake.daemon, clock)).toBe(client); + expect(device.alerts).toBe(1); + }); + + it("goes by the claim when accepting the question reports a failure", async () => { + const fake = createMemoryDaemon(); + const device = fakeDevice(); + device.alert = async () => { + device.alerts += 1; + fake.appClaims("s1", client); + throw new Error("alert accept exhausted its deadline"); + }; + + expect(await pairWith({ platform: "ios", device }, fake.daemon, clock)).toBe(client); + }); + + it("never looks for a question on Android", async () => { + const fake = createMemoryDaemon(); + const device = fakeDevice(); + const pairing = pairWith({ platform: "android", device }, fake.daemon, clock); + await new Promise((resolve) => setTimeout(resolve, 0)); + fake.appClaims("s1", client); + + await pairing; + expect(device.alerts).toBe(0); + }); +}); + +describe("pairing a web page", () => { + const browser = (evaluated: string[]) => ({ + url: async () => "http://localhost:5173/cart", + evaluate: async (script: string) => void evaluated.push(script), + }); + + it("runs the connect script for the page's own address", async () => { + const fake = createMemoryDaemon(); + const evaluated: string[] = []; + fake.appClaims("s1", client); + + expect(await pairWith({ platform: "web", browser: browser(evaluated) }, fake.daemon, clock)).toBe(client); + expect(fake.links[0]?.url).toBe("http://localhost:5173/cart"); + expect(evaluated).toEqual(['(connect("s1"), null)']); + }); + + it("gives up on a page that does not connect within ten seconds, saying what to check", async () => { + const fake = createMemoryDaemon(); + fake.expire("s1", new AppductError("timeout", "Timed out.")); + + await expect(pairWith({ platform: "web", browser: browser([]) }, fake.daemon, clock)).rejects.toMatchObject({ + type: "timeout", + message: expect.stringContaining("@appduct/web"), + }); + expect(fake.claimTimeouts.get("s1")).toBe(10_000); + expect(fake.links[0]?.ttlSeconds).toBe(10); + }); +}); diff --git a/packages/e2e/src/__tests__/session.test.ts b/packages/e2e/src/__tests__/session.test.ts index f58379bb..d180e574 100644 --- a/packages/e2e/src/__tests__/session.test.ts +++ b/packages/e2e/src/__tests__/session.test.ts @@ -16,7 +16,7 @@ type FakeApp = { state?: SessionState; }; -type Fake = AppClient & { closed: boolean; revoked: boolean; app: FakeApp }; +type Fake = AppClient & { closed: boolean; revoked: boolean; closedBeforeRevoked: boolean; app: FakeApp }; /** An app client whose answers the test scripts, recording whether it was closed or revoked. */ const fakeClient = (app: FakeApp = { state: "active" }): Fake => { @@ -25,8 +25,13 @@ const fakeClient = (app: FakeApp = { state: "active" }): Fake => { app, closed: false, revoked: false, + closedBeforeRevoked: false, state: async () => app.state ?? Promise.reject(new AppductError("unknown_session", "No such session.")), - revoke: async () => void (client.revoked = true), + revoke: async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + client.closedBeforeRevoked = client.closed; + client.revoked = true; + }, call: app.call ?? (async () => ({ ok: true })), tools: app.tools ?? (async () => [{ name: "sum" } as ListedToolDescriptor]), events: async () => ({ events: [], cursor: 0, dropped: 0, remaining: 0 }), @@ -78,6 +83,7 @@ describe("one test", () => { expect(count()).toBe(2); expect(first.revoked).toBe(true); expect(first.closed).toBe(true); + expect(first.closedBeforeRevoked).toBe(false); }); it("pairs again once when concurrent calls find the session gone", async () => { @@ -119,6 +125,30 @@ describe("one test", () => { expect(await session.call("sum")).toEqual({ total: 3 }); }); + it("starts the wait for a missing tool once the app has claimed, however long pairing took", async () => { + const clock = fakeClock(); + let calls = 0; + const app = fakeClient({ state: "active", call: async () => (++calls < 3 ? Promise.reject(missing()) : { total: 3 }) }); + const session = createSessions(clock).forTest(async () => { + await clock.sleep(15_000); // a slow device launch + return app; + }); + + expect(await session.call("sum")).toEqual({ total: 3 }); + }); + + it("starts the wait for the first tool once the app has claimed, however long pairing took", async () => { + const clock = fakeClock(); + let lists = 0; + const app = fakeClient({ state: "active", tools: async () => (++lists < 3 ? [] : [{ name: "sum" } as ListedToolDescriptor]) }); + const session = createSessions(clock).forTest(async () => { + await clock.sleep(15_000); + return app; + }); + + expect((await session.tools()).map((tool) => tool.name)).toEqual(["sum"]); + }); + it("fails with tool_not_found when the tool is still missing after ten seconds", async () => { const clock = fakeClock(); const app = fakeClient({ state: "active", call: () => Promise.reject(missing()) }); @@ -182,6 +212,7 @@ describe("across tests in a worker", () => { expect(count()).toBe(2); expect(relaunched.revoked).toBe(true); expect(relaunched.closed).toBe(true); + expect(relaunched.closedBeforeRevoked).toBe(false); }); it("pairs again when the previous test's session no longer exists", async () => { diff --git a/packages/e2e/src/agent-tools.ts b/packages/e2e/src/agent-tools.ts index c585f479..d7ea2eb5 100644 --- a/packages/e2e/src/agent-tools.ts +++ b/packages/e2e/src/agent-tools.ts @@ -3,18 +3,24 @@ import { defineTool } from "e2e/agent"; import type { Appduct } from "./session.js"; -/** The session of the running test. e2e runs one test at a time in a worker. */ -let current: Appduct | undefined; +/** + * The session of the running test; e2e runs one test at a time in a worker. It lives on + * `globalThis` so a second copy of this module, such as one the config loads through another path, + * sees the same session. + */ +const SLOT = Symbol.for("@appduct/e2e/session"); +const slot = globalThis as { [SLOT]?: Appduct }; /** Points the agent tools at `session` until the returned function is called. */ export const offerToAgent = (session: Appduct): (() => void) => { - current = session; + slot[SLOT] = session; return () => { - if (current === session) current = undefined; + if (slot[SLOT] === session) delete slot[SLOT]; }; }; const session = (): Appduct => { + const current = slot[SLOT]; if (!current) throw new Error("No Appduct session in this test. Write the test with a `test` from `withAppduct`."); return current; }; @@ -29,7 +35,7 @@ export const appductTools = () => ({ description: "List the tools the app under test registered with Appduct: what each one does and the arguments it takes. Use one with appduct_call to read or set app state directly instead of through the screen.", inputSchema: jsonSchema>({ type: "object", properties: {}, additionalProperties: false }), - execute: () => session().tools(), + execute: async () => session().tools(), }), { mutates: false }, ), @@ -45,7 +51,7 @@ export const appductTools = () => ({ required: ["name"], additionalProperties: false, }), - execute: ({ name, args }) => session().call(name, args), + execute: async ({ name, args }) => session().call(name, args), }), // A tool may change app state, which e2e cannot replay from its cache. { mutates: true }, diff --git a/packages/e2e/src/daemon.ts b/packages/e2e/src/daemon.ts new file mode 100644 index 00000000..d7a3a94f --- /dev/null +++ b/packages/e2e/src/daemon.ts @@ -0,0 +1,18 @@ +import { link, waitForSession, type AppClient } from "appduct/client"; + +/** The Appduct daemon, as pairing uses it. */ +export type Daemon = { + /** A link that opens the app and pairs it, valid for `ttlSeconds`. */ + deviceLink(ttlSeconds: number): Promise<{ sessionId: string; deepLink: string }>; + /** A link for the page at `url`, and the script that connects an open page to it. */ + webLink(url: string, ttlSeconds: number): Promise<{ sessionId: string; script: string }>; + /** Resolves once the app claims `sessionId`; rejects with `"timeout"` after `timeoutMs`. */ + claim(sessionId: string, timeoutMs: number): Promise; +}; + +/** The daemon `appduct/client` reaches, started on first use. */ +export const appductDaemon: Daemon = { + deviceLink: (ttlSeconds) => link({ ttlSeconds }), + webLink: (url, ttlSeconds) => link({ target: "web", url, ttlSeconds }), + claim: (sessionId, timeoutMs) => waitForSession(sessionId, { timeoutMs }), +}; diff --git a/packages/e2e/src/index.ts b/packages/e2e/src/index.ts index 1152c5cf..31830e95 100644 --- a/packages/e2e/src/index.ts +++ b/packages/e2e/src/index.ts @@ -20,6 +20,7 @@ import type { FixtureDefinitions, FixtureFn, TestAPI, TestFixtures } from "e2e"; import { offerToAgent } from "./agent-tools.js"; +import { appductDaemon } from "./daemon.js"; import { pairWith, type PairingBrowser, type PairingDevice, type PairingTarget } from "./pairing.js"; import { createSessions, type Appduct, type Clock } from "./session.js"; @@ -51,10 +52,13 @@ const targetOf = (fixtures: EngineFixtures): PairingTarget => { */ export const withAppduct = (base: TestAPI): TestAPI => { const appduct: FixtureFn = async (fixtures, use) => { - const session = sessions.forTest(() => pairWith(targetOf(fixtures as unknown as EngineFixtures), clock)); + const session = sessions.forTest(() => pairWith(targetOf(fixtures as unknown as EngineFixtures), appductDaemon, clock)); const withdraw = offerToAgent(session); - await use(session); - withdraw(); + try { + await use(session); + } finally { + withdraw(); + } }; // The mapped definition type cannot tell that `appduct` is absent from a generic `Fixtures`; a // base that has one fails at import with e2e's own COLLECTION_ERROR. diff --git a/packages/e2e/src/memory-daemon.ts b/packages/e2e/src/memory-daemon.ts new file mode 100644 index 00000000..40ff7156 --- /dev/null +++ b/packages/e2e/src/memory-daemon.ts @@ -0,0 +1,54 @@ +import type { AppClient } from "appduct/client"; + +import type { Daemon } from "./daemon.js"; + +type Claim = { promise: Promise; resolve(client: AppClient): void; reject(error: unknown): void }; + +/** A daemon in memory: the test decides when an app claims a link or a claim wait runs out. */ +export const createMemoryDaemon = () => { + const claims = new Map(); + const links: { sessionId: string; ttlSeconds: number; url?: string }[] = []; + const claimTimeouts = new Map(); + + const claimOf = (sessionId: string): Claim => { + let claim = claims.get(sessionId); + if (!claim) { + let resolve!: (client: AppClient) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => ((resolve = res), (reject = rej))); + promise.catch(() => undefined); // a claim nobody waits for must not fail the test run + claim = { promise, resolve, reject }; + claims.set(sessionId, claim); + } + return claim; + }; + + const daemon: Daemon = { + deviceLink: async (ttlSeconds) => { + const sessionId = `s${links.length + 1}`; + links.push({ sessionId, ttlSeconds }); + return { sessionId, deepLink: `myapp:///?appduct=${sessionId}` }; + }, + webLink: async (url, ttlSeconds) => { + const sessionId = `s${links.length + 1}`; + links.push({ sessionId, ttlSeconds, url }); + return { sessionId, script: `connect("${sessionId}")` }; + }, + claim: (sessionId, timeoutMs) => { + claimTimeouts.set(sessionId, timeoutMs); + return claimOf(sessionId).promise; + }, + }; + + return { + daemon, + /** Every link minted, oldest first. */ + links, + /** The `timeoutMs` each claim wait was given. */ + claimTimeouts, + /** The app claims `sessionId`, before or after anyone waits for it. */ + appClaims: (sessionId: string, client: AppClient) => claimOf(sessionId).resolve(client), + /** The wait for `sessionId` runs out. */ + expire: (sessionId: string, error: unknown) => claimOf(sessionId).reject(error), + }; +}; diff --git a/packages/e2e/src/pairing.ts b/packages/e2e/src/pairing.ts index 2c7d777e..43f5c7b1 100644 --- a/packages/e2e/src/pairing.ts +++ b/packages/e2e/src/pairing.ts @@ -1,5 +1,6 @@ -import { link, waitForSession, type AppClient } from "appduct/client"; +import { AppductError, type AppClient } from "appduct/client"; +import type { Daemon } from "./daemon.js"; import type { Clock } from "./session.js"; /** The part of e2e's `browser` fixture pairing uses. */ @@ -18,18 +19,24 @@ export type PairingTarget = | { platform: "web"; browser: PairingBrowser } | { platform: "ios" | "android"; device: PairingDevice }; -/** A web page claims at once; a device may still be launching the app. */ -const CLAIM_TIMEOUT_MS = 60_000; +/** A device may still be launching the app; a page that loads `@appduct/web` connects at once. */ +const DEVICE_CLAIM_SECONDS = 60; +const WEB_CLAIM_SECONDS = 10; /** How long an iOS app gets to claim before pairing looks for iOS's "Open in …?" question. */ const PROMPT_WAIT_MS = 2_000; -const claim = (sessionId: string): Promise => waitForSession(sessionId, { timeoutMs: CLAIM_TIMEOUT_MS }); - -/** Connects the page in the test's browser, which must load `@appduct/web`. */ -const linkPage = async (browser: PairingBrowser): Promise => { - const { sessionId, script } = await link({ target: "web", url: await browser.url() }); +/** Connects the page in the test's browser. */ +const linkPage = async (browser: PairingBrowser, daemon: Daemon): Promise => { + // The link lasts only as long as the wait, so a failed pairing leaves nothing behind. + const { sessionId, script } = await daemon.webLink(await browser.url(), WEB_CLAIM_SECONDS); await browser.evaluate(`(${script}, null)`); - return claim(sessionId); + return daemon.claim(sessionId, WEB_CLAIM_SECONDS * 1000).catch((error: unknown) => { + if (!(error instanceof AppductError && error.type === "timeout")) throw error; + throw new AppductError( + "timeout", + `The page did not connect to Appduct within ${WEB_CLAIM_SECONDS} s. Check that it loads @appduct/web from a development build.`, + ); + }); }; /** @@ -37,10 +44,14 @@ const linkPage = async (browser: PairingBrowser): Promise => { * "Open in …?" for the first link it opens into an app and not again once accepted, so the question * is accepted only when the app has not claimed in time. */ -const linkApp = async (target: Extract, clock: Clock): Promise => { - const { sessionId, deepLink } = await link(); +const linkApp = async ( + target: Extract, + daemon: Daemon, + clock: Clock, +): Promise => { + const { sessionId, deepLink } = await daemon.deviceLink(DEVICE_CLAIM_SECONDS); await target.device.openLink(deepLink); - const claimed = claim(sessionId); + const claimed = daemon.claim(sessionId, DEVICE_CLAIM_SECONDS * 1000); if (target.platform === "ios") { const settled = claimed.then( () => true, @@ -55,5 +66,5 @@ const linkApp = async (target: Extract }; /** Hands the app under test a fresh link and resolves once the app has claimed it. */ -export const pairWith = (target: PairingTarget, clock: Clock): Promise => - target.platform === "web" ? linkPage(target.browser) : linkApp(target, clock); +export const pairWith = (target: PairingTarget, daemon: Daemon, clock: Clock): Promise => + target.platform === "web" ? linkPage(target.browser, daemon) : linkApp(target, daemon, clock); diff --git a/packages/e2e/src/session.ts b/packages/e2e/src/session.ts index 2dcf2668..8ea8c8f5 100644 --- a/packages/e2e/src/session.ts +++ b/packages/e2e/src/session.ts @@ -27,9 +27,9 @@ const isGone = (error: unknown): boolean => (error.type === "session_suspended" || error.type === "session_not_active" || error.type === "unknown_session"); /** Ends a session that is no longer worth keeping. The daemon may have forgotten it already. */ -const discard = (client: AppClient): void => { - void client.revoke().catch(() => undefined); - client.close(); +const discard = async (client: AppClient): Promise => { + await client.revoke().catch(() => undefined); + client.close(); // only now: closing drops a revoke still on its way }; /** @@ -46,7 +46,7 @@ export const createSessions = (clock: Clock) => { if (previous) { // Anything but `active` means the app went away: relaunched, reloaded or closed. if ((await previous.state().catch(() => undefined)) === "active") return (kept = previous); - discard(previous); + await discard(previous); } return (kept = await pair()); }; @@ -82,22 +82,25 @@ export const createSessions = (clock: Clock) => { }; return { + // Each wait starts at the app's first answer, so the time pairing took does not count. call: async (name, args = {}, options) => { - const deadline = clock.now() + TOOL_WAIT_MS; + let deadline: number | undefined; for (;;) { try { return await withClient((client) => client.call(name, args, options)); } catch (error) { - const missing = error instanceof AppductError && error.type === "tool_not_found"; - if (!missing || clock.now() >= deadline) throw error; + if (!(error instanceof AppductError && error.type === "tool_not_found")) throw error; + deadline ??= clock.now() + TOOL_WAIT_MS; + if (clock.now() >= deadline) throw error; await clock.sleep(POLL_MS); } } }, tools: async () => { - const deadline = clock.now() + TOOL_WAIT_MS; + let deadline: number | undefined; for (;;) { const tools = await withClient((client) => client.tools()); + deadline ??= clock.now() + TOOL_WAIT_MS; if (tools.length > 0 || clock.now() >= deadline) return tools; await clock.sleep(POLL_MS); }