@appduct/e2e 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Szymon Chmal and Callstack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,87 @@
1
- # Temporary Holding Version
1
+ <a href="https://callstackincubator.github.io/appduct/"><img src="https://raw.githubusercontent.com/callstackincubator/appduct/main/.github/assets/readme-banner.png" alt="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." width="100%" /></a>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # Appduct for e2e
4
+
5
+ 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.
6
+
7
+ ## Requirements
8
+
9
+ - An app that registers tools with an Appduct SDK, in a debug build. Web pages load `@appduct/web` from a development build.
10
+ - e2e 0.21 or newer, with its web or mobile engine.
11
+ - Web, iOS simulator or Android emulator targets.
12
+ - For iOS and Android, the app's URL scheme in `.appduct/config.json` at your e2e project root, as the `appduct` CLI reads it.
13
+
14
+ ## Getting started
15
+
16
+ ```bash
17
+ npm install -D @appduct/e2e
18
+ ```
19
+
20
+ Add the `appduct` fixture to your `test` in one place:
21
+
22
+ ```ts
23
+ // tests/fixtures.ts
24
+ import { test as base } from "e2e";
25
+ import { withAppduct } from "@appduct/e2e";
26
+
27
+ export const test = withAppduct(base);
28
+ ```
29
+
30
+ `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:
31
+
32
+ ```ts
33
+ // tests/cart.e2e.ts
34
+ import { expect } from "e2e";
35
+ import { test } from "./fixtures.ts";
36
+
37
+ test("a seeded cart checks out", async ({ app, appduct, screen }) => {
38
+ await app.open();
39
+ await appduct.call("seed_cart", { items: 3 });
40
+
41
+ await screen.getByRole("button", { name: "Check out" }).tap();
42
+
43
+ expect(await appduct.call("order_status")).toEqual({ status: "paid" });
44
+ });
45
+ ```
46
+
47
+ The first `appduct` call in a test connects to the app the test opened. A test that never uses `appduct` doesn't connect.
48
+
49
+ ## Let the agent call your tools
50
+
51
+ Add `appductTools()` to an agent's `tools` in `e2e.config.ts`:
52
+
53
+ ```ts
54
+ import type { E2EConfig } from "e2e";
55
+ import { appductTools } from "@appduct/e2e";
56
+
57
+ export default {
58
+ // targets: …
59
+ agents: { default: { model, tools: appductTools() } },
60
+ } satisfies E2EConfig;
61
+ ```
62
+
63
+ 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`.
64
+
65
+ ## The `appduct` fixture
66
+
67
+ | Member | What it does |
68
+ | --- | --- |
69
+ | `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. |
70
+ | `tools()` | Lists the app's tools. Waits up to 10 seconds for the app to register its first. |
71
+ | `waitForEvent(name, options?)` | Resolves with the next event the app posts under `name`. |
72
+
73
+ Errors are `AppductError`s from `appduct/client`, with the same `type` the CLI reports.
74
+
75
+ ## Things to know
76
+
77
+ - **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.
78
+ - **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.
79
+ - **Agent steps that call `appduct_call` run live.** e2e can't replay a call that may change app state from its cache.
80
+
81
+ ## Made with ❤️ at Callstack
82
+
83
+ `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!
84
+
85
+ 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! 🔥
86
+
87
+ [callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=appduct&utm_term=readme-with-love
@@ -0,0 +1,11 @@
1
+ import type { Appduct } from "./session.js";
2
+ /** Points the agent tools at `session` until the returned function is called. */
3
+ export declare const offerToAgent: (session: Appduct) => (() => void);
4
+ /**
5
+ * Tools for e2e's agent that call the tools the app registered with Appduct. Add them to an
6
+ * agent's `tools` in `e2e.config.ts`; they work in tests written with a `test` from `withAppduct`.
7
+ */
8
+ export declare const appductTools: () => {
9
+ appduct_tools: import("e2e/agent").DefinedTool;
10
+ appduct_call: import("e2e/agent").DefinedTool;
11
+ };
@@ -0,0 +1,49 @@
1
+ import { jsonSchema, tool } from "ai";
2
+ import { defineTool } from "e2e/agent";
3
+ /**
4
+ * The session of the running test; e2e runs one test at a time in a worker. It lives on
5
+ * `globalThis` so a second copy of this module, such as one the config loads through another path,
6
+ * sees the same session.
7
+ */
8
+ const SLOT = Symbol.for("@appduct/e2e/session");
9
+ const slot = globalThis;
10
+ /** Points the agent tools at `session` until the returned function is called. */
11
+ export const offerToAgent = (session) => {
12
+ slot[SLOT] = session;
13
+ return () => {
14
+ if (slot[SLOT] === session)
15
+ delete slot[SLOT];
16
+ };
17
+ };
18
+ const session = () => {
19
+ const current = slot[SLOT];
20
+ if (!current)
21
+ throw new Error("No Appduct session in this test. Write the test with a `test` from `withAppduct`.");
22
+ return current;
23
+ };
24
+ /**
25
+ * Tools for e2e's agent that call the tools the app registered with Appduct. Add them to an
26
+ * agent's `tools` in `e2e.config.ts`; they work in tests written with a `test` from `withAppduct`.
27
+ */
28
+ export const appductTools = () => ({
29
+ appduct_tools: defineTool(tool({
30
+ 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.",
31
+ inputSchema: jsonSchema({ type: "object", properties: {}, additionalProperties: false }),
32
+ execute: async () => session().tools(),
33
+ }), { mutates: false }),
34
+ appduct_call: defineTool(tool({
35
+ description: "Call a tool the app under test registered with Appduct and get its result. List the tools with appduct_tools first.",
36
+ inputSchema: jsonSchema({
37
+ type: "object",
38
+ properties: {
39
+ name: { type: "string", description: "The tool's name, as appduct_tools lists it." },
40
+ args: { type: "object", description: "The tool's arguments, matching its input schema." },
41
+ },
42
+ required: ["name"],
43
+ additionalProperties: false,
44
+ }),
45
+ execute: async ({ name, args }) => session().call(name, args),
46
+ }),
47
+ // A tool may change app state, which e2e cannot replay from its cache.
48
+ { mutates: true }),
49
+ });
@@ -0,0 +1,18 @@
1
+ import { type AppClient } from "appduct/client";
2
+ /** The Appduct daemon, as pairing uses it. */
3
+ export type Daemon = {
4
+ /** A link that opens the app and pairs it, valid for `ttlSeconds`. */
5
+ deviceLink(ttlSeconds: number): Promise<{
6
+ sessionId: string;
7
+ deepLink: string;
8
+ }>;
9
+ /** A link for the page at `url`, and the script that connects an open page to it. */
10
+ webLink(url: string, ttlSeconds: number): Promise<{
11
+ sessionId: string;
12
+ script: string;
13
+ }>;
14
+ /** Resolves once the app claims `sessionId`; rejects with `"timeout"` after `timeoutMs`. */
15
+ claim(sessionId: string, timeoutMs: number): Promise<AppClient>;
16
+ };
17
+ /** The daemon `appduct/client` reaches, started on first use. */
18
+ export declare const appductDaemon: Daemon;
package/dist/daemon.js ADDED
@@ -0,0 +1,7 @@
1
+ import { link, waitForSession } from "appduct/client";
2
+ /** The daemon `appduct/client` reaches, started on first use. */
3
+ export const appductDaemon = {
4
+ deviceLink: (ttlSeconds) => link({ ttlSeconds }),
5
+ webLink: (url, ttlSeconds) => link({ target: "web", url, ttlSeconds }),
6
+ claim: (sessionId, timeoutMs) => waitForSession(sessionId, { timeoutMs }),
7
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `@appduct/e2e`: Appduct in e2e (https://github.com/tester-army/e2e) tests. `withAppduct(test)`
3
+ * gives every test an `appduct` fixture that calls the app's tools, and `appductTools()` gives
4
+ * e2e's agent the same reach.
5
+ *
6
+ * ```ts
7
+ * import { test as base } from "e2e";
8
+ * import { withAppduct } from "@appduct/e2e";
9
+ *
10
+ * const test = withAppduct(base);
11
+ *
12
+ * test("checks out", async ({ app, appduct, agent }) => {
13
+ * await app.open();
14
+ * await appduct.call("seed_cart", { sku: "SHIRT" });
15
+ * await agent.act("check out");
16
+ * expect(await appduct.call("order_status")).toEqual({ status: "paid" });
17
+ * });
18
+ * ```
19
+ */
20
+ import type { TestAPI, TestFixtures } from "e2e";
21
+ import { type Appduct } from "./session.js";
22
+ export { appductTools } from "./agent-tools.js";
23
+ export type { Appduct, EventWait } from "./session.js";
24
+ /**
25
+ * Adds an `appduct` fixture to `base`: the app's Appduct session, connected on first use and kept
26
+ * for the next test while the app stays connected. `base` is
27
+ * e2e's `test`, an engine's typed `test`, or one you already extended.
28
+ */
29
+ export declare const withAppduct: <Fixtures extends TestFixtures>(base: TestAPI<Fixtures>) => TestAPI<Fixtures & {
30
+ appduct: Appduct;
31
+ }>;
package/dist/index.js ADDED
@@ -0,0 +1,40 @@
1
+ import { offerToAgent } from "./agent-tools.js";
2
+ import { appductDaemon } from "./daemon.js";
3
+ import { pairWith } from "./pairing.js";
4
+ import { createSessions } from "./session.js";
5
+ export { appductTools } from "./agent-tools.js";
6
+ const clock = {
7
+ now: () => Date.now(),
8
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
9
+ };
10
+ /** One per worker process, shared by every `withAppduct` test in it. */
11
+ const sessions = createSessions(clock);
12
+ /** Reads the engine's fixture for the platform only when pairing, since e2e acquires fixtures on access. */
13
+ const targetOf = (fixtures) => {
14
+ const { platform } = fixtures;
15
+ if (platform === "web")
16
+ return { platform, browser: fixtures.browser };
17
+ if (platform === "ios" || platform === "android")
18
+ return { platform, device: fixtures.device };
19
+ throw new Error(`Appduct pairs with web, iOS and Android targets, not "${platform}".`);
20
+ };
21
+ /**
22
+ * Adds an `appduct` fixture to `base`: the app's Appduct session, connected on first use and kept
23
+ * for the next test while the app stays connected. `base` is
24
+ * e2e's `test`, an engine's typed `test`, or one you already extended.
25
+ */
26
+ export const withAppduct = (base) => {
27
+ const appduct = async (fixtures, use) => {
28
+ const session = sessions.forTest(() => pairWith(targetOf(fixtures), appductDaemon, clock));
29
+ const withdraw = offerToAgent(session);
30
+ try {
31
+ await use(session);
32
+ }
33
+ finally {
34
+ withdraw();
35
+ }
36
+ };
37
+ // The mapped definition type cannot tell that `appduct` is absent from a generic `Fixtures`; a
38
+ // base that has one fails at import with e2e's own COLLECTION_ERROR.
39
+ return base.extend({ appduct });
40
+ };
@@ -0,0 +1,18 @@
1
+ import type { AppClient } from "appduct/client";
2
+ import type { Daemon } from "./daemon.js";
3
+ /** A daemon in memory: the test decides when an app claims a link or a claim wait runs out. */
4
+ export declare const createMemoryDaemon: () => {
5
+ daemon: Daemon;
6
+ /** Every link minted, oldest first. */
7
+ links: {
8
+ sessionId: string;
9
+ ttlSeconds: number;
10
+ url?: string;
11
+ }[];
12
+ /** The `timeoutMs` each claim wait was given. */
13
+ claimTimeouts: Map<string, number>;
14
+ /** The app claims `sessionId`, before or after anyone waits for it. */
15
+ appClaims: (sessionId: string, client: AppClient) => void;
16
+ /** The wait for `sessionId` runs out. */
17
+ expire: (sessionId: string, error: unknown) => void;
18
+ };
@@ -0,0 +1,45 @@
1
+ /** A daemon in memory: the test decides when an app claims a link or a claim wait runs out. */
2
+ export const createMemoryDaemon = () => {
3
+ const claims = new Map();
4
+ const links = [];
5
+ const claimTimeouts = new Map();
6
+ const claimOf = (sessionId) => {
7
+ let claim = claims.get(sessionId);
8
+ if (!claim) {
9
+ let resolve;
10
+ let reject;
11
+ const promise = new Promise((res, rej) => ((resolve = res), (reject = rej)));
12
+ promise.catch(() => undefined); // a claim nobody waits for must not fail the test run
13
+ claim = { promise, resolve, reject };
14
+ claims.set(sessionId, claim);
15
+ }
16
+ return claim;
17
+ };
18
+ const daemon = {
19
+ deviceLink: async (ttlSeconds) => {
20
+ const sessionId = `s${links.length + 1}`;
21
+ links.push({ sessionId, ttlSeconds });
22
+ return { sessionId, deepLink: `myapp:///?appduct=${sessionId}` };
23
+ },
24
+ webLink: async (url, ttlSeconds) => {
25
+ const sessionId = `s${links.length + 1}`;
26
+ links.push({ sessionId, ttlSeconds, url });
27
+ return { sessionId, script: `connect("${sessionId}")` };
28
+ },
29
+ claim: (sessionId, timeoutMs) => {
30
+ claimTimeouts.set(sessionId, timeoutMs);
31
+ return claimOf(sessionId).promise;
32
+ },
33
+ };
34
+ return {
35
+ daemon,
36
+ /** Every link minted, oldest first. */
37
+ links,
38
+ /** The `timeoutMs` each claim wait was given. */
39
+ claimTimeouts,
40
+ /** The app claims `sessionId`, before or after anyone waits for it. */
41
+ appClaims: (sessionId, client) => claimOf(sessionId).resolve(client),
42
+ /** The wait for `sessionId` runs out. */
43
+ expire: (sessionId, error) => claimOf(sessionId).reject(error),
44
+ };
45
+ };
@@ -0,0 +1,22 @@
1
+ import { type AppClient } from "appduct/client";
2
+ import type { Daemon } from "./daemon.js";
3
+ import type { Clock } from "./session.js";
4
+ /** The part of e2e's `browser` fixture pairing uses. */
5
+ export type PairingBrowser = {
6
+ url(): Promise<string>;
7
+ evaluate(script: string): Promise<unknown>;
8
+ };
9
+ /** The part of e2e's `device` fixture pairing uses. */
10
+ export type PairingDevice = {
11
+ openLink(url: string): Promise<void>;
12
+ alert(action: "accept"): Promise<void>;
13
+ };
14
+ export type PairingTarget = {
15
+ platform: "web";
16
+ browser: PairingBrowser;
17
+ } | {
18
+ platform: "ios" | "android";
19
+ device: PairingDevice;
20
+ };
21
+ /** Hands the app under test a fresh link and resolves once the app has claimed it. */
22
+ export declare const pairWith: (target: PairingTarget, daemon: Daemon, clock: Clock) => Promise<AppClient>;
@@ -0,0 +1,37 @@
1
+ import { AppductError } from "appduct/client";
2
+ /** A device may still be launching the app; a page that loads `@appduct/web` connects at once. */
3
+ const DEVICE_CLAIM_SECONDS = 60;
4
+ const WEB_CLAIM_SECONDS = 10;
5
+ /** How long an iOS app gets to claim before pairing looks for iOS's "Open in …?" question. */
6
+ const PROMPT_WAIT_MS = 2_000;
7
+ /** Connects the page in the test's browser. */
8
+ const linkPage = async (browser, daemon) => {
9
+ // The link lasts only as long as the wait, so a failed pairing leaves nothing behind.
10
+ const { sessionId, script } = await daemon.webLink(await browser.url(), WEB_CLAIM_SECONDS);
11
+ await browser.evaluate(`(${script}, null)`);
12
+ return daemon.claim(sessionId, WEB_CLAIM_SECONDS * 1000).catch((error) => {
13
+ if (!(error instanceof AppductError && error.type === "timeout"))
14
+ throw error;
15
+ 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.`);
16
+ });
17
+ };
18
+ /**
19
+ * Has e2e open the app's deep link, which launches the app if it is not running. A simulator asks
20
+ * "Open in …?" for the first link it opens into an app and not again once accepted, so the question
21
+ * is accepted only when the app has not claimed in time.
22
+ */
23
+ const linkApp = async (target, daemon, clock) => {
24
+ const { sessionId, deepLink } = await daemon.deviceLink(DEVICE_CLAIM_SECONDS);
25
+ await target.device.openLink(deepLink);
26
+ const claimed = daemon.claim(sessionId, DEVICE_CLAIM_SECONDS * 1000);
27
+ if (target.platform === "ios") {
28
+ const settled = claimed.then(() => true, () => true);
29
+ if (!(await Promise.race([settled, clock.sleep(PROMPT_WAIT_MS).then(() => false)]))) {
30
+ // agent-device can report a failure for a question it did accept, so the claim decides.
31
+ await target.device.alert("accept").catch(() => undefined);
32
+ }
33
+ }
34
+ return claimed;
35
+ };
36
+ /** Hands the app under test a fresh link and resolves once the app has claimed it. */
37
+ export const pairWith = (target, daemon, clock) => target.platform === "web" ? linkPage(target.browser, daemon) : linkApp(target, daemon, clock);
@@ -0,0 +1,23 @@
1
+ import { type AppClient, type CallOptions, type FullAppEvent, type ListedToolDescriptor, type WaitForEventOptions } from "appduct/client";
2
+ /** The app's Appduct session in one e2e test, paired the first time a test or agent step uses it. */
3
+ export type Appduct = {
4
+ /** Calls a tool the app registered, waiting up to 10 s for a tool the app has not registered yet. */
5
+ call(name: string, args?: Record<string, unknown>, options?: CallOptions): Promise<unknown>;
6
+ /** The tools the app registered, waiting up to 10 s for the app to register its first. */
7
+ tools(): Promise<ListedToolDescriptor[]>;
8
+ /** Resolves with the next event the app posts under `name`. */
9
+ waitForEvent<TPayload = unknown>(name: string, options?: EventWait): Promise<FullAppEvent<TPayload>>;
10
+ };
11
+ /** How long to wait and which events count; the payload always arrives in full. */
12
+ export type EventWait = Omit<WaitForEventOptions, "payloadMaxBytes">;
13
+ export type Clock = {
14
+ now(): number;
15
+ sleep(ms: number): Promise<void>;
16
+ };
17
+ /**
18
+ * The worker's Appduct session, kept across tests: e2e runs one test at a time in a worker, and a
19
+ * test that leaves the app running hands the next one an app that is still connected.
20
+ */
21
+ export declare const createSessions: (clock: Clock) => {
22
+ forTest: (pair: () => Promise<AppClient>) => Appduct;
23
+ };
@@ -0,0 +1,93 @@
1
+ import { AppductError } from "appduct/client";
2
+ /** An app registers its tools just after it claims a session, and a screen may register more as it opens. */
3
+ const TOOL_WAIT_MS = 10_000;
4
+ const POLL_MS = 50;
5
+ const isGone = (error) => error instanceof AppductError &&
6
+ (error.type === "session_suspended" || error.type === "session_not_active" || error.type === "unknown_session");
7
+ /** Ends a session that is no longer worth keeping. The daemon may have forgotten it already. */
8
+ const discard = async (client) => {
9
+ await client.revoke().catch(() => undefined);
10
+ client.close(); // only now: closing drops a revoke still on its way
11
+ };
12
+ /**
13
+ * The worker's Appduct session, kept across tests: e2e runs one test at a time in a worker, and a
14
+ * test that leaves the app running hands the next one an app that is still connected.
15
+ */
16
+ export const createSessions = (clock) => {
17
+ let kept;
18
+ /** The kept session while the app is still connected to it, else a newly paired one. */
19
+ const resume = async (pair) => {
20
+ const previous = kept;
21
+ kept = undefined;
22
+ if (previous) {
23
+ // Anything but `active` means the app went away: relaunched, reloaded or closed.
24
+ if ((await previous.state().catch(() => undefined)) === "active")
25
+ return (kept = previous);
26
+ await discard(previous);
27
+ }
28
+ return (kept = await pair());
29
+ };
30
+ /**
31
+ * One test's view of the session. It connects on first use, reusing the kept session when it can,
32
+ * and pairs again once if the session goes away during the test.
33
+ */
34
+ const forTest = (pair) => {
35
+ let current;
36
+ const connected = () => {
37
+ if (!current) {
38
+ const attempt = resume(pair);
39
+ attempt.catch(() => {
40
+ if (current === attempt)
41
+ current = undefined; // so the next use tries again
42
+ });
43
+ current = attempt;
44
+ }
45
+ return current;
46
+ };
47
+ const withClient = async (use) => {
48
+ const attempt = connected();
49
+ const client = await attempt;
50
+ try {
51
+ return await use(client);
52
+ }
53
+ catch (error) {
54
+ if (!isGone(error))
55
+ throw error;
56
+ if (current === attempt)
57
+ current = undefined; // a concurrent call may have reconnected already
58
+ return use(await connected());
59
+ }
60
+ };
61
+ return {
62
+ // Each wait starts at the app's first answer, so the time pairing took does not count.
63
+ call: async (name, args = {}, options) => {
64
+ let deadline;
65
+ for (;;) {
66
+ try {
67
+ return await withClient((client) => client.call(name, args, options));
68
+ }
69
+ catch (error) {
70
+ if (!(error instanceof AppductError && error.type === "tool_not_found"))
71
+ throw error;
72
+ deadline ??= clock.now() + TOOL_WAIT_MS;
73
+ if (clock.now() >= deadline)
74
+ throw error;
75
+ await clock.sleep(POLL_MS);
76
+ }
77
+ }
78
+ },
79
+ tools: async () => {
80
+ let deadline;
81
+ for (;;) {
82
+ const tools = await withClient((client) => client.tools());
83
+ deadline ??= clock.now() + TOOL_WAIT_MS;
84
+ if (tools.length > 0 || clock.now() >= deadline)
85
+ return tools;
86
+ await clock.sleep(POLL_MS);
87
+ }
88
+ },
89
+ waitForEvent: (name, options) => withClient((client) => client.waitForEvent(name, options)),
90
+ };
91
+ };
92
+ return { forTest };
93
+ };
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@appduct/e2e",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.0",
4
+ "description": "Use Appduct in e2e tests: call your app's tools from a test or let e2e's agent call them.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/callstackincubator/appduct"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/callstackincubator/appduct/issues"
21
+ },
22
+ "author": "Szymon Chmal <szymon@chmal.it> (V3RON)",
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "appduct": "0.15.1"
26
+ },
27
+ "peerDependencies": {
28
+ "ai": "^7.0.0",
29
+ "e2e": ">=0.21.0 <1"
30
+ },
31
+ "devDependencies": {
32
+ "@e2e-dev/mobile": "0.12.3",
33
+ "@e2e-dev/web": "0.15.2",
34
+ "@types/node": "^22.10.0",
35
+ "ai": "^7.0.0",
36
+ "e2e": "0.21.2",
37
+ "typescript": "~5.9.2"
38
+ },
39
+ "homepage": "https://github.com/callstackincubator/appduct/tree/main/packages/e2e#readme",
40
+ "files": [
41
+ "dist"
42
+ ],
43
+ "keywords": [
44
+ "appduct",
45
+ "e2e",
46
+ "testing"
47
+ ],
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.build.json",
50
+ "clean": "rm -rf dist",
51
+ "lint": "eslint src",
52
+ "test": "vitest run --config ../../vitest.config.ts src",
53
+ "typecheck": "tsc -p tsconfig.json"
54
+ }
6
55
  }