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.
@@ -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-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
- 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-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(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
  }
@@ -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(confirmation)
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: write.text
1403
+ value
1401
1404
  } : {
1402
1405
  kind: "open",
1403
- value: write.text
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
- 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
- }
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 clamped to the image, because a rect comes from a snapshot and a
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 clamped = clamp(box, {
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: clamped.width,
1762
- height: clamped.height
1761
+ width: clipped.width,
1762
+ height: clipped.height
1763
1763
  });
1764
- PNG.bitblt(image, cut, clamped.x, clamped.y, clamped.width, clamped.height, 0, 0);
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
- 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));
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: Math.max(Math.min(box.width, size.width - x), 1),
1800
- height: Math.max(Math.min(box.height, size.height - y), 1)
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 = clamp(box, {
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 { 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 };
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.1.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": {