nativeproof 0.5.0 → 0.7.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 +41 -0
- package/README.md +51 -13
- package/dist/app.d.ts +7 -1
- package/dist/app.js +1 -0
- package/dist/expect.d.ts +4 -0
- package/dist/expect.js +6 -0
- 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/locator.d.ts +42 -12
- package/dist/locator.js +76 -12
- package/dist/page.d.ts +11 -10
- package/dist/page.js +6 -4
- package/dist/runner.d.ts +3 -9
- package/dist/runner.js +3 -1
- package/dist/source.d.ts +19 -1
- package/dist/source.js +60 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,47 @@ 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.7.0
|
|
8
|
+
|
|
9
|
+
Role selectors — `getByRole` matches by element role, not just name.
|
|
10
|
+
|
|
11
|
+
**Added**
|
|
12
|
+
|
|
13
|
+
- **`getByRole(role)` / `by.role(role)` match by element class/type** when no `name` is given —
|
|
14
|
+
`checkbox`, `switch`, `button`, `textfield`, `image`. Maps to the Android widget `class` / iOS
|
|
15
|
+
XCUITest `type` as a substring, so `SwitchCompat`, `MaterialButton`, and Compose's
|
|
16
|
+
`android.widget.CheckBox` all resolve. Combine with `.nth()` / `expect(locator).toBeChecked()`.
|
|
17
|
+
`getByRole(role, { name })` is unchanged (matches the accessibility label); an unknown role throws
|
|
18
|
+
with the supported list. New `nodesForRole` source helper.
|
|
19
|
+
|
|
20
|
+
## 0.6.0
|
|
21
|
+
|
|
22
|
+
Playwright-parity additions to locators, assertions, fixtures, and evidence.
|
|
23
|
+
|
|
24
|
+
**Added**
|
|
25
|
+
|
|
26
|
+
- **Checkbox/switch state** — `Locator.isChecked()`, `Locator.check()` / `uncheck()` (tap to the
|
|
27
|
+
desired state, a no-op if already there), and auto-waiting `expect(locator).toBeChecked()` (+ `.not`),
|
|
28
|
+
reading `checked="true"` on the matched node.
|
|
29
|
+
- **Multi-match locators** — `Locator.nth(i)` / `first()` / `last()` (negative `i` counts from the end),
|
|
30
|
+
`Locator.count()`, and auto-waiting `expect(locator).toHaveCount(n)`. An unindexed locator still
|
|
31
|
+
resolves to the first match. New `nodesForAttribute` source helper backs it.
|
|
32
|
+
- **Scenario `beforeEach` / `afterEach`** — `test.beforeEach(fn)` / `test.afterEach(fn)` (and the
|
|
33
|
+
`describeScenario` registrar's `.beforeEach` / `.afterEach`) register per-behaviour hooks that
|
|
34
|
+
receive the provisioned fixture context — a repeatable reset between behaviours without leaving the
|
|
35
|
+
harness. `BddHooks` gained optional `beforeEach` / `afterEach` (present on Mocha and node:test).
|
|
36
|
+
- **`onFailure` evidence hook** — `ScenarioFixture.onFailure` and `defineApp({ onFailure })` run when a
|
|
37
|
+
behaviour throws, before the failure propagates, so on-failure evidence (e.g. `captureState(...)`)
|
|
38
|
+
lives in one place instead of every behaviour. The hook receives the context + `{ title, error }`;
|
|
39
|
+
its own errors are swallowed (logged) so they never mask the real failure.
|
|
40
|
+
- **`by.*` and every `page().getBy*` accept a `RegExp`** as well as a string —
|
|
41
|
+
`getByText(/Save( draft)?/)`, `getByLabel(/^Remove /)`, `getByRole("checkbox", { name: /terms/i })`.
|
|
42
|
+
A string matches the element's value exactly; a RegExp is tested against the element's **decoded**
|
|
43
|
+
value, so a human pattern matches the entity-escaped source and tolerant labels
|
|
44
|
+
(`/complete(d)? phrases/`) no longer need source-scraping. Matching, `bounds`, `textContent`,
|
|
45
|
+
`tap`/`fill`, and `expect(locator)` all honour it. New `nodeForAttribute` / `attributeMatches`
|
|
46
|
+
source helpers back it (`boundsForAttribute` now takes `string | RegExp`).
|
|
47
|
+
|
|
7
48
|
## 0.5.0
|
|
8
49
|
|
|
9
50
|
Two additive, backward-compatible seams so an app can drive more of its lifecycle and
|
package/README.md
CHANGED
|
@@ -60,10 +60,11 @@ 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.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
`tap()` / `fill()` / `check()` for interaction. Each takes a string (exact) or a **`RegExp`**
|
|
64
|
+
(`getByText(/Save( draft)?/)`); `.nth()` / `.first()` / `.last()` / `.count()` handle multiple matches.
|
|
65
|
+
- **Auto-waiting `expect`** — `expect(locator).toBeVisible()/toShow()/toHaveText()/toBeChecked()/
|
|
66
|
+
toHaveCount()` and `.not`, each polling until the condition holds (default 10s); plus synchronous
|
|
67
|
+
`expect(value)` matchers (`toBe`/`toEqual`/`toContain`/…) so non-UI checks need no second assertion library.
|
|
67
68
|
- **Network interception** — a first-party HTTP + WebSocket mock server with
|
|
68
69
|
`route().fulfill/reject/abort` (like `page.route()`) and `expect(mock).toHaveSent()/
|
|
69
70
|
toHaveReceived()` traffic assertions. No per-app adapter.
|
|
@@ -370,14 +371,28 @@ const app = defineApp({
|
|
|
370
371
|
member: ({ driver }) => ({ messages: page(driver).getByTestId("message-list") }),
|
|
371
372
|
guest: ({ driver }) => ({ banner: page(driver).getByTestId("signup-banner") }),
|
|
372
373
|
},
|
|
374
|
+
|
|
375
|
+
// optional lifecycle hooks:
|
|
376
|
+
teardown: async ({ driver }) => { /* release app resources before the session is deleted */ },
|
|
377
|
+
onFailure: async (ctx, { title, error }) => { await captureState(`fail-${title}`); }, // evidence on any failed behaviour
|
|
373
378
|
});
|
|
374
379
|
```
|
|
375
380
|
|
|
376
381
|
```ts
|
|
377
|
-
test.describe("a signed-in member", "member", () => {
|
|
382
|
+
test.describe("a signed-in member", "member", () => {
|
|
383
|
+
test.beforeEach(async ({ member }) => { /* reset to a known state before each behaviour */ });
|
|
384
|
+
test.afterEach(async ({ member }) => { /* per-behaviour cleanup, if any */ });
|
|
385
|
+
test("renders the latest message", async ({ member, mock }) => { /* … */ });
|
|
386
|
+
});
|
|
378
387
|
test.describe("a guest", "guest", () => { /* uses { guest, mock } */ });
|
|
379
388
|
```
|
|
380
389
|
|
|
390
|
+
The session fixture is provisioned **once** per `describe` (the slow login + join) and shared across
|
|
391
|
+
its behaviours; `beforeEach` / `afterEach` run around each behaviour with the context injected — a
|
|
392
|
+
repeatable reset without per-spec boilerplate. `teardown` runs before the mock stops and the session
|
|
393
|
+
is deleted (e.g. force-stop the app); `onFailure` runs when a behaviour throws, before the failure
|
|
394
|
+
propagates, so on-failure evidence lives in one place instead of every behaviour.
|
|
395
|
+
|
|
381
396
|
> NativeProof locators **read, tap and fill** (text entry). For input `fill()` doesn't cover
|
|
382
397
|
> (clearing a field first, custom keyboards, key chords), drive WebdriverIO's `$(...).setValue(...)`
|
|
383
398
|
> directly inside `login` (the `@wdio/globals` `browser`/`$` are available in the live session).
|
|
@@ -390,19 +405,26 @@ you address elements the way a person describes them, not by `content-desc` vs `
|
|
|
390
405
|
```ts
|
|
391
406
|
const p = page(driver);
|
|
392
407
|
p.getByText("Sign in"); // visible text
|
|
408
|
+
p.getByText(/Sign ?in/i); // ...or a RegExp, matched against the element's decoded value
|
|
393
409
|
p.getByTestId("login-button"); // your test id
|
|
394
410
|
p.getByLabel("Sign out"); // accessibility label
|
|
395
411
|
p.getById("message-list"); // resource id
|
|
396
|
-
p.getByRole("button", { name: "Send" }); // accessible name
|
|
412
|
+
p.getByRole("button", { name: "Send" }); // accessible name
|
|
413
|
+
p.getByRole("checkbox"); // by role/class — checkbox/switch/button/textfield/image
|
|
397
414
|
p.locator(by.desc("Open menu")); // escape hatch: a raw selector
|
|
398
415
|
```
|
|
399
416
|
|
|
417
|
+
Every `getBy*` (and `by.*`) takes a **string** (exact match) or a **`RegExp`** (tested against the
|
|
418
|
+
element's decoded value), so a human pattern matches even entity-escaped source — `getByText(/Save( draft)?/)`,
|
|
419
|
+
`getByLabel(/^Remove /)`, `getByRole("checkbox", { name: /terms/i })`. The Playwright muscle memory carries over.
|
|
420
|
+
|
|
400
421
|
How each maps to the page source:
|
|
401
422
|
|
|
402
423
|
| Locator | Android attribute | iOS attribute |
|
|
403
424
|
|---|---|---|
|
|
404
425
|
| `getByText` / `by.text` | `text` or `content-desc` | `label` or `value` |
|
|
405
426
|
| `getByLabel` / `by.label` / `getByRole({name})` | `content-desc` | `label` |
|
|
427
|
+
| `getByRole(role)` / `by.role` (no name) | widget `class` | XCUITest `type` |
|
|
406
428
|
| `getByTestId` / `by.testId` | `resource-id` | `name` |
|
|
407
429
|
| `getById` / `by.id` | `resource-id` | `name` |
|
|
408
430
|
| `by.desc` | `content-desc` | `name` |
|
|
@@ -422,6 +444,11 @@ await member.sendButton.tap(); // wait for it, then tap its centre
|
|
|
422
444
|
await member.sendButton.tap({ timeout: 2_000, interval: 100 }); // tune the wait
|
|
423
445
|
await member.row.tap({ clickableAncestor: true }); // tap the clickable parent of a non-clickable label
|
|
424
446
|
await member.composer.fill("Hello team"); // focus the field (tap), then type
|
|
447
|
+
|
|
448
|
+
await p.getByText("Item").count(); // how many elements match
|
|
449
|
+
await p.getByText("Item").nth(1).tap(); // the 2nd match (.first() / .last(); negative counts from the end)
|
|
450
|
+
await member.terms.check(); // checkbox/switch → tap to checked (no-op if already there); also uncheck()
|
|
451
|
+
await member.terms.isChecked(); // boolean
|
|
425
452
|
```
|
|
426
453
|
|
|
427
454
|
`tap()` resolves the element's bounds from the page source and taps the centre — a coordinate
|
|
@@ -446,12 +473,16 @@ Assertions **auto-wait** (poll until the condition holds or the timeout elapses,
|
|
|
446
473
|
await expect(member.messages).toBeVisible();
|
|
447
474
|
await expect(member.messages).toShow("Welcome to the room"); // present + text anywhere on screen
|
|
448
475
|
await expect(member.roomTitle).toHaveText(/Room: \w+/); // the node's OWN text (substring or regex)
|
|
476
|
+
await expect(member.terms).toBeChecked(); // checkbox / switch is on
|
|
477
|
+
await expect(member.results).toHaveCount(3); // exactly 3 elements match
|
|
449
478
|
await expect(member.spinner).not.toBeVisible({ timeout: 5_000 });
|
|
450
479
|
```
|
|
451
480
|
|
|
452
481
|
- `toBeVisible(opts?)` — the selector matches a node in the source.
|
|
453
482
|
- `toShow(text, opts?)` — the selector is present **and** `text` appears in the source.
|
|
454
483
|
- `toHaveText(text, opts?)` — the matched node's **own** text contains / matches `text`.
|
|
484
|
+
- `toBeChecked(opts?)` — the matched checkbox / switch is checked (`checked="true"`).
|
|
485
|
+
- `toHaveCount(n, opts?)` — exactly `n` elements match the selector.
|
|
455
486
|
- `opts` is `{ timeout?, interval? }` (ms).
|
|
456
487
|
|
|
457
488
|
**Value matchers** — `expect(value)` also takes a plain value, for the non-UI assertions a spec
|
|
@@ -572,6 +603,11 @@ const app = defineApp({
|
|
|
572
603
|
The framework depends only on the `MockBackend` interface, never a concrete server — so
|
|
573
604
|
`expect(mock).toHaveSent(...)` reads your backend's traffic with no other changes.
|
|
574
605
|
|
|
606
|
+
> **Just need the assertions?** If your mock can't implement `route` / `stop` (e.g. it only writes a
|
|
607
|
+
> request/socket log), expose a frames-only **`FrameLog`** — `{ frames(): Promise<MockFrame[]> }`,
|
|
608
|
+
> which `MockBackend` extends — and pass *that* to `expect(...)`: `expect(traffic).toHaveSent({ path, type })`
|
|
609
|
+
> / `.toHaveReceived(...)` auto-wait over it, no `route`/`stop` needed.
|
|
610
|
+
|
|
575
611
|
### Gestures & scrolling
|
|
576
612
|
|
|
577
613
|
For motion the locator layer doesn't cover (scrolling a list, swiping a carousel), use the
|
|
@@ -723,15 +759,17 @@ The framework's own unit suite (`npm test`) needs **no device** and runs anywher
|
|
|
723
759
|
|
|
724
760
|
## API reference
|
|
725
761
|
|
|
726
|
-
- `defineApp(definition)` → `app` — the seam; `app.session(role?)` is a scenario fixture.
|
|
727
|
-
|
|
762
|
+
- `defineApp(definition)` → `app` — the seam; `app.session(role?)` is a scenario fixture. `definition` also
|
|
763
|
+
takes optional `teardown(ctx)` (before mock stop / session delete) and `onFailure(ctx, { title, error })`.
|
|
764
|
+
- `createHarness(app)` → `{ test, expect }` — typed, app-bound test surface; `test.beforeEach` / `test.afterEach`
|
|
765
|
+
register per-behaviour hooks with the context injected.
|
|
728
766
|
- `defineConfig({ app, projects, testDir?, testMatch?, appium?, mochaTimeout? })` — the config the CLI runs.
|
|
729
|
-
- `by.text/desc/id/testId/label
|
|
767
|
+
- `by.text/desc/id/testId/label` (string **or** `RegExp`), `page(driver).getByText/getByTestId/getByLabel/getById/getByRole`,
|
|
730
768
|
`page(driver).locator(selector)`, `new Locator(driver, selector)` — locators
|
|
731
|
-
(`isVisible`, `textContent`, `bounds`, `shows`, `waitFor`, `tap`, `fill`
|
|
732
|
-
for non-clickable labels).
|
|
733
|
-
- `expect(locator)` → `toBeVisible` / `toShow` / `toHaveText` (+ `.not`), each `(value?, { timeout?, interval? })`.
|
|
734
|
-
- `expect(mock)` → `toHaveSent` / `toHaveReceived` (+ `.not`), matched by partial frame.
|
|
769
|
+
(`isVisible`, `textContent`, `bounds`, `shows`, `waitFor`, `tap`, `fill`, `isChecked`, `check`, `uncheck`,
|
|
770
|
+
`count`, `nth`, `first`, `last` — `tap({ clickableAncestor })` for non-clickable labels).
|
|
771
|
+
- `expect(locator)` → `toBeVisible` / `toShow` / `toHaveText` / `toBeChecked` / `toHaveCount` (+ `.not`), each `(value?, { timeout?, interval? })`.
|
|
772
|
+
- `expect(mock | frameLog)` → `toHaveSent` / `toHaveReceived` (+ `.not`), matched by partial frame; accepts any `FrameLog` (`{ frames() }`).
|
|
735
773
|
- `expect(value)` → `toBe` / `toEqual` / `toContain` / `toBeTruthy` / `toBeFalsy` / `toBeDefined` / `toBeNull` (+ `.not`) — synchronous matchers for plain values.
|
|
736
774
|
- `startMockServer({ port?, host? })` → a `MockServer` (`url`, `wsUrl`, `route()`, `frames()`, `send()`, `stop()`).
|
|
737
775
|
- `swipe`, `tapAt`, `pause` — low-level pointer gestures.
|
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.
|
|
@@ -44,6 +44,12 @@ export interface AppDefinition<S extends ScreenFactories> {
|
|
|
44
44
|
* even if this throws.
|
|
45
45
|
*/
|
|
46
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;
|
|
47
53
|
}
|
|
48
54
|
/** The fixture context a session injects: the device handles plus each app screen. */
|
|
49
55
|
export type SessionContext<S extends ScreenFactories> = DeviceContext & {
|
package/dist/app.js
CHANGED
package/dist/expect.d.ts
CHANGED
|
@@ -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;
|
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) };
|
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/locator.d.ts
CHANGED
|
@@ -11,34 +11,40 @@ 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 | RegExp;
|
|
27
|
+
} | {
|
|
28
|
+
readonly by: "role";
|
|
26
29
|
readonly value: string;
|
|
27
30
|
};
|
|
28
31
|
/**
|
|
29
32
|
* Build selectors Playwright-style. The cross-platform attribute each maps to is resolved
|
|
30
33
|
* per platform (see {@link attributeFor}), so you never have to know whether it's a
|
|
31
34
|
* `content-desc` or a `name`: `by.text("Submit")`, `by.testId("login-button")`,
|
|
32
|
-
* `by.label("Sign out")`, `by.id("message-list")`.
|
|
35
|
+
* `by.label("Sign out")`, `by.id("message-list")`. Each accepts a string (exact match) or
|
|
36
|
+
* a RegExp (`by.text(/Save( draft)?/)`), tested against the element's decoded value.
|
|
33
37
|
*/
|
|
34
38
|
export declare const by: {
|
|
35
|
-
readonly text: (value: string) => Selector;
|
|
36
|
-
readonly desc: (value: string) => Selector;
|
|
37
|
-
readonly id: (value: string) => Selector;
|
|
39
|
+
readonly text: (value: string | RegExp) => Selector;
|
|
40
|
+
readonly desc: (value: string | RegExp) => Selector;
|
|
41
|
+
readonly id: (value: string | RegExp) => Selector;
|
|
38
42
|
/** The app's test id (Android `resource-id` / Compose testTag, iOS accessibilityIdentifier). */
|
|
39
|
-
readonly testId: (value: string) => Selector;
|
|
43
|
+
readonly testId: (value: string | RegExp) => Selector;
|
|
40
44
|
/** The accessibility label (Android `content-desc`, iOS `label`). */
|
|
41
|
-
readonly label: (value: string) => Selector;
|
|
45
|
+
readonly label: (value: string | RegExp) => Selector;
|
|
46
|
+
/** A semantic role, matched by element class/type — `checkbox`, `switch`, `button`, `textfield`, `image`. */
|
|
47
|
+
readonly role: (value: string) => Selector;
|
|
42
48
|
};
|
|
43
49
|
export declare function describeSelector(selector: Selector): string;
|
|
44
50
|
export interface WaitOptions {
|
|
@@ -66,9 +72,26 @@ export declare class Locator {
|
|
|
66
72
|
readonly driver: Driver;
|
|
67
73
|
readonly selector: Selector;
|
|
68
74
|
private readonly options;
|
|
69
|
-
|
|
75
|
+
/** When set, the locator resolves to the nth match (negative counts from the end). */
|
|
76
|
+
private readonly index?;
|
|
77
|
+
constructor(driver: Driver, selector: Selector, options?: WaitOptions,
|
|
78
|
+
/** When set, the locator resolves to the nth match (negative counts from the end). */
|
|
79
|
+
index?: number | undefined);
|
|
70
80
|
private attribute;
|
|
71
|
-
|
|
81
|
+
/** Node tags this selector matches in `source`, in document order (role- or attribute-based). */
|
|
82
|
+
private nodesIn;
|
|
83
|
+
/** All node tags this selector matches in the current source, in document order. */
|
|
84
|
+
private matchedNodes;
|
|
85
|
+
/** The single node this locator resolves to (the nth match, or the first when unindexed). */
|
|
86
|
+
private pick;
|
|
87
|
+
/** A locator scoped to the nth match (0-based; negative counts from the end). */
|
|
88
|
+
nth(index: number): Locator;
|
|
89
|
+
/** A locator scoped to the first match. */
|
|
90
|
+
first(): Locator;
|
|
91
|
+
/** A locator scoped to the last match. */
|
|
92
|
+
last(): Locator;
|
|
93
|
+
/** How many elements currently match the selector. */
|
|
94
|
+
count(): Promise<number>;
|
|
72
95
|
/** True if the selector matches a node in the current source. */
|
|
73
96
|
isVisible(): Promise<boolean>;
|
|
74
97
|
/** Bounds of the matched node in the current source, or null if absent. */
|
|
@@ -87,6 +110,13 @@ export declare class Locator {
|
|
|
87
110
|
* field — it does not clear existing content first.
|
|
88
111
|
*/
|
|
89
112
|
fill(text: string, options?: WaitOptions): Promise<void>;
|
|
113
|
+
/** True if the matched node is a checked checkbox/switch (`checked="true"`). */
|
|
114
|
+
isChecked(): Promise<boolean>;
|
|
115
|
+
/** Tap to bring a checkbox/switch to checked; a no-op if it already is. */
|
|
116
|
+
check(options?: WaitOptions): Promise<void>;
|
|
117
|
+
/** Tap to bring a checkbox/switch to unchecked; a no-op if it already is. */
|
|
118
|
+
uncheck(options?: WaitOptions): Promise<void>;
|
|
119
|
+
private setChecked;
|
|
90
120
|
}
|
|
91
121
|
/** Convenience factory: `locator(driver, by.text("Submit"))`. */
|
|
92
122
|
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, nodesForRole, 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 }),
|
|
@@ -13,9 +14,12 @@ export const by = {
|
|
|
13
14
|
testId: (value) => ({ by: "testId", value }),
|
|
14
15
|
/** The accessibility label (Android `content-desc`, iOS `label`). */
|
|
15
16
|
label: (value) => ({ by: "label", value }),
|
|
17
|
+
/** A semantic role, matched by element class/type — `checkbox`, `switch`, `button`, `textfield`, `image`. */
|
|
18
|
+
role: (value) => ({ by: "role", value }),
|
|
16
19
|
};
|
|
17
20
|
export function describeSelector(selector) {
|
|
18
|
-
|
|
21
|
+
const value = selector.value instanceof RegExp ? String(selector.value) : JSON.stringify(selector.value);
|
|
22
|
+
return `by.${selector.by}(${value})`;
|
|
19
23
|
}
|
|
20
24
|
/**
|
|
21
25
|
* The page-source attribute a selector resolves to on each platform. `text` is an
|
|
@@ -24,6 +28,9 @@ export function describeSelector(selector) {
|
|
|
24
28
|
* the toolkit put it, not just the node's own `text` attribute.
|
|
25
29
|
*/
|
|
26
30
|
function attributeFor(selector, platform) {
|
|
31
|
+
if (selector.by === "role") {
|
|
32
|
+
throw new Error("role selectors match by element class/type, not a single attribute");
|
|
33
|
+
}
|
|
27
34
|
const android = {
|
|
28
35
|
text: "(?:text|content-desc)",
|
|
29
36
|
desc: "content-desc",
|
|
@@ -58,30 +65,63 @@ export class Locator {
|
|
|
58
65
|
driver;
|
|
59
66
|
selector;
|
|
60
67
|
options;
|
|
61
|
-
|
|
68
|
+
index;
|
|
69
|
+
constructor(driver, selector, options = {},
|
|
70
|
+
/** When set, the locator resolves to the nth match (negative counts from the end). */
|
|
71
|
+
index) {
|
|
62
72
|
this.driver = driver;
|
|
63
73
|
this.selector = selector;
|
|
64
74
|
this.options = options;
|
|
75
|
+
this.index = index;
|
|
65
76
|
}
|
|
66
77
|
attribute() {
|
|
67
78
|
return attributeFor(this.selector, this.driver.platform);
|
|
68
79
|
}
|
|
69
|
-
|
|
70
|
-
|
|
80
|
+
/** Node tags this selector matches in `source`, in document order (role- or attribute-based). */
|
|
81
|
+
nodesIn(source) {
|
|
82
|
+
return this.selector.by === "role"
|
|
83
|
+
? nodesForRole(source, this.selector.value, this.driver.platform)
|
|
84
|
+
: nodesForAttribute(source, this.attribute(), this.selector.value);
|
|
85
|
+
}
|
|
86
|
+
/** All node tags this selector matches in the current source, in document order. */
|
|
87
|
+
async matchedNodes() {
|
|
88
|
+
return this.nodesIn(await this.driver.source());
|
|
89
|
+
}
|
|
90
|
+
/** The single node this locator resolves to (the nth match, or the first when unindexed). */
|
|
91
|
+
pick(nodes) {
|
|
92
|
+
if (this.index === undefined)
|
|
93
|
+
return nodes[0] ?? null;
|
|
94
|
+
const at = this.index < 0 ? nodes.length + this.index : this.index;
|
|
95
|
+
return nodes[at] ?? null;
|
|
96
|
+
}
|
|
97
|
+
/** A locator scoped to the nth match (0-based; negative counts from the end). */
|
|
98
|
+
nth(index) {
|
|
99
|
+
return new Locator(this.driver, this.selector, this.options, index);
|
|
100
|
+
}
|
|
101
|
+
/** A locator scoped to the first match. */
|
|
102
|
+
first() {
|
|
103
|
+
return this.nth(0);
|
|
104
|
+
}
|
|
105
|
+
/** A locator scoped to the last match. */
|
|
106
|
+
last() {
|
|
107
|
+
return this.nth(-1);
|
|
108
|
+
}
|
|
109
|
+
/** How many elements currently match the selector. */
|
|
110
|
+
async count() {
|
|
111
|
+
return (await this.matchedNodes()).length;
|
|
71
112
|
}
|
|
72
113
|
/** True if the selector matches a node in the current source. */
|
|
73
114
|
async isVisible() {
|
|
74
|
-
return this.
|
|
115
|
+
return this.pick(await this.matchedNodes()) !== null;
|
|
75
116
|
}
|
|
76
117
|
/** Bounds of the matched node in the current source, or null if absent. */
|
|
77
118
|
async bounds() {
|
|
78
|
-
|
|
119
|
+
const node = this.pick(await this.matchedNodes());
|
|
120
|
+
return node ? parseBounds(/bounds="([^"]+)"/.exec(node)?.[1]) : null;
|
|
79
121
|
}
|
|
80
122
|
/** The matched node's own visible text, or null if the node is absent. */
|
|
81
123
|
async textContent() {
|
|
82
|
-
const
|
|
83
|
-
const selectorPattern = escapeRegExp(encodeXmlEntities(this.selector.value));
|
|
84
|
-
const node = new RegExp(`<[^>]*${this.attribute()}="${selectorPattern}"[^>]*>`).exec(source)?.[0];
|
|
124
|
+
const node = this.pick(await this.matchedNodes());
|
|
85
125
|
if (!node)
|
|
86
126
|
return null;
|
|
87
127
|
// A visible label can live in either of two attributes per platform, in the same
|
|
@@ -98,7 +138,7 @@ export class Locator {
|
|
|
98
138
|
/** True if the selector is present AND `text` appears in the source. */
|
|
99
139
|
async shows(text) {
|
|
100
140
|
const source = await this.driver.source();
|
|
101
|
-
if (
|
|
141
|
+
if (this.pick(this.nodesIn(source)) === null)
|
|
102
142
|
return false;
|
|
103
143
|
const pattern = typeof text === "string" ? new RegExp(escapeRegExp(encodeXmlEntities(text))) : text;
|
|
104
144
|
return pattern.test(source);
|
|
@@ -135,6 +175,30 @@ export class Locator {
|
|
|
135
175
|
await this.tap(options);
|
|
136
176
|
await this.driver.typeText(text);
|
|
137
177
|
}
|
|
178
|
+
/** True if the matched node is a checked checkbox/switch (`checked="true"`). */
|
|
179
|
+
async isChecked() {
|
|
180
|
+
const node = this.pick(await this.matchedNodes());
|
|
181
|
+
return node !== null && /\bchecked="true"/.test(node);
|
|
182
|
+
}
|
|
183
|
+
/** Tap to bring a checkbox/switch to checked; a no-op if it already is. */
|
|
184
|
+
async check(options = {}) {
|
|
185
|
+
await this.setChecked(true, options);
|
|
186
|
+
}
|
|
187
|
+
/** Tap to bring a checkbox/switch to unchecked; a no-op if it already is. */
|
|
188
|
+
async uncheck(options = {}) {
|
|
189
|
+
await this.setChecked(false, options);
|
|
190
|
+
}
|
|
191
|
+
async setChecked(desired, options) {
|
|
192
|
+
if ((await this.isChecked()) === desired)
|
|
193
|
+
return;
|
|
194
|
+
await this.tap(options);
|
|
195
|
+
const opts = { ...this.options, ...options, sleep: (ms) => this.driver.pause(ms) };
|
|
196
|
+
const settled = await waitUntil(() => this.isChecked(), (value) => value === desired, opts);
|
|
197
|
+
if (settled !== desired) {
|
|
198
|
+
const state = desired ? "checked" : "unchecked";
|
|
199
|
+
throw new Error(`${describeSelector(this.selector)} did not become ${state} within ${opts.timeout ?? DEFAULTS.timeout}ms`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
138
202
|
}
|
|
139
203
|
/** Convenience factory: `locator(driver, by.text("Submit"))`. */
|
|
140
204
|
export function locator(driver, selector, options = {}) {
|
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
|
-
* Match by
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* Match by role. With `{ name }`, matches the element's accessibility label (a string or RegExp) —
|
|
18
|
+
* the dependable signal on native. Without a name, matches by element class/type
|
|
19
|
+
* (`checkbox`, `switch`, `button`, `textfield`, `image`) — e.g. `getByRole("checkbox")`.
|
|
19
20
|
*/
|
|
20
|
-
getByRole(role: string, options
|
|
21
|
-
name
|
|
21
|
+
getByRole(role: string, options?: {
|
|
22
|
+
name?: string | RegExp;
|
|
22
23
|
} & WaitOptions): Locator;
|
|
23
24
|
}
|
|
24
25
|
export declare function page(driver: Driver): Page;
|
package/dist/page.js
CHANGED
|
@@ -6,12 +6,14 @@ export function page(driver) {
|
|
|
6
6
|
getByTestId: (testId, options = {}) => locator(driver, by.testId(testId), options),
|
|
7
7
|
getByLabel: (label, options = {}) => locator(driver, by.label(label), options),
|
|
8
8
|
getById: (id, options = {}) => locator(driver, by.id(id), options),
|
|
9
|
-
getByRole: (role, options) => {
|
|
9
|
+
getByRole: (role, options = {}) => {
|
|
10
10
|
const { name, ...wait } = options;
|
|
11
|
-
if (
|
|
12
|
-
throw new Error(`getByRole(${JSON.stringify(role)}
|
|
11
|
+
if (name === "") {
|
|
12
|
+
throw new Error(`getByRole(${JSON.stringify(role)}, { name: "" }) — name must be non-empty; omit it to match by role`);
|
|
13
13
|
}
|
|
14
|
-
return
|
|
14
|
+
return name !== undefined
|
|
15
|
+
? locator(driver, by.label(name), wait) // name is the dependable signal on native
|
|
16
|
+
: locator(driver, by.role(role), wait); // no name → match by element class/type
|
|
15
17
|
},
|
|
16
18
|
};
|
|
17
19
|
}
|
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
|
@@ -31,12 +31,30 @@ export declare function decodeXmlEntities(value: string): string;
|
|
|
31
31
|
* (`text="Terms & Conditions"`). `&` is encoded first to avoid double-encoding.
|
|
32
32
|
*/
|
|
33
33
|
export declare function encodeXmlEntities(value: string): string;
|
|
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
|
+
/** Roles `by.role` / `getByRole(role)` can match without a name. */
|
|
44
|
+
export declare const KNOWN_ROLES: string[];
|
|
45
|
+
/**
|
|
46
|
+
* Every element whose class (Android) / type (iOS) backs `role`, in document order.
|
|
47
|
+
* Throws on an unknown role, listing the supported set.
|
|
48
|
+
*/
|
|
49
|
+
export declare function nodesForRole(source: string, role: string, platform: "android" | "ios"): string[];
|
|
50
|
+
/** True if any element exposes `attribute` with a value matching `value` (string exact or RegExp). */
|
|
51
|
+
export declare function attributeMatches(source: string, attribute: string, value: string | RegExp): boolean;
|
|
34
52
|
/**
|
|
35
53
|
* Bounds of the first element carrying the given attribute match. The element tag is
|
|
36
54
|
* matched first, then `bounds` is extracted from within it regardless of attribute order —
|
|
37
55
|
* so a source that emits `bounds` before the selector attribute still resolves.
|
|
38
56
|
*/
|
|
39
|
-
export declare function boundsForAttribute(source: string, attribute: string, value: string): Bounds | null;
|
|
57
|
+
export declare function boundsForAttribute(source: string, attribute: string, value: string | RegExp): Bounds | null;
|
|
40
58
|
/** Bounds of an element addressed by Android `content-desc`. */
|
|
41
59
|
export declare function boundsForContentDesc(source: string, contentDesc: string): Bounds | null;
|
|
42
60
|
/** Bounds of an element addressed by visible `text`. */
|
package/dist/source.js
CHANGED
|
@@ -50,17 +50,72 @@ 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
|
+
/**
|
|
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
|
+
/**
|
|
81
|
+
* Element classes/types that back a semantic role, per platform — Android exposes a widget
|
|
82
|
+
* `class`, iOS an XCUITest `type`. Matched as a substring, so framework variants
|
|
83
|
+
* (`SwitchCompat`, `MaterialButton`, Compose's `android.widget.CheckBox`) all resolve.
|
|
84
|
+
*/
|
|
85
|
+
const ROLE_PATTERNS = {
|
|
86
|
+
checkbox: { android: "CheckBox", ios: "XCUIElementTypeSwitch" },
|
|
87
|
+
switch: { android: "Switch", ios: "XCUIElementTypeSwitch" },
|
|
88
|
+
button: { android: "Button", ios: "XCUIElementTypeButton" },
|
|
89
|
+
textfield: { android: "EditText", ios: "XCUIElementTypeTextField" },
|
|
90
|
+
image: { android: "ImageView", ios: "XCUIElementTypeImage" },
|
|
91
|
+
};
|
|
92
|
+
/** Roles `by.role` / `getByRole(role)` can match without a name. */
|
|
93
|
+
export const KNOWN_ROLES = Object.keys(ROLE_PATTERNS);
|
|
94
|
+
/**
|
|
95
|
+
* Every element whose class (Android) / type (iOS) backs `role`, in document order.
|
|
96
|
+
* Throws on an unknown role, listing the supported set.
|
|
97
|
+
*/
|
|
98
|
+
export function nodesForRole(source, role, platform) {
|
|
99
|
+
const patterns = ROLE_PATTERNS[role.toLowerCase()];
|
|
100
|
+
if (!patterns) {
|
|
101
|
+
throw new Error(`Unknown role "${role}". Known roles: ${KNOWN_ROLES.join(", ")}. Use getByLabel / getByText for arbitrary elements.`);
|
|
102
|
+
}
|
|
103
|
+
const attribute = platform === "ios" ? "type" : "class";
|
|
104
|
+
const pattern = escapeRegExp(platform === "ios" ? patterns.ios : patterns.android);
|
|
105
|
+
return [...source.matchAll(new RegExp(`<[^>]*${attribute}="[^"]*${pattern}[^"]*"[^>]*>`, "g"))].map((m) => m[0]);
|
|
106
|
+
}
|
|
107
|
+
/** True if any element exposes `attribute` with a value matching `value` (string exact or RegExp). */
|
|
108
|
+
export function attributeMatches(source, attribute, value) {
|
|
109
|
+
return nodeForAttribute(source, attribute, value) !== null;
|
|
110
|
+
}
|
|
53
111
|
/**
|
|
54
112
|
* Bounds of the first element carrying the given attribute match. The element tag is
|
|
55
113
|
* matched first, then `bounds` is extracted from within it regardless of attribute order —
|
|
56
114
|
* so a source that emits `bounds` before the selector attribute still resolves.
|
|
57
115
|
*/
|
|
58
116
|
export function boundsForAttribute(source, attribute, value) {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
if (!node)
|
|
62
|
-
return null;
|
|
63
|
-
return parseBounds(/bounds="([^"]+)"/.exec(node)?.[1]);
|
|
117
|
+
const node = nodeForAttribute(source, attribute, value);
|
|
118
|
+
return node ? parseBounds(/bounds="([^"]+)"/.exec(node)?.[1]) : null;
|
|
64
119
|
}
|
|
65
120
|
/** Bounds of an element addressed by Android `content-desc`. */
|
|
66
121
|
export function boundsForContentDesc(source, contentDesc) {
|
package/package.json
CHANGED