nativeproof 0.3.0 → 0.6.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 +87 -0
- package/README.md +8 -7
- package/dist/app.d.ts +14 -1
- package/dist/app.js +8 -1
- package/dist/cli.js +6 -1
- package/dist/driver.js +6 -1
- package/dist/evidence.js +5 -1
- package/dist/expect.d.ts +6 -1
- package/dist/expect.js +18 -11
- package/dist/fixtures.d.ts +20 -1
- package/dist/fixtures.js +34 -6
- package/dist/harness.d.ts +4 -0
- package/dist/harness.js +8 -0
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -3
- package/dist/locator.d.ts +36 -13
- package/dist/locator.js +75 -18
- 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/page.d.ts +8 -7
- package/dist/runner.d.ts +3 -9
- package/dist/runner.js +3 -1
- package/dist/source.d.ts +18 -2
- package/dist/source.js +42 -8
- 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,93 @@ 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.6.0
|
|
8
|
+
|
|
9
|
+
Playwright-parity additions to locators, assertions, fixtures, and evidence.
|
|
10
|
+
|
|
11
|
+
**Added**
|
|
12
|
+
|
|
13
|
+
- **Checkbox/switch state** — `Locator.isChecked()`, `Locator.check()` / `uncheck()` (tap to the
|
|
14
|
+
desired state, a no-op if already there), and auto-waiting `expect(locator).toBeChecked()` (+ `.not`),
|
|
15
|
+
reading `checked="true"` on the matched node.
|
|
16
|
+
- **Multi-match locators** — `Locator.nth(i)` / `first()` / `last()` (negative `i` counts from the end),
|
|
17
|
+
`Locator.count()`, and auto-waiting `expect(locator).toHaveCount(n)`. An unindexed locator still
|
|
18
|
+
resolves to the first match. New `nodesForAttribute` source helper backs it.
|
|
19
|
+
- **Scenario `beforeEach` / `afterEach`** — `test.beforeEach(fn)` / `test.afterEach(fn)` (and the
|
|
20
|
+
`describeScenario` registrar's `.beforeEach` / `.afterEach`) register per-behaviour hooks that
|
|
21
|
+
receive the provisioned fixture context — a repeatable reset between behaviours without leaving the
|
|
22
|
+
harness. `BddHooks` gained optional `beforeEach` / `afterEach` (present on Mocha and node:test).
|
|
23
|
+
- **`onFailure` evidence hook** — `ScenarioFixture.onFailure` and `defineApp({ onFailure })` run when a
|
|
24
|
+
behaviour throws, before the failure propagates, so on-failure evidence (e.g. `captureState(...)`)
|
|
25
|
+
lives in one place instead of every behaviour. The hook receives the context + `{ title, error }`;
|
|
26
|
+
its own errors are swallowed (logged) so they never mask the real failure.
|
|
27
|
+
- **`by.*` and every `page().getBy*` accept a `RegExp`** as well as a string —
|
|
28
|
+
`getByText(/Save( draft)?/)`, `getByLabel(/^Remove /)`, `getByRole("checkbox", { name: /terms/i })`.
|
|
29
|
+
A string matches the element's value exactly; a RegExp is tested against the element's **decoded**
|
|
30
|
+
value, so a human pattern matches the entity-escaped source and tolerant labels
|
|
31
|
+
(`/complete(d)? phrases/`) no longer need source-scraping. Matching, `bounds`, `textContent`,
|
|
32
|
+
`tap`/`fill`, and `expect(locator)` all honour it. New `nodeForAttribute` / `attributeMatches`
|
|
33
|
+
source helpers back it (`boundsForAttribute` now takes `string | RegExp`).
|
|
34
|
+
|
|
35
|
+
## 0.5.0
|
|
36
|
+
|
|
37
|
+
Two additive, backward-compatible seams so an app can drive more of its lifecycle and
|
|
38
|
+
traffic assertions through the framework instead of around it.
|
|
39
|
+
|
|
40
|
+
**Added**
|
|
41
|
+
|
|
42
|
+
- **`expect(...)` accepts any frame source, not just a full `MockBackend`** — a new
|
|
43
|
+
`FrameLog` interface (`{ frames() }`, which `MockBackend` extends) is all the traffic
|
|
44
|
+
matchers need. An app whose mock predates `MockBackend` can expose a frames-only adapter
|
|
45
|
+
over its existing request/socket log and get auto-waiting `expect(traffic).toHaveSent({
|
|
46
|
+
path, type, ...payload })` / `.toHaveReceived(...)` — no `route`/`stop` to implement.
|
|
47
|
+
- **`defineApp({ teardown })`** — an optional app-level teardown hook, run on session
|
|
48
|
+
teardown BEFORE the mock stops and before the runner deletes the device session (e.g.
|
|
49
|
+
force-stop the app so its background sockets are gone before `deleteSession`). The mock
|
|
50
|
+
is still stopped even if the hook throws.
|
|
51
|
+
|
|
52
|
+
## 0.4.0
|
|
53
|
+
|
|
54
|
+
Dead-code removal. The dropped exports were undocumented and unused by the framework, but
|
|
55
|
+
since they were technically part of the published surface this is a minor bump.
|
|
56
|
+
|
|
57
|
+
**Removed**
|
|
58
|
+
|
|
59
|
+
- **`src/text.ts`** — a legacy WebdriverIO-`$`-based selector/text layer (`exactText`,
|
|
60
|
+
`tapVisibleText`, `typeInto`, `waitForAnyVisibleText`, …) superseded by the `Driver` / `Locator` /
|
|
61
|
+
`page` stack. Use `page(driver).getByText(...)` / `Locator.fill(...)` instead.
|
|
62
|
+
- **`src/screen.ts`** — the unused `Screen` base class (nothing extended it).
|
|
63
|
+
- **`src/wait.ts`** — `waitAndClick` / `waitForAnyDisplayed`, only ever used by `Screen`.
|
|
64
|
+
- **`readLog`** (from `log.ts`) and **`waitForFrame`** (from `mock.ts`) — unused exports. The
|
|
65
|
+
documented mock-traffic API (`expect(mock).toHaveSent/toHaveReceived`) is unaffected.
|
|
66
|
+
|
|
67
|
+
## 0.3.1
|
|
68
|
+
|
|
69
|
+
Correctness and robustness fixes; no API changes.
|
|
70
|
+
|
|
71
|
+
**Fixed**
|
|
72
|
+
|
|
73
|
+
- **`expect(mock)` now matches object/array payload fields by deep equality** — `matchesFrame`
|
|
74
|
+
used reference equality, so `toHaveSent({ data: { id: 1 } })` / `{ tags: ["a"] }` could never
|
|
75
|
+
match and surfaced only as a misleading timeout. Now uses `isDeepStrictEqual`.
|
|
76
|
+
- **`textContent()` prefers the first non-empty label attribute** — an iOS node with
|
|
77
|
+
`value="" label="Submit"` (or an Android Compose node with `text="" content-desc="…"`) read as
|
|
78
|
+
`""`; it now returns the populated value, in the same precedence `attributeFor` uses.
|
|
79
|
+
- **Bounds parsing no longer assumes attribute order** — `boundsForAttribute` /
|
|
80
|
+
`smallestClickableAncestorBounds` match the element tag first, then extract `bounds`, so a source
|
|
81
|
+
that emits `bounds` before the selector attribute still resolves.
|
|
82
|
+
- **`parseBounds` accepts negative coordinates** — off-screen / RTL-shifted nodes (`[-5,0]…`) now
|
|
83
|
+
parse instead of becoming untappable.
|
|
84
|
+
- **`--appium-port` is validated** — a non-numeric / out-of-range value throws a clear error instead
|
|
85
|
+
of producing an opaque `http://host:NaN/…` connection failure.
|
|
86
|
+
- **`toContain` throws a usage error** on a non-string/array actual instead of silently failing.
|
|
87
|
+
- **Page-source capture failures are logged** — `wdioDriver().source()` and `captureState` warn on a
|
|
88
|
+
`getPageSource` error before degrading to empty, so a dead session isn't mistaken for an empty screen.
|
|
89
|
+
|
|
90
|
+
**Internal**
|
|
91
|
+
|
|
92
|
+
- Deduplicated `escapeRegExp` (now shared from `source.ts`).
|
|
93
|
+
|
|
7
94
|
## 0.3.0
|
|
8
95
|
|
|
9
96
|
Maintenance release — version bump only; no functional changes since 0.2.0.
|
package/README.md
CHANGED
|
@@ -60,7 +60,8 @@ nativeproof --platform android
|
|
|
60
60
|
- **Locators** — `by.text/testId/label/desc/id` and `page(driver).getByText/getByTestId/
|
|
61
61
|
getByLabel/getById/getByRole`, each mapped to the right native attribute per platform (so you
|
|
62
62
|
never guess `content-desc` vs `accessibilityIdentifier`), with built-in auto-waiting and
|
|
63
|
-
`tap()` / `fill()` for interaction.
|
|
63
|
+
`tap()` / `fill()` for interaction. Each takes a string (exact) or a **`RegExp`**
|
|
64
|
+
(`getByText(/Save( draft)?/)`), tested against the element's decoded value.
|
|
64
65
|
- **Auto-waiting `expect`** — `expect(locator).toBeVisible()/toShow()/toHaveText()` and
|
|
65
66
|
`.not`, each polling until the condition holds (default 10s); plus synchronous `expect(value)`
|
|
66
67
|
matchers (`toBe`/`toEqual`/`toContain`/…) so non-UI checks need no second assertion library.
|
|
@@ -641,9 +642,9 @@ jobs:
|
|
|
641
642
|
android-e2e:
|
|
642
643
|
runs-on: ubuntu-latest
|
|
643
644
|
steps:
|
|
644
|
-
- uses: actions/checkout@
|
|
645
|
-
- uses: actions/setup-node@
|
|
646
|
-
with: { node-version:
|
|
645
|
+
- uses: actions/checkout@v5
|
|
646
|
+
- uses: actions/setup-node@v5
|
|
647
|
+
with: { node-version: 24 }
|
|
647
648
|
- run: npm ci
|
|
648
649
|
- run: npx appium driver install uiautomator2
|
|
649
650
|
- uses: reactivecircus/android-emulator-runner@v2
|
|
@@ -659,9 +660,9 @@ jobs:
|
|
|
659
660
|
ios-e2e:
|
|
660
661
|
runs-on: macos-latest
|
|
661
662
|
steps:
|
|
662
|
-
- uses: actions/checkout@
|
|
663
|
-
- uses: actions/setup-node@
|
|
664
|
-
with: { node-version:
|
|
663
|
+
- uses: actions/checkout@v5
|
|
664
|
+
- uses: actions/setup-node@v5
|
|
665
|
+
with: { node-version: 24 }
|
|
665
666
|
- run: npm ci
|
|
666
667
|
- run: npx appium driver install xcuitest
|
|
667
668
|
- run: xcrun simctl boot "iPhone 15" || true
|
package/dist/app.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Driver } from "./driver.js";
|
|
2
|
-
import type { ScenarioFixture } from "./fixtures.js";
|
|
2
|
+
import type { FailureInfo, ScenarioFixture } from "./fixtures.js";
|
|
3
3
|
import type { MockBackend } from "./mock.js";
|
|
4
4
|
/**
|
|
5
5
|
* `defineApp` — the single seam script.
|
|
@@ -37,6 +37,19 @@ 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;
|
|
47
|
+
/**
|
|
48
|
+
* Invoked when a behaviour throws, before the failure propagates — wire on-failure
|
|
49
|
+
* evidence here (e.g. `captureState(...)`) so capture lives in one place, not in every
|
|
50
|
+
* behaviour. Its own errors are swallowed so they never mask the real failure.
|
|
51
|
+
*/
|
|
52
|
+
onFailure?: (context: SessionContext<S>, info: FailureInfo) => Promise<void> | void;
|
|
40
53
|
}
|
|
41
54
|
/** The fixture context a session injects: the device handles plus each app screen. */
|
|
42
55
|
export type SessionContext<S extends ScreenFactories> = DeviceContext & {
|
package/dist/app.js
CHANGED
|
@@ -25,9 +25,16 @@ 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
|
},
|
|
37
|
+
...(definition.onFailure ? { onFailure: definition.onFailure } : {}),
|
|
31
38
|
};
|
|
32
39
|
},
|
|
33
40
|
};
|
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.
|
|
@@ -14,6 +14,10 @@ export interface LocatorAssertions {
|
|
|
14
14
|
toShow(text: string | RegExp, options?: WaitOptions): Promise<void>;
|
|
15
15
|
/** The matched node's own text equals/contains/matches `text`. */
|
|
16
16
|
toHaveText(text: string | RegExp, options?: WaitOptions): Promise<void>;
|
|
17
|
+
/** The matched checkbox/switch is checked (`checked="true"`). */
|
|
18
|
+
toBeChecked(options?: WaitOptions): Promise<void>;
|
|
19
|
+
/** Exactly `count` elements match the selector. */
|
|
20
|
+
toHaveCount(count: number, options?: WaitOptions): Promise<void>;
|
|
17
21
|
}
|
|
18
22
|
export interface MockAssertions {
|
|
19
23
|
readonly not: MockAssertions;
|
|
@@ -42,4 +46,5 @@ export interface ValueAssertions<T> {
|
|
|
42
46
|
}
|
|
43
47
|
export declare function expect(target: Locator): LocatorAssertions;
|
|
44
48
|
export declare function expect(target: MockBackend): MockAssertions;
|
|
49
|
+
export declare function expect(target: FrameLog): MockAssertions;
|
|
45
50
|
export declare function expect<T>(target: T): ValueAssertions<T>;
|
package/dist/expect.js
CHANGED
|
@@ -25,6 +25,12 @@ class LocatorExpectation {
|
|
|
25
25
|
return typeof text === "string" ? content.includes(text) : text.test(content);
|
|
26
26
|
}, `have text ${JSON.stringify(String(text))}`, options);
|
|
27
27
|
}
|
|
28
|
+
toBeChecked(options = {}) {
|
|
29
|
+
return this.check(() => this.locator.isChecked(), "be checked", options);
|
|
30
|
+
}
|
|
31
|
+
toHaveCount(count, options = {}) {
|
|
32
|
+
return this.check(async () => (await this.locator.count()) === count, `have count ${count}`, options);
|
|
33
|
+
}
|
|
28
34
|
async check(predicate, description, options) {
|
|
29
35
|
const want = !this.negated;
|
|
30
36
|
const opts = { ...options, sleep: (ms) => this.locator.driver.pause(ms) };
|
|
@@ -86,11 +92,12 @@ class ValueExpectation {
|
|
|
86
92
|
this.assert(isDeepStrictEqual(this.actual, expected), "toEqual", describeValue(expected));
|
|
87
93
|
}
|
|
88
94
|
toContain(expected) {
|
|
95
|
+
if (typeof this.actual !== "string" && !Array.isArray(this.actual)) {
|
|
96
|
+
throw new TypeError(`expect(...).toContain(...) needs a string or array actual, got ${describeValue(this.actual)}`);
|
|
97
|
+
}
|
|
89
98
|
const ok = typeof this.actual === "string"
|
|
90
99
|
? this.actual.includes(String(expected))
|
|
91
|
-
:
|
|
92
|
-
? this.actual.includes(expected)
|
|
93
|
-
: false;
|
|
100
|
+
: this.actual.includes(expected);
|
|
94
101
|
this.assert(ok, "toContain", describeValue(expected));
|
|
95
102
|
}
|
|
96
103
|
toBeTruthy() {
|
|
@@ -113,18 +120,18 @@ class ValueExpectation {
|
|
|
113
120
|
throw new Error(`expect(${describeValue(this.actual)})${not}.${matcher}(${expectedDesc}) — assertion not met`);
|
|
114
121
|
}
|
|
115
122
|
}
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Structural guard: anything with a `frames()` method is a {@link FrameLog} we can
|
|
125
|
+
* assert traffic on — a full {@link MockBackend} or a frames-only adapter alike. The
|
|
126
|
+
* traffic matchers only read `frames()`, so `route`/`stop` are not required here.
|
|
127
|
+
*/
|
|
128
|
+
function isFrameLog(target) {
|
|
129
|
+
return typeof target === "object" && target !== null && typeof target.frames === "function";
|
|
123
130
|
}
|
|
124
131
|
export function expect(target) {
|
|
125
132
|
if (target instanceof Locator)
|
|
126
133
|
return new LocatorExpectation(target);
|
|
127
|
-
if (
|
|
134
|
+
if (isFrameLog(target))
|
|
128
135
|
return new MockExpectation(target);
|
|
129
136
|
return new ValueExpectation(target);
|
|
130
137
|
}
|
package/dist/fixtures.d.ts
CHANGED
|
@@ -36,13 +36,32 @@ export interface ScenarioFixture<Ctx> {
|
|
|
36
36
|
* produced and must be safe to call in that state.
|
|
37
37
|
*/
|
|
38
38
|
teardown(context: Ctx | undefined): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Invoked when a behaviour throws, before the failure propagates — the seam for
|
|
41
|
+
* on-failure evidence (e.g. `captureState(...)`), so capture lives here instead of in
|
|
42
|
+
* every behaviour. Receives the provisioned context and the failing behaviour's title +
|
|
43
|
+
* error. Its own errors are swallowed (logged) so they never mask the real failure.
|
|
44
|
+
*/
|
|
45
|
+
onFailure?(context: Ctx, info: FailureInfo): void | Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
/** The failing behaviour's title and the error it threw. */
|
|
48
|
+
export interface FailureInfo {
|
|
49
|
+
title: string;
|
|
50
|
+
error: unknown;
|
|
39
51
|
}
|
|
40
52
|
/**
|
|
41
53
|
* Registers one behaviour. The provisioned context is injected, so the body is pure
|
|
42
54
|
* behaviour — no `before`/`after`, no threading a backend handle through assertions.
|
|
43
55
|
* Read it Playwright-style: destructure the fixtures the behaviour needs.
|
|
44
56
|
*/
|
|
45
|
-
export
|
|
57
|
+
export interface BehaviourRegistrar<Ctx> {
|
|
58
|
+
/** Register one behaviour; the provisioned context is injected into its body. */
|
|
59
|
+
(title: string, body: (context: Ctx) => void | Promise<void>): void;
|
|
60
|
+
/** Run before each behaviour in the scenario, with the provisioned context. */
|
|
61
|
+
beforeEach(body: (context: Ctx) => void | Promise<void>): void;
|
|
62
|
+
/** Run after each behaviour in the scenario, with the provisioned context. */
|
|
63
|
+
afterEach(body: (context: Ctx) => void | Promise<void>): void;
|
|
64
|
+
}
|
|
46
65
|
/**
|
|
47
66
|
* Define a behaviour scenario: a Mocha `describe` whose fixture context is
|
|
48
67
|
* provisioned once, injected into every behaviour, and torn down once.
|
package/dist/fixtures.js
CHANGED
|
@@ -31,7 +31,8 @@ import { runner } from "./runner.js";
|
|
|
31
31
|
* });
|
|
32
32
|
*/
|
|
33
33
|
export function describeScenario(title, fixture, define) {
|
|
34
|
-
const
|
|
34
|
+
const hooks = runner();
|
|
35
|
+
const { describe, before, after, it } = hooks;
|
|
35
36
|
describe(title, () => {
|
|
36
37
|
let context;
|
|
37
38
|
before(async () => {
|
|
@@ -40,14 +41,41 @@ export function describeScenario(title, fixture, define) {
|
|
|
40
41
|
after(async () => {
|
|
41
42
|
await fixture.teardown(context);
|
|
42
43
|
});
|
|
43
|
-
const
|
|
44
|
+
const requireContext = () => {
|
|
45
|
+
if (context === undefined) {
|
|
46
|
+
throw new Error(`Scenario "${title}" ran a behaviour before its fixture context was provisioned`);
|
|
47
|
+
}
|
|
48
|
+
return context;
|
|
49
|
+
};
|
|
50
|
+
const registerHook = (hook, name, body) => {
|
|
51
|
+
if (!hook) {
|
|
52
|
+
throw new Error(`The active runner has no ${name} hook; scenario ${name} is unavailable`);
|
|
53
|
+
}
|
|
54
|
+
hook(async () => {
|
|
55
|
+
await body(requireContext());
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
const test = ((behaviourTitle, body) => {
|
|
44
59
|
it(behaviourTitle, async () => {
|
|
45
|
-
|
|
46
|
-
|
|
60
|
+
const context = requireContext();
|
|
61
|
+
try {
|
|
62
|
+
await body(context);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (fixture.onFailure) {
|
|
66
|
+
try {
|
|
67
|
+
await fixture.onFailure(context, { title: behaviourTitle, error });
|
|
68
|
+
}
|
|
69
|
+
catch (hookError) {
|
|
70
|
+
console.warn(`[nativeproof] onFailure hook threw (ignored): ${hookError}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
throw error;
|
|
47
74
|
}
|
|
48
|
-
await body(context);
|
|
49
75
|
});
|
|
50
|
-
};
|
|
76
|
+
});
|
|
77
|
+
test.beforeEach = (body) => registerHook(hooks.beforeEach, "beforeEach", body);
|
|
78
|
+
test.afterEach = (body) => registerHook(hooks.afterEach, "afterEach", body);
|
|
51
79
|
define(test);
|
|
52
80
|
});
|
|
53
81
|
}
|
package/dist/harness.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export interface HarnessTest<S extends ScreenFactories> {
|
|
|
24
24
|
describe(title: string, body: () => void): void;
|
|
25
25
|
/** Open a scenario block for a specific role (e.g. "member" / "guest"). */
|
|
26
26
|
describe(title: string, role: string, body: () => void): void;
|
|
27
|
+
/** Run before each behaviour in the open scenario, with the session context injected. */
|
|
28
|
+
beforeEach(body: (context: SessionContext<S>) => void | Promise<void>): void;
|
|
29
|
+
/** Run after each behaviour in the open scenario, with the session context injected. */
|
|
30
|
+
afterEach(body: (context: SessionContext<S>) => void | Promise<void>): void;
|
|
27
31
|
}
|
|
28
32
|
export interface Harness<S extends ScreenFactories> {
|
|
29
33
|
test: HarnessTest<S>;
|
package/dist/harness.js
CHANGED
|
@@ -25,5 +25,13 @@ export function createHarness(app) {
|
|
|
25
25
|
}
|
|
26
26
|
});
|
|
27
27
|
});
|
|
28
|
+
const requireActive = (hook) => {
|
|
29
|
+
if (!active) {
|
|
30
|
+
throw new Error(`test.${hook}(...) must be called inside test.describe(...)`);
|
|
31
|
+
}
|
|
32
|
+
return active;
|
|
33
|
+
};
|
|
34
|
+
test.beforeEach = (body) => requireActive("beforeEach").beforeEach(body);
|
|
35
|
+
test.afterEach = (body) => requireActive("afterEach").afterEach(body);
|
|
28
36
|
return { test, expect };
|
|
29
37
|
}
|
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.d.ts
CHANGED
|
@@ -11,34 +11,35 @@ import { type Bounds } from "./source.js";
|
|
|
11
11
|
/** A cross-platform element selector. */
|
|
12
12
|
export type Selector = {
|
|
13
13
|
readonly by: "text";
|
|
14
|
-
readonly value: string;
|
|
14
|
+
readonly value: string | RegExp;
|
|
15
15
|
} | {
|
|
16
16
|
readonly by: "desc";
|
|
17
|
-
readonly value: string;
|
|
17
|
+
readonly value: string | RegExp;
|
|
18
18
|
} | {
|
|
19
19
|
readonly by: "id";
|
|
20
|
-
readonly value: string;
|
|
20
|
+
readonly value: string | RegExp;
|
|
21
21
|
} | {
|
|
22
22
|
readonly by: "testId";
|
|
23
|
-
readonly value: string;
|
|
23
|
+
readonly value: string | RegExp;
|
|
24
24
|
} | {
|
|
25
25
|
readonly by: "label";
|
|
26
|
-
readonly value: string;
|
|
26
|
+
readonly value: string | RegExp;
|
|
27
27
|
};
|
|
28
28
|
/**
|
|
29
29
|
* Build selectors Playwright-style. The cross-platform attribute each maps to is resolved
|
|
30
30
|
* per platform (see {@link attributeFor}), so you never have to know whether it's a
|
|
31
31
|
* `content-desc` or a `name`: `by.text("Submit")`, `by.testId("login-button")`,
|
|
32
|
-
* `by.label("Sign out")`, `by.id("message-list")`.
|
|
32
|
+
* `by.label("Sign out")`, `by.id("message-list")`. Each accepts a string (exact match) or
|
|
33
|
+
* a RegExp (`by.text(/Save( draft)?/)`), tested against the element's decoded value.
|
|
33
34
|
*/
|
|
34
35
|
export declare const by: {
|
|
35
|
-
readonly text: (value: string) => Selector;
|
|
36
|
-
readonly desc: (value: string) => Selector;
|
|
37
|
-
readonly id: (value: string) => Selector;
|
|
36
|
+
readonly text: (value: string | RegExp) => Selector;
|
|
37
|
+
readonly desc: (value: string | RegExp) => Selector;
|
|
38
|
+
readonly id: (value: string | RegExp) => Selector;
|
|
38
39
|
/** The app's test id (Android `resource-id` / Compose testTag, iOS accessibilityIdentifier). */
|
|
39
|
-
readonly testId: (value: string) => Selector;
|
|
40
|
+
readonly testId: (value: string | RegExp) => Selector;
|
|
40
41
|
/** The accessibility label (Android `content-desc`, iOS `label`). */
|
|
41
|
-
readonly label: (value: string) => Selector;
|
|
42
|
+
readonly label: (value: string | RegExp) => Selector;
|
|
42
43
|
};
|
|
43
44
|
export declare function describeSelector(selector: Selector): string;
|
|
44
45
|
export interface WaitOptions {
|
|
@@ -66,9 +67,24 @@ export declare class Locator {
|
|
|
66
67
|
readonly driver: Driver;
|
|
67
68
|
readonly selector: Selector;
|
|
68
69
|
private readonly options;
|
|
69
|
-
|
|
70
|
+
/** When set, the locator resolves to the nth match (negative counts from the end). */
|
|
71
|
+
private readonly index?;
|
|
72
|
+
constructor(driver: Driver, selector: Selector, options?: WaitOptions,
|
|
73
|
+
/** When set, the locator resolves to the nth match (negative counts from the end). */
|
|
74
|
+
index?: number | undefined);
|
|
70
75
|
private attribute;
|
|
71
|
-
|
|
76
|
+
/** All node tags this selector matches in the current source, in document order. */
|
|
77
|
+
private matchedNodes;
|
|
78
|
+
/** The single node this locator resolves to (the nth match, or the first when unindexed). */
|
|
79
|
+
private pick;
|
|
80
|
+
/** A locator scoped to the nth match (0-based; negative counts from the end). */
|
|
81
|
+
nth(index: number): Locator;
|
|
82
|
+
/** A locator scoped to the first match. */
|
|
83
|
+
first(): Locator;
|
|
84
|
+
/** A locator scoped to the last match. */
|
|
85
|
+
last(): Locator;
|
|
86
|
+
/** How many elements currently match the selector. */
|
|
87
|
+
count(): Promise<number>;
|
|
72
88
|
/** True if the selector matches a node in the current source. */
|
|
73
89
|
isVisible(): Promise<boolean>;
|
|
74
90
|
/** Bounds of the matched node in the current source, or null if absent. */
|
|
@@ -87,6 +103,13 @@ export declare class Locator {
|
|
|
87
103
|
* field — it does not clear existing content first.
|
|
88
104
|
*/
|
|
89
105
|
fill(text: string, options?: WaitOptions): Promise<void>;
|
|
106
|
+
/** True if the matched node is a checked checkbox/switch (`checked="true"`). */
|
|
107
|
+
isChecked(): Promise<boolean>;
|
|
108
|
+
/** Tap to bring a checkbox/switch to checked; a no-op if it already is. */
|
|
109
|
+
check(options?: WaitOptions): Promise<void>;
|
|
110
|
+
/** Tap to bring a checkbox/switch to unchecked; a no-op if it already is. */
|
|
111
|
+
uncheck(options?: WaitOptions): Promise<void>;
|
|
112
|
+
private setChecked;
|
|
90
113
|
}
|
|
91
114
|
/** Convenience factory: `locator(driver, by.text("Submit"))`. */
|
|
92
115
|
export declare function locator(driver: Driver, selector: Selector, options?: WaitOptions): Locator;
|
package/dist/locator.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { decodeXmlEntities, encodeXmlEntities, escapeRegExp, nodesForAttribute, parseBounds, 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
|
|
5
5
|
* `content-desc` or a `name`: `by.text("Submit")`, `by.testId("login-button")`,
|
|
6
|
-
* `by.label("Sign out")`, `by.id("message-list")`.
|
|
6
|
+
* `by.label("Sign out")`, `by.id("message-list")`. Each accepts a string (exact match) or
|
|
7
|
+
* a RegExp (`by.text(/Save( draft)?/)`), tested against the element's decoded value.
|
|
7
8
|
*/
|
|
8
9
|
export const by = {
|
|
9
10
|
text: (value) => ({ by: "text", value }),
|
|
@@ -15,10 +16,8 @@ export const by = {
|
|
|
15
16
|
label: (value) => ({ by: "label", value }),
|
|
16
17
|
};
|
|
17
18
|
export function describeSelector(selector) {
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
function escapeRegExp(value) {
|
|
21
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
19
|
+
const value = selector.value instanceof RegExp ? String(selector.value) : JSON.stringify(selector.value);
|
|
20
|
+
return `by.${selector.by}(${value})`;
|
|
22
21
|
}
|
|
23
22
|
/**
|
|
24
23
|
* The page-source attribute a selector resolves to on each platform. `text` is an
|
|
@@ -61,40 +60,74 @@ export class Locator {
|
|
|
61
60
|
driver;
|
|
62
61
|
selector;
|
|
63
62
|
options;
|
|
64
|
-
|
|
63
|
+
index;
|
|
64
|
+
constructor(driver, selector, options = {},
|
|
65
|
+
/** When set, the locator resolves to the nth match (negative counts from the end). */
|
|
66
|
+
index) {
|
|
65
67
|
this.driver = driver;
|
|
66
68
|
this.selector = selector;
|
|
67
69
|
this.options = options;
|
|
70
|
+
this.index = index;
|
|
68
71
|
}
|
|
69
72
|
attribute() {
|
|
70
73
|
return attributeFor(this.selector, this.driver.platform);
|
|
71
74
|
}
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
/** All node tags this selector matches in the current source, in document order. */
|
|
76
|
+
async matchedNodes() {
|
|
77
|
+
return nodesForAttribute(await this.driver.source(), this.attribute(), this.selector.value);
|
|
78
|
+
}
|
|
79
|
+
/** The single node this locator resolves to (the nth match, or the first when unindexed). */
|
|
80
|
+
pick(nodes) {
|
|
81
|
+
if (this.index === undefined)
|
|
82
|
+
return nodes[0] ?? null;
|
|
83
|
+
const at = this.index < 0 ? nodes.length + this.index : this.index;
|
|
84
|
+
return nodes[at] ?? null;
|
|
85
|
+
}
|
|
86
|
+
/** A locator scoped to the nth match (0-based; negative counts from the end). */
|
|
87
|
+
nth(index) {
|
|
88
|
+
return new Locator(this.driver, this.selector, this.options, index);
|
|
89
|
+
}
|
|
90
|
+
/** A locator scoped to the first match. */
|
|
91
|
+
first() {
|
|
92
|
+
return this.nth(0);
|
|
93
|
+
}
|
|
94
|
+
/** A locator scoped to the last match. */
|
|
95
|
+
last() {
|
|
96
|
+
return this.nth(-1);
|
|
97
|
+
}
|
|
98
|
+
/** How many elements currently match the selector. */
|
|
99
|
+
async count() {
|
|
100
|
+
return (await this.matchedNodes()).length;
|
|
74
101
|
}
|
|
75
102
|
/** True if the selector matches a node in the current source. */
|
|
76
103
|
async isVisible() {
|
|
77
|
-
return this.
|
|
104
|
+
return this.pick(await this.matchedNodes()) !== null;
|
|
78
105
|
}
|
|
79
106
|
/** Bounds of the matched node in the current source, or null if absent. */
|
|
80
107
|
async bounds() {
|
|
81
|
-
|
|
108
|
+
const node = this.pick(await this.matchedNodes());
|
|
109
|
+
return node ? parseBounds(/bounds="([^"]+)"/.exec(node)?.[1]) : null;
|
|
82
110
|
}
|
|
83
111
|
/** The matched node's own visible text, or null if the node is absent. */
|
|
84
112
|
async textContent() {
|
|
85
|
-
const
|
|
86
|
-
const selectorPattern = escapeRegExp(encodeXmlEntities(this.selector.value));
|
|
87
|
-
const node = new RegExp(`<[^>]*${this.attribute()}="${selectorPattern}"[^>]*>`).exec(source)?.[0];
|
|
113
|
+
const node = this.pick(await this.matchedNodes());
|
|
88
114
|
if (!node)
|
|
89
115
|
return null;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
116
|
+
// A visible label can live in either of two attributes per platform, in the same
|
|
117
|
+
// precedence `attributeFor` uses (iOS label→value, Android text→content-desc). Prefer
|
|
118
|
+
// the first NON-empty one, so a node like `value="" label="Submit"` reads "Submit",
|
|
119
|
+
// falling back to the first present (possibly empty) attribute.
|
|
120
|
+
const attrs = this.driver.platform === "ios" ? ["label", "value"] : ["text", "content-desc"];
|
|
121
|
+
const present = attrs
|
|
122
|
+
.map((attr) => new RegExp(`${attr}="([^"]*)"`).exec(node)?.[1])
|
|
123
|
+
.filter((v) => v !== undefined);
|
|
124
|
+
const raw = present.find((v) => v !== "") ?? present[0];
|
|
125
|
+
return raw === undefined ? null : decodeXmlEntities(raw);
|
|
93
126
|
}
|
|
94
127
|
/** True if the selector is present AND `text` appears in the source. */
|
|
95
128
|
async shows(text) {
|
|
96
129
|
const source = await this.driver.source();
|
|
97
|
-
if (
|
|
130
|
+
if (this.pick(nodesForAttribute(source, this.attribute(), this.selector.value)) === null)
|
|
98
131
|
return false;
|
|
99
132
|
const pattern = typeof text === "string" ? new RegExp(escapeRegExp(encodeXmlEntities(text))) : text;
|
|
100
133
|
return pattern.test(source);
|
|
@@ -131,6 +164,30 @@ export class Locator {
|
|
|
131
164
|
await this.tap(options);
|
|
132
165
|
await this.driver.typeText(text);
|
|
133
166
|
}
|
|
167
|
+
/** True if the matched node is a checked checkbox/switch (`checked="true"`). */
|
|
168
|
+
async isChecked() {
|
|
169
|
+
const node = this.pick(await this.matchedNodes());
|
|
170
|
+
return node !== null && /\bchecked="true"/.test(node);
|
|
171
|
+
}
|
|
172
|
+
/** Tap to bring a checkbox/switch to checked; a no-op if it already is. */
|
|
173
|
+
async check(options = {}) {
|
|
174
|
+
await this.setChecked(true, options);
|
|
175
|
+
}
|
|
176
|
+
/** Tap to bring a checkbox/switch to unchecked; a no-op if it already is. */
|
|
177
|
+
async uncheck(options = {}) {
|
|
178
|
+
await this.setChecked(false, options);
|
|
179
|
+
}
|
|
180
|
+
async setChecked(desired, options) {
|
|
181
|
+
if ((await this.isChecked()) === desired)
|
|
182
|
+
return;
|
|
183
|
+
await this.tap(options);
|
|
184
|
+
const opts = { ...this.options, ...options, sleep: (ms) => this.driver.pause(ms) };
|
|
185
|
+
const settled = await waitUntil(() => this.isChecked(), (value) => value === desired, opts);
|
|
186
|
+
if (settled !== desired) {
|
|
187
|
+
const state = desired ? "checked" : "unchecked";
|
|
188
|
+
throw new Error(`${describeSelector(this.selector)} did not become ${state} within ${opts.timeout ?? DEFAULTS.timeout}ms`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
134
191
|
}
|
|
135
192
|
/** Convenience factory: `locator(driver, by.text("Submit"))`. */
|
|
136
193
|
export function locator(driver, selector, options = {}) {
|
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/page.d.ts
CHANGED
|
@@ -3,22 +3,23 @@ import { type Locator, type Selector, type WaitOptions } from "./locator.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Playwright-style `getBy*` ergonomics over a {@link Driver}, so selectors are
|
|
5
5
|
* discovered, not inferred. `page(driver).getByText("Submit")` returns a {@link Locator}
|
|
6
|
-
* and you never spell out which native attribute backs it.
|
|
6
|
+
* and you never spell out which native attribute backs it. Every `getBy*` takes a string
|
|
7
|
+
* (exact match) or a RegExp (`getByText(/Save( draft)?/)`).
|
|
7
8
|
*/
|
|
8
9
|
export interface Page {
|
|
9
10
|
/** Escape hatch: a locator from a raw selector. */
|
|
10
11
|
locator(selector: Selector, options?: WaitOptions): Locator;
|
|
11
|
-
getByText(text: string, options?: WaitOptions): Locator;
|
|
12
|
-
getByTestId(testId: string, options?: WaitOptions): Locator;
|
|
13
|
-
getByLabel(label: string, options?: WaitOptions): Locator;
|
|
14
|
-
getById(id: string, options?: WaitOptions): Locator;
|
|
12
|
+
getByText(text: string | RegExp, options?: WaitOptions): Locator;
|
|
13
|
+
getByTestId(testId: string | RegExp, options?: WaitOptions): Locator;
|
|
14
|
+
getByLabel(label: string | RegExp, options?: WaitOptions): Locator;
|
|
15
|
+
getById(id: string | RegExp, options?: WaitOptions): Locator;
|
|
15
16
|
/**
|
|
16
17
|
* Match by accessible name. Native accessibility trees don't expose web-style roles
|
|
17
18
|
* reliably, so `role` is advisory and the dependable signal is the name (the element's
|
|
18
|
-
* accessibility label). Pass `{ name }
|
|
19
|
+
* accessibility label). Pass `{ name }` — a string for exact match, or a RegExp.
|
|
19
20
|
*/
|
|
20
21
|
getByRole(role: string, options: {
|
|
21
|
-
name: string;
|
|
22
|
+
name: string | RegExp;
|
|
22
23
|
} & WaitOptions): Locator;
|
|
23
24
|
}
|
|
24
25
|
export declare function page(driver: Driver): Page;
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* BDD-runner seam.
|
|
3
|
-
*
|
|
4
|
-
* The fixtures and the `test` facade register suites through whatever BDD runner
|
|
5
|
-
* hosts them: Mocha by default (its `describe` / `before` / `after` / `it` globals, as
|
|
6
|
-
* WebdriverIO uses), or any other runner wired explicitly with {@link useRunner}
|
|
7
|
-
* (for example node:test). This keeps the framework from hard-coding a single runner,
|
|
8
|
-
* the way Playwright owns its own.
|
|
9
|
-
*/
|
|
10
1
|
export interface BddHooks {
|
|
11
2
|
describe(title: string, fn: () => void): void;
|
|
12
3
|
before(fn: () => void | Promise<void>): void;
|
|
13
4
|
after(fn: () => void | Promise<void>): void;
|
|
5
|
+
/** Optional per-behaviour hooks; present on Mocha and node:test. */
|
|
6
|
+
beforeEach?(fn: () => void | Promise<void>): void;
|
|
7
|
+
afterEach?(fn: () => void | Promise<void>): void;
|
|
14
8
|
it(title: string, fn: () => void | Promise<void>): void;
|
|
15
9
|
}
|
|
16
10
|
/** Wire an explicit BDD runner (e.g. node:test's hooks). Overrides global detection. */
|
package/dist/runner.js
CHANGED
|
@@ -5,7 +5,7 @@ export function useRunner(hooks) {
|
|
|
5
5
|
}
|
|
6
6
|
function fromGlobals() {
|
|
7
7
|
const globals = globalThis;
|
|
8
|
-
const { describe, before, after, it } = globals;
|
|
8
|
+
const { describe, before, after, beforeEach, afterEach, it } = globals;
|
|
9
9
|
if (typeof describe === "function" &&
|
|
10
10
|
typeof before === "function" &&
|
|
11
11
|
typeof after === "function" &&
|
|
@@ -15,6 +15,8 @@ function fromGlobals() {
|
|
|
15
15
|
before: before,
|
|
16
16
|
after: after,
|
|
17
17
|
it: it,
|
|
18
|
+
...(typeof beforeEach === "function" ? { beforeEach: beforeEach } : {}),
|
|
19
|
+
...(typeof afterEach === "function" ? { afterEach: afterEach } : {}),
|
|
18
20
|
};
|
|
19
21
|
}
|
|
20
22
|
return null;
|
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,8 +31,23 @@ 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
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The first element tag exposing `attribute` with a value matching `value`. `attribute`
|
|
36
|
+
* may be a regex alternation (e.g. `"(?:text|content-desc)"`). A string matches the
|
|
37
|
+
* entity-escaped source exactly; a RegExp is tested against each candidate's DECODED
|
|
38
|
+
* value, so `by.text(/Save( draft)?/)` matches whether the source XML-escaped it or not.
|
|
39
|
+
*/
|
|
40
|
+
export declare function nodeForAttribute(source: string, attribute: string, value: string | RegExp): string | null;
|
|
41
|
+
/** Every element tag exposing `attribute` with a value matching `value`, in document order. */
|
|
42
|
+
export declare function nodesForAttribute(source: string, attribute: string, value: string | RegExp): string[];
|
|
43
|
+
/** True if any element exposes `attribute` with a value matching `value` (string exact or RegExp). */
|
|
44
|
+
export declare function attributeMatches(source: string, attribute: string, value: string | RegExp): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Bounds of the first element carrying the given attribute match. The element tag is
|
|
47
|
+
* matched first, then `bounds` is extracted from within it regardless of attribute order —
|
|
48
|
+
* so a source that emits `bounds` before the selector attribute still resolves.
|
|
49
|
+
*/
|
|
50
|
+
export declare function boundsForAttribute(source: string, attribute: string, value: string | RegExp): Bounds | null;
|
|
35
51
|
/** Bounds of an element addressed by Android `content-desc`. */
|
|
36
52
|
export declare function boundsForContentDesc(source: string, contentDesc: string): Bounds | null;
|
|
37
53
|
/** Bounds of an element addressed by visible `text`. */
|
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,45 @@ 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
|
+
* The first element tag exposing `attribute` with a value matching `value`. `attribute`
|
|
55
|
+
* may be a regex alternation (e.g. `"(?:text|content-desc)"`). A string matches the
|
|
56
|
+
* entity-escaped source exactly; a RegExp is tested against each candidate's DECODED
|
|
57
|
+
* value, so `by.text(/Save( draft)?/)` matches whether the source XML-escaped it or not.
|
|
58
|
+
*/
|
|
59
|
+
export function nodeForAttribute(source, attribute, value) {
|
|
60
|
+
return nodesForAttribute(source, attribute, value)[0] ?? null;
|
|
61
|
+
}
|
|
62
|
+
/** Every element tag exposing `attribute` with a value matching `value`, in document order. */
|
|
63
|
+
export function nodesForAttribute(source, attribute, value) {
|
|
64
|
+
if (typeof value === "string") {
|
|
65
|
+
const escaped = escapeRegExp(encodeXmlEntities(value));
|
|
66
|
+
return [...source.matchAll(new RegExp(`<[^>]*${attribute}="${escaped}"[^>]*>`, "g"))].map((m) => m[0]);
|
|
67
|
+
}
|
|
68
|
+
// A `g`-flagged RegExp is stateful across `.test()` calls; use a non-global copy so the
|
|
69
|
+
// per-candidate test is order-independent.
|
|
70
|
+
const test = value.global ? new RegExp(value.source, value.flags.replace("g", "")) : value;
|
|
71
|
+
const candidate = new RegExp(`${attribute}="([^"]*)"`);
|
|
72
|
+
const nodes = [];
|
|
73
|
+
for (const tag of source.matchAll(/<[^>]*>/g)) {
|
|
74
|
+
const attr = candidate.exec(tag[0]);
|
|
75
|
+
if (attr && test.test(decodeXmlEntities(attr[1] ?? "")))
|
|
76
|
+
nodes.push(tag[0]);
|
|
77
|
+
}
|
|
78
|
+
return nodes;
|
|
79
|
+
}
|
|
80
|
+
/** True if any element exposes `attribute` with a value matching `value` (string exact or RegExp). */
|
|
81
|
+
export function attributeMatches(source, attribute, value) {
|
|
82
|
+
return nodeForAttribute(source, attribute, value) !== null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Bounds of the first element carrying the given attribute match. The element tag is
|
|
86
|
+
* matched first, then `bounds` is extracted from within it regardless of attribute order —
|
|
87
|
+
* so a source that emits `bounds` before the selector attribute still resolves.
|
|
88
|
+
*/
|
|
54
89
|
export function boundsForAttribute(source, attribute, value) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
return match ? parseBounds(match[1]) : null;
|
|
90
|
+
const node = nodeForAttribute(source, attribute, value);
|
|
91
|
+
return node ? parseBounds(/bounds="([^"]+)"/.exec(node)?.[1]) : null;
|
|
58
92
|
}
|
|
59
93
|
/** Bounds of an element addressed by Android `content-desc`. */
|
|
60
94
|
export function boundsForContentDesc(source, contentDesc) {
|
|
@@ -70,8 +104,8 @@ export function boundsForText(source, text) {
|
|
|
70
104
|
* into a reliable tap target (e.g. the "Members (3)" list rows).
|
|
71
105
|
*/
|
|
72
106
|
export function smallestClickableAncestorBounds(source, nodeBounds) {
|
|
73
|
-
const clickable = [...source.matchAll(
|
|
74
|
-
.map((m) => parseBounds(m[1]))
|
|
107
|
+
const clickable = [...source.matchAll(/<[^>]*clickable="true"[^>]*>/g)]
|
|
108
|
+
.map((m) => parseBounds(/bounds="([^"]+)"/.exec(m[0])?.[1]))
|
|
75
109
|
.filter((b) => b !== null)
|
|
76
110
|
.filter((b) => b.x1 <= nodeBounds.x1 && b.x2 >= nodeBounds.x2 && b.y1 <= nodeBounds.y1 && b.y2 >= nodeBounds.y2)
|
|
77
111
|
.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
|
-
}
|