touchpress 0.1.0 → 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/dist/core/index.d.mts +4 -2
- package/dist/core/index.mjs +1 -1
- package/dist/index.d.mts +43 -17
- package/dist/index.mjs +43 -13
- package/dist/{preflight-B7KrbqSO.mjs → preflight-aUba9u1k.mjs} +128 -119
- package/package.json +1 -1
package/dist/core/index.d.mts
CHANGED
|
@@ -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,6 +1,28 @@
|
|
|
1
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
|
-
|
|
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
|
|
4
26
|
//#region src/ai/device.d.ts
|
|
5
27
|
/** A loop can run for minutes, so its budget is its own rather than the action timeout a deterministic step takes. */
|
|
6
28
|
type ActOptions = {
|
|
@@ -10,6 +32,24 @@ type ActOptions = {
|
|
|
10
32
|
type ExtractOptions = {
|
|
11
33
|
timeout?: number;
|
|
12
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
|
+
};
|
|
13
53
|
type AiDevice = {
|
|
14
54
|
/**
|
|
15
55
|
* Drives the app with a model until the instruction is satisfied. Resolves
|
|
@@ -18,21 +58,7 @@ type AiDevice = {
|
|
|
18
58
|
*/
|
|
19
59
|
act(instruction: string, options?: ActOptions): Promise<string>;
|
|
20
60
|
/** Asks a model one question about the current screen and validates the answer against `schema`. */
|
|
21
|
-
extract<T>(question: string, schema:
|
|
22
|
-
};
|
|
23
|
-
//#endregion
|
|
24
|
-
//#region src/ai/options.d.ts
|
|
25
|
-
/**
|
|
26
|
-
* The one key `act` and `extract` add to Playwright's `use`. It is not part of
|
|
27
|
-
* `core/config.ts`, because nothing under `core/` may name an AI SDK type.
|
|
28
|
-
*
|
|
29
|
-
* A gateway model id such as `'anthropic/claude-sonnet-5'` and a provider
|
|
30
|
-
* model instance are both `LanguageModel`, so a config picks either without a
|
|
31
|
-
* second key. Unset is the default, and it fails at the first `act` rather than
|
|
32
|
-
* at worker start, so a project that never calls one needs no model.
|
|
33
|
-
*/
|
|
34
|
-
type AiOptions = {
|
|
35
|
-
aiModel: LanguageModel | undefined;
|
|
61
|
+
extract<T>(question: string, schema: ExtractSchema<T>, options?: ExtractOptions): Promise<T>;
|
|
36
62
|
};
|
|
37
63
|
//#endregion
|
|
38
64
|
//#region src/playwright/fixtures.d.ts
|
|
@@ -117,4 +143,4 @@ declare const expect: import("@playwright/test").Expect<{
|
|
|
117
143
|
type TouchpressOptions = TouchpressOptions$1 & AiOptions;
|
|
118
144
|
type Device = Device$1 & AiDevice;
|
|
119
145
|
//#endregion
|
|
120
|
-
export { type ActOptions, type AiDevice, type AiOptions, Device, type ErrorInfo, type ExpectedValue, type ExtractOptions, 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 };
|
|
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,4 +1,4 @@
|
|
|
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";
|
|
@@ -106,10 +106,11 @@ async function createDeviceTools(session, platform) {
|
|
|
106
106
|
for (const name of DEVICE_TOOLS.keys()) {
|
|
107
107
|
const built = tools[name];
|
|
108
108
|
if (built === void 0) throw new Error(`agent-device no longer exposes the "${name}" tool`);
|
|
109
|
+
const schema = prune(built.inputSchema.jsonSchema);
|
|
109
110
|
kept[name] = tool({
|
|
110
111
|
description: built.description,
|
|
111
|
-
inputSchema: jsonSchema(
|
|
112
|
-
execute: wrapDeviceTool(name, platform, built.execute)
|
|
112
|
+
inputSchema: jsonSchema(schema),
|
|
113
|
+
execute: wrapDeviceTool(name, platform, built.execute, new Set(Object.keys(schema.properties ?? {})))
|
|
113
114
|
});
|
|
114
115
|
}
|
|
115
116
|
return kept;
|
|
@@ -117,14 +118,19 @@ async function createDeviceTools(session, platform) {
|
|
|
117
118
|
/**
|
|
118
119
|
* The one place a command's input and output are reshaped for the model, so the
|
|
119
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.
|
|
120
125
|
*/
|
|
121
|
-
function wrapDeviceTool(name, platform, execute) {
|
|
126
|
+
function wrapDeviceTool(name, platform, execute, accepted) {
|
|
122
127
|
const shape = DEVICE_TOOLS.get(name)?.output ?? "raw";
|
|
123
128
|
return async (input, options) => {
|
|
129
|
+
const declared = Object.fromEntries(Object.entries(asObject(input)).filter(([key]) => accepted.has(key)));
|
|
124
130
|
const output = await execute(shape === "screen" ? {
|
|
125
|
-
...
|
|
131
|
+
...declared,
|
|
126
132
|
forceFull: true
|
|
127
|
-
} : withRefSigil(
|
|
133
|
+
} : withRefSigil(declared), options);
|
|
128
134
|
return shape === "screen" ? compactSnapshot(asSnapshot(output), platform) : compactResult(name, output);
|
|
129
135
|
};
|
|
130
136
|
}
|
|
@@ -273,7 +279,7 @@ function runAct(run) {
|
|
|
273
279
|
}), async () => {
|
|
274
280
|
const { ToolLoopAgent, hasToolCall, jsonSchema, stepCountIs, tool } = await loadAi();
|
|
275
281
|
const result = await new ToolLoopAgent({
|
|
276
|
-
model: run.model,
|
|
282
|
+
model: languageModel(run.model),
|
|
277
283
|
instructions: instructionsFor(run.platform),
|
|
278
284
|
tools: {
|
|
279
285
|
...reporting(run.tools, run.sink),
|
|
@@ -349,6 +355,18 @@ function runAct(run) {
|
|
|
349
355
|
return outcome.summary;
|
|
350
356
|
});
|
|
351
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
|
+
}
|
|
352
370
|
/** One capture, one question, one answer. No tools, so the model cannot change the screen it is describing. */
|
|
353
371
|
function runExtract(run) {
|
|
354
372
|
return run.sink.step(renderTitle({
|
|
@@ -357,9 +375,9 @@ function runExtract(run) {
|
|
|
357
375
|
}), async () => {
|
|
358
376
|
const { ToolLoopAgent, Output } = await loadAi();
|
|
359
377
|
return (await new ToolLoopAgent({
|
|
360
|
-
model: run.model,
|
|
378
|
+
model: languageModel(run.model),
|
|
361
379
|
instructions: EXTRACT_INSTRUCTIONS,
|
|
362
|
-
output: Output.object({ schema: run.schema })
|
|
380
|
+
output: Output.object({ schema: flexibleSchema(run.schema) })
|
|
363
381
|
}).generate({
|
|
364
382
|
prompt: [
|
|
365
383
|
`Question: ${run.question}`,
|
|
@@ -371,8 +389,14 @@ function runExtract(run) {
|
|
|
371
389
|
})).output;
|
|
372
390
|
});
|
|
373
391
|
}
|
|
374
|
-
/**
|
|
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
|
+
*/
|
|
375
398
|
function reporting(tools, sink) {
|
|
399
|
+
const queue = createQueue();
|
|
376
400
|
const wrapped = {};
|
|
377
401
|
for (const [name, built] of Object.entries(tools)) {
|
|
378
402
|
const { execute } = built;
|
|
@@ -382,7 +406,7 @@ function reporting(tools, sink) {
|
|
|
382
406
|
}
|
|
383
407
|
wrapped[name] = {
|
|
384
408
|
...built,
|
|
385
|
-
execute: (input, options) => sink.step(renderTitle(toolRecord(name, input)), async () => {
|
|
409
|
+
execute: (input, options) => queue.enqueue(() => sink.step(renderTitle(toolRecord(name, input)), async () => {
|
|
386
410
|
const text = typedText(name, input);
|
|
387
411
|
if (text !== null) await sink.step(renderTitle({
|
|
388
412
|
kind: "typed",
|
|
@@ -392,7 +416,7 @@ function reporting(tools, sink) {
|
|
|
392
416
|
}
|
|
393
417
|
}), () => Promise.resolve(), { box: true });
|
|
394
418
|
return execute(input, options);
|
|
395
|
-
})
|
|
419
|
+
}))
|
|
396
420
|
};
|
|
397
421
|
}
|
|
398
422
|
return wrapped;
|
|
@@ -870,10 +894,16 @@ function write(path, png) {
|
|
|
870
894
|
mkdirSync(dirname(path), { recursive: true });
|
|
871
895
|
writeFileSync(path, png);
|
|
872
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
|
+
*/
|
|
873
903
|
function defaultName(info) {
|
|
874
904
|
const next = (ordinals.get(info) ?? 0) + 1;
|
|
875
905
|
ordinals.set(info, next);
|
|
876
|
-
return `${info.
|
|
906
|
+
return `${info.titlePath.slice(1).join(" ").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}-${String(next)}.png`;
|
|
877
907
|
}
|
|
878
908
|
//#endregion
|
|
879
909
|
//#region src/playwright/expect.ts
|
|
@@ -145,7 +145,9 @@ function matchesText(match, candidate) {
|
|
|
145
145
|
switch (match.kind) {
|
|
146
146
|
case "exact": return candidate === match.value;
|
|
147
147
|
case "substring": return candidate.toLowerCase().includes(match.value.toLowerCase());
|
|
148
|
-
case "regex":
|
|
148
|
+
case "regex":
|
|
149
|
+
match.value.lastIndex = 0;
|
|
150
|
+
return match.value.test(candidate);
|
|
149
151
|
default: throw new Error(`unhandled text match ${JSON.stringify(match)}`);
|
|
150
152
|
}
|
|
151
153
|
}
|
|
@@ -701,89 +703,6 @@ const silentSink = {
|
|
|
701
703
|
outputPath: (fileName) => fileName
|
|
702
704
|
};
|
|
703
705
|
//#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
|
|
787
706
|
//#region src/core/session.ts
|
|
788
707
|
const READY_POLL_MS = 250;
|
|
789
708
|
const SNAPSHOT_TIMEOUT_MS = 15e3;
|
|
@@ -986,6 +905,89 @@ function sleep(ms) {
|
|
|
986
905
|
return new Promise((done) => setTimeout(done, ms));
|
|
987
906
|
}
|
|
988
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
|
|
989
991
|
//#region src/core/probe.ts
|
|
990
992
|
const POLL_INTERVAL_MS = 250;
|
|
991
993
|
const SCREEN_LISTING_NODES = 60;
|
|
@@ -1300,10 +1302,10 @@ function perform(session, sink, record, options, dispatch, write) {
|
|
|
1300
1302
|
}
|
|
1301
1303
|
attempts += 1;
|
|
1302
1304
|
if (!settled.settled) sink.note("settle", `${renderTitle(record)} finished before the screen went quiet`);
|
|
1303
|
-
if (confirmation === null) return;
|
|
1305
|
+
if (confirmation === null || write === void 0) return;
|
|
1304
1306
|
if (attempts === 1) await sink.step(renderTitle({
|
|
1305
1307
|
kind: "typed",
|
|
1306
|
-
typed: typedOf(
|
|
1308
|
+
typed: typedOf(resolution.node.role, write)
|
|
1307
1309
|
}), () => Promise.resolve(), { box: true });
|
|
1308
1310
|
target = identityOf(screen, resolution.node);
|
|
1309
1311
|
screen = await device.capture();
|
|
@@ -1395,12 +1397,13 @@ function confirmationOf(role, write) {
|
|
|
1395
1397
|
kind: "mask",
|
|
1396
1398
|
length: write.text.length
|
|
1397
1399
|
};
|
|
1400
|
+
const value = normalizeText(write.text);
|
|
1398
1401
|
return write.secret ? {
|
|
1399
1402
|
kind: "secret",
|
|
1400
|
-
value
|
|
1403
|
+
value
|
|
1401
1404
|
} : {
|
|
1402
1405
|
kind: "open",
|
|
1403
|
-
value
|
|
1406
|
+
value
|
|
1404
1407
|
};
|
|
1405
1408
|
}
|
|
1406
1409
|
function holds(confirmation, actual) {
|
|
@@ -1428,22 +1431,16 @@ function expectedOf(confirmation) {
|
|
|
1428
1431
|
default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
|
|
1429
1432
|
}
|
|
1430
1433
|
}
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
case "open": return {
|
|
1442
|
-
kind: "text",
|
|
1443
|
-
value: confirmation.value
|
|
1444
|
-
};
|
|
1445
|
-
default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
|
|
1446
|
-
}
|
|
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
|
+
};
|
|
1447
1444
|
}
|
|
1448
1445
|
/** An actual value may be repeated back only as far as the expected one could be. */
|
|
1449
1446
|
function disclose(expected, actual) {
|
|
@@ -1747,21 +1744,24 @@ function compareScreenshot(expected, actual, options) {
|
|
|
1747
1744
|
};
|
|
1748
1745
|
}
|
|
1749
1746
|
/**
|
|
1750
|
-
* The box is
|
|
1747
|
+
* The box is clipped to the image, because a rect comes from a snapshot and a
|
|
1751
1748
|
* screenshot is a separate capture. A control flush against the bottom edge can
|
|
1752
|
-
* 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.
|
|
1753
1752
|
*/
|
|
1754
1753
|
function cropScreenshot(source, box) {
|
|
1755
1754
|
const image = PNG.sync.read(source);
|
|
1756
|
-
const
|
|
1755
|
+
const clipped = intersect(box, {
|
|
1757
1756
|
width: image.width,
|
|
1758
1757
|
height: image.height
|
|
1759
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`);
|
|
1760
1760
|
const cut = new PNG({
|
|
1761
|
-
width:
|
|
1762
|
-
height:
|
|
1761
|
+
width: clipped.width,
|
|
1762
|
+
height: clipped.height
|
|
1763
1763
|
});
|
|
1764
|
-
PNG.bitblt(image, cut,
|
|
1764
|
+
PNG.bitblt(image, cut, clipped.x, clipped.y, clipped.width, clipped.height, 0, 0);
|
|
1765
1765
|
return PNG.sync.write(cut);
|
|
1766
1766
|
}
|
|
1767
1767
|
function sizeOf(source) {
|
|
@@ -1790,21 +1790,30 @@ function relativeTo(box, origin) {
|
|
|
1790
1790
|
y: box.y - origin.y
|
|
1791
1791
|
};
|
|
1792
1792
|
}
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
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;
|
|
1796
1804
|
return {
|
|
1797
|
-
x,
|
|
1798
|
-
y,
|
|
1799
|
-
width:
|
|
1800
|
-
height:
|
|
1805
|
+
x: left,
|
|
1806
|
+
y: top,
|
|
1807
|
+
width: right - left,
|
|
1808
|
+
height: bottom - top
|
|
1801
1809
|
};
|
|
1802
1810
|
}
|
|
1803
1811
|
function paintBlack(image, box) {
|
|
1804
|
-
const region =
|
|
1812
|
+
const region = intersect(box, {
|
|
1805
1813
|
width: image.width,
|
|
1806
1814
|
height: image.height
|
|
1807
1815
|
});
|
|
1816
|
+
if (region === null) return;
|
|
1808
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) {
|
|
1809
1818
|
const at = image.width * row + column << 2;
|
|
1810
1819
|
image.data[at] = 0;
|
|
@@ -1896,4 +1905,4 @@ function notBooted(platform, name, booted) {
|
|
|
1896
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}'.`;
|
|
1897
1906
|
}
|
|
1898
1907
|
//#endregion
|
|
1899
|
-
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 };
|