touchpress 0.0.2 → 0.1.1
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/README.md +14 -0
- package/dist/core/index.d.mts +5 -3
- package/dist/core/index.mjs +1 -1
- package/dist/index.d.mts +76 -6
- package/dist/index.mjs +531 -4
- package/dist/{preflight-CCKYplYi.d.mts → preflight-Czhj9QFM.d.mts} +32 -0
- package/dist/{preflight-DzSceNMx.mjs → preflight-aUba9u1k.mjs} +165 -122
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -66,6 +66,19 @@ npx playwright test --project=ios
|
|
|
66
66
|
|
|
67
67
|
`preflight` reads a project's options and reports whether the device they name is booted, so a missing simulator fails once, in about a second, instead of once per test after the launch timeout. Wire it as a setup project per platform that the device projects depend on. [Basics](https://github.com/wobsoriano/touchpress/blob/main/docs/basics.md) has the spec, and [`apps/e2e/e2e/preflight.setup.mts`](https://github.com/wobsoriano/touchpress/blob/main/apps/e2e/e2e/preflight.setup.mts) is a working one.
|
|
68
68
|
|
|
69
|
+
### Drive a step with a model
|
|
70
|
+
|
|
71
|
+
`device.act` takes an instruction in English and drives the app until it is satisfied. `device.extract` asks one question about the screen and returns a typed answer.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
test('sign in', async ({ device }) => {
|
|
75
|
+
await device.act('Sign in with the email rob@example.com and the password hunter2');
|
|
76
|
+
await expect(device.getByTestId('greeting')).toHaveText('Hi, Rob');
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Set `use.aiModel` to a gateway model id or a provider model instance, and install `ai`, an optional peer dependency. A loop can run for minutes, so raise the test timeout on any spec that calls `act`. The example uses the sample app's fake account. Keep real credentials in deterministic `fill(text, { secret: true })` calls outside `act`, and assert deterministically afterwards. [AI](https://github.com/wobsoriano/touchpress/blob/main/docs/ai.md) covers `act`, `extract`, the tools the model gets, and the report.
|
|
81
|
+
|
|
69
82
|
## Run the sample project
|
|
70
83
|
|
|
71
84
|
`apps/e2e` is an Expo app with a home, login, and profile route and a fake sign-in. It is the app touchpress is tested against. Build the library first with `vp run -r build` so the app can resolve `dist`, then run these from `apps/e2e`:
|
|
@@ -93,6 +106,7 @@ Leave Metro running for the whole suite. The first build takes several minutes.
|
|
|
93
106
|
- [Configuration](https://github.com/wobsoriano/touchpress/blob/main/docs/configuration.md)
|
|
94
107
|
- [Locators](https://github.com/wobsoriano/touchpress/blob/main/docs/locators.md)
|
|
95
108
|
- [Assertions](https://github.com/wobsoriano/touchpress/blob/main/docs/assertions.md)
|
|
109
|
+
- [AI](https://github.com/wobsoriano/touchpress/blob/main/docs/ai.md)
|
|
96
110
|
- [Lifecycle](https://github.com/wobsoriano/touchpress/blob/main/docs/lifecycle.md)
|
|
97
111
|
- [Continuous integration](https://github.com/wobsoriano/touchpress/blob/main/docs/ci.md)
|
|
98
112
|
|
package/dist/core/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as Tree, A as deviceNameForSlot, B as ProbeResult, C as openSession, D as ResolvedOptions, E as ReadyQuery, F as StepOptions, G as CaptureOptions, H as formatFailure, I as Typed, J as DeviceInfo, K as DeviceDriver, L as renderTitle, M as ActionRecord, N as ActionSink, O as TOUCHPRESS_DEFAULTS, P as EvidenceFile, Q as Settled, R as silentSink, S as SessionState, T as DeviceChoice, U as probe, V as ProbeTarget, W as Binding, X as OpenRequest, Y as DeviceSelection, Z as ScrollDirection, _ as RoleOptions, _t as Screen, a as ExpectedValue, at as Filter, b as DeviceSession, bt as renderScreen, c as ScrollSearch, ct as TextMatch, d as directionToward, dt as textMatch, et as Check, f as ActionOptions, ft as PinnedRef, g as Locator, gt as Resolution, h as FilterOptions, ht as Rect, i as ErrorInfo, it as evaluate, j as parseDeviceOptions, k as TouchpressOptions, l as ScrollTrail, lt as describeQuery, m as FillOptions, mt as RawSnapshot, n as PreflightReport, nt as Verdict, o as TouchpressError, ot as Query, p as Device, pt as Platform, q as DeviceFailure, r as preflight, rt as describeCheck, s as ScrollDevice, st as Role, t as PreflightDevice, tt as CheckName, u as createScrollSearch, ut as normalizeText, v as TextOptions, vt as ScreenNode, w as sessionName, x as OpenSessionInput, xt as resolve, y as createDevice, yt as parseScreen, z as ProbeOptions } from "../preflight-
|
|
1
|
+
import { $ as Tree, A as deviceNameForSlot, B as ProbeResult, C as openSession, D as ResolvedOptions, E as ReadyQuery, F as StepOptions, G as CaptureOptions, H as formatFailure, I as Typed, J as DeviceInfo, K as DeviceDriver, L as renderTitle, M as ActionRecord, N as ActionSink, O as TOUCHPRESS_DEFAULTS, P as EvidenceFile, Q as Settled, R as silentSink, S as SessionState, T as DeviceChoice, U as probe, V as ProbeTarget, W as Binding, X as OpenRequest, Y as DeviceSelection, Z as ScrollDirection, _ as RoleOptions, _t as Screen, a as ExpectedValue, at as Filter, b as DeviceSession, bt as renderScreen, c as ScrollSearch, ct as TextMatch, d as directionToward, dt as textMatch, et as Check, f as ActionOptions, ft as PinnedRef, g as Locator, gt as Resolution, h as FilterOptions, ht as Rect, i as ErrorInfo, it as evaluate, j as parseDeviceOptions, k as TouchpressOptions, l as ScrollTrail, lt as describeQuery, m as FillOptions, mt as RawSnapshot, n as PreflightReport, nt as Verdict, o as TouchpressError, ot as Query, p as Device, pt as Platform, q as DeviceFailure, r as preflight, rt as describeCheck, s as ScrollDevice, st as Role, t as PreflightDevice, tt as CheckName, u as createScrollSearch, ut as normalizeText, v as TextOptions, vt as ScreenNode, w as sessionName, x as OpenSessionInput, xt as resolve, y as createDevice, yt as parseScreen, z as ProbeOptions } from "../preflight-Czhj9QFM.mjs";
|
|
2
2
|
//#region src/core/evidence.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* Never throws. A capture that fails records a note and returns, because masking
|
|
@@ -52,9 +52,11 @@ type Comparison = {
|
|
|
52
52
|
*/
|
|
53
53
|
declare function compareScreenshot(expected: Buffer, actual: Buffer, options: CompareOptions): Comparison;
|
|
54
54
|
/**
|
|
55
|
-
* The box is
|
|
55
|
+
* The box is clipped to the image, because a rect comes from a snapshot and a
|
|
56
56
|
* screenshot is a separate capture. A control flush against the bottom edge can
|
|
57
|
-
* round a pixel past it, and that is not a reason to fail an assertion.
|
|
57
|
+
* round a pixel past it, and that is not a reason to fail an assertion. A box
|
|
58
|
+
* with no pixel inside the image is a different thing, a rect the screenshot
|
|
59
|
+
* does not show, and cutting a placeholder out of the corner would hide that.
|
|
58
60
|
*/
|
|
59
61
|
declare function cropScreenshot(source: Buffer, box: PixelBox): Buffer;
|
|
60
62
|
declare function sizeOf(source: Buffer): Size;
|
package/dist/core/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as normalizeText, C as parseScreen, D as deviceNameForSlot, E as TOUCHPRESS_DEFAULTS, M as TouchpressError, O as parseDeviceOptions, S as silentSink, T as resolve, a as sizeOf, d as createScrollSearch, f as directionToward, g as evaluate, h as describeCheck, i as relativeTo, j as textMatch, k as describeQuery, l as captureEvidence, m as probe, n as compareScreenshot, o as toPixelBox, p as formatFailure, r as cropScreenshot, t as preflight, u as createDevice, v as openSession, w as renderScreen, x as renderTitle, y as sessionName } from "../preflight-aUba9u1k.mjs";
|
|
2
2
|
export { TOUCHPRESS_DEFAULTS, TouchpressError, captureEvidence, compareScreenshot, createDevice, createScrollSearch, cropScreenshot, describeCheck, describeQuery, deviceNameForSlot, directionToward, evaluate, formatFailure, normalizeText, openSession, parseDeviceOptions, parseScreen, preflight, probe, relativeTo, renderScreen, renderTitle, resolve, sessionName, silentSink, sizeOf, textMatch, toPixelBox };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
|
-
import { E as ReadyQuery, _t as Screen, a as ExpectedValue, at as Filter, b as DeviceSession, ct as TextMatch, g as Locator, h as FilterOptions, ht as Rect, i as ErrorInfo, k as TouchpressOptions, n as PreflightReport, o as TouchpressError, ot as Query, p as Device, pt as Platform, r as preflight, st as Role, t as PreflightDevice, vt as ScreenNode } from "./preflight-
|
|
1
|
+
import { E as ReadyQuery, _t as Screen, a as ExpectedValue, at as Filter, b as DeviceSession, ct as TextMatch, g as Locator, h as FilterOptions, ht as Rect, i as ErrorInfo, k as TouchpressOptions$1, n as PreflightReport, o as TouchpressError, ot as Query, p as Device$1, pt as Platform, r as preflight, st as Role, t as PreflightDevice, vt as ScreenNode } from "./preflight-Czhj9QFM.mjs";
|
|
2
2
|
import { ExpectMatcherState } from "@playwright/test";
|
|
3
|
+
//#region src/ai/options.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* What `use.aiModel` accepts, spelled structurally so the main entry's
|
|
6
|
+
* declarations never import from `ai`. A gateway model id such as
|
|
7
|
+
* `'anthropic/claude-sonnet-5'` and a provider model instance both fit, and
|
|
8
|
+
* every AI SDK language model, v2 through v4, carries these three fields.
|
|
9
|
+
*/
|
|
10
|
+
type AiModel = string | {
|
|
11
|
+
readonly specificationVersion: string;
|
|
12
|
+
readonly provider: string;
|
|
13
|
+
readonly modelId: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The one key `act` and `extract` add to Playwright's `use`. It is not part of
|
|
17
|
+
* `core/config.ts`, because nothing under `core/` may name an AI SDK type.
|
|
18
|
+
*
|
|
19
|
+
* Unset is the default, and it fails at the first `act` rather than at worker
|
|
20
|
+
* start, so a project that never calls one needs no model.
|
|
21
|
+
*/
|
|
22
|
+
type AiOptions = {
|
|
23
|
+
aiModel: AiModel | undefined;
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/ai/device.d.ts
|
|
27
|
+
/** A loop can run for minutes, so its budget is its own rather than the action timeout a deterministic step takes. */
|
|
28
|
+
type ActOptions = {
|
|
29
|
+
timeout?: number;
|
|
30
|
+
maxSteps?: number;
|
|
31
|
+
};
|
|
32
|
+
type ExtractOptions = {
|
|
33
|
+
timeout?: number;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* What `extract` accepts, spelled structurally so the main entry's declarations
|
|
37
|
+
* never import from `ai`. The first branch is a Standard Schema, which Zod
|
|
38
|
+
* 3.25+, Zod 4, and Valibot implement, with `T` read off its output type. The
|
|
39
|
+
* second is the AI SDK's own `Schema`, which `jsonSchema()` returns.
|
|
40
|
+
*/
|
|
41
|
+
type ExtractSchema<T> = {
|
|
42
|
+
readonly '~standard': {
|
|
43
|
+
readonly version: 1;
|
|
44
|
+
readonly vendor: string;
|
|
45
|
+
readonly types?: {
|
|
46
|
+
readonly output: T;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
} | {
|
|
50
|
+
readonly jsonSchema: unknown;
|
|
51
|
+
readonly _type?: T;
|
|
52
|
+
};
|
|
53
|
+
type AiDevice = {
|
|
54
|
+
/**
|
|
55
|
+
* Drives the app with a model until the instruction is satisfied. Resolves
|
|
56
|
+
* with the model's summary of what it did, and throws when the model reports
|
|
57
|
+
* it could not proceed or runs out of steps.
|
|
58
|
+
*/
|
|
59
|
+
act(instruction: string, options?: ActOptions): Promise<string>;
|
|
60
|
+
/** Asks a model one question about the current screen and validates the answer against `schema`. */
|
|
61
|
+
extract<T>(question: string, schema: ExtractSchema<T>, options?: ExtractOptions): Promise<T>;
|
|
62
|
+
};
|
|
63
|
+
//#endregion
|
|
3
64
|
//#region src/playwright/fixtures.d.ts
|
|
4
65
|
/**
|
|
5
66
|
* Touchpress's options and none of its fixtures, for a setup project that reads the
|
|
@@ -10,7 +71,7 @@ import { ExpectMatcherState } from "@playwright/test";
|
|
|
10
71
|
* type, and `parseDeviceOptions` rejects `undefined` by name, so a config that
|
|
11
72
|
* forgot a key and one that never set it fail the same way.
|
|
12
73
|
*/
|
|
13
|
-
declare const setupTest: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & object, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & TouchpressOptions>;
|
|
74
|
+
declare const setupTest: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & object, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & TouchpressOptions$1 & AiOptions>;
|
|
14
75
|
/**
|
|
15
76
|
* `device` is auto so evidence capture runs for every test in a device project,
|
|
16
77
|
* whether or not the body touched it. Its teardown runs before the session's,
|
|
@@ -21,8 +82,8 @@ declare const setupTest: import("@playwright/test").TestType<import("@playwright
|
|
|
21
82
|
* `page` are lazy and non-auto, and nothing here names them.
|
|
22
83
|
*/
|
|
23
84
|
declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & object & {
|
|
24
|
-
device: Device;
|
|
25
|
-
}, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & TouchpressOptions & {
|
|
85
|
+
device: Device$1 & AiDevice;
|
|
86
|
+
}, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & TouchpressOptions$1 & AiOptions & {
|
|
26
87
|
session: DeviceSession;
|
|
27
88
|
}>;
|
|
28
89
|
//#endregion
|
|
@@ -64,7 +125,7 @@ declare const expect: import("@playwright/test").Expect<{
|
|
|
64
125
|
toHaveText(this: ExpectMatcherState, locator: Locator, expected: string | RegExp, options?: TextMatcherOptions): Promise<MatcherResult>;
|
|
65
126
|
toHaveValue(this: ExpectMatcherState, locator: Locator, expected: string | RegExp, options?: MatcherOptions): Promise<MatcherResult>;
|
|
66
127
|
toHaveCount(this: ExpectMatcherState, locator: Locator, expected: number, options?: MatcherOptions): Promise<MatcherResult>;
|
|
67
|
-
toHaveScreenshot(this: ExpectMatcherState, target: Device | Locator, nameOrOptions?: string | ScreenshotOptions, options?: ScreenshotOptions): Promise<{
|
|
128
|
+
toHaveScreenshot(this: ExpectMatcherState, target: Device$1 | Locator, nameOrOptions?: string | ScreenshotOptions, options?: ScreenshotOptions): Promise<{
|
|
68
129
|
pass: boolean;
|
|
69
130
|
message: () => string;
|
|
70
131
|
name: string;
|
|
@@ -73,4 +134,13 @@ declare const expect: import("@playwright/test").Expect<{
|
|
|
73
134
|
}>;
|
|
74
135
|
}>;
|
|
75
136
|
//#endregion
|
|
76
|
-
|
|
137
|
+
//#region src/index.d.ts
|
|
138
|
+
/**
|
|
139
|
+
* `Device` and `TouchpressOptions` carry the AI surface here and only here.
|
|
140
|
+
* `touchpress/core` exports the runner-independent pair without it, because
|
|
141
|
+
* nothing under `core/` may name an AI SDK type.
|
|
142
|
+
*/
|
|
143
|
+
type TouchpressOptions = TouchpressOptions$1 & AiOptions;
|
|
144
|
+
type Device = Device$1 & AiDevice;
|
|
145
|
+
//#endregion
|
|
146
|
+
export { type ActOptions, type AiDevice, type AiModel, type AiOptions, Device, type ErrorInfo, type ExpectedValue, type ExtractOptions, type ExtractSchema, type Filter, type FilterOptions, type Locator, type Platform, type PreflightDevice, type PreflightReport, type Query, type ReadyQuery, type Rect, type Role, type Screen, type ScreenNode, type ScreenshotOptions, type TextMatch, TouchpressError, TouchpressOptions, expect, preflight, setupTest, test };
|
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,524 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C as parseScreen, E as TOUCHPRESS_DEFAULTS, M as TouchpressError, O as parseDeviceOptions, S as silentSink, T as resolve, _ as createQueue, a as sizeOf, b as sleep, c as createClient, i as relativeTo, j as textMatch, l as captureEvidence, n as compareScreenshot, o as toPixelBox, r as cropScreenshot, s as createAgentDeviceDriver, t as preflight, u as createDevice, v as openSession, w as renderScreen, x as renderTitle } from "./preflight-aUba9u1k.mjs";
|
|
2
2
|
import { expect as expect$1, test as test$1 } from "@playwright/test";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
|
+
//#region src/ai/tools.ts
|
|
6
|
+
/**
|
|
7
|
+
* The AI SDK is an optional peer, so it is imported here and never at module
|
|
8
|
+
* scope. Importing `touchpress` must not require it, because a project that
|
|
9
|
+
* calls neither `act` nor `extract` never installs it.
|
|
10
|
+
*/
|
|
11
|
+
async function loadAi() {
|
|
12
|
+
try {
|
|
13
|
+
return await import("ai");
|
|
14
|
+
} catch {
|
|
15
|
+
throw new TouchpressError({ kind: "ai-missing-peer" });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const READ = {
|
|
19
|
+
detail: null,
|
|
20
|
+
echoesText: false,
|
|
21
|
+
output: "raw"
|
|
22
|
+
};
|
|
23
|
+
const DEVICE_TOOLS = /* @__PURE__ */ new Map([
|
|
24
|
+
["snapshot", {
|
|
25
|
+
detail: null,
|
|
26
|
+
echoesText: false,
|
|
27
|
+
output: "screen"
|
|
28
|
+
}],
|
|
29
|
+
["press", {
|
|
30
|
+
detail: "target",
|
|
31
|
+
echoesText: false,
|
|
32
|
+
output: "outcome"
|
|
33
|
+
}],
|
|
34
|
+
["fill", {
|
|
35
|
+
detail: "target",
|
|
36
|
+
echoesText: true,
|
|
37
|
+
output: "outcome"
|
|
38
|
+
}],
|
|
39
|
+
["type", {
|
|
40
|
+
detail: null,
|
|
41
|
+
echoesText: true,
|
|
42
|
+
output: "outcome"
|
|
43
|
+
}],
|
|
44
|
+
["scroll", {
|
|
45
|
+
detail: "direction",
|
|
46
|
+
echoesText: false,
|
|
47
|
+
output: "outcome"
|
|
48
|
+
}],
|
|
49
|
+
["back", {
|
|
50
|
+
detail: null,
|
|
51
|
+
echoesText: false,
|
|
52
|
+
output: "outcome"
|
|
53
|
+
}],
|
|
54
|
+
["wait", READ],
|
|
55
|
+
["get", READ],
|
|
56
|
+
["is", READ],
|
|
57
|
+
["alert", READ]
|
|
58
|
+
]);
|
|
59
|
+
/**
|
|
60
|
+
* Everything an input schema says about which device, daemon, or workspace a
|
|
61
|
+
* command reaches. The session already pins all of it, and a model that could
|
|
62
|
+
* name a daemon could leave the device this test opened.
|
|
63
|
+
*
|
|
64
|
+
* `recordAs` rides along for a different reason: a `fill` carrying it fails
|
|
65
|
+
* unless script recording is armed, which under touchpress it never is.
|
|
66
|
+
*/
|
|
67
|
+
const CUT_KEYS = /* @__PURE__ */ new Set([
|
|
68
|
+
"udid",
|
|
69
|
+
"serial",
|
|
70
|
+
"device",
|
|
71
|
+
"deviceTarget",
|
|
72
|
+
"daemonBaseUrl",
|
|
73
|
+
"daemonAuthToken",
|
|
74
|
+
"tenant",
|
|
75
|
+
"runId",
|
|
76
|
+
"leaseId",
|
|
77
|
+
"cwd",
|
|
78
|
+
"debug",
|
|
79
|
+
"iosSimulatorDeviceSet",
|
|
80
|
+
"iosXctestrunFile",
|
|
81
|
+
"iosXctestDerivedDataPath",
|
|
82
|
+
"iosXctestEnvDir",
|
|
83
|
+
"androidDeviceAllowlist",
|
|
84
|
+
"noRecord",
|
|
85
|
+
"record",
|
|
86
|
+
"saveScript",
|
|
87
|
+
"stateDir",
|
|
88
|
+
"recordAs"
|
|
89
|
+
]);
|
|
90
|
+
/**
|
|
91
|
+
* The tools a model drives the app with, built from agent-device's own command
|
|
92
|
+
* registry so the descriptions and the executors stay upstream's. Three things
|
|
93
|
+
* change: the set is narrowed to the ten commands that perceive and act, every
|
|
94
|
+
* key that could point a command at another device is cut from the input
|
|
95
|
+
* schema, and each executor is wrapped so what crosses to the model is the
|
|
96
|
+
* shape the model can act on. Building them contacts no device.
|
|
97
|
+
*/
|
|
98
|
+
async function createDeviceTools(session, platform) {
|
|
99
|
+
const { createAgentDeviceTools } = await import("agent-device/ai-sdk");
|
|
100
|
+
const { jsonSchema, tool } = await loadAi();
|
|
101
|
+
const { tools } = await createAgentDeviceTools({
|
|
102
|
+
session,
|
|
103
|
+
platform
|
|
104
|
+
});
|
|
105
|
+
const kept = {};
|
|
106
|
+
for (const name of DEVICE_TOOLS.keys()) {
|
|
107
|
+
const built = tools[name];
|
|
108
|
+
if (built === void 0) throw new Error(`agent-device no longer exposes the "${name}" tool`);
|
|
109
|
+
const schema = prune(built.inputSchema.jsonSchema);
|
|
110
|
+
kept[name] = tool({
|
|
111
|
+
description: built.description,
|
|
112
|
+
inputSchema: jsonSchema(schema),
|
|
113
|
+
execute: wrapDeviceTool(name, platform, built.execute, new Set(Object.keys(schema.properties ?? {})))
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return kept;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The one place a command's input and output are reshaped for the model, so the
|
|
120
|
+
* table above stays the only thing that says which command gets which shape.
|
|
121
|
+
*
|
|
122
|
+
* `accepted` is the key set of the pruned schema. A schema handed to
|
|
123
|
+
* `jsonSchema()` describes the tool and validates nothing, so a model that
|
|
124
|
+
* sends a key the schema no longer lists would otherwise have it forwarded.
|
|
125
|
+
*/
|
|
126
|
+
function wrapDeviceTool(name, platform, execute, accepted) {
|
|
127
|
+
const shape = DEVICE_TOOLS.get(name)?.output ?? "raw";
|
|
128
|
+
return async (input, options) => {
|
|
129
|
+
const declared = Object.fromEntries(Object.entries(asObject(input)).filter(([key]) => accepted.has(key)));
|
|
130
|
+
const output = await execute(shape === "screen" ? {
|
|
131
|
+
...declared,
|
|
132
|
+
forceFull: true
|
|
133
|
+
} : withRefSigil(declared), options);
|
|
134
|
+
return shape === "screen" ? compactSnapshot(asSnapshot(output), platform) : compactResult(name, output);
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The tree the model reads, in the same listing a failure message prints, one
|
|
139
|
+
* node per line indented by depth. The JSON upstream returns says the same thing
|
|
140
|
+
* with rects, indexes, and flags the model never uses, at three to seven times
|
|
141
|
+
* the size across this repo's fixtures, and its refs arrive without the `@` the
|
|
142
|
+
* action schemas demand.
|
|
143
|
+
*/
|
|
144
|
+
function compactSnapshot(raw, platform) {
|
|
145
|
+
const screen = parseScreen(raw, platform);
|
|
146
|
+
const listing = renderScreen(screen);
|
|
147
|
+
return screen.truncated ? `${listing}\n ... the tree is truncated, so some nodes are missing` : listing;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* What the model needs off an action it just ran: whether it landed, on what,
|
|
151
|
+
* and whether the screen went quiet. Upstream also returns the settle diff, the
|
|
152
|
+
* evidence paths, the resolution, and the cost, which are most of the step's
|
|
153
|
+
* tokens and none of its meaning.
|
|
154
|
+
*/
|
|
155
|
+
function compactResult(name, output) {
|
|
156
|
+
if (DEVICE_TOOLS.get(name)?.output !== "outcome") return output;
|
|
157
|
+
if (typeof output !== "object" || output === null) return output;
|
|
158
|
+
const settle = asObject(output)["settle"];
|
|
159
|
+
return {
|
|
160
|
+
...pick(output, "message"),
|
|
161
|
+
...pick(output, "targetKind"),
|
|
162
|
+
...typeof settle === "object" && settle !== null ? { settle: {
|
|
163
|
+
...pick(settle, "settled"),
|
|
164
|
+
...pick(settle, "waitedMs")
|
|
165
|
+
} } : {}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Upstream's own snapshot nodes carry a bare `e4` while the action schemas
|
|
170
|
+
* demand `@e4`, and a model that sends the bare form gets an error it tends to
|
|
171
|
+
* read as "refs do not work here" before falling back to coordinates for the
|
|
172
|
+
* rest of the run. The listing above now prints the `@`, and this catches the
|
|
173
|
+
* model that typed it from memory anyway.
|
|
174
|
+
*/
|
|
175
|
+
function withRefSigil(input) {
|
|
176
|
+
const target = asObject(asObject(input)["target"]);
|
|
177
|
+
if (target["kind"] !== "ref") return input;
|
|
178
|
+
const ref = target["ref"];
|
|
179
|
+
if (typeof ref !== "string" || ref.startsWith("@")) return input;
|
|
180
|
+
return {
|
|
181
|
+
...asObject(input),
|
|
182
|
+
target: {
|
|
183
|
+
...target,
|
|
184
|
+
ref: `@${ref}`
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function asObject(input) {
|
|
189
|
+
return typeof input === "object" && input !== null ? input : {};
|
|
190
|
+
}
|
|
191
|
+
function asSnapshot(output) {
|
|
192
|
+
if (Array.isArray(asObject(output)["nodes"])) return output;
|
|
193
|
+
throw new Error("agent-device returned a snapshot without nodes");
|
|
194
|
+
}
|
|
195
|
+
function pick(source, key) {
|
|
196
|
+
const value = asObject(source)[key];
|
|
197
|
+
return value === void 0 ? {} : { [key]: value };
|
|
198
|
+
}
|
|
199
|
+
function prune(schema) {
|
|
200
|
+
if (schema.properties === void 0) return schema;
|
|
201
|
+
const properties = {};
|
|
202
|
+
for (const [key, value] of Object.entries(schema.properties)) if (!CUT_KEYS.has(key) && !(key === "target" && isDeviceAlias(value))) properties[key] = value;
|
|
203
|
+
return {
|
|
204
|
+
...schema,
|
|
205
|
+
properties,
|
|
206
|
+
...schema.required === void 0 ? {} : { required: schema.required.filter((key) => key in properties) }
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Upstream spends `target` twice. On `press`, `fill`, and `get` it is the UI
|
|
211
|
+
* element, a `oneOf` over ref, selector, and point. On the rest it is an alias
|
|
212
|
+
* for `deviceTarget`, an enum of device forms, and a model reading both in one
|
|
213
|
+
* tool set answers the second where the first was meant. The session pins the
|
|
214
|
+
* device, so the alias goes and only the UI target survives.
|
|
215
|
+
*/
|
|
216
|
+
function isDeviceAlias(schema) {
|
|
217
|
+
return Array.isArray(asObject(schema)["enum"]);
|
|
218
|
+
}
|
|
219
|
+
/** A tool the table does not name still reports, by its name alone. */
|
|
220
|
+
function toolRecord(name, input) {
|
|
221
|
+
const detail = DEVICE_TOOLS.get(name)?.detail ?? null;
|
|
222
|
+
const target = detail === null ? null : readText(input, detail);
|
|
223
|
+
return target === null ? {
|
|
224
|
+
kind: "tool",
|
|
225
|
+
name
|
|
226
|
+
} : {
|
|
227
|
+
kind: "tool",
|
|
228
|
+
name,
|
|
229
|
+
target
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The text a `fill` or a `type` wrote, for the nested step under it. Never
|
|
234
|
+
* masked: the model composed this string and already holds it, so hiding it
|
|
235
|
+
* from the report would cost evidence and buy nothing. A credential belongs in
|
|
236
|
+
* a deterministic `fill(text, { secret: true })` outside `act`.
|
|
237
|
+
*/
|
|
238
|
+
function typedText(name, input) {
|
|
239
|
+
return DEVICE_TOOLS.get(name)?.echoesText === true ? readText(input, "text") : null;
|
|
240
|
+
}
|
|
241
|
+
function readText(input, key) {
|
|
242
|
+
const value = asObject(input)[key];
|
|
243
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
244
|
+
}
|
|
245
|
+
//#endregion
|
|
246
|
+
//#region src/ai/act.ts
|
|
247
|
+
const TRANSCRIPT_RESULT_LIMIT = 2048;
|
|
248
|
+
function instructionsFor(platform) {
|
|
249
|
+
return [
|
|
250
|
+
`You are driving a ${platform} mobile app that is already launched and in the foreground.`,
|
|
251
|
+
"Start with snapshot.",
|
|
252
|
+
"A snapshot prints one node per line, indented by depth, as in: @e4 [button] \"Sign in\" #signIn",
|
|
253
|
+
"Use press to tap a node and fill to replace a field's text.",
|
|
254
|
+
"Both take the ref as { \"kind\": \"ref\", \"ref\": \"@e4\" }, copied exactly as the line printed it.",
|
|
255
|
+
"Every ref stops working when the next command runs, so snapshot again after each action before you use one.",
|
|
256
|
+
"Use coordinates only when no line on the snapshot is the thing you need.",
|
|
257
|
+
"Once a snapshot shows the instruction is satisfied, call done with outcome \"completed\" and a one-line summary. Do not take a second snapshot to double-check.",
|
|
258
|
+
"If you cannot proceed, call done with outcome \"blocked\" and say what stopped you."
|
|
259
|
+
].join("\n");
|
|
260
|
+
}
|
|
261
|
+
const EXTRACT_INSTRUCTIONS = [
|
|
262
|
+
"You are reading one accessibility tree captured from a mobile app.",
|
|
263
|
+
"Each line is one node, indented by depth, carrying its ref, role, name, and test id, as in: @e4 [button] \"Sign in\" #signIn",
|
|
264
|
+
"The tree never prints a field's value.",
|
|
265
|
+
"Answer from what the tree shows, not from what the app is expected to show."
|
|
266
|
+
].join("\n");
|
|
267
|
+
/**
|
|
268
|
+
* Runs the model against the tools until it calls `done`. Nothing here knows
|
|
269
|
+
* about a session, so a test drives it with fake tools and a mock model.
|
|
270
|
+
*
|
|
271
|
+
* A tool error thrown inside `execute` is not caught: the AI SDK hands it back
|
|
272
|
+
* to the model as a tool result, which is what lets it recover from a ref that
|
|
273
|
+
* went stale by taking a fresh snapshot.
|
|
274
|
+
*/
|
|
275
|
+
function runAct(run) {
|
|
276
|
+
return run.sink.step(renderTitle({
|
|
277
|
+
kind: "act",
|
|
278
|
+
instruction: run.instruction
|
|
279
|
+
}), async () => {
|
|
280
|
+
const { ToolLoopAgent, hasToolCall, jsonSchema, stepCountIs, tool } = await loadAi();
|
|
281
|
+
const result = await new ToolLoopAgent({
|
|
282
|
+
model: languageModel(run.model),
|
|
283
|
+
instructions: instructionsFor(run.platform),
|
|
284
|
+
tools: {
|
|
285
|
+
...reporting(run.tools, run.sink),
|
|
286
|
+
done: tool({
|
|
287
|
+
description: "Finish the instruction. Call this when it is satisfied, or when you cannot proceed.",
|
|
288
|
+
inputSchema: jsonSchema({
|
|
289
|
+
type: "object",
|
|
290
|
+
properties: {
|
|
291
|
+
outcome: {
|
|
292
|
+
type: "string",
|
|
293
|
+
enum: ["completed", "blocked"],
|
|
294
|
+
description: "completed when the instruction is satisfied, blocked when it is not"
|
|
295
|
+
},
|
|
296
|
+
summary: {
|
|
297
|
+
type: "string",
|
|
298
|
+
description: "One line saying what you did, or what stopped you."
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
required: ["outcome", "summary"],
|
|
302
|
+
additionalProperties: false
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
},
|
|
306
|
+
stopWhen: [hasToolCall("done"), stepCountIs(run.maxSteps)]
|
|
307
|
+
}).generate({
|
|
308
|
+
prompt: run.instruction,
|
|
309
|
+
abortSignal: AbortSignal.timeout(run.timeout)
|
|
310
|
+
}).catch(async (error) => {
|
|
311
|
+
if (!timedOut(error)) throw error;
|
|
312
|
+
throw new TouchpressError({
|
|
313
|
+
kind: "ai-timeout",
|
|
314
|
+
instruction: run.instruction,
|
|
315
|
+
timeoutMs: run.timeout,
|
|
316
|
+
screen: await run.screen()
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
await run.sink.attach({
|
|
320
|
+
name: `ai-act-${String(run.attempt)}.json`,
|
|
321
|
+
contentType: "application/json",
|
|
322
|
+
body: JSON.stringify({
|
|
323
|
+
instruction: run.instruction,
|
|
324
|
+
usage: result.usage,
|
|
325
|
+
steps: result.steps.map((step) => {
|
|
326
|
+
const errors = toolErrors(step.content);
|
|
327
|
+
return {
|
|
328
|
+
text: step.text,
|
|
329
|
+
toolCalls: step.toolCalls.map((call) => ({
|
|
330
|
+
name: call.toolName,
|
|
331
|
+
input: call.input
|
|
332
|
+
})),
|
|
333
|
+
toolResults: step.toolResults.map((toolResult) => ({
|
|
334
|
+
name: toolResult.toolName,
|
|
335
|
+
output: clip(toolResult.output)
|
|
336
|
+
})),
|
|
337
|
+
...errors.length === 0 ? {} : { toolErrors: errors }
|
|
338
|
+
};
|
|
339
|
+
})
|
|
340
|
+
}, null, 2)
|
|
341
|
+
});
|
|
342
|
+
const outcome = outcomeOf(result.steps.at(-1)?.toolCalls);
|
|
343
|
+
if (outcome === null) throw new TouchpressError({
|
|
344
|
+
kind: "ai-incomplete",
|
|
345
|
+
instruction: run.instruction,
|
|
346
|
+
steps: result.steps.length,
|
|
347
|
+
screen: await run.screen()
|
|
348
|
+
});
|
|
349
|
+
if (outcome.kind === "blocked") throw new TouchpressError({
|
|
350
|
+
kind: "ai-blocked",
|
|
351
|
+
instruction: run.instruction,
|
|
352
|
+
summary: outcome.summary,
|
|
353
|
+
screen: await run.screen()
|
|
354
|
+
});
|
|
355
|
+
return outcome.summary;
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The public types are structural so the main entry's declarations never
|
|
360
|
+
* import from `ai`, and these two casts are where they meet the SDK's own.
|
|
361
|
+
* Every `LanguageModel` and every `FlexibleSchema` satisfies the structural
|
|
362
|
+
* type it is cast from, so nothing the SDK accepts is turned away.
|
|
363
|
+
*/
|
|
364
|
+
function languageModel(model) {
|
|
365
|
+
return model;
|
|
366
|
+
}
|
|
367
|
+
function flexibleSchema(schema) {
|
|
368
|
+
return schema;
|
|
369
|
+
}
|
|
370
|
+
/** One capture, one question, one answer. No tools, so the model cannot change the screen it is describing. */
|
|
371
|
+
function runExtract(run) {
|
|
372
|
+
return run.sink.step(renderTitle({
|
|
373
|
+
kind: "extract",
|
|
374
|
+
question: run.question
|
|
375
|
+
}), async () => {
|
|
376
|
+
const { ToolLoopAgent, Output } = await loadAi();
|
|
377
|
+
return (await new ToolLoopAgent({
|
|
378
|
+
model: languageModel(run.model),
|
|
379
|
+
instructions: EXTRACT_INSTRUCTIONS,
|
|
380
|
+
output: Output.object({ schema: flexibleSchema(run.schema) })
|
|
381
|
+
}).generate({
|
|
382
|
+
prompt: [
|
|
383
|
+
`Question: ${run.question}`,
|
|
384
|
+
``,
|
|
385
|
+
`Screen:`,
|
|
386
|
+
run.screen
|
|
387
|
+
].join("\n"),
|
|
388
|
+
abortSignal: AbortSignal.timeout(run.timeout)
|
|
389
|
+
})).output;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Every model action becomes a step wrapping its own execution, so a report
|
|
394
|
+
* shows the loop as it ran. The executions share one queue, because the AI SDK
|
|
395
|
+
* runs the tool calls of one step concurrently and a snapshot overlapping a
|
|
396
|
+
* press on the device reads a screen the press is changing.
|
|
397
|
+
*/
|
|
398
|
+
function reporting(tools, sink) {
|
|
399
|
+
const queue = createQueue();
|
|
400
|
+
const wrapped = {};
|
|
401
|
+
for (const [name, built] of Object.entries(tools)) {
|
|
402
|
+
const { execute } = built;
|
|
403
|
+
if (execute === void 0) {
|
|
404
|
+
wrapped[name] = built;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
wrapped[name] = {
|
|
408
|
+
...built,
|
|
409
|
+
execute: (input, options) => queue.enqueue(() => sink.step(renderTitle(toolRecord(name, input)), async () => {
|
|
410
|
+
const text = typedText(name, input);
|
|
411
|
+
if (text !== null) await sink.step(renderTitle({
|
|
412
|
+
kind: "typed",
|
|
413
|
+
typed: {
|
|
414
|
+
kind: "text",
|
|
415
|
+
value: text
|
|
416
|
+
}
|
|
417
|
+
}), () => Promise.resolve(), { box: true });
|
|
418
|
+
return execute(input, options);
|
|
419
|
+
}))
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
return wrapped;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* The `done` call is validated here rather than trusted, because a JSON schema
|
|
426
|
+
* handed to `jsonSchema()` describes the tool to the model and validates
|
|
427
|
+
* nothing. A malformed call is a run that never reached an outcome.
|
|
428
|
+
*/
|
|
429
|
+
function outcomeOf(calls) {
|
|
430
|
+
const call = calls?.find((one) => one.toolName === "done");
|
|
431
|
+
if (call === void 0 || typeof call.input !== "object" || call.input === null) return null;
|
|
432
|
+
const summary = Reflect.get(call.input, "summary");
|
|
433
|
+
if (typeof summary !== "string") return null;
|
|
434
|
+
const outcome = Reflect.get(call.input, "outcome");
|
|
435
|
+
if (outcome === "completed") return {
|
|
436
|
+
kind: "completed",
|
|
437
|
+
summary
|
|
438
|
+
};
|
|
439
|
+
if (outcome === "blocked") return {
|
|
440
|
+
kind: "blocked",
|
|
441
|
+
summary
|
|
442
|
+
};
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
/** `AbortSignal.timeout` rejects with a DOMException named TimeoutError, which a provider surfaces as is or as an AbortError. */
|
|
446
|
+
function timedOut(error) {
|
|
447
|
+
return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The calls that failed. `step.toolResults` holds only the ones that returned,
|
|
451
|
+
* so a transcript built from it alone shows a loop doing nothing and never says
|
|
452
|
+
* why, which is what a nine-error run looked like from the attachment.
|
|
453
|
+
*/
|
|
454
|
+
function toolErrors(content) {
|
|
455
|
+
return content.filter((part) => part.type === "tool-error").map((part) => ({
|
|
456
|
+
name: part.toolName,
|
|
457
|
+
input: part.input,
|
|
458
|
+
error: messageOf(part.error)
|
|
459
|
+
}));
|
|
460
|
+
}
|
|
461
|
+
function messageOf(error) {
|
|
462
|
+
if (error instanceof Error) return error.message;
|
|
463
|
+
return typeof error === "string" ? error : JSON.stringify(error) ?? "unknown error";
|
|
464
|
+
}
|
|
465
|
+
function clip(output) {
|
|
466
|
+
const text = JSON.stringify(output) ?? "undefined";
|
|
467
|
+
return text.length <= TRANSCRIPT_RESULT_LIMIT ? text : `${text.slice(0, TRANSCRIPT_RESULT_LIMIT)}...`;
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/ai/device.ts
|
|
471
|
+
const DEFAULT_ACT_TIMEOUT_MS = 12e4;
|
|
472
|
+
const DEFAULT_ACT_STEPS = 25;
|
|
473
|
+
const DEFAULT_EXTRACT_TIMEOUT_MS = 6e4;
|
|
474
|
+
/**
|
|
475
|
+
* Adds `act` and `extract` to a device without touching what is already there.
|
|
476
|
+
*
|
|
477
|
+
* Both run inside `session.run`, so the whole loop holds the session queue. A
|
|
478
|
+
* test body is sequential anyway, and the model's snapshot refs carry the same
|
|
479
|
+
* rule every deterministic action does: they are valid for the next command
|
|
480
|
+
* only, so nothing else may reach the device in between.
|
|
481
|
+
*/
|
|
482
|
+
function withAi(device, session, sink, model) {
|
|
483
|
+
let acts = 0;
|
|
484
|
+
let built = null;
|
|
485
|
+
const tools = () => built ??= createDeviceTools(session.name, session.options.platform);
|
|
486
|
+
return {
|
|
487
|
+
...device,
|
|
488
|
+
act: async (instruction, options) => {
|
|
489
|
+
const configured = configuredModel(model);
|
|
490
|
+
const deviceTools = await tools();
|
|
491
|
+
return session.run((one) => runAct({
|
|
492
|
+
model: configured,
|
|
493
|
+
tools: deviceTools,
|
|
494
|
+
sink,
|
|
495
|
+
instruction,
|
|
496
|
+
platform: session.options.platform,
|
|
497
|
+
maxSteps: options?.maxSteps ?? DEFAULT_ACT_STEPS,
|
|
498
|
+
timeout: options?.timeout ?? DEFAULT_ACT_TIMEOUT_MS,
|
|
499
|
+
screen: async () => renderScreen(await one.capture()),
|
|
500
|
+
attempt: acts += 1
|
|
501
|
+
}));
|
|
502
|
+
},
|
|
503
|
+
extract: async (question, schema, options) => {
|
|
504
|
+
const configured = configuredModel(model);
|
|
505
|
+
return session.run(async (one) => runExtract({
|
|
506
|
+
model: configured,
|
|
507
|
+
screen: renderScreen(await one.capture()),
|
|
508
|
+
question,
|
|
509
|
+
schema,
|
|
510
|
+
sink,
|
|
511
|
+
timeout: options?.timeout ?? DEFAULT_EXTRACT_TIMEOUT_MS
|
|
512
|
+
}));
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
/** Checked before the queue, so a project that forgot the key fails at once rather than holding the session. */
|
|
517
|
+
function configuredModel(model) {
|
|
518
|
+
if (model === void 0) throw new TouchpressError({ kind: "ai-not-configured" });
|
|
519
|
+
return model;
|
|
520
|
+
}
|
|
521
|
+
//#endregion
|
|
5
522
|
//#region src/playwright/fixtures.ts
|
|
6
523
|
const SESSION_FIXTURE_TIMEOUT_MS = 18e4;
|
|
7
524
|
const DEVICE_FIXTURE_TIMEOUT_MS = 12e4;
|
|
@@ -62,6 +579,10 @@ const setupTest = test$1.extend({
|
|
|
62
579
|
sessionPrefix: [TOUCHPRESS_DEFAULTS.sessionPrefix, {
|
|
63
580
|
option: true,
|
|
64
581
|
scope: "worker"
|
|
582
|
+
}],
|
|
583
|
+
aiModel: [void 0, {
|
|
584
|
+
option: true,
|
|
585
|
+
scope: "worker"
|
|
65
586
|
}]
|
|
66
587
|
});
|
|
67
588
|
/** The worker session already opened the app with a relaunch, so the first test skips one. */
|
|
@@ -105,11 +626,11 @@ const test = setupTest.extend({
|
|
|
105
626
|
scope: "worker",
|
|
106
627
|
timeout: SESSION_FIXTURE_TIMEOUT_MS
|
|
107
628
|
}],
|
|
108
|
-
device: [async ({ session }, use, testInfo) => {
|
|
629
|
+
device: [async ({ session, aiModel }, use, testInfo) => {
|
|
109
630
|
const sink = playwrightSink();
|
|
110
631
|
if (session.options.relaunch === "per-test" && startedTests.has(session)) await session.relaunch(sink);
|
|
111
632
|
startedTests.add(session);
|
|
112
|
-
await use(createDevice(session, sink));
|
|
633
|
+
await use(withAi(createDevice(session, sink), session, sink, aiModel));
|
|
113
634
|
if (shouldCapture(testInfo, session.options.evidence)) await captureEvidence(session, sink);
|
|
114
635
|
}, {
|
|
115
636
|
auto: true,
|
|
@@ -373,10 +894,16 @@ function write(path, png) {
|
|
|
373
894
|
mkdirSync(dirname(path), { recursive: true });
|
|
374
895
|
writeFileSync(path, png);
|
|
375
896
|
}
|
|
897
|
+
/**
|
|
898
|
+
* The describe path is part of the name, the way Playwright's own screenshot
|
|
899
|
+
* assertion names its baselines, so two blocks each holding a test called
|
|
900
|
+
* "shot" do not write over one baseline. The first element is the file, which
|
|
901
|
+
* `snapshotPath` already places the baseline under.
|
|
902
|
+
*/
|
|
376
903
|
function defaultName(info) {
|
|
377
904
|
const next = (ordinals.get(info) ?? 0) + 1;
|
|
378
905
|
ordinals.set(info, next);
|
|
379
|
-
return `${info.
|
|
906
|
+
return `${info.titlePath.slice(1).join(" ").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}-${String(next)}.png`;
|
|
380
907
|
}
|
|
381
908
|
//#endregion
|
|
382
909
|
//#region src/playwright/expect.ts
|
|
@@ -448,6 +448,18 @@ type ActionRecord = {
|
|
|
448
448
|
} | {
|
|
449
449
|
readonly kind: 'screenshot';
|
|
450
450
|
readonly path: string;
|
|
451
|
+
} | {
|
|
452
|
+
readonly kind: 'act';
|
|
453
|
+
readonly instruction: string;
|
|
454
|
+
} | {
|
|
455
|
+
readonly kind: 'extract';
|
|
456
|
+
readonly question: string;
|
|
457
|
+
} |
|
|
458
|
+
/** One command the model ran inside an `act`. `target` is its snapshot ref, or a scroll's direction. */
|
|
459
|
+
{
|
|
460
|
+
readonly kind: 'tool';
|
|
461
|
+
readonly name: string;
|
|
462
|
+
readonly target?: string;
|
|
451
463
|
};
|
|
452
464
|
/** A boxed step reports as one line rather than as something to open. */
|
|
453
465
|
type StepOptions = {
|
|
@@ -825,6 +837,26 @@ type ErrorInfo = {
|
|
|
825
837
|
readonly kind: 'driver';
|
|
826
838
|
readonly command: string;
|
|
827
839
|
readonly failure: DeviceFailure;
|
|
840
|
+
} | {
|
|
841
|
+
readonly kind: 'ai-not-configured';
|
|
842
|
+
} | {
|
|
843
|
+
readonly kind: 'ai-missing-peer';
|
|
844
|
+
} | {
|
|
845
|
+
readonly kind: 'ai-blocked';
|
|
846
|
+
readonly instruction: string;
|
|
847
|
+
/** The model's own account of what stopped it. */
|
|
848
|
+
readonly summary: string;
|
|
849
|
+
readonly screen: string;
|
|
850
|
+
} | {
|
|
851
|
+
readonly kind: 'ai-incomplete';
|
|
852
|
+
readonly instruction: string;
|
|
853
|
+
readonly steps: number;
|
|
854
|
+
readonly screen: string;
|
|
855
|
+
} | {
|
|
856
|
+
readonly kind: 'ai-timeout';
|
|
857
|
+
readonly instruction: string;
|
|
858
|
+
readonly timeoutMs: number;
|
|
859
|
+
readonly screen: string;
|
|
828
860
|
};
|
|
829
861
|
declare class TouchpressError extends Error {
|
|
830
862
|
readonly info: ErrorInfo;
|
|
@@ -59,6 +59,36 @@ function formatError(info) {
|
|
|
59
59
|
info.screen
|
|
60
60
|
].join("\n");
|
|
61
61
|
case "driver": return `${info.command} failed: ${describeFailure(info.failure)}`;
|
|
62
|
+
case "ai-not-configured": return ["device.act and device.extract need a model.", "Set use.aiModel to a gateway model id, such as 'anthropic/claude-sonnet-5', or to a provider model instance."].join("\n");
|
|
63
|
+
case "ai-missing-peer": return ["device.act and device.extract need the optional peer dependency 'ai'.", "Install it with: pnpm add -D ai"].join("\n");
|
|
64
|
+
case "ai-blocked": return [
|
|
65
|
+
`act stopped without finishing: ${info.summary}`,
|
|
66
|
+
``,
|
|
67
|
+
`Instruction: ${info.instruction}`,
|
|
68
|
+
``,
|
|
69
|
+
`Screen:`,
|
|
70
|
+
info.screen
|
|
71
|
+
].join("\n");
|
|
72
|
+
case "ai-incomplete": return [
|
|
73
|
+
`act ran ${String(info.steps)} steps without reaching an outcome.`,
|
|
74
|
+
``,
|
|
75
|
+
`Instruction: ${info.instruction}`,
|
|
76
|
+
``,
|
|
77
|
+
"Raise maxSteps, or split the instruction into smaller ones.",
|
|
78
|
+
``,
|
|
79
|
+
`Screen:`,
|
|
80
|
+
info.screen
|
|
81
|
+
].join("\n");
|
|
82
|
+
case "ai-timeout": return [
|
|
83
|
+
`act ran out of its ${String(info.timeoutMs)}ms budget before reaching an outcome.`,
|
|
84
|
+
``,
|
|
85
|
+
`Instruction: ${info.instruction}`,
|
|
86
|
+
``,
|
|
87
|
+
"Raise the act timeout, and the test timeout with it.",
|
|
88
|
+
``,
|
|
89
|
+
`Screen:`,
|
|
90
|
+
info.screen
|
|
91
|
+
].join("\n");
|
|
62
92
|
default: throw new Error(`unhandled error info ${JSON.stringify(info)}`);
|
|
63
93
|
}
|
|
64
94
|
}
|
|
@@ -115,7 +145,9 @@ function matchesText(match, candidate) {
|
|
|
115
145
|
switch (match.kind) {
|
|
116
146
|
case "exact": return candidate === match.value;
|
|
117
147
|
case "substring": return candidate.toLowerCase().includes(match.value.toLowerCase());
|
|
118
|
-
case "regex":
|
|
148
|
+
case "regex":
|
|
149
|
+
match.value.lastIndex = 0;
|
|
150
|
+
return match.value.test(candidate);
|
|
119
151
|
default: throw new Error(`unhandled text match ${JSON.stringify(match)}`);
|
|
120
152
|
}
|
|
121
153
|
}
|
|
@@ -355,89 +387,6 @@ function fail(field, detail) {
|
|
|
355
387
|
});
|
|
356
388
|
}
|
|
357
389
|
//#endregion
|
|
358
|
-
//#region src/core/checks.ts
|
|
359
|
-
/**
|
|
360
|
-
* A `many` outcome never passes anything but `toHaveCount`. An ambiguous locator
|
|
361
|
-
* is a strictness violation, and it reports through the same message path as a
|
|
362
|
-
* plain mismatch rather than guessing which node was meant.
|
|
363
|
-
*/
|
|
364
|
-
function evaluate(check, resolution) {
|
|
365
|
-
if (check.name === "toHaveCount") {
|
|
366
|
-
const count = countOf(resolution);
|
|
367
|
-
return {
|
|
368
|
-
pass: count === check.expected,
|
|
369
|
-
actual: String(count)
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
if (resolution.outcome === "none") return {
|
|
373
|
-
pass: false,
|
|
374
|
-
actual: null
|
|
375
|
-
};
|
|
376
|
-
if (resolution.outcome === "many") return {
|
|
377
|
-
pass: false,
|
|
378
|
-
actual: `${String(resolution.nodes.length)} matching nodes`
|
|
379
|
-
};
|
|
380
|
-
const node = resolution.node;
|
|
381
|
-
switch (check.name) {
|
|
382
|
-
case "toBeVisible": return {
|
|
383
|
-
pass: true,
|
|
384
|
-
actual: describeNode(node)
|
|
385
|
-
};
|
|
386
|
-
case "toHaveText": {
|
|
387
|
-
const text = node.name ?? node.value;
|
|
388
|
-
return {
|
|
389
|
-
pass: matchesText(check.expected, text),
|
|
390
|
-
actual: text === null ? null : `"${text}"`
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
case "toHaveValue": return {
|
|
394
|
-
pass: matchesText(check.expected, node.value),
|
|
395
|
-
actual: node.value === null ? null : `"${node.value}"`
|
|
396
|
-
};
|
|
397
|
-
case "toBeEnabled": return {
|
|
398
|
-
pass: node.enabled,
|
|
399
|
-
actual: node.enabled ? "enabled" : "disabled"
|
|
400
|
-
};
|
|
401
|
-
case "toBeSelected": return {
|
|
402
|
-
pass: node.selected,
|
|
403
|
-
actual: node.selected ? "selected" : "not selected"
|
|
404
|
-
};
|
|
405
|
-
case "toBeFocused": return {
|
|
406
|
-
pass: node.focused,
|
|
407
|
-
actual: node.focused ? "focused" : "not focused"
|
|
408
|
-
};
|
|
409
|
-
default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
function countOf(resolution) {
|
|
413
|
-
switch (resolution.outcome) {
|
|
414
|
-
case "one": return 1;
|
|
415
|
-
case "none": return 0;
|
|
416
|
-
case "many": return resolution.nodes.length;
|
|
417
|
-
default: throw new Error(`unhandled resolution ${JSON.stringify(resolution)}`);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
/** The `Expected:` line. */
|
|
421
|
-
function describeCheck(check) {
|
|
422
|
-
switch (check.name) {
|
|
423
|
-
case "toBeVisible": return "visible";
|
|
424
|
-
case "toHaveText": return `text ${describeExpected(check.expected)}`;
|
|
425
|
-
case "toHaveValue": return `value ${describeExpected(check.expected)}`;
|
|
426
|
-
case "toBeEnabled": return "enabled";
|
|
427
|
-
case "toBeSelected": return "selected";
|
|
428
|
-
case "toBeFocused": return "focused";
|
|
429
|
-
case "toHaveCount": return `count ${String(check.expected)}`;
|
|
430
|
-
default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
function describeExpected(match) {
|
|
434
|
-
return match.kind === "regex" ? String(match.value) : `"${match.value}"`;
|
|
435
|
-
}
|
|
436
|
-
function describeNode(node) {
|
|
437
|
-
const name = node.name === null ? "" : ` "${node.name}"`;
|
|
438
|
-
return `${node.ref} [${node.role}]${name}`;
|
|
439
|
-
}
|
|
440
|
-
//#endregion
|
|
441
390
|
//#region src/core/screen.ts
|
|
442
391
|
const IOS_ROLES = {
|
|
443
392
|
Application: "application",
|
|
@@ -716,6 +665,7 @@ function renderNode(node) {
|
|
|
716
665
|
//#endregion
|
|
717
666
|
//#region src/core/report.ts
|
|
718
667
|
const FILL_TEXT_LIMIT = 40;
|
|
668
|
+
const PROMPT_LIMIT = 80;
|
|
719
669
|
/** Rendered here rather than in an adapter so every runner produces the same text. */
|
|
720
670
|
function renderTitle(record) {
|
|
721
671
|
switch (record.kind) {
|
|
@@ -729,18 +679,21 @@ function renderTitle(record) {
|
|
|
729
679
|
case "relaunch": return `relaunch ${record.app}`;
|
|
730
680
|
case "dismiss-overlay": return "dismiss the React Native dev overlay";
|
|
731
681
|
case "screenshot": return `screenshot ${record.path}`;
|
|
682
|
+
case "act": return `act "${truncate(record.instruction, PROMPT_LIMIT)}"`;
|
|
683
|
+
case "extract": return `extract "${truncate(record.question, PROMPT_LIMIT)}"`;
|
|
684
|
+
case "tool": return record.target === void 0 ? record.name : `${record.name} ${record.target}`;
|
|
732
685
|
default: throw new Error(`unhandled action record ${JSON.stringify(record)}`);
|
|
733
686
|
}
|
|
734
687
|
}
|
|
735
688
|
function renderTyped(typed) {
|
|
736
689
|
switch (typed.kind) {
|
|
737
|
-
case "text": return `type "${truncate(typed.value)}"`;
|
|
690
|
+
case "text": return `type "${truncate(typed.value, FILL_TEXT_LIMIT)}"`;
|
|
738
691
|
case "hidden": return `type ${String(typed.length)} characters`;
|
|
739
692
|
default: throw new Error(`unhandled typed value ${JSON.stringify(typed)}`);
|
|
740
693
|
}
|
|
741
694
|
}
|
|
742
|
-
function truncate(text) {
|
|
743
|
-
return text.length <=
|
|
695
|
+
function truncate(text, limit) {
|
|
696
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}...`;
|
|
744
697
|
}
|
|
745
698
|
/** Discards everything. The default for scripts, unit tests, and runners with no reporting. */
|
|
746
699
|
const silentSink = {
|
|
@@ -952,6 +905,89 @@ function sleep(ms) {
|
|
|
952
905
|
return new Promise((done) => setTimeout(done, ms));
|
|
953
906
|
}
|
|
954
907
|
//#endregion
|
|
908
|
+
//#region src/core/checks.ts
|
|
909
|
+
/**
|
|
910
|
+
* A `many` outcome never passes anything but `toHaveCount`. An ambiguous locator
|
|
911
|
+
* is a strictness violation, and it reports through the same message path as a
|
|
912
|
+
* plain mismatch rather than guessing which node was meant.
|
|
913
|
+
*/
|
|
914
|
+
function evaluate(check, resolution) {
|
|
915
|
+
if (check.name === "toHaveCount") {
|
|
916
|
+
const count = countOf(resolution);
|
|
917
|
+
return {
|
|
918
|
+
pass: count === check.expected,
|
|
919
|
+
actual: String(count)
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
if (resolution.outcome === "none") return {
|
|
923
|
+
pass: false,
|
|
924
|
+
actual: null
|
|
925
|
+
};
|
|
926
|
+
if (resolution.outcome === "many") return {
|
|
927
|
+
pass: false,
|
|
928
|
+
actual: `${String(resolution.nodes.length)} matching nodes`
|
|
929
|
+
};
|
|
930
|
+
const node = resolution.node;
|
|
931
|
+
switch (check.name) {
|
|
932
|
+
case "toBeVisible": return {
|
|
933
|
+
pass: true,
|
|
934
|
+
actual: describeNode(node)
|
|
935
|
+
};
|
|
936
|
+
case "toHaveText": {
|
|
937
|
+
const text = node.name ?? node.value;
|
|
938
|
+
return {
|
|
939
|
+
pass: matchesText(check.expected, text),
|
|
940
|
+
actual: text === null ? null : `"${text}"`
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
case "toHaveValue": return {
|
|
944
|
+
pass: matchesText(check.expected, node.value),
|
|
945
|
+
actual: node.value === null ? null : `"${node.value}"`
|
|
946
|
+
};
|
|
947
|
+
case "toBeEnabled": return {
|
|
948
|
+
pass: node.enabled,
|
|
949
|
+
actual: node.enabled ? "enabled" : "disabled"
|
|
950
|
+
};
|
|
951
|
+
case "toBeSelected": return {
|
|
952
|
+
pass: node.selected,
|
|
953
|
+
actual: node.selected ? "selected" : "not selected"
|
|
954
|
+
};
|
|
955
|
+
case "toBeFocused": return {
|
|
956
|
+
pass: node.focused,
|
|
957
|
+
actual: node.focused ? "focused" : "not focused"
|
|
958
|
+
};
|
|
959
|
+
default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
function countOf(resolution) {
|
|
963
|
+
switch (resolution.outcome) {
|
|
964
|
+
case "one": return 1;
|
|
965
|
+
case "none": return 0;
|
|
966
|
+
case "many": return resolution.nodes.length;
|
|
967
|
+
default: throw new Error(`unhandled resolution ${JSON.stringify(resolution)}`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
/** The `Expected:` line. */
|
|
971
|
+
function describeCheck(check) {
|
|
972
|
+
switch (check.name) {
|
|
973
|
+
case "toBeVisible": return "visible";
|
|
974
|
+
case "toHaveText": return `text ${describeExpected(check.expected)}`;
|
|
975
|
+
case "toHaveValue": return `value ${describeExpected(check.expected)}`;
|
|
976
|
+
case "toBeEnabled": return "enabled";
|
|
977
|
+
case "toBeSelected": return "selected";
|
|
978
|
+
case "toBeFocused": return "focused";
|
|
979
|
+
case "toHaveCount": return `count ${String(check.expected)}`;
|
|
980
|
+
default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
function describeExpected(match) {
|
|
984
|
+
return match.kind === "regex" ? String(match.value) : `"${match.value}"`;
|
|
985
|
+
}
|
|
986
|
+
function describeNode(node) {
|
|
987
|
+
const name = node.name === null ? "" : ` "${node.name}"`;
|
|
988
|
+
return `${node.ref} [${node.role}]${name}`;
|
|
989
|
+
}
|
|
990
|
+
//#endregion
|
|
955
991
|
//#region src/core/probe.ts
|
|
956
992
|
const POLL_INTERVAL_MS = 250;
|
|
957
993
|
const SCREEN_LISTING_NODES = 60;
|
|
@@ -1266,10 +1302,10 @@ function perform(session, sink, record, options, dispatch, write) {
|
|
|
1266
1302
|
}
|
|
1267
1303
|
attempts += 1;
|
|
1268
1304
|
if (!settled.settled) sink.note("settle", `${renderTitle(record)} finished before the screen went quiet`);
|
|
1269
|
-
if (confirmation === null) return;
|
|
1305
|
+
if (confirmation === null || write === void 0) return;
|
|
1270
1306
|
if (attempts === 1) await sink.step(renderTitle({
|
|
1271
1307
|
kind: "typed",
|
|
1272
|
-
typed: typedOf(
|
|
1308
|
+
typed: typedOf(resolution.node.role, write)
|
|
1273
1309
|
}), () => Promise.resolve(), { box: true });
|
|
1274
1310
|
target = identityOf(screen, resolution.node);
|
|
1275
1311
|
screen = await device.capture();
|
|
@@ -1361,12 +1397,13 @@ function confirmationOf(role, write) {
|
|
|
1361
1397
|
kind: "mask",
|
|
1362
1398
|
length: write.text.length
|
|
1363
1399
|
};
|
|
1400
|
+
const value = normalizeText(write.text);
|
|
1364
1401
|
return write.secret ? {
|
|
1365
1402
|
kind: "secret",
|
|
1366
|
-
value
|
|
1403
|
+
value
|
|
1367
1404
|
} : {
|
|
1368
1405
|
kind: "open",
|
|
1369
|
-
value
|
|
1406
|
+
value
|
|
1370
1407
|
};
|
|
1371
1408
|
}
|
|
1372
1409
|
function holds(confirmation, actual) {
|
|
@@ -1394,22 +1431,16 @@ function expectedOf(confirmation) {
|
|
|
1394
1431
|
default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
|
|
1395
1432
|
}
|
|
1396
1433
|
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
case "open": return {
|
|
1408
|
-
kind: "text",
|
|
1409
|
-
value: confirmation.value
|
|
1410
|
-
};
|
|
1411
|
-
default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
|
|
1412
|
-
}
|
|
1434
|
+
/** What the report says was typed, verbatim, which the normalized confirmation no longer holds. */
|
|
1435
|
+
function typedOf(role, write) {
|
|
1436
|
+
if (role === "secure-text-field" || write.secret) return {
|
|
1437
|
+
kind: "hidden",
|
|
1438
|
+
length: write.text.length
|
|
1439
|
+
};
|
|
1440
|
+
return {
|
|
1441
|
+
kind: "text",
|
|
1442
|
+
value: write.text
|
|
1443
|
+
};
|
|
1413
1444
|
}
|
|
1414
1445
|
/** An actual value may be repeated back only as far as the expected one could be. */
|
|
1415
1446
|
function disclose(expected, actual) {
|
|
@@ -1713,21 +1744,24 @@ function compareScreenshot(expected, actual, options) {
|
|
|
1713
1744
|
};
|
|
1714
1745
|
}
|
|
1715
1746
|
/**
|
|
1716
|
-
* The box is
|
|
1747
|
+
* The box is clipped to the image, because a rect comes from a snapshot and a
|
|
1717
1748
|
* screenshot is a separate capture. A control flush against the bottom edge can
|
|
1718
|
-
* round a pixel past it, and that is not a reason to fail an assertion.
|
|
1749
|
+
* round a pixel past it, and that is not a reason to fail an assertion. A box
|
|
1750
|
+
* with no pixel inside the image is a different thing, a rect the screenshot
|
|
1751
|
+
* does not show, and cutting a placeholder out of the corner would hide that.
|
|
1719
1752
|
*/
|
|
1720
1753
|
function cropScreenshot(source, box) {
|
|
1721
1754
|
const image = PNG.sync.read(source);
|
|
1722
|
-
const
|
|
1755
|
+
const clipped = intersect(box, {
|
|
1723
1756
|
width: image.width,
|
|
1724
1757
|
height: image.height
|
|
1725
1758
|
});
|
|
1759
|
+
if (clipped === null) throw new Error(`the crop at ${String(box.x)},${String(box.y)} ${String(box.width)}x${String(box.height)} lies outside the ${String(image.width)}x${String(image.height)} screenshot`);
|
|
1726
1760
|
const cut = new PNG({
|
|
1727
|
-
width:
|
|
1728
|
-
height:
|
|
1761
|
+
width: clipped.width,
|
|
1762
|
+
height: clipped.height
|
|
1729
1763
|
});
|
|
1730
|
-
PNG.bitblt(image, cut,
|
|
1764
|
+
PNG.bitblt(image, cut, clipped.x, clipped.y, clipped.width, clipped.height, 0, 0);
|
|
1731
1765
|
return PNG.sync.write(cut);
|
|
1732
1766
|
}
|
|
1733
1767
|
function sizeOf(source) {
|
|
@@ -1756,21 +1790,30 @@ function relativeTo(box, origin) {
|
|
|
1756
1790
|
y: box.y - origin.y
|
|
1757
1791
|
};
|
|
1758
1792
|
}
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1793
|
+
/**
|
|
1794
|
+
* The part of the box inside the image, or null when none of it is. A mask
|
|
1795
|
+
* resolved off a node outside a crop must paint nothing, because moving it
|
|
1796
|
+
* inside would black out real pixels at the crop's edge.
|
|
1797
|
+
*/
|
|
1798
|
+
function intersect(box, size) {
|
|
1799
|
+
const left = Math.max(box.x, 0);
|
|
1800
|
+
const top = Math.max(box.y, 0);
|
|
1801
|
+
const right = Math.min(box.x + box.width, size.width);
|
|
1802
|
+
const bottom = Math.min(box.y + box.height, size.height);
|
|
1803
|
+
if (right <= left || bottom <= top) return null;
|
|
1762
1804
|
return {
|
|
1763
|
-
x,
|
|
1764
|
-
y,
|
|
1765
|
-
width:
|
|
1766
|
-
height:
|
|
1805
|
+
x: left,
|
|
1806
|
+
y: top,
|
|
1807
|
+
width: right - left,
|
|
1808
|
+
height: bottom - top
|
|
1767
1809
|
};
|
|
1768
1810
|
}
|
|
1769
1811
|
function paintBlack(image, box) {
|
|
1770
|
-
const region =
|
|
1812
|
+
const region = intersect(box, {
|
|
1771
1813
|
width: image.width,
|
|
1772
1814
|
height: image.height
|
|
1773
1815
|
});
|
|
1816
|
+
if (region === null) return;
|
|
1774
1817
|
for (let row = region.y; row < region.y + region.height; row += 1) for (let column = region.x; column < region.x + region.width; column += 1) {
|
|
1775
1818
|
const at = image.width * row + column << 2;
|
|
1776
1819
|
image.data[at] = 0;
|
|
@@ -1862,4 +1905,4 @@ function notBooted(platform, name, booted) {
|
|
|
1862
1905
|
return `No booted ${platform} device is named '${name}'. Booted right now: ${booted.map((device) => `'${device.name}'`).join(", ")}. Set use.deviceName to one of those or boot '${name}'.`;
|
|
1863
1906
|
}
|
|
1864
1907
|
//#endregion
|
|
1865
|
-
export {
|
|
1908
|
+
export { normalizeText as A, parseScreen as C, deviceNameForSlot as D, TOUCHPRESS_DEFAULTS as E, TouchpressError as M, parseDeviceOptions as O, silentSink as S, resolve as T, createQueue as _, sizeOf as a, sleep as b, createClient as c, createScrollSearch as d, directionToward as f, evaluate as g, describeCheck as h, relativeTo as i, textMatch as j, describeQuery as k, captureEvidence as l, probe as m, compareScreenshot as n, toPixelBox as o, formatFailure as p, cropScreenshot as r, createAgentDeviceDriver as s, preflight as t, createDevice as u, openSession as v, renderScreen as w, renderTitle as x, sessionName as y };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "touchpress",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "End-to-end testing for mobile apps.",
|
|
5
5
|
"homepage": "https://github.com/wobsoriano/touchpress#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -33,13 +33,21 @@
|
|
|
33
33
|
"@playwright/test": "1.63.0",
|
|
34
34
|
"@types/node": "^26.1.1",
|
|
35
35
|
"@types/pngjs": "^6.0.5",
|
|
36
|
+
"ai": "^7.0.93",
|
|
36
37
|
"bumpp": "^11.1.0",
|
|
37
38
|
"typescript": "^7.0.2",
|
|
38
39
|
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.0",
|
|
39
|
-
"vite-plus": "0.3.0"
|
|
40
|
+
"vite-plus": "0.3.0",
|
|
41
|
+
"zod": "^4.5.4"
|
|
40
42
|
},
|
|
41
43
|
"peerDependencies": {
|
|
42
|
-
"@playwright/test": ">=1.63"
|
|
44
|
+
"@playwright/test": ">=1.63",
|
|
45
|
+
"ai": "^6.0.0 || ^7.0.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"ai": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
43
51
|
},
|
|
44
52
|
"devEngines": {
|
|
45
53
|
"packageManager": {
|