nativeproof 0.2.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/README.md +6 -6
- package/dist/app.d.ts +7 -0
- package/dist/app.js +7 -1
- package/dist/cli.js +6 -1
- package/dist/driver.js +6 -1
- package/dist/evidence.js +5 -1
- package/dist/expect.d.ts +2 -1
- package/dist/expect.js +12 -11
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -3
- package/dist/locator.js +11 -7
- package/dist/log.d.ts +0 -1
- package/dist/log.js +0 -4
- package/dist/mock.d.ts +10 -5
- package/dist/mock.js +4 -8
- package/dist/source.d.ts +6 -1
- package/dist/source.js +13 -7
- package/package.json +1 -1
- package/dist/screen.d.ts +0 -35
- package/dist/screen.js +0 -67
- package/dist/text.d.ts +0 -18
- package/dist/text.js +0 -129
- package/dist/wait.d.ts +0 -14
- package/dist/wait.js +0 -26
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,69 @@ All notable changes to NativeProof are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/) and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## 0.5.0
|
|
8
|
+
|
|
9
|
+
Two additive, backward-compatible seams so an app can drive more of its lifecycle and
|
|
10
|
+
traffic assertions through the framework instead of around it.
|
|
11
|
+
|
|
12
|
+
**Added**
|
|
13
|
+
|
|
14
|
+
- **`expect(...)` accepts any frame source, not just a full `MockBackend`** — a new
|
|
15
|
+
`FrameLog` interface (`{ frames() }`, which `MockBackend` extends) is all the traffic
|
|
16
|
+
matchers need. An app whose mock predates `MockBackend` can expose a frames-only adapter
|
|
17
|
+
over its existing request/socket log and get auto-waiting `expect(traffic).toHaveSent({
|
|
18
|
+
path, type, ...payload })` / `.toHaveReceived(...)` — no `route`/`stop` to implement.
|
|
19
|
+
- **`defineApp({ teardown })`** — an optional app-level teardown hook, run on session
|
|
20
|
+
teardown BEFORE the mock stops and before the runner deletes the device session (e.g.
|
|
21
|
+
force-stop the app so its background sockets are gone before `deleteSession`). The mock
|
|
22
|
+
is still stopped even if the hook throws.
|
|
23
|
+
|
|
24
|
+
## 0.4.0
|
|
25
|
+
|
|
26
|
+
Dead-code removal. The dropped exports were undocumented and unused by the framework, but
|
|
27
|
+
since they were technically part of the published surface this is a minor bump.
|
|
28
|
+
|
|
29
|
+
**Removed**
|
|
30
|
+
|
|
31
|
+
- **`src/text.ts`** — a legacy WebdriverIO-`$`-based selector/text layer (`exactText`,
|
|
32
|
+
`tapVisibleText`, `typeInto`, `waitForAnyVisibleText`, …) superseded by the `Driver` / `Locator` /
|
|
33
|
+
`page` stack. Use `page(driver).getByText(...)` / `Locator.fill(...)` instead.
|
|
34
|
+
- **`src/screen.ts`** — the unused `Screen` base class (nothing extended it).
|
|
35
|
+
- **`src/wait.ts`** — `waitAndClick` / `waitForAnyDisplayed`, only ever used by `Screen`.
|
|
36
|
+
- **`readLog`** (from `log.ts`) and **`waitForFrame`** (from `mock.ts`) — unused exports. The
|
|
37
|
+
documented mock-traffic API (`expect(mock).toHaveSent/toHaveReceived`) is unaffected.
|
|
38
|
+
|
|
39
|
+
## 0.3.1
|
|
40
|
+
|
|
41
|
+
Correctness and robustness fixes; no API changes.
|
|
42
|
+
|
|
43
|
+
**Fixed**
|
|
44
|
+
|
|
45
|
+
- **`expect(mock)` now matches object/array payload fields by deep equality** — `matchesFrame`
|
|
46
|
+
used reference equality, so `toHaveSent({ data: { id: 1 } })` / `{ tags: ["a"] }` could never
|
|
47
|
+
match and surfaced only as a misleading timeout. Now uses `isDeepStrictEqual`.
|
|
48
|
+
- **`textContent()` prefers the first non-empty label attribute** — an iOS node with
|
|
49
|
+
`value="" label="Submit"` (or an Android Compose node with `text="" content-desc="…"`) read as
|
|
50
|
+
`""`; it now returns the populated value, in the same precedence `attributeFor` uses.
|
|
51
|
+
- **Bounds parsing no longer assumes attribute order** — `boundsForAttribute` /
|
|
52
|
+
`smallestClickableAncestorBounds` match the element tag first, then extract `bounds`, so a source
|
|
53
|
+
that emits `bounds` before the selector attribute still resolves.
|
|
54
|
+
- **`parseBounds` accepts negative coordinates** — off-screen / RTL-shifted nodes (`[-5,0]…`) now
|
|
55
|
+
parse instead of becoming untappable.
|
|
56
|
+
- **`--appium-port` is validated** — a non-numeric / out-of-range value throws a clear error instead
|
|
57
|
+
of producing an opaque `http://host:NaN/…` connection failure.
|
|
58
|
+
- **`toContain` throws a usage error** on a non-string/array actual instead of silently failing.
|
|
59
|
+
- **Page-source capture failures are logged** — `wdioDriver().source()` and `captureState` warn on a
|
|
60
|
+
`getPageSource` error before degrading to empty, so a dead session isn't mistaken for an empty screen.
|
|
61
|
+
|
|
62
|
+
**Internal**
|
|
63
|
+
|
|
64
|
+
- Deduplicated `escapeRegExp` (now shared from `source.ts`).
|
|
65
|
+
|
|
66
|
+
## 0.3.0
|
|
67
|
+
|
|
68
|
+
Maintenance release — version bump only; no functional changes since 0.2.0.
|
|
69
|
+
|
|
7
70
|
## 0.2.0
|
|
8
71
|
|
|
9
72
|
Locator interaction, generic assertions, and Compose/SwiftUI robustness — plus the docs for each.
|
package/README.md
CHANGED
|
@@ -641,9 +641,9 @@ jobs:
|
|
|
641
641
|
android-e2e:
|
|
642
642
|
runs-on: ubuntu-latest
|
|
643
643
|
steps:
|
|
644
|
-
- uses: actions/checkout@
|
|
645
|
-
- uses: actions/setup-node@
|
|
646
|
-
with: { node-version:
|
|
644
|
+
- uses: actions/checkout@v5
|
|
645
|
+
- uses: actions/setup-node@v5
|
|
646
|
+
with: { node-version: 24 }
|
|
647
647
|
- run: npm ci
|
|
648
648
|
- run: npx appium driver install uiautomator2
|
|
649
649
|
- uses: reactivecircus/android-emulator-runner@v2
|
|
@@ -659,9 +659,9 @@ jobs:
|
|
|
659
659
|
ios-e2e:
|
|
660
660
|
runs-on: macos-latest
|
|
661
661
|
steps:
|
|
662
|
-
- uses: actions/checkout@
|
|
663
|
-
- uses: actions/setup-node@
|
|
664
|
-
with: { node-version:
|
|
662
|
+
- uses: actions/checkout@v5
|
|
663
|
+
- uses: actions/setup-node@v5
|
|
664
|
+
with: { node-version: 24 }
|
|
665
665
|
- run: npm ci
|
|
666
666
|
- run: npx appium driver install xcuitest
|
|
667
667
|
- run: xcrun simctl boot "iPhone 15" || true
|
package/dist/app.d.ts
CHANGED
|
@@ -37,6 +37,13 @@ export interface AppDefinition<S extends ScreenFactories> {
|
|
|
37
37
|
join?: (context: FlowContext) => Promise<void>;
|
|
38
38
|
/** Screen-object factories, bound to the device context. */
|
|
39
39
|
screens: S;
|
|
40
|
+
/**
|
|
41
|
+
* Release app-level resources acquired across the session, run on teardown BEFORE the
|
|
42
|
+
* mock stops and before the runner deletes the device session — e.g. force-stop the app
|
|
43
|
+
* so its background sockets are gone before `deleteSession`. The mock is still stopped
|
|
44
|
+
* even if this throws.
|
|
45
|
+
*/
|
|
46
|
+
teardown?: (context: SessionContext<S>) => Promise<void> | void;
|
|
40
47
|
}
|
|
41
48
|
/** The fixture context a session injects: the device handles plus each app screen. */
|
|
42
49
|
export type SessionContext<S extends ScreenFactories> = DeviceContext & {
|
package/dist/app.js
CHANGED
|
@@ -25,8 +25,14 @@ export function defineApp(definition) {
|
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
27
|
async teardown(context) {
|
|
28
|
-
if (context)
|
|
28
|
+
if (!context)
|
|
29
|
+
return;
|
|
30
|
+
try {
|
|
31
|
+
await definition.teardown?.(context);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
29
34
|
await context.mock.stop();
|
|
35
|
+
}
|
|
30
36
|
},
|
|
31
37
|
};
|
|
32
38
|
},
|
package/dist/cli.js
CHANGED
|
@@ -60,7 +60,12 @@ export function parseArgs(argv) {
|
|
|
60
60
|
}
|
|
61
61
|
else if (arg === "--appium-port") {
|
|
62
62
|
i += 1;
|
|
63
|
-
|
|
63
|
+
const raw = valueFor(argv, i, "--appium-port");
|
|
64
|
+
const port = Number(raw);
|
|
65
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
66
|
+
throw new Error(`--appium-port must be an integer between 0 and 65535, got "${raw}"`);
|
|
67
|
+
}
|
|
68
|
+
args.appiumPort = port;
|
|
64
69
|
}
|
|
65
70
|
else if (arg === "--appium-path") {
|
|
66
71
|
i += 1;
|
package/dist/driver.js
CHANGED
|
@@ -6,7 +6,12 @@ export function wdioDriver() {
|
|
|
6
6
|
get platform() {
|
|
7
7
|
return browser.isAndroid ? "android" : "ios";
|
|
8
8
|
},
|
|
9
|
-
source: () => browser.getPageSource().catch(() =>
|
|
9
|
+
source: () => browser.getPageSource().catch((err) => {
|
|
10
|
+
// Don't let a dead/unreachable session masquerade as "element not visible";
|
|
11
|
+
// surface it so a timeout's cause is visible, then degrade to empty source.
|
|
12
|
+
console.warn(`[nativeproof] getPageSource failed: ${err}`);
|
|
13
|
+
return "";
|
|
14
|
+
}),
|
|
10
15
|
pause: (ms) => browser.pause(ms),
|
|
11
16
|
tapAt: (x, y) => tapAt(x, y),
|
|
12
17
|
typeText: (text) => browser.keys(text),
|
package/dist/evidence.js
CHANGED
|
@@ -33,7 +33,11 @@ export async function captureScreenshot(filename) {
|
|
|
33
33
|
}
|
|
34
34
|
/** Capture a screenshot + redacted source pair under one prefix; returns the source. */
|
|
35
35
|
export async function captureState(prefix) {
|
|
36
|
-
const source = await browser.getPageSource().catch(() =>
|
|
36
|
+
const source = await browser.getPageSource().catch((err) => {
|
|
37
|
+
// A failed capture must not look like a clean empty screen in the evidence trail.
|
|
38
|
+
console.warn(`[nativeproof] getPageSource failed during captureState("${prefix}"): ${err}`);
|
|
39
|
+
return "";
|
|
40
|
+
});
|
|
37
41
|
await captureScreenshot(`${prefix}.png`);
|
|
38
42
|
await captureText(`${prefix}.xml`, source);
|
|
39
43
|
return source;
|
package/dist/expect.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Locator, type WaitOptions } from "./locator.js";
|
|
2
|
-
import { type FrameMatch, type MockBackend } from "./mock.js";
|
|
2
|
+
import { type FrameLog, type FrameMatch, type MockBackend } from "./mock.js";
|
|
3
3
|
/**
|
|
4
4
|
* Playwright-style assertions with built-in auto-waiting — the "easy visibility"
|
|
5
5
|
* layer. `expect(locator)` asserts UI state; `expect(mock)` asserts backend traffic.
|
|
@@ -42,4 +42,5 @@ export interface ValueAssertions<T> {
|
|
|
42
42
|
}
|
|
43
43
|
export declare function expect(target: Locator): LocatorAssertions;
|
|
44
44
|
export declare function expect(target: MockBackend): MockAssertions;
|
|
45
|
+
export declare function expect(target: FrameLog): MockAssertions;
|
|
45
46
|
export declare function expect<T>(target: T): ValueAssertions<T>;
|
package/dist/expect.js
CHANGED
|
@@ -86,11 +86,12 @@ class ValueExpectation {
|
|
|
86
86
|
this.assert(isDeepStrictEqual(this.actual, expected), "toEqual", describeValue(expected));
|
|
87
87
|
}
|
|
88
88
|
toContain(expected) {
|
|
89
|
+
if (typeof this.actual !== "string" && !Array.isArray(this.actual)) {
|
|
90
|
+
throw new TypeError(`expect(...).toContain(...) needs a string or array actual, got ${describeValue(this.actual)}`);
|
|
91
|
+
}
|
|
89
92
|
const ok = typeof this.actual === "string"
|
|
90
93
|
? this.actual.includes(String(expected))
|
|
91
|
-
:
|
|
92
|
-
? this.actual.includes(expected)
|
|
93
|
-
: false;
|
|
94
|
+
: this.actual.includes(expected);
|
|
94
95
|
this.assert(ok, "toContain", describeValue(expected));
|
|
95
96
|
}
|
|
96
97
|
toBeTruthy() {
|
|
@@ -113,18 +114,18 @@ class ValueExpectation {
|
|
|
113
114
|
throw new Error(`expect(${describeValue(this.actual)})${not}.${matcher}(${expectedDesc}) — assertion not met`);
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Structural guard: anything with a `frames()` method is a {@link FrameLog} we can
|
|
119
|
+
* assert traffic on — a full {@link MockBackend} or a frames-only adapter alike. The
|
|
120
|
+
* traffic matchers only read `frames()`, so `route`/`stop` are not required here.
|
|
121
|
+
*/
|
|
122
|
+
function isFrameLog(target) {
|
|
123
|
+
return typeof target === "object" && target !== null && typeof target.frames === "function";
|
|
123
124
|
}
|
|
124
125
|
export function expect(target) {
|
|
125
126
|
if (target instanceof Locator)
|
|
126
127
|
return new LocatorExpectation(target);
|
|
127
|
-
if (
|
|
128
|
+
if (isFrameLog(target))
|
|
128
129
|
return new MockExpectation(target);
|
|
129
130
|
return new ValueExpectation(target);
|
|
130
131
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -23,8 +23,5 @@ export * from "./mock.js";
|
|
|
23
23
|
export * from "./mock-server.js";
|
|
24
24
|
export * from "./page.js";
|
|
25
25
|
export * from "./runner.js";
|
|
26
|
-
export * from "./screen.js";
|
|
27
26
|
export * from "./source.js";
|
|
28
27
|
export * from "./test.js";
|
|
29
|
-
export * from "./text.js";
|
|
30
|
-
export * from "./wait.js";
|
package/dist/index.js
CHANGED
|
@@ -23,8 +23,5 @@ export * from "./mock.js";
|
|
|
23
23
|
export * from "./mock-server.js";
|
|
24
24
|
export * from "./page.js";
|
|
25
25
|
export * from "./runner.js";
|
|
26
|
-
export * from "./screen.js";
|
|
27
26
|
export * from "./source.js";
|
|
28
27
|
export * from "./test.js";
|
|
29
|
-
export * from "./text.js";
|
|
30
|
-
export * from "./wait.js";
|
package/dist/locator.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { boundsForAttribute, decodeXmlEntities, encodeXmlEntities, smallestClickableAncestorBounds, } from "./source.js";
|
|
1
|
+
import { boundsForAttribute, decodeXmlEntities, encodeXmlEntities, escapeRegExp, smallestClickableAncestorBounds, } from "./source.js";
|
|
2
2
|
/**
|
|
3
3
|
* Build selectors Playwright-style. The cross-platform attribute each maps to is resolved
|
|
4
4
|
* per platform (see {@link attributeFor}), so you never have to know whether it's a
|
|
@@ -17,9 +17,6 @@ export const by = {
|
|
|
17
17
|
export function describeSelector(selector) {
|
|
18
18
|
return `by.${selector.by}(${JSON.stringify(selector.value)})`;
|
|
19
19
|
}
|
|
20
|
-
function escapeRegExp(value) {
|
|
21
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
22
|
-
}
|
|
23
20
|
/**
|
|
24
21
|
* The page-source attribute a selector resolves to on each platform. `text` is an
|
|
25
22
|
* alternation, because a visible label surfaces as `text` OR `content-desc` on Android
|
|
@@ -87,9 +84,16 @@ export class Locator {
|
|
|
87
84
|
const node = new RegExp(`<[^>]*${this.attribute()}="${selectorPattern}"[^>]*>`).exec(source)?.[0];
|
|
88
85
|
if (!node)
|
|
89
86
|
return null;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
// A visible label can live in either of two attributes per platform, in the same
|
|
88
|
+
// precedence `attributeFor` uses (iOS label→value, Android text→content-desc). Prefer
|
|
89
|
+
// the first NON-empty one, so a node like `value="" label="Submit"` reads "Submit",
|
|
90
|
+
// falling back to the first present (possibly empty) attribute.
|
|
91
|
+
const attrs = this.driver.platform === "ios" ? ["label", "value"] : ["text", "content-desc"];
|
|
92
|
+
const present = attrs
|
|
93
|
+
.map((attr) => new RegExp(`${attr}="([^"]*)"`).exec(node)?.[1])
|
|
94
|
+
.filter((v) => v !== undefined);
|
|
95
|
+
const raw = present.find((v) => v !== "") ?? present[0];
|
|
96
|
+
return raw === undefined ? null : decodeXmlEntities(raw);
|
|
93
97
|
}
|
|
94
98
|
/** True if the selector is present AND `text` appears in the source. */
|
|
95
99
|
async shows(text) {
|
package/dist/log.d.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
* Playwright-`page.route()` equivalent that native Appium otherwise lacks.
|
|
7
7
|
* App-agnostic; app-specific frame semantics live in the consumer's assertions.
|
|
8
8
|
*/
|
|
9
|
-
export declare function readLog(logPath: string): Promise<string>;
|
|
10
9
|
export declare function countMatches(text: string, pattern: RegExp): number;
|
|
11
10
|
/**
|
|
12
11
|
* Tokens that must never appear in captured evidence. App-agnostic by default
|
package/dist/log.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
1
|
/**
|
|
3
2
|
* Network-evidence log helpers.
|
|
4
3
|
*
|
|
@@ -7,9 +6,6 @@ import fs from "node:fs/promises";
|
|
|
7
6
|
* Playwright-`page.route()` equivalent that native Appium otherwise lacks.
|
|
8
7
|
* App-agnostic; app-specific frame semantics live in the consumer's assertions.
|
|
9
8
|
*/
|
|
10
|
-
export async function readLog(logPath) {
|
|
11
|
-
return fs.readFile(logPath, "utf8").catch(() => "");
|
|
12
|
-
}
|
|
13
9
|
export function countMatches(text, pattern) {
|
|
14
10
|
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
|
15
11
|
return (text.match(new RegExp(pattern.source, flags)) ?? []).length;
|
package/dist/mock.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { type WaitOptions } from "./locator.js";
|
|
2
1
|
/**
|
|
3
2
|
* Backend mocking that feels like Playwright's `page.route()`.
|
|
4
3
|
*
|
|
@@ -34,9 +33,17 @@ export interface MockRoute {
|
|
|
34
33
|
/** Drop the connect/request entirely. */
|
|
35
34
|
abort(): void;
|
|
36
35
|
}
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* A read-only source of observed frames — the minimum `expect(...)` needs to assert
|
|
38
|
+
* traffic. A full {@link MockBackend} is one, but so is any adapter that produces the
|
|
39
|
+
* frames an app exchanged (e.g. a view over an existing request/socket log), so a mock
|
|
40
|
+
* that predates {@link MockBackend} can be asserted on without implementing `route`/`stop`.
|
|
41
|
+
*/
|
|
42
|
+
export interface FrameLog {
|
|
38
43
|
/** Every frame observed so far, in order, both directions. */
|
|
39
44
|
frames(): Promise<readonly MockFrame[]>;
|
|
45
|
+
}
|
|
46
|
+
export interface MockBackend extends FrameLog {
|
|
40
47
|
/** Intercept a path and control its reply. */
|
|
41
48
|
route(path: string): MockRoute;
|
|
42
49
|
/** Release the backend (stop the server, close sockets). */
|
|
@@ -49,6 +56,4 @@ export interface MockBackend {
|
|
|
49
56
|
export declare function matchesFrame(frame: MockFrame, match: FrameMatch): boolean;
|
|
50
57
|
export declare function describeMatch(match: FrameMatch): string;
|
|
51
58
|
/** Single-shot check: does any observed frame match `match` in `direction`? */
|
|
52
|
-
export declare function frameExists(
|
|
53
|
-
/** Poll until a frame matching `match` in `direction` appears, or the timeout elapses. */
|
|
54
|
-
export declare function waitForFrame(mock: MockBackend, direction: FrameDirection, match: FrameMatch, options?: WaitOptions): Promise<boolean>;
|
|
59
|
+
export declare function frameExists(source: FrameLog, direction: FrameDirection, match: FrameMatch): Promise<boolean>;
|
package/dist/mock.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
2
|
/**
|
|
3
3
|
* True if `frame` satisfies every field of `match`: `path` / `type` at the top level,
|
|
4
4
|
* every other key against the frame's payload.
|
|
@@ -11,7 +11,7 @@ export function matchesFrame(frame, match) {
|
|
|
11
11
|
for (const [key, value] of Object.entries(match)) {
|
|
12
12
|
if (key === "path" || key === "type")
|
|
13
13
|
continue;
|
|
14
|
-
if (frame.payload?.[key]
|
|
14
|
+
if (!isDeepStrictEqual(frame.payload?.[key], value))
|
|
15
15
|
return false;
|
|
16
16
|
}
|
|
17
17
|
return true;
|
|
@@ -20,11 +20,7 @@ export function describeMatch(match) {
|
|
|
20
20
|
return JSON.stringify(match);
|
|
21
21
|
}
|
|
22
22
|
/** Single-shot check: does any observed frame match `match` in `direction`? */
|
|
23
|
-
export async function frameExists(
|
|
24
|
-
const frames = await
|
|
23
|
+
export async function frameExists(source, direction, match) {
|
|
24
|
+
const frames = await source.frames();
|
|
25
25
|
return frames.some((frame) => frame.direction === direction && matchesFrame(frame, match));
|
|
26
26
|
}
|
|
27
|
-
/** Poll until a frame matching `match` in `direction` appears, or the timeout elapses. */
|
|
28
|
-
export function waitForFrame(mock, direction, match, options = {}) {
|
|
29
|
-
return waitUntil(() => frameExists(mock, direction, match), (present) => present, options);
|
|
30
|
-
}
|
package/dist/source.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type Bounds = {
|
|
|
18
18
|
centerY: number;
|
|
19
19
|
};
|
|
20
20
|
export declare function parseBounds(bounds: string | undefined | null): Bounds | null;
|
|
21
|
+
export declare function escapeRegExp(value: string): string;
|
|
21
22
|
/**
|
|
22
23
|
* Decode the XML entities UiAutomator/XCUITest escape attribute values with
|
|
23
24
|
* (`&` → `&`, `<` → `<`, …). `&` is decoded LAST so `&lt;` round-trips
|
|
@@ -30,7 +31,11 @@ export declare function decodeXmlEntities(value: string): string;
|
|
|
30
31
|
* (`text="Terms & Conditions"`). `&` is encoded first to avoid double-encoding.
|
|
31
32
|
*/
|
|
32
33
|
export declare function encodeXmlEntities(value: string): string;
|
|
33
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Bounds of the first element carrying the given attribute match. The element tag is
|
|
36
|
+
* matched first, then `bounds` is extracted from within it regardless of attribute order —
|
|
37
|
+
* so a source that emits `bounds` before the selector attribute still resolves.
|
|
38
|
+
*/
|
|
34
39
|
export declare function boundsForAttribute(source: string, attribute: string, value: string): Bounds | null;
|
|
35
40
|
/** Bounds of an element addressed by Android `content-desc`. */
|
|
36
41
|
export declare function boundsForContentDesc(source: string, contentDesc: string): Bounds | null;
|
package/dist/source.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* a single, replaceable seam rather than scattered regexes.
|
|
9
9
|
*/
|
|
10
10
|
export function parseBounds(bounds) {
|
|
11
|
-
const match = /\[(
|
|
11
|
+
const match = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(bounds ?? "");
|
|
12
12
|
if (!match)
|
|
13
13
|
return null;
|
|
14
14
|
const x1 = Number(match[1]);
|
|
@@ -26,7 +26,7 @@ export function parseBounds(bounds) {
|
|
|
26
26
|
centerY: Math.round((y1 + y2) / 2),
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
-
function escapeRegExp(value) {
|
|
29
|
+
export function escapeRegExp(value) {
|
|
30
30
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
@@ -50,11 +50,17 @@ export function decodeXmlEntities(value) {
|
|
|
50
50
|
export function encodeXmlEntities(value) {
|
|
51
51
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Bounds of the first element carrying the given attribute match. The element tag is
|
|
55
|
+
* matched first, then `bounds` is extracted from within it regardless of attribute order —
|
|
56
|
+
* so a source that emits `bounds` before the selector attribute still resolves.
|
|
57
|
+
*/
|
|
54
58
|
export function boundsForAttribute(source, attribute, value) {
|
|
55
59
|
const escaped = escapeRegExp(encodeXmlEntities(value));
|
|
56
|
-
const
|
|
57
|
-
|
|
60
|
+
const node = new RegExp(`<[^>]*${attribute}="${escaped}"[^>]*>`).exec(source)?.[0];
|
|
61
|
+
if (!node)
|
|
62
|
+
return null;
|
|
63
|
+
return parseBounds(/bounds="([^"]+)"/.exec(node)?.[1]);
|
|
58
64
|
}
|
|
59
65
|
/** Bounds of an element addressed by Android `content-desc`. */
|
|
60
66
|
export function boundsForContentDesc(source, contentDesc) {
|
|
@@ -70,8 +76,8 @@ export function boundsForText(source, text) {
|
|
|
70
76
|
* into a reliable tap target (e.g. the "Members (3)" list rows).
|
|
71
77
|
*/
|
|
72
78
|
export function smallestClickableAncestorBounds(source, nodeBounds) {
|
|
73
|
-
const clickable = [...source.matchAll(
|
|
74
|
-
.map((m) => parseBounds(m[1]))
|
|
79
|
+
const clickable = [...source.matchAll(/<[^>]*clickable="true"[^>]*>/g)]
|
|
80
|
+
.map((m) => parseBounds(/bounds="([^"]+)"/.exec(m[0])?.[1]))
|
|
75
81
|
.filter((b) => b !== null)
|
|
76
82
|
.filter((b) => b.x1 <= nodeBounds.x1 && b.x2 >= nodeBounds.x2 && b.y1 <= nodeBounds.y1 && b.y2 >= nodeBounds.y2)
|
|
77
83
|
.sort((a, b) => a.width * a.height - b.width * b.height);
|
package/package.json
CHANGED
package/dist/screen.d.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { captureScreenshot, captureState, captureText } from "./evidence.js";
|
|
2
|
-
import { type Bounds } from "./source.js";
|
|
3
|
-
/**
|
|
4
|
-
* Base class for all Screen Objects.
|
|
5
|
-
*
|
|
6
|
-
* Encapsulates the "try a semantic selector, fall back to a source-bounds
|
|
7
|
-
* coordinate tap" pattern that native Compose/SwiftUI surfaces require, plus
|
|
8
|
-
* evidence shortcuts. App screen objects extend this; nothing here is app-specific,
|
|
9
|
-
* so it travels with the framework as a standalone, reusable core.
|
|
10
|
-
*/
|
|
11
|
-
export declare abstract class Screen {
|
|
12
|
-
/** Current Appium page source (best-effort; empty string on driver error). */
|
|
13
|
-
source(): Promise<string>;
|
|
14
|
-
isPresent(pattern: RegExp): Promise<boolean>;
|
|
15
|
-
/**
|
|
16
|
-
* Assert a pattern is visible via the Appium source, falling back to a raw
|
|
17
|
-
* `adb uiautomator dump` for nodes hidden from the Appium accessibility tree.
|
|
18
|
-
*/
|
|
19
|
-
assertVisible(pattern: RegExp, message: string): Promise<{
|
|
20
|
-
source: string;
|
|
21
|
-
sourceKind: "appium" | "adb-uiautomator";
|
|
22
|
-
}>;
|
|
23
|
-
/**
|
|
24
|
-
* Tap an Android control by `content-desc`, falling back to a coordinate tap
|
|
25
|
-
* computed from the page-source bounds when the accessibility node is present
|
|
26
|
-
* but not directly clickable.
|
|
27
|
-
*/
|
|
28
|
-
tapContentDesc(contentDesc: string, timeout?: number): Promise<void>;
|
|
29
|
-
protected tapBounds(bounds: Bounds): Promise<void>;
|
|
30
|
-
/** Dismiss an open bottom sheet via the system back affordance. */
|
|
31
|
-
dismissBottomSheet(): Promise<void>;
|
|
32
|
-
protected captureScreenshot: typeof captureScreenshot;
|
|
33
|
-
protected captureText: typeof captureText;
|
|
34
|
-
protected captureState: typeof captureState;
|
|
35
|
-
}
|
package/dist/screen.js
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { browser } from "@wdio/globals";
|
|
2
|
-
import { adbDump } from "./adb.js";
|
|
3
|
-
import { captureScreenshot, captureState, captureText } from "./evidence.js";
|
|
4
|
-
import { tapAt } from "./gestures.js";
|
|
5
|
-
import { boundsForContentDesc } from "./source.js";
|
|
6
|
-
import { waitAndClick } from "./wait.js";
|
|
7
|
-
/**
|
|
8
|
-
* Base class for all Screen Objects.
|
|
9
|
-
*
|
|
10
|
-
* Encapsulates the "try a semantic selector, fall back to a source-bounds
|
|
11
|
-
* coordinate tap" pattern that native Compose/SwiftUI surfaces require, plus
|
|
12
|
-
* evidence shortcuts. App screen objects extend this; nothing here is app-specific,
|
|
13
|
-
* so it travels with the framework as a standalone, reusable core.
|
|
14
|
-
*/
|
|
15
|
-
export class Screen {
|
|
16
|
-
/** Current Appium page source (best-effort; empty string on driver error). */
|
|
17
|
-
async source() {
|
|
18
|
-
return browser.getPageSource().catch(() => "");
|
|
19
|
-
}
|
|
20
|
-
async isPresent(pattern) {
|
|
21
|
-
return pattern.test(await this.source());
|
|
22
|
-
}
|
|
23
|
-
/**
|
|
24
|
-
* Assert a pattern is visible via the Appium source, falling back to a raw
|
|
25
|
-
* `adb uiautomator dump` for nodes hidden from the Appium accessibility tree.
|
|
26
|
-
*/
|
|
27
|
-
async assertVisible(pattern, message) {
|
|
28
|
-
const source = await this.source();
|
|
29
|
-
if (pattern.test(source))
|
|
30
|
-
return { source, sourceKind: "appium" };
|
|
31
|
-
const dump = adbDump();
|
|
32
|
-
if (pattern.test(dump))
|
|
33
|
-
return { source: dump, sourceKind: "adb-uiautomator" };
|
|
34
|
-
throw new Error(`${message}; source was: ${source}\n--- adb ---\n${dump}`);
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Tap an Android control by `content-desc`, falling back to a coordinate tap
|
|
38
|
-
* computed from the page-source bounds when the accessibility node is present
|
|
39
|
-
* but not directly clickable.
|
|
40
|
-
*/
|
|
41
|
-
async tapContentDesc(contentDesc, timeout = 10000) {
|
|
42
|
-
try {
|
|
43
|
-
await waitAndClick(`android=new UiSelector().description("${contentDesc}")`, timeout);
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
const bounds = boundsForContentDesc(await this.source(), contentDesc);
|
|
47
|
-
if (!bounds) {
|
|
48
|
-
throw new Error(`Expected to find a control with content-desc "${contentDesc}"`);
|
|
49
|
-
}
|
|
50
|
-
await tapAt(bounds.centerX, bounds.centerY);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
async tapBounds(bounds) {
|
|
54
|
-
await tapAt(bounds.centerX, bounds.centerY);
|
|
55
|
-
}
|
|
56
|
-
/** Dismiss an open bottom sheet via the system back affordance. */
|
|
57
|
-
async dismissBottomSheet() {
|
|
58
|
-
await browser.back().catch(async () => {
|
|
59
|
-
await tapAt(540, 850);
|
|
60
|
-
});
|
|
61
|
-
await browser.pause(1000);
|
|
62
|
-
}
|
|
63
|
-
// Evidence shortcuts ------------------------------------------------------
|
|
64
|
-
captureScreenshot = captureScreenshot;
|
|
65
|
-
captureText = captureText;
|
|
66
|
-
captureState = captureState;
|
|
67
|
-
}
|
package/dist/text.d.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { $ } from "@wdio/globals";
|
|
2
|
-
export declare function exactText(text: string): string;
|
|
3
|
-
export declare function textContaining(text: string): string;
|
|
4
|
-
export declare function accessibleName(text: string): string;
|
|
5
|
-
/** A trimmed, non-empty environment credential, or undefined when not configured. */
|
|
6
|
-
export declare function configuredCredential(name: string): string | undefined;
|
|
7
|
-
export declare function firstTextInput(): string;
|
|
8
|
-
export declare function firstPasswordInput(): string;
|
|
9
|
-
export declare function tapVisibleText(text: string, timeout?: number): Promise<void>;
|
|
10
|
-
export declare function tapTextIfVisible(text: string, timeout?: number): Promise<boolean>;
|
|
11
|
-
export declare function expectVisibleText(text: string, timeout?: number): Promise<void>;
|
|
12
|
-
export declare function waitForAnyVisibleText(labels: string[], timeout?: number): Promise<{
|
|
13
|
-
label: string;
|
|
14
|
-
element: ReturnType<typeof $>;
|
|
15
|
-
}>;
|
|
16
|
-
export declare function waitForPageSourceToMention(phrases: string[], timeout?: number): Promise<void>;
|
|
17
|
-
export declare function expectPageSourceToMention(phrases: string[]): Promise<void>;
|
|
18
|
-
export declare function typeInto(selector: string, value: string): Promise<void>;
|
package/dist/text.js
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import { $, browser, driver, expect } from "@wdio/globals";
|
|
2
|
-
/**
|
|
3
|
-
* Cross-platform text/selector layer.
|
|
4
|
-
*
|
|
5
|
-
* Resolves the same human-readable control name to the right native selector on
|
|
6
|
-
* each platform (Android `UiSelector`, iOS predicate strings), preferring
|
|
7
|
-
* accessibility names with visible-text fallbacks. This is the single place a
|
|
8
|
-
* brittle selector becomes a stable automation id once the app teams add them.
|
|
9
|
-
* App-agnostic framework core.
|
|
10
|
-
*/
|
|
11
|
-
const DEFAULT_WAIT_MS = 15_000;
|
|
12
|
-
export function exactText(text) {
|
|
13
|
-
if (driver.isAndroid) {
|
|
14
|
-
return `android=new UiSelector().text(${JSON.stringify(text)})`;
|
|
15
|
-
}
|
|
16
|
-
const literal = iosLiteral(text);
|
|
17
|
-
return `-ios predicate string:name == ${literal} OR label == ${literal} OR value == ${literal}`;
|
|
18
|
-
}
|
|
19
|
-
export function textContaining(text) {
|
|
20
|
-
if (driver.isAndroid) {
|
|
21
|
-
return `android=new UiSelector().textContains(${JSON.stringify(text)})`;
|
|
22
|
-
}
|
|
23
|
-
const literal = iosLiteral(text);
|
|
24
|
-
return `-ios predicate string:name CONTAINS ${literal} OR label CONTAINS ${literal} OR value CONTAINS ${literal}`;
|
|
25
|
-
}
|
|
26
|
-
export function accessibleName(text) {
|
|
27
|
-
return `~${text}`;
|
|
28
|
-
}
|
|
29
|
-
/** A trimmed, non-empty environment credential, or undefined when not configured. */
|
|
30
|
-
export function configuredCredential(name) {
|
|
31
|
-
const value = process.env[name]?.trim();
|
|
32
|
-
return value && value.length > 0 ? value : undefined;
|
|
33
|
-
}
|
|
34
|
-
export function firstTextInput() {
|
|
35
|
-
if (driver.isAndroid) {
|
|
36
|
-
return 'android=new UiSelector().className("android.widget.EditText").instance(0)';
|
|
37
|
-
}
|
|
38
|
-
return "-ios class chain:**/XCUIElementTypeTextField[1]";
|
|
39
|
-
}
|
|
40
|
-
export function firstPasswordInput() {
|
|
41
|
-
if (driver.isAndroid) {
|
|
42
|
-
return 'android=new UiSelector().className("android.widget.EditText").instance(1)';
|
|
43
|
-
}
|
|
44
|
-
return "-ios class chain:**/XCUIElementTypeSecureTextField[1]";
|
|
45
|
-
}
|
|
46
|
-
export async function tapVisibleText(text, timeout = DEFAULT_WAIT_MS) {
|
|
47
|
-
const element = await visibleElementMatchingText(text, timeout);
|
|
48
|
-
await element.click();
|
|
49
|
-
}
|
|
50
|
-
export async function tapTextIfVisible(text, timeout = 1_000) {
|
|
51
|
-
try {
|
|
52
|
-
const element = await visibleElementMatchingText(text, timeout);
|
|
53
|
-
await element.click();
|
|
54
|
-
return true;
|
|
55
|
-
}
|
|
56
|
-
catch {
|
|
57
|
-
return false;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
export async function expectVisibleText(text, timeout = DEFAULT_WAIT_MS) {
|
|
61
|
-
const element = await visibleElementMatchingText(text, timeout);
|
|
62
|
-
await expect(element).toBeDisplayed();
|
|
63
|
-
}
|
|
64
|
-
export async function waitForAnyVisibleText(labels, timeout = DEFAULT_WAIT_MS) {
|
|
65
|
-
const deadline = Date.now() + timeout;
|
|
66
|
-
let lastError;
|
|
67
|
-
while (Date.now() < deadline) {
|
|
68
|
-
for (const label of labels) {
|
|
69
|
-
try {
|
|
70
|
-
const element = await visibleElementMatchingText(label, 500);
|
|
71
|
-
return { label, element };
|
|
72
|
-
}
|
|
73
|
-
catch (error) {
|
|
74
|
-
lastError = error;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
await browser.pause(500);
|
|
78
|
-
}
|
|
79
|
-
throw new Error(`None of these labels became visible: ${labels.join(", ")}${lastError instanceof Error ? `; last error: ${lastError.message}` : ""}`);
|
|
80
|
-
}
|
|
81
|
-
export async function waitForPageSourceToMention(phrases, timeout = DEFAULT_WAIT_MS) {
|
|
82
|
-
await browser.waitUntil(async () => {
|
|
83
|
-
const source = await driver.getPageSource();
|
|
84
|
-
return phrases.some((phrase) => source.toLowerCase().includes(phrase.toLowerCase()));
|
|
85
|
-
}, { timeout, timeoutMsg: `Page source did not mention any of: ${phrases.join(", ")}` });
|
|
86
|
-
}
|
|
87
|
-
export async function expectPageSourceToMention(phrases) {
|
|
88
|
-
const source = await driver.getPageSource();
|
|
89
|
-
const matched = phrases.some((phrase) => source.toLowerCase().includes(phrase.toLowerCase()));
|
|
90
|
-
await expect(matched).toBe(true);
|
|
91
|
-
}
|
|
92
|
-
export async function typeInto(selector, value) {
|
|
93
|
-
const input = await $(selector);
|
|
94
|
-
await input.waitForDisplayed({ timeout: DEFAULT_WAIT_MS });
|
|
95
|
-
await input.click();
|
|
96
|
-
await input.setValue(value);
|
|
97
|
-
}
|
|
98
|
-
async function visibleElementMatchingText(text, timeout) {
|
|
99
|
-
const deadline = Date.now() + timeout;
|
|
100
|
-
let lastError;
|
|
101
|
-
while (Date.now() < deadline) {
|
|
102
|
-
for (const selector of [accessibleName(text), exactText(text)]) {
|
|
103
|
-
try {
|
|
104
|
-
const element = await $(selector);
|
|
105
|
-
if ((await element.isDisplayed()) && (await elementCenterIsInsideViewport(element))) {
|
|
106
|
-
return element;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
catch (error) {
|
|
110
|
-
lastError = error;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
await browser.pause(300);
|
|
114
|
-
}
|
|
115
|
-
throw new Error(`No visible element for "${text}"${lastError instanceof Error ? `; last error: ${lastError.message}` : ""}`);
|
|
116
|
-
}
|
|
117
|
-
async function elementCenterIsInsideViewport(element) {
|
|
118
|
-
const [location, size, viewport] = await Promise.all([
|
|
119
|
-
element.getLocation(),
|
|
120
|
-
element.getSize(),
|
|
121
|
-
driver.getWindowRect(),
|
|
122
|
-
]);
|
|
123
|
-
const centerX = location.x + size.width / 2;
|
|
124
|
-
const centerY = location.y + size.height / 2;
|
|
125
|
-
return centerX >= 0 && centerX <= viewport.width && centerY >= 0 && centerY <= viewport.height;
|
|
126
|
-
}
|
|
127
|
-
function iosLiteral(text) {
|
|
128
|
-
return `"${text.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
129
|
-
}
|
package/dist/wait.d.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { $ } from "@wdio/globals";
|
|
2
|
-
/**
|
|
3
|
-
* Selector waiting helpers built on the WebdriverIO runner globals.
|
|
4
|
-
*
|
|
5
|
-
* `waitForAnyDisplayed` resolves the first of several candidate selectors to
|
|
6
|
-
* appear — essential on screens that render one of multiple possible states
|
|
7
|
-
* (e.g. an onboarding "Later" sheet vs. the role-selection home).
|
|
8
|
-
*/
|
|
9
|
-
export type DisplayedMatch = {
|
|
10
|
-
selector: string;
|
|
11
|
-
element: ReturnType<typeof $>;
|
|
12
|
-
};
|
|
13
|
-
export declare function waitAndClick(selector: string, timeout?: number): Promise<void>;
|
|
14
|
-
export declare function waitForAnyDisplayed(selectors: string[], timeout?: number): Promise<DisplayedMatch>;
|
package/dist/wait.js
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { $, browser } from "@wdio/globals";
|
|
2
|
-
export async function waitAndClick(selector, timeout = 10000) {
|
|
3
|
-
const element = await $(selector);
|
|
4
|
-
await element.waitForDisplayed({ timeout });
|
|
5
|
-
await element.click();
|
|
6
|
-
}
|
|
7
|
-
export async function waitForAnyDisplayed(selectors, timeout = 30000) {
|
|
8
|
-
let matched;
|
|
9
|
-
await browser.waitUntil(async () => {
|
|
10
|
-
for (const selector of selectors) {
|
|
11
|
-
const element = $(selector);
|
|
12
|
-
const displayed = await element.isDisplayed().catch(() => false);
|
|
13
|
-
if (displayed) {
|
|
14
|
-
matched = { selector, element };
|
|
15
|
-
return true;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
return false;
|
|
19
|
-
}, {
|
|
20
|
-
timeout,
|
|
21
|
-
interval: 500,
|
|
22
|
-
timeoutMsg: `None of the selectors became displayed within ${timeout}ms: ${selectors.join(", ")}`,
|
|
23
|
-
});
|
|
24
|
-
// waitUntil only resolves once the predicate set `matched`.
|
|
25
|
-
return matched;
|
|
26
|
-
}
|