touchpress 0.1.0 → 0.1.2

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.
@@ -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 clamped to the image, because a rect comes from a snapshot and a
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;
@@ -1,2 +1,2 @@
1
- import { A as textMatch, C as renderScreen, D as parseDeviceOptions, E as deviceNameForSlot, O as describeQuery, S as parseScreen, T as TOUCHPRESS_DEFAULTS, a as sizeOf, b as renderTitle, d as createScrollSearch, f as directionToward, g as sessionName, h as openSession, i as relativeTo, j as TouchpressError, k as normalizeText, 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 describeCheck, w as resolve, x as silentSink, y as evaluate } from "../preflight-B7KrbqSO.mjs";
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-CWeJYtS7.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
- import { FlexibleSchema, LanguageModel } from "ai";
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: FlexibleSchema<T>, options?: ExtractOptions): Promise<T>;
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 { A as textMatch, C as renderScreen, D as parseDeviceOptions, S as parseScreen, T as TOUCHPRESS_DEFAULTS, _ as sleep, a as sizeOf, b as renderTitle, c as createClient, h as openSession, i as relativeTo, j as TouchpressError, l as captureEvidence, n as compareScreenshot, o as toPixelBox, r as cropScreenshot, s as createAgentDeviceDriver, t as preflight, u as createDevice, w as resolve, x as silentSink } from "./preflight-B7KrbqSO.mjs";
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-CWeJYtS7.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(prune(built.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
- ...asObject(input),
131
+ ...declared,
126
132
  forceFull: true
127
- } : withRefSigil(input), options);
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
- /** Every model action becomes a step wrapping its own execution, so a report shows the loop as it ran. */
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.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}-${String(next)}.png`;
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": return match.value.test(candidate);
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
  }
@@ -417,7 +419,9 @@ const IOS_ROLES = {
417
419
  * Native 0.86 sample app, except the trailing widget block, which this app never
418
420
  * renders and stays a plausible guess. `android.view.ViewGroup` is what every
419
421
  * React Native `View` reports, testID containers included, so it is stated here
420
- * rather than left to the `other` fall-through in `roleOf`.
422
+ * rather than left to the `other` fall-through in `roleOf`. A password field is
423
+ * an `EditText` too. agent-device 0.20.10 parses the tree's `password` flag but
424
+ * does not emit it, so once it does, map it to `secure-text-field` here.
421
425
  */
422
426
  const ANDROID_ROLES = {
423
427
  "android.widget.Button": "button",
@@ -701,89 +705,6 @@ const silentSink = {
701
705
  outputPath: (fileName) => fileName
702
706
  };
703
707
  //#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
708
  //#region src/core/session.ts
788
709
  const READY_POLL_MS = 250;
789
710
  const SNAPSHOT_TIMEOUT_MS = 15e3;
@@ -986,6 +907,89 @@ function sleep(ms) {
986
907
  return new Promise((done) => setTimeout(done, ms));
987
908
  }
988
909
  //#endregion
910
+ //#region src/core/checks.ts
911
+ /**
912
+ * A `many` outcome never passes anything but `toHaveCount`. An ambiguous locator
913
+ * is a strictness violation, and it reports through the same message path as a
914
+ * plain mismatch rather than guessing which node was meant.
915
+ */
916
+ function evaluate(check, resolution) {
917
+ if (check.name === "toHaveCount") {
918
+ const count = countOf(resolution);
919
+ return {
920
+ pass: count === check.expected,
921
+ actual: String(count)
922
+ };
923
+ }
924
+ if (resolution.outcome === "none") return {
925
+ pass: false,
926
+ actual: null
927
+ };
928
+ if (resolution.outcome === "many") return {
929
+ pass: false,
930
+ actual: `${String(resolution.nodes.length)} matching nodes`
931
+ };
932
+ const node = resolution.node;
933
+ switch (check.name) {
934
+ case "toBeVisible": return {
935
+ pass: true,
936
+ actual: describeNode(node)
937
+ };
938
+ case "toHaveText": {
939
+ const text = node.name ?? node.value;
940
+ return {
941
+ pass: matchesText(check.expected, text),
942
+ actual: text === null ? null : `"${text}"`
943
+ };
944
+ }
945
+ case "toHaveValue": return {
946
+ pass: matchesText(check.expected, node.value),
947
+ actual: node.value === null ? null : `"${node.value}"`
948
+ };
949
+ case "toBeEnabled": return {
950
+ pass: node.enabled,
951
+ actual: node.enabled ? "enabled" : "disabled"
952
+ };
953
+ case "toBeSelected": return {
954
+ pass: node.selected,
955
+ actual: node.selected ? "selected" : "not selected"
956
+ };
957
+ case "toBeFocused": return {
958
+ pass: node.focused,
959
+ actual: node.focused ? "focused" : "not focused"
960
+ };
961
+ default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
962
+ }
963
+ }
964
+ function countOf(resolution) {
965
+ switch (resolution.outcome) {
966
+ case "one": return 1;
967
+ case "none": return 0;
968
+ case "many": return resolution.nodes.length;
969
+ default: throw new Error(`unhandled resolution ${JSON.stringify(resolution)}`);
970
+ }
971
+ }
972
+ /** The `Expected:` line. */
973
+ function describeCheck(check) {
974
+ switch (check.name) {
975
+ case "toBeVisible": return "visible";
976
+ case "toHaveText": return `text ${describeExpected(check.expected)}`;
977
+ case "toHaveValue": return `value ${describeExpected(check.expected)}`;
978
+ case "toBeEnabled": return "enabled";
979
+ case "toBeSelected": return "selected";
980
+ case "toBeFocused": return "focused";
981
+ case "toHaveCount": return `count ${String(check.expected)}`;
982
+ default: throw new Error(`unhandled check ${JSON.stringify(check)}`);
983
+ }
984
+ }
985
+ function describeExpected(match) {
986
+ return match.kind === "regex" ? String(match.value) : `"${match.value}"`;
987
+ }
988
+ function describeNode(node) {
989
+ const name = node.name === null ? "" : ` "${node.name}"`;
990
+ return `${node.ref} [${node.role}]${name}`;
991
+ }
992
+ //#endregion
989
993
  //#region src/core/probe.ts
990
994
  const POLL_INTERVAL_MS = 250;
991
995
  const SCREEN_LISTING_NODES = 60;
@@ -1300,10 +1304,10 @@ function perform(session, sink, record, options, dispatch, write) {
1300
1304
  }
1301
1305
  attempts += 1;
1302
1306
  if (!settled.settled) sink.note("settle", `${renderTitle(record)} finished before the screen went quiet`);
1303
- if (confirmation === null) return;
1307
+ if (confirmation === null || write === void 0) return;
1304
1308
  if (attempts === 1) await sink.step(renderTitle({
1305
1309
  kind: "typed",
1306
- typed: typedOf(confirmation)
1310
+ typed: typedOf(resolution.node.role, write)
1307
1311
  }), () => Promise.resolve(), { box: true });
1308
1312
  target = identityOf(screen, resolution.node);
1309
1313
  screen = await device.capture();
@@ -1391,26 +1395,37 @@ function ambiguous(locator, nodes, screen) {
1391
1395
  });
1392
1396
  }
1393
1397
  function confirmationOf(role, write) {
1398
+ const length = write.text.length;
1394
1399
  if (role === "secure-text-field") return {
1395
1400
  kind: "mask",
1396
- length: write.text.length
1401
+ length
1397
1402
  };
1403
+ const value = normalizeText(write.text);
1398
1404
  return write.secret ? {
1399
1405
  kind: "secret",
1400
- value: write.text
1406
+ value,
1407
+ length
1401
1408
  } : {
1402
1409
  kind: "open",
1403
- value: write.text
1410
+ value,
1411
+ length
1404
1412
  };
1405
1413
  }
1406
1414
  function holds(confirmation, actual) {
1407
1415
  switch (confirmation.kind) {
1408
- case "mask": return actual.length === confirmation.length && new Set(actual).size <= 1;
1416
+ case "mask": return isMask(actual, confirmation.length);
1409
1417
  case "secret":
1410
- case "open": return actual === confirmation.value;
1418
+ case "open": return actual === confirmation.value || isMask(actual, confirmation.length);
1411
1419
  default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
1412
1420
  }
1413
1421
  }
1422
+ /**
1423
+ * Requiring one repeated character stops a placeholder of the right length, which
1424
+ * is what an untouched password field reports, from passing as a landed write.
1425
+ */
1426
+ function isMask(actual, length) {
1427
+ return actual.length === length && new Set(actual).size <= 1;
1428
+ }
1414
1429
  function expectedOf(confirmation) {
1415
1430
  switch (confirmation.kind) {
1416
1431
  case "mask": return {
@@ -1428,22 +1443,16 @@ function expectedOf(confirmation) {
1428
1443
  default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
1429
1444
  }
1430
1445
  }
1431
- function typedOf(confirmation) {
1432
- switch (confirmation.kind) {
1433
- case "mask": return {
1434
- kind: "hidden",
1435
- length: confirmation.length
1436
- };
1437
- case "secret": return {
1438
- kind: "hidden",
1439
- length: confirmation.value.length
1440
- };
1441
- case "open": return {
1442
- kind: "text",
1443
- value: confirmation.value
1444
- };
1445
- default: throw new Error(`unhandled confirmation ${JSON.stringify(confirmation)}`);
1446
- }
1446
+ /** What the report says was typed, verbatim, which the normalized confirmation no longer holds. */
1447
+ function typedOf(role, write) {
1448
+ if (role === "secure-text-field" || write.secret) return {
1449
+ kind: "hidden",
1450
+ length: write.text.length
1451
+ };
1452
+ return {
1453
+ kind: "text",
1454
+ value: write.text
1455
+ };
1447
1456
  }
1448
1457
  /** An actual value may be repeated back only as far as the expected one could be. */
1449
1458
  function disclose(expected, actual) {
@@ -1747,21 +1756,24 @@ function compareScreenshot(expected, actual, options) {
1747
1756
  };
1748
1757
  }
1749
1758
  /**
1750
- * The box is clamped to the image, because a rect comes from a snapshot and a
1759
+ * The box is clipped to the image, because a rect comes from a snapshot and a
1751
1760
  * 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.
1761
+ * round a pixel past it, and that is not a reason to fail an assertion. A box
1762
+ * with no pixel inside the image is a different thing, a rect the screenshot
1763
+ * does not show, and cutting a placeholder out of the corner would hide that.
1753
1764
  */
1754
1765
  function cropScreenshot(source, box) {
1755
1766
  const image = PNG.sync.read(source);
1756
- const clamped = clamp(box, {
1767
+ const clipped = intersect(box, {
1757
1768
  width: image.width,
1758
1769
  height: image.height
1759
1770
  });
1771
+ 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
1772
  const cut = new PNG({
1761
- width: clamped.width,
1762
- height: clamped.height
1773
+ width: clipped.width,
1774
+ height: clipped.height
1763
1775
  });
1764
- PNG.bitblt(image, cut, clamped.x, clamped.y, clamped.width, clamped.height, 0, 0);
1776
+ PNG.bitblt(image, cut, clipped.x, clipped.y, clipped.width, clipped.height, 0, 0);
1765
1777
  return PNG.sync.write(cut);
1766
1778
  }
1767
1779
  function sizeOf(source) {
@@ -1790,21 +1802,30 @@ function relativeTo(box, origin) {
1790
1802
  y: box.y - origin.y
1791
1803
  };
1792
1804
  }
1793
- function clamp(box, size) {
1794
- const x = Math.min(Math.max(box.x, 0), Math.max(size.width - 1, 0));
1795
- const y = Math.min(Math.max(box.y, 0), Math.max(size.height - 1, 0));
1805
+ /**
1806
+ * The part of the box inside the image, or null when none of it is. A mask
1807
+ * resolved off a node outside a crop must paint nothing, because moving it
1808
+ * inside would black out real pixels at the crop's edge.
1809
+ */
1810
+ function intersect(box, size) {
1811
+ const left = Math.max(box.x, 0);
1812
+ const top = Math.max(box.y, 0);
1813
+ const right = Math.min(box.x + box.width, size.width);
1814
+ const bottom = Math.min(box.y + box.height, size.height);
1815
+ if (right <= left || bottom <= top) return null;
1796
1816
  return {
1797
- x,
1798
- y,
1799
- width: Math.max(Math.min(box.width, size.width - x), 1),
1800
- height: Math.max(Math.min(box.height, size.height - y), 1)
1817
+ x: left,
1818
+ y: top,
1819
+ width: right - left,
1820
+ height: bottom - top
1801
1821
  };
1802
1822
  }
1803
1823
  function paintBlack(image, box) {
1804
- const region = clamp(box, {
1824
+ const region = intersect(box, {
1805
1825
  width: image.width,
1806
1826
  height: image.height
1807
1827
  });
1828
+ if (region === null) return;
1808
1829
  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
1830
  const at = image.width * row + column << 2;
1810
1831
  image.data[at] = 0;
@@ -1896,4 +1917,4 @@ function notBooted(platform, name, booted) {
1896
1917
  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
1918
  }
1898
1919
  //#endregion
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 };
1920
+ 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.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "End-to-end testing for mobile apps.",
5
5
  "homepage": "https://github.com/wobsoriano/touchpress#readme",
6
6
  "bugs": {