touchpress 0.0.1 → 0.1.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/README.md +33 -19
- package/dist/core/index.d.mts +2 -2
- package/dist/core/index.mjs +2 -2
- package/dist/index.d.mts +51 -7
- package/dist/index.mjs +509 -12
- package/dist/{preflight-C83jCNPs.mjs → preflight-B7KrbqSO.mjs} +146 -112
- package/dist/{preflight--Z-REtl-.d.mts → preflight-Czhj9QFM.d.mts} +40 -8
- package/package.json +14 -6
|
@@ -2,17 +2,17 @@ import { createAgentDeviceClient, normalizeAgentDeviceError } from "agent-device
|
|
|
2
2
|
import pixelmatch from "pixelmatch";
|
|
3
3
|
import { PNG } from "pngjs";
|
|
4
4
|
//#region src/core/errors.ts
|
|
5
|
-
var
|
|
5
|
+
var TouchpressError = class extends Error {
|
|
6
6
|
info;
|
|
7
7
|
constructor(info) {
|
|
8
8
|
super(formatError(info));
|
|
9
|
-
this.name = "
|
|
9
|
+
this.name = "TouchpressError";
|
|
10
10
|
this.info = info;
|
|
11
11
|
}
|
|
12
12
|
};
|
|
13
13
|
function formatError(info) {
|
|
14
14
|
switch (info.kind) {
|
|
15
|
-
case "config": return `Invalid
|
|
15
|
+
case "config": return `Invalid touchpress option: use.${info.field} ${info.detail}`;
|
|
16
16
|
case "device-in-use": return [
|
|
17
17
|
`Device ${info.device} is held by ${info.owner === null ? "another session" : `session "${info.owner}"`}.`,
|
|
18
18
|
`Release it with: ${info.releaseCommand}`,
|
|
@@ -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
|
}
|
|
@@ -183,14 +213,14 @@ function describeIndex(index) {
|
|
|
183
213
|
* to the same values, so a caller that reaches the parser without the fixtures,
|
|
184
214
|
* such as `preflight`, resolves identically.
|
|
185
215
|
*/
|
|
186
|
-
const
|
|
216
|
+
const TOUCHPRESS_DEFAULTS = {
|
|
187
217
|
relaunch: "per-test",
|
|
188
218
|
onDeviceInUse: "fail",
|
|
189
219
|
settleQuietMs: 500,
|
|
190
220
|
launchTimeout: 9e4,
|
|
191
221
|
dismissDevOverlay: false,
|
|
192
222
|
evidence: "on-failure",
|
|
193
|
-
sessionPrefix: "
|
|
223
|
+
sessionPrefix: "touchpress"
|
|
194
224
|
};
|
|
195
225
|
const DEFAULT_ACTION_TIMEOUT_MS = 1e4;
|
|
196
226
|
const ROLES = [
|
|
@@ -226,18 +256,18 @@ function parseDeviceOptions(raw) {
|
|
|
226
256
|
readyWhen: parseReadyWhen(read(raw, "readyWhen")),
|
|
227
257
|
device: parseDeviceChoice(read(raw, "deviceName")),
|
|
228
258
|
launchUrl: optionalText("launchUrl", read(raw, "launchUrl")),
|
|
229
|
-
relaunch: oneOf("relaunch", read(raw, "relaunch"), ["per-test", "per-worker"],
|
|
230
|
-
onDeviceInUse: oneOf("onDeviceInUse", read(raw, "onDeviceInUse"), ["fail", "reclaim"],
|
|
259
|
+
relaunch: oneOf("relaunch", read(raw, "relaunch"), ["per-test", "per-worker"], TOUCHPRESS_DEFAULTS.relaunch),
|
|
260
|
+
onDeviceInUse: oneOf("onDeviceInUse", read(raw, "onDeviceInUse"), ["fail", "reclaim"], TOUCHPRESS_DEFAULTS.onDeviceInUse),
|
|
231
261
|
actionTimeout: parseActionTimeout(read(raw, "actionTimeout")),
|
|
232
|
-
settleQuietMs: positive("settleQuietMs", read(raw, "settleQuietMs"),
|
|
233
|
-
launchTimeout: positive("launchTimeout", read(raw, "launchTimeout"),
|
|
234
|
-
dismissDevOverlay: flag("dismissDevOverlay", read(raw, "dismissDevOverlay"),
|
|
262
|
+
settleQuietMs: positive("settleQuietMs", read(raw, "settleQuietMs"), TOUCHPRESS_DEFAULTS.settleQuietMs),
|
|
263
|
+
launchTimeout: positive("launchTimeout", read(raw, "launchTimeout"), TOUCHPRESS_DEFAULTS.launchTimeout),
|
|
264
|
+
dismissDevOverlay: flag("dismissDevOverlay", read(raw, "dismissDevOverlay"), TOUCHPRESS_DEFAULTS.dismissDevOverlay),
|
|
235
265
|
evidence: oneOf("evidence", read(raw, "evidence"), [
|
|
236
266
|
"on-failure",
|
|
237
267
|
"always",
|
|
238
268
|
"off"
|
|
239
|
-
],
|
|
240
|
-
sessionPrefix: text("sessionPrefix", read(raw, "sessionPrefix"),
|
|
269
|
+
], TOUCHPRESS_DEFAULTS.evidence),
|
|
270
|
+
sessionPrefix: text("sessionPrefix", read(raw, "sessionPrefix"), TOUCHPRESS_DEFAULTS.sessionPrefix)
|
|
241
271
|
};
|
|
242
272
|
}
|
|
243
273
|
/** A non-object source reads as every key unset, so a caller that passes nothing fails on the first required key. */
|
|
@@ -246,7 +276,7 @@ function read(source, key) {
|
|
|
246
276
|
return key in source ? Reflect.get(source, key) : void 0;
|
|
247
277
|
}
|
|
248
278
|
/**
|
|
249
|
-
* Playwright's own `use.actionTimeout`, not one of
|
|
279
|
+
* Playwright's own `use.actionTimeout`, not one of touchpress's. Playwright defaults
|
|
250
280
|
* it to 0, which means "no timeout" there and would mean "give up at once" here,
|
|
251
281
|
* so 0 falls back the way an unset value does.
|
|
252
282
|
*/
|
|
@@ -348,96 +378,13 @@ function text(field, value, fallback) {
|
|
|
348
378
|
return value;
|
|
349
379
|
}
|
|
350
380
|
function fail(field, detail) {
|
|
351
|
-
return new
|
|
381
|
+
return new TouchpressError({
|
|
352
382
|
kind: "config",
|
|
353
383
|
field,
|
|
354
384
|
detail
|
|
355
385
|
});
|
|
356
386
|
}
|
|
357
387
|
//#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
388
|
//#region src/core/screen.ts
|
|
442
389
|
const IOS_ROLES = {
|
|
443
390
|
Application: "application",
|
|
@@ -716,6 +663,7 @@ function renderNode(node) {
|
|
|
716
663
|
//#endregion
|
|
717
664
|
//#region src/core/report.ts
|
|
718
665
|
const FILL_TEXT_LIMIT = 40;
|
|
666
|
+
const PROMPT_LIMIT = 80;
|
|
719
667
|
/** Rendered here rather than in an adapter so every runner produces the same text. */
|
|
720
668
|
function renderTitle(record) {
|
|
721
669
|
switch (record.kind) {
|
|
@@ -729,18 +677,21 @@ function renderTitle(record) {
|
|
|
729
677
|
case "relaunch": return `relaunch ${record.app}`;
|
|
730
678
|
case "dismiss-overlay": return "dismiss the React Native dev overlay";
|
|
731
679
|
case "screenshot": return `screenshot ${record.path}`;
|
|
680
|
+
case "act": return `act "${truncate(record.instruction, PROMPT_LIMIT)}"`;
|
|
681
|
+
case "extract": return `extract "${truncate(record.question, PROMPT_LIMIT)}"`;
|
|
682
|
+
case "tool": return record.target === void 0 ? record.name : `${record.name} ${record.target}`;
|
|
732
683
|
default: throw new Error(`unhandled action record ${JSON.stringify(record)}`);
|
|
733
684
|
}
|
|
734
685
|
}
|
|
735
686
|
function renderTyped(typed) {
|
|
736
687
|
switch (typed.kind) {
|
|
737
|
-
case "text": return `type "${truncate(typed.value)}"`;
|
|
688
|
+
case "text": return `type "${truncate(typed.value, FILL_TEXT_LIMIT)}"`;
|
|
738
689
|
case "hidden": return `type ${String(typed.length)} characters`;
|
|
739
690
|
default: throw new Error(`unhandled typed value ${JSON.stringify(typed)}`);
|
|
740
691
|
}
|
|
741
692
|
}
|
|
742
|
-
function truncate(text) {
|
|
743
|
-
return text.length <=
|
|
693
|
+
function truncate(text, limit) {
|
|
694
|
+
return text.length <= limit ? text : `${text.slice(0, limit)}...`;
|
|
744
695
|
}
|
|
745
696
|
/** Discards everything. The default for scripts, unit tests, and runners with no reporting. */
|
|
746
697
|
const silentSink = {
|
|
@@ -750,6 +701,89 @@ const silentSink = {
|
|
|
750
701
|
outputPath: (fileName) => fileName
|
|
751
702
|
};
|
|
752
703
|
//#endregion
|
|
704
|
+
//#region src/core/checks.ts
|
|
705
|
+
/**
|
|
706
|
+
* A `many` outcome never passes anything but `toHaveCount`. An ambiguous locator
|
|
707
|
+
* is a strictness violation, and it reports through the same message path as a
|
|
708
|
+
* plain mismatch rather than guessing which node was meant.
|
|
709
|
+
*/
|
|
710
|
+
function evaluate(check, resolution) {
|
|
711
|
+
if (check.name === "toHaveCount") {
|
|
712
|
+
const count = countOf(resolution);
|
|
713
|
+
return {
|
|
714
|
+
pass: count === check.expected,
|
|
715
|
+
actual: String(count)
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
if (resolution.outcome === "none") return {
|
|
719
|
+
pass: false,
|
|
720
|
+
actual: null
|
|
721
|
+
};
|
|
722
|
+
if (resolution.outcome === "many") return {
|
|
723
|
+
pass: false,
|
|
724
|
+
actual: `${String(resolution.nodes.length)} matching nodes`
|
|
725
|
+
};
|
|
726
|
+
const node = resolution.node;
|
|
727
|
+
switch (check.name) {
|
|
728
|
+
case "toBeVisible": return {
|
|
729
|
+
pass: true,
|
|
730
|
+
actual: describeNode(node)
|
|
731
|
+
};
|
|
732
|
+
case "toHaveText": {
|
|
733
|
+
const text = node.name ?? node.value;
|
|
734
|
+
return {
|
|
735
|
+
pass: matchesText(check.expected, text),
|
|
736
|
+
actual: text === null ? null : `"${text}"`
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
case "toHaveValue": return {
|
|
740
|
+
pass: matchesText(check.expected, node.value),
|
|
741
|
+
actual: node.value === null ? null : `"${node.value}"`
|
|
742
|
+
};
|
|
743
|
+
case "toBeEnabled": return {
|
|
744
|
+
pass: node.enabled,
|
|
745
|
+
actual: node.enabled ? "enabled" : "disabled"
|
|
746
|
+
};
|
|
747
|
+
case "toBeSelected": return {
|
|
748
|
+
pass: node.selected,
|
|
749
|
+
actual: node.selected ? "selected" : "not selected"
|
|
750
|
+
};
|
|
751
|
+
case "toBeFocused": return {
|
|
752
|
+
pass: node.focused,
|
|
753
|
+
actual: node.focused ? "focused" : "not focused"
|
|
754
|
+
};
|
|
755
|
+
default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
function countOf(resolution) {
|
|
759
|
+
switch (resolution.outcome) {
|
|
760
|
+
case "one": return 1;
|
|
761
|
+
case "none": return 0;
|
|
762
|
+
case "many": return resolution.nodes.length;
|
|
763
|
+
default: throw new Error(`unhandled resolution ${JSON.stringify(resolution)}`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/** The `Expected:` line. */
|
|
767
|
+
function describeCheck(check) {
|
|
768
|
+
switch (check.name) {
|
|
769
|
+
case "toBeVisible": return "visible";
|
|
770
|
+
case "toHaveText": return `text ${describeExpected(check.expected)}`;
|
|
771
|
+
case "toHaveValue": return `value ${describeExpected(check.expected)}`;
|
|
772
|
+
case "toBeEnabled": return "enabled";
|
|
773
|
+
case "toBeSelected": return "selected";
|
|
774
|
+
case "toBeFocused": return "focused";
|
|
775
|
+
case "toHaveCount": return `count ${String(check.expected)}`;
|
|
776
|
+
default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
function describeExpected(match) {
|
|
780
|
+
return match.kind === "regex" ? String(match.value) : `"${match.value}"`;
|
|
781
|
+
}
|
|
782
|
+
function describeNode(node) {
|
|
783
|
+
const name = node.name === null ? "" : ` "${node.name}"`;
|
|
784
|
+
return `${node.ref} [${node.role}]${name}`;
|
|
785
|
+
}
|
|
786
|
+
//#endregion
|
|
753
787
|
//#region src/core/session.ts
|
|
754
788
|
const READY_POLL_MS = 250;
|
|
755
789
|
const SNAPSHOT_TIMEOUT_MS = 15e3;
|
|
@@ -806,7 +840,7 @@ async function openWithRecovery(driver, options, name, deviceName) {
|
|
|
806
840
|
if (failure === null) throw error;
|
|
807
841
|
if (failure.kind === "device-busy") {
|
|
808
842
|
const owner = failure.owner;
|
|
809
|
-
if (!(owner !== null && (owner.startsWith(options.sessionPrefix) || options.onDeviceInUse === "reclaim"))) throw new
|
|
843
|
+
if (!(owner !== null && (owner.startsWith(options.sessionPrefix) || options.onDeviceInUse === "reclaim"))) throw new TouchpressError({
|
|
810
844
|
kind: "device-in-use",
|
|
811
845
|
owner,
|
|
812
846
|
device: deviceName ?? options.platform,
|
|
@@ -820,7 +854,7 @@ async function openWithRecovery(driver, options, name, deviceName) {
|
|
|
820
854
|
await driver.close(name);
|
|
821
855
|
return await driver.open(request);
|
|
822
856
|
}
|
|
823
|
-
throw new
|
|
857
|
+
throw new TouchpressError({
|
|
824
858
|
kind: "launch-failed",
|
|
825
859
|
app: options.app,
|
|
826
860
|
device: deviceName ?? options.platform,
|
|
@@ -877,7 +911,7 @@ function createSession(driver, options, name, binding) {
|
|
|
877
911
|
if (remaining <= 0) break;
|
|
878
912
|
await sleep(Math.min(READY_POLL_MS, remaining));
|
|
879
913
|
}
|
|
880
|
-
throw new
|
|
914
|
+
throw new TouchpressError({
|
|
881
915
|
kind: "not-ready",
|
|
882
916
|
locator: describeQuery(options.readyWhen),
|
|
883
917
|
timeoutMs: Date.now() - started,
|
|
@@ -920,12 +954,12 @@ function createSession(driver, options, name, binding) {
|
|
|
920
954
|
};
|
|
921
955
|
}
|
|
922
956
|
function unusable(state) {
|
|
923
|
-
if (state.phase === "broken") return new
|
|
957
|
+
if (state.phase === "broken") return new TouchpressError({
|
|
924
958
|
kind: "driver",
|
|
925
959
|
command: "device command",
|
|
926
960
|
failure: state.failure
|
|
927
961
|
});
|
|
928
|
-
return new
|
|
962
|
+
return new TouchpressError({
|
|
929
963
|
kind: "session-closed",
|
|
930
964
|
command: "run a device command"
|
|
931
965
|
});
|
|
@@ -943,7 +977,7 @@ function sessionName(options, project, slot) {
|
|
|
943
977
|
return `${options.sessionPrefix}-${project === "" ? "default" : project}-${String(slot)}`;
|
|
944
978
|
}
|
|
945
979
|
function failureOf(error) {
|
|
946
|
-
if (!(error instanceof
|
|
980
|
+
if (!(error instanceof TouchpressError)) return null;
|
|
947
981
|
if (error.info.kind === "driver") return error.info.failure;
|
|
948
982
|
if (error.info.kind === "launch-failed") return error.info.failure;
|
|
949
983
|
return null;
|
|
@@ -1240,7 +1274,7 @@ function perform(session, sink, record, options, dispatch, write) {
|
|
|
1240
1274
|
let target = record.query;
|
|
1241
1275
|
let lastActual = null;
|
|
1242
1276
|
let screen = await device.capture();
|
|
1243
|
-
const unconfirmed = (expected) => new
|
|
1277
|
+
const unconfirmed = (expected) => new TouchpressError({
|
|
1244
1278
|
kind: "fill-unconfirmed",
|
|
1245
1279
|
locator,
|
|
1246
1280
|
expected,
|
|
@@ -1288,7 +1322,7 @@ function perform(session, sink, record, options, dispatch, write) {
|
|
|
1288
1322
|
continue;
|
|
1289
1323
|
}
|
|
1290
1324
|
const remaining = deadline - Date.now();
|
|
1291
|
-
if (remaining <= 0) throw new
|
|
1325
|
+
if (remaining <= 0) throw new TouchpressError({
|
|
1292
1326
|
kind: "not-found",
|
|
1293
1327
|
locator,
|
|
1294
1328
|
timeoutMs: timeout,
|
|
@@ -1326,7 +1360,7 @@ function scrollIntoView(session, sink, query, options) {
|
|
|
1326
1360
|
if (resolution.outcome === "many") throw ambiguous(locator, resolution.nodes, screen);
|
|
1327
1361
|
if (resolution.outcome === "one") return;
|
|
1328
1362
|
const remaining = deadline - Date.now();
|
|
1329
|
-
if (remaining <= 0 || !await search.step(screen, query, remaining)) throw new
|
|
1363
|
+
if (remaining <= 0 || !await search.step(screen, query, remaining)) throw new TouchpressError({
|
|
1330
1364
|
kind: "not-found",
|
|
1331
1365
|
locator,
|
|
1332
1366
|
timeoutMs: timeout,
|
|
@@ -1349,7 +1383,7 @@ function reportingScrolls(device, sink) {
|
|
|
1349
1383
|
};
|
|
1350
1384
|
}
|
|
1351
1385
|
function ambiguous(locator, nodes, screen) {
|
|
1352
|
-
return new
|
|
1386
|
+
return new TouchpressError({
|
|
1353
1387
|
kind: "strict-mode",
|
|
1354
1388
|
locator,
|
|
1355
1389
|
matches: nodes.map((node) => describeNode(node)),
|
|
@@ -1495,7 +1529,7 @@ function createAgentDeviceDriver(client, session, selection) {
|
|
|
1495
1529
|
try {
|
|
1496
1530
|
return await body();
|
|
1497
1531
|
} catch (error) {
|
|
1498
|
-
throw new
|
|
1532
|
+
throw new TouchpressError({
|
|
1499
1533
|
kind: "driver",
|
|
1500
1534
|
command,
|
|
1501
1535
|
failure: classifyError(error)
|
|
@@ -1862,4 +1896,4 @@ function notBooted(platform, name, booted) {
|
|
|
1862
1896
|
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
1897
|
}
|
|
1864
1898
|
//#endregion
|
|
1865
|
-
export { textMatch as A,
|
|
1899
|
+
export { textMatch as A, renderScreen as C, parseDeviceOptions as D, deviceNameForSlot as E, describeQuery as O, parseScreen as S, TOUCHPRESS_DEFAULTS as T, sleep as _, sizeOf as a, renderTitle as b, createClient as c, createScrollSearch as d, directionToward as f, sessionName as g, openSession as h, relativeTo as i, TouchpressError as j, normalizeText 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, describeCheck as v, resolve as w, silentSink as x, evaluate as y };
|
|
@@ -323,7 +323,7 @@ type CaptureOptions = {
|
|
|
323
323
|
* Contract every implementation owes the core. `open` converges when called on
|
|
324
324
|
* an already-open session. Mutations take a `PinnedRef` and never a selector, so
|
|
325
325
|
* the driver's own matcher is never a second opinion on which node was meant.
|
|
326
|
-
* Every failure throws a `
|
|
326
|
+
* Every failure throws a `TouchpressError` carrying a `DeviceFailure`.
|
|
327
327
|
*/
|
|
328
328
|
type DeviceDriver = {
|
|
329
329
|
/** Takes no session, so it is the one call that binds nothing. */
|
|
@@ -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 = {
|
|
@@ -475,14 +487,14 @@ declare const silentSink: ActionSink;
|
|
|
475
487
|
//#endregion
|
|
476
488
|
//#region src/core/config.d.ts
|
|
477
489
|
/**
|
|
478
|
-
* The keys
|
|
490
|
+
* The keys touchpress adds to Playwright's `use`. Each is its own option fixture, so
|
|
479
491
|
* a project overrides one without restating the rest.
|
|
480
492
|
*
|
|
481
493
|
* Playwright types `use` as a partial of this, so a config may leave any key out.
|
|
482
494
|
* `parseDeviceOptions` is what makes `platform`, `app`, and `readyWhen` required.
|
|
483
495
|
* It runs once at worker start and nothing downstream re-validates.
|
|
484
496
|
*/
|
|
485
|
-
type
|
|
497
|
+
type TouchpressOptions = {
|
|
486
498
|
/** Required. */
|
|
487
499
|
platform: Platform | undefined;
|
|
488
500
|
/** Required. Bundle id on iOS, package name on Android. Never a path to an artifact. */
|
|
@@ -513,7 +525,7 @@ type TangereOptions = {
|
|
|
513
525
|
dismissDevOverlay: boolean;
|
|
514
526
|
/** @default 'on-failure' */
|
|
515
527
|
evidence: 'on-failure' | 'always' | 'off';
|
|
516
|
-
/** @default '
|
|
528
|
+
/** @default 'touchpress'. Session names are `${prefix}-${project}-${parallelIndex}`. */
|
|
517
529
|
sessionPrefix: string;
|
|
518
530
|
};
|
|
519
531
|
/**
|
|
@@ -521,7 +533,7 @@ type TangereOptions = {
|
|
|
521
533
|
* to the same values, so a caller that reaches the parser without the fixtures,
|
|
522
534
|
* such as `preflight`, resolves identically.
|
|
523
535
|
*/
|
|
524
|
-
declare const
|
|
536
|
+
declare const TOUCHPRESS_DEFAULTS: Omit<TouchpressOptions, 'platform' | 'app' | 'readyWhen' | 'deviceName' | 'launchUrl'>;
|
|
525
537
|
/** The config-file friendly subset of a locator. Matched by the same rules as any other query. */
|
|
526
538
|
type ReadyQuery = {
|
|
527
539
|
text: string;
|
|
@@ -825,8 +837,28 @@ 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
|
-
declare class
|
|
861
|
+
declare class TouchpressError extends Error {
|
|
830
862
|
readonly info: ErrorInfo;
|
|
831
863
|
constructor(info: ErrorInfo);
|
|
832
864
|
}
|
|
@@ -848,6 +880,6 @@ type PreflightReport = {
|
|
|
848
880
|
* runner names every one of them at once. A malformed config still throws, the
|
|
849
881
|
* way it does everywhere else. `driver` is the seam tests inject.
|
|
850
882
|
*/
|
|
851
|
-
declare function preflight(options: Partial<
|
|
883
|
+
declare function preflight(options: Partial<TouchpressOptions>, driver?: DeviceDriver): Promise<PreflightReport>;
|
|
852
884
|
//#endregion
|
|
853
|
-
export { Tree as $, deviceNameForSlot as A, ProbeResult as B, openSession as C, ResolvedOptions as D, ReadyQuery as E, StepOptions as F, CaptureOptions as G, formatFailure as H, Typed as I, DeviceInfo as J, DeviceDriver as K, renderTitle as L, ActionRecord as M, ActionSink as N,
|
|
885
|
+
export { Tree as $, deviceNameForSlot as A, ProbeResult as B, openSession as C, ResolvedOptions as D, ReadyQuery as E, StepOptions as F, CaptureOptions as G, formatFailure as H, Typed as I, DeviceInfo as J, DeviceDriver as K, renderTitle as L, ActionRecord as M, ActionSink as N, TOUCHPRESS_DEFAULTS as O, EvidenceFile as P, Settled as Q, silentSink as R, SessionState as S, DeviceChoice as T, probe as U, ProbeTarget as V, Binding as W, OpenRequest as X, DeviceSelection as Y, ScrollDirection as Z, RoleOptions as _, Screen as _t, ExpectedValue as a, Filter as at, DeviceSession as b, renderScreen as bt, ScrollSearch as c, TextMatch as ct, directionToward as d, textMatch as dt, Check as et, ActionOptions as f, PinnedRef as ft, Locator as g, Resolution as gt, FilterOptions as h, Rect as ht, ErrorInfo as i, evaluate as it, parseDeviceOptions as j, TouchpressOptions as k, ScrollTrail as l, describeQuery as lt, FillOptions as m, RawSnapshot as mt, PreflightReport as n, Verdict as nt, TouchpressError as o, Query as ot, Device as p, Platform as pt, DeviceFailure as q, preflight as r, describeCheck as rt, ScrollDevice as s, Role as st, PreflightDevice as t, CheckName as tt, createScrollSearch as u, normalizeText as ut, TextOptions as v, ScreenNode as vt, sessionName as w, OpenSessionInput as x, resolve as xt, createDevice as y, parseScreen as yt, ProbeOptions as z };
|
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "touchpress",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "End-to-end testing for mobile apps.",
|
|
5
|
-
"homepage": "https://github.com/wobsoriano/
|
|
5
|
+
"homepage": "https://github.com/wobsoriano/touchpress#readme",
|
|
6
6
|
"bugs": {
|
|
7
|
-
"url": "https://github.com/wobsoriano/
|
|
7
|
+
"url": "https://github.com/wobsoriano/touchpress/issues"
|
|
8
8
|
},
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"author": "Robert Soriano",
|
|
11
11
|
"repository": {
|
|
12
12
|
"type": "git",
|
|
13
|
-
"url": "git+https://github.com/wobsoriano/
|
|
13
|
+
"url": "git+https://github.com/wobsoriano/touchpress.git"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist"
|
|
@@ -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": {
|