touchpress 0.0.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/index.mjs ADDED
@@ -0,0 +1,439 @@
1
+ import { A as textMatch, D as parseDeviceOptions, S as resolve, T as TANGERE_DEFAULTS, _ as sleep, a as sizeOf, c as createClient, h as openSession, i as relativeTo, j as TangereError, l as captureEvidence, n as compareScreenshot, o as toPixelBox, r as cropScreenshot, s as createAgentDeviceDriver, t as preflight, u as createDevice, y as silentSink } from "./preflight-C83jCNPs.mjs";
2
+ import { expect as expect$1, test as test$1 } from "@playwright/test";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname } from "node:path";
5
+ //#region src/playwright/fixtures.ts
6
+ const SESSION_FIXTURE_TIMEOUT_MS = 18e4;
7
+ const DEVICE_FIXTURE_TIMEOUT_MS = 12e4;
8
+ /**
9
+ * Tangere's options and none of its fixtures, for a setup project that reads the
10
+ * configuration before any session exists, such as one calling `preflight`.
11
+ *
12
+ * `platform`, `app`, and `readyWhen` default to `undefined` rather than to a
13
+ * plausible value. A Playwright option fixture needs a default of its declared
14
+ * type, and `parseDeviceOptions` rejects `undefined` by name, so a config that
15
+ * forgot a key and one that never set it fail the same way.
16
+ */
17
+ const setupTest = test$1.extend({
18
+ platform: [void 0, {
19
+ option: true,
20
+ scope: "worker"
21
+ }],
22
+ app: [void 0, {
23
+ option: true,
24
+ scope: "worker"
25
+ }],
26
+ readyWhen: [void 0, {
27
+ option: true,
28
+ scope: "worker"
29
+ }],
30
+ deviceName: [void 0, {
31
+ option: true,
32
+ scope: "worker"
33
+ }],
34
+ launchUrl: [void 0, {
35
+ option: true,
36
+ scope: "worker"
37
+ }],
38
+ relaunch: [TANGERE_DEFAULTS.relaunch, {
39
+ option: true,
40
+ scope: "worker"
41
+ }],
42
+ onDeviceInUse: [TANGERE_DEFAULTS.onDeviceInUse, {
43
+ option: true,
44
+ scope: "worker"
45
+ }],
46
+ settleQuietMs: [TANGERE_DEFAULTS.settleQuietMs, {
47
+ option: true,
48
+ scope: "worker"
49
+ }],
50
+ launchTimeout: [TANGERE_DEFAULTS.launchTimeout, {
51
+ option: true,
52
+ scope: "worker"
53
+ }],
54
+ dismissDevOverlay: [TANGERE_DEFAULTS.dismissDevOverlay, {
55
+ option: true,
56
+ scope: "worker"
57
+ }],
58
+ evidence: [TANGERE_DEFAULTS.evidence, {
59
+ option: true,
60
+ scope: "worker"
61
+ }],
62
+ sessionPrefix: [TANGERE_DEFAULTS.sessionPrefix, {
63
+ option: true,
64
+ scope: "worker"
65
+ }]
66
+ });
67
+ /** The worker session already opened the app with a relaunch, so the first test skips one. */
68
+ const startedTests = /* @__PURE__ */ new WeakSet();
69
+ /**
70
+ * `device` is auto so evidence capture runs for every test in a device project,
71
+ * whether or not the body touched it. Its teardown runs before the session's,
72
+ * inside the separate budget Playwright grants after the test finishes, so a
73
+ * timed-out test still gets a screenshot.
74
+ *
75
+ * Importing and extending `test` launches no browser: `browser`, `context`, and
76
+ * `page` are lazy and non-auto, and nothing here names them.
77
+ */
78
+ const test = setupTest.extend({
79
+ session: [async ({ platform, app, readyWhen, deviceName, launchUrl, relaunch, onDeviceInUse, settleQuietMs, launchTimeout, dismissDevOverlay, evidence, sessionPrefix }, use, workerInfo) => {
80
+ const options = parseDeviceOptions({
81
+ platform,
82
+ app,
83
+ readyWhen,
84
+ deviceName,
85
+ launchUrl,
86
+ relaunch,
87
+ onDeviceInUse,
88
+ settleQuietMs,
89
+ launchTimeout,
90
+ dismissDevOverlay,
91
+ evidence,
92
+ sessionPrefix,
93
+ actionTimeout: workerInfo.project.use.actionTimeout
94
+ });
95
+ const session = await openSession({
96
+ options,
97
+ slot: workerInfo.parallelIndex,
98
+ scope: workerInfo.project.name,
99
+ sink: playwrightSink(),
100
+ createDriver: (name, selection) => createAgentDeviceDriver(createClient(), name, selection)
101
+ });
102
+ await use(session);
103
+ await session.close("worker-exit");
104
+ }, {
105
+ scope: "worker",
106
+ timeout: SESSION_FIXTURE_TIMEOUT_MS
107
+ }],
108
+ device: [async ({ session }, use, testInfo) => {
109
+ const sink = playwrightSink();
110
+ if (session.options.relaunch === "per-test" && startedTests.has(session)) await session.relaunch(sink);
111
+ startedTests.add(session);
112
+ await use(createDevice(session, sink));
113
+ if (shouldCapture(testInfo, session.options.evidence)) await captureEvidence(session, sink);
114
+ }, {
115
+ auto: true,
116
+ timeout: DEVICE_FIXTURE_TIMEOUT_MS
117
+ }]
118
+ });
119
+ function shouldCapture(testInfo, evidence) {
120
+ if (evidence === "off") return false;
121
+ return evidence === "always" || testInfo.status !== testInfo.expectedStatus;
122
+ }
123
+ /**
124
+ * Resolves the running test on every call rather than capturing a `TestInfo`. A
125
+ * worker outlives every test in it, so a captured one would file the second
126
+ * test's evidence under the first test's report entry.
127
+ */
128
+ function playwrightSink() {
129
+ return {
130
+ step: (title, body, options) => test$1.step(title, body, options),
131
+ attach: async (file) => {
132
+ const info = currentTest();
133
+ if (info === null) return;
134
+ await info.attach(file.name, "path" in file ? {
135
+ path: file.path,
136
+ contentType: file.contentType
137
+ } : {
138
+ body: file.body,
139
+ contentType: file.contentType
140
+ });
141
+ },
142
+ note: (key, value) => {
143
+ currentTest()?.annotations.push({
144
+ type: key,
145
+ description: value
146
+ });
147
+ },
148
+ outputPath: (fileName) => currentTest()?.outputPath(fileName) ?? silentSink.outputPath(fileName)
149
+ };
150
+ }
151
+ function currentTest() {
152
+ try {
153
+ return test$1.info();
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+ //#endregion
159
+ //#region src/playwright/screenshot.ts
160
+ const POLL_INTERVAL_MS = 250;
161
+ const DEFAULT_MAX_DIFF_PIXEL_RATIO = .01;
162
+ const DEFAULT_THRESHOLD = .2;
163
+ /**
164
+ * Numbers an unnamed screenshot per test, so two assertions in one test do not
165
+ * write over each other's baseline. A retried test gets a fresh `TestInfo`, so
166
+ * the numbering starts again and the same run reproduces the same paths.
167
+ */
168
+ const ordinals = /* @__PURE__ */ new WeakMap();
169
+ /**
170
+ * The baseline path comes from `testInfo.snapshotPath`, so
171
+ * `snapshotPathTemplate`, the per-project suffix and `--update-snapshots` behave
172
+ * the way they do for Playwright's own screenshot assertion.
173
+ *
174
+ * A locator's crop and every mask are resolved off one snapshot taken next to
175
+ * the image. Rects from two snapshots would index into the image at two
176
+ * different scroll positions, which crops the wrong thing rather than failing.
177
+ */
178
+ async function assertScreenshot(state, target, nameOrOptions, extra) {
179
+ const options = (typeof nameOrOptions === "string" ? extra : nameOrOptions) ?? {};
180
+ const info = test$1.info();
181
+ const sink = playwrightSink();
182
+ const timeout = options.timeout ?? state.timeout;
183
+ const maxDiffPixelRatio = options.maxDiffPixelRatio ?? DEFAULT_MAX_DIFF_PIXEL_RATIO;
184
+ const expected = `${state.isNot ? "not " : ""}at most ${percent(maxDiffPixelRatio)} of pixels to differ`;
185
+ const label = "query" in target ? target.description : "the whole device";
186
+ const baseline = info.snapshotPath(typeof nameOrOptions === "string" ? nameOrOptions : defaultName(info), { kind: "screenshot" });
187
+ const update = info.config.updateSnapshots;
188
+ const deadline = Date.now() + timeout;
189
+ let captures = 0;
190
+ let attempt = await capture(target, info, options.mask ?? []);
191
+ for (;;) {
192
+ captures += 1;
193
+ if (attempt.kind === "captured") {
194
+ if (!existsSync(baseline)) return missingBaseline(state, {
195
+ expected,
196
+ label,
197
+ baseline,
198
+ update,
199
+ png: attempt.png
200
+ });
201
+ const comparison = compareScreenshot(readFileSync(baseline), attempt.png, {
202
+ threshold: options.threshold ?? DEFAULT_THRESHOLD,
203
+ maxDiffPixelRatio,
204
+ mask: attempt.mask
205
+ });
206
+ const matched = comparison.kind === "match";
207
+ if (matched !== state.isNot) return {
208
+ pass: matched,
209
+ name: "toHaveScreenshot",
210
+ expected,
211
+ actual: received(comparison),
212
+ message: () => ""
213
+ };
214
+ if (!state.isNot && (update === "all" || update === "changed")) {
215
+ write(baseline, attempt.png);
216
+ return {
217
+ pass: true,
218
+ name: "toHaveScreenshot",
219
+ expected,
220
+ actual: received(comparison),
221
+ message: () => ""
222
+ };
223
+ }
224
+ if (Date.now() >= deadline) {
225
+ await attachAll(sink, baseline, attempt.png, comparison);
226
+ return fail(state, expected, received(comparison), [
227
+ `Expected ${state.isNot ? "not." : ""}toHaveScreenshot but it never ${state.isNot ? "differed" : "matched"}.`,
228
+ ``,
229
+ `Target: ${label}`,
230
+ `Baseline: ${baseline}`,
231
+ `Expected: ${expected}`,
232
+ `Received: ${received(comparison)}`,
233
+ timeoutLine(timeout, captures),
234
+ ``,
235
+ `expected.png, actual.png and diff.png are attached to this test in the HTML report.`
236
+ ]);
237
+ }
238
+ } else if (Date.now() >= deadline) return fail(state, expected, null, [
239
+ `Expected ${state.isNot ? "not." : ""}toHaveScreenshot but the locator never resolved.`,
240
+ ``,
241
+ `Target: ${label}`,
242
+ `Received: ${attempt.detail}`,
243
+ timeoutLine(timeout, captures)
244
+ ]);
245
+ await sleep(Math.min(POLL_INTERVAL_MS, deadline - Date.now()));
246
+ attempt = await capture(target, info, options.mask ?? []);
247
+ }
248
+ }
249
+ /**
250
+ * The scale is derived rather than asked for. A tree reports rects in whatever
251
+ * units its platform uses, so the image width over the widest rect on screen,
252
+ * which is the window, is what one unit is worth in pixels. It comes out at 1 on
253
+ * both devices this is tested against, because agent-device writes the iOS
254
+ * simulator's image at point resolution and the Android tree already reports
255
+ * pixels. Deriving it keeps a device writing a 2x or 3x image from cropping the
256
+ * wrong region.
257
+ */
258
+ async function capture(target, info, masks) {
259
+ const device = "query" in target ? target.device : target;
260
+ const screen = await device.screen();
261
+ const path = await device.screenshot({ path: info.outputPath(`toHaveScreenshot-actual.png`) });
262
+ const full = readFileSync(path);
263
+ const scale = scaleOf(screen, sizeOf(full).width);
264
+ const region = "query" in target ? regionOf(screen, target.query, scale) : null;
265
+ if (typeof region === "string") return {
266
+ kind: "unresolved",
267
+ detail: region
268
+ };
269
+ const boxes = masks.flatMap((mask) => boxesOf(screen, mask.query, scale));
270
+ if (region === null) return {
271
+ kind: "captured",
272
+ png: full,
273
+ mask: boxes
274
+ };
275
+ return {
276
+ kind: "captured",
277
+ png: cropScreenshot(full, region),
278
+ mask: boxes.map((box) => relativeTo(box, region))
279
+ };
280
+ }
281
+ /** The crop for a locator, or why it could not be taken. */
282
+ function regionOf(screen, query, scale) {
283
+ const resolution = resolve(screen, query);
284
+ switch (resolution.outcome) {
285
+ case "none": return "no node matched";
286
+ case "many": return `${String(resolution.nodes.length)} nodes matched, which is ambiguous. Narrow the locator or use .first() / .nth(n).`;
287
+ case "one": {
288
+ const rect = resolution.node.rect;
289
+ if (rect === null) return `${resolution.node.ref} reports no rect, so there is nothing to crop`;
290
+ return toPixelBox(rect, scale);
291
+ }
292
+ default: throw new Error(`unhandled resolution ${JSON.stringify(resolution)}`);
293
+ }
294
+ }
295
+ /** A mask hides a region rather than picking one node, so ambiguity is not an error here. */
296
+ function boxesOf(screen, query, scale) {
297
+ const resolution = resolve(screen, query);
298
+ return (resolution.outcome === "one" ? [resolution.node] : resolution.outcome === "many" ? resolution.nodes : []).flatMap((node) => node.rect === null ? [] : [toPixelBox(node.rect, scale)]);
299
+ }
300
+ function scaleOf(screen, imageWidth) {
301
+ const widest = Math.max(0, ...screen.nodes.map((node) => node.rect?.width ?? 0));
302
+ return widest > 0 ? imageWidth / widest : 1;
303
+ }
304
+ function missingBaseline(state, input) {
305
+ if (state.isNot) return fail(state, input.expected, null, [
306
+ `Expected not.toHaveScreenshot, but there is no baseline to differ from.`,
307
+ ``,
308
+ `Target: ${input.label}`,
309
+ `Baseline: ${input.baseline}`,
310
+ ``,
311
+ `Write one with a passing toHaveScreenshot first.`
312
+ ]);
313
+ write(input.baseline, input.png);
314
+ if (input.update === "all" || input.update === "missing") return {
315
+ pass: true,
316
+ name: "toHaveScreenshot",
317
+ expected: input.expected,
318
+ actual: null,
319
+ message: () => ""
320
+ };
321
+ return fail(state, input.expected, null, [`A snapshot doesn't exist at ${input.baseline}, writing actual.`]);
322
+ }
323
+ async function attachAll(sink, baseline, actual, comparison) {
324
+ await sink.attach({
325
+ name: "expected.png",
326
+ path: baseline,
327
+ contentType: "image/png"
328
+ });
329
+ const actualPath = sink.outputPath("actual.png");
330
+ writeFileSync(actualPath, actual);
331
+ await sink.attach({
332
+ name: "actual.png",
333
+ path: actualPath,
334
+ contentType: "image/png"
335
+ });
336
+ if (comparison.kind !== "mismatch") return;
337
+ const diffPath = sink.outputPath("diff.png");
338
+ writeFileSync(diffPath, comparison.diff);
339
+ await sink.attach({
340
+ name: "diff.png",
341
+ path: diffPath,
342
+ contentType: "image/png"
343
+ });
344
+ }
345
+ function received(comparison) {
346
+ switch (comparison.kind) {
347
+ case "match":
348
+ case "mismatch": return `${percent(comparison.ratio)} of pixels differ`;
349
+ case "size-mismatch": return `the screenshot is ${size(comparison.actual)} and the baseline is ${size(comparison.expected)}`;
350
+ default: throw new Error(`unhandled comparison ${JSON.stringify(comparison)}`);
351
+ }
352
+ }
353
+ function size(value) {
354
+ return `${String(value.width)}x${String(value.height)}`;
355
+ }
356
+ function percent(ratio) {
357
+ const shown = ratio * 100;
358
+ return `${shown < .1 && shown > 0 ? shown.toFixed(3) : String(Math.round(shown * 100) / 100)}%`;
359
+ }
360
+ function timeoutLine(timeout, captures) {
361
+ return `Timeout: ${String(timeout)}ms (${String(captures)} capture${captures === 1 ? "" : "s"})`;
362
+ }
363
+ function fail(state, expected, actual, lines) {
364
+ return {
365
+ pass: state.isNot,
366
+ name: "toHaveScreenshot",
367
+ expected,
368
+ actual,
369
+ message: () => lines.join("\n")
370
+ };
371
+ }
372
+ function write(path, png) {
373
+ mkdirSync(dirname(path), { recursive: true });
374
+ writeFileSync(path, png);
375
+ }
376
+ function defaultName(info) {
377
+ const next = (ordinals.get(info) ?? 0) + 1;
378
+ ordinals.set(info, next);
379
+ return `${info.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}-${String(next)}.png`;
380
+ }
381
+ //#endregion
382
+ //#region src/playwright/expect.ts
383
+ /**
384
+ * `this.timeout` is `expect.timeout` from the Playwright config. `this.isNot`
385
+ * selects the predicate the poll waits for, which is what makes
386
+ * `.not.toBeVisible()` wait for a control to leave instead of passing on a race.
387
+ */
388
+ async function runCheck(state, locator, check, timeout) {
389
+ const result = await locator.expect(check, {
390
+ negate: state.isNot,
391
+ timeoutMs: timeout ?? state.timeout
392
+ });
393
+ return {
394
+ pass: result.pass,
395
+ name: check.name,
396
+ expected: result.expected,
397
+ actual: result.actual,
398
+ message: () => result.message
399
+ };
400
+ }
401
+ function retrying(name) {
402
+ return function(locator, options) {
403
+ return runCheck(this, locator, { name }, options?.timeout);
404
+ };
405
+ }
406
+ /**
407
+ * Playwright's own matcher names on this package's `expect` only. Matcher
408
+ * typing is by the first parameter, so these surface on `expect(locator)` and
409
+ * nothing else.
410
+ */
411
+ const expect = expect$1.extend({
412
+ toBeVisible: retrying("toBeVisible"),
413
+ toBeEnabled: retrying("toBeEnabled"),
414
+ toBeSelected: retrying("toBeSelected"),
415
+ toBeFocused: retrying("toBeFocused"),
416
+ toHaveText(locator, expected, options) {
417
+ return runCheck(this, locator, {
418
+ name: "toHaveText",
419
+ expected: textMatch(expected, options?.exact)
420
+ }, options?.timeout);
421
+ },
422
+ toHaveValue(locator, expected, options) {
423
+ return runCheck(this, locator, {
424
+ name: "toHaveValue",
425
+ expected: textMatch(expected, true)
426
+ }, options?.timeout);
427
+ },
428
+ toHaveCount(locator, expected, options) {
429
+ return runCheck(this, locator, {
430
+ name: "toHaveCount",
431
+ expected
432
+ }, options?.timeout);
433
+ },
434
+ toHaveScreenshot(target, nameOrOptions, options) {
435
+ return assertScreenshot(this, target, nameOrOptions, options);
436
+ }
437
+ });
438
+ //#endregion
439
+ export { TangereError, expect, preflight, setupTest, test };