nativeproof 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/dist/adb.d.ts +28 -0
- package/dist/adb.js +97 -0
- package/dist/app.d.ts +49 -0
- package/dist/app.js +34 -0
- package/dist/cli.d.ts +32 -0
- package/dist/cli.js +225 -0
- package/dist/config.d.ts +70 -0
- package/dist/config.js +64 -0
- package/dist/driver.d.ts +20 -0
- package/dist/driver.js +13 -0
- package/dist/evidence.d.ts +5 -0
- package/dist/evidence.js +40 -0
- package/dist/expect.d.ts +26 -0
- package/dist/expect.js +65 -0
- package/dist/fixtures.d.ts +62 -0
- package/dist/fixtures.js +53 -0
- package/dist/gestures.d.ts +11 -0
- package/dist/gestures.js +45 -0
- package/dist/harness.d.ts +32 -0
- package/dist/harness.js +29 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +30 -0
- package/dist/ios.d.ts +39 -0
- package/dist/ios.js +92 -0
- package/dist/locator.d.ts +78 -0
- package/dist/locator.js +116 -0
- package/dist/log.d.ts +17 -0
- package/dist/log.js +25 -0
- package/dist/mock-server.d.ts +17 -0
- package/dist/mock-server.js +122 -0
- package/dist/mock.d.ts +54 -0
- package/dist/mock.js +30 -0
- package/dist/page.d.ts +24 -0
- package/dist/page.js +17 -0
- package/dist/runner-config.d.ts +1 -0
- package/dist/runner-config.js +32 -0
- package/dist/runner.d.ts +19 -0
- package/dist/runner.js +29 -0
- package/dist/screen.d.ts +35 -0
- package/dist/screen.js +67 -0
- package/dist/source.d.ts +32 -0
- package/dist/source.js +60 -0
- package/dist/test.d.ts +13 -0
- package/dist/test.js +15 -0
- package/dist/text.d.ts +18 -0
- package/dist/text.js +129 -0
- package/dist/wait.d.ts +14 -0
- package/dist/wait.js +26 -0
- package/package.json +72 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { WebSocketServer } from "ws";
|
|
3
|
+
function pathOf(rawUrl) {
|
|
4
|
+
const url = rawUrl ?? "/";
|
|
5
|
+
const query = url.indexOf("?");
|
|
6
|
+
return query === -1 ? url : url.slice(0, query);
|
|
7
|
+
}
|
|
8
|
+
function frameType(frame, fallback) {
|
|
9
|
+
return typeof frame.type === "string" ? frame.type : fallback;
|
|
10
|
+
}
|
|
11
|
+
/** Valid WebSocket application close codes are 3000-4999; anything else falls back to 4000. */
|
|
12
|
+
function wsCloseCode(code) {
|
|
13
|
+
return code >= 3000 && code <= 4999 ? code : 4000;
|
|
14
|
+
}
|
|
15
|
+
export async function startMockServer(options = {}) {
|
|
16
|
+
const host = options.host ?? "127.0.0.1";
|
|
17
|
+
const recorded = [];
|
|
18
|
+
const routes = new Map();
|
|
19
|
+
const sockets = new Set();
|
|
20
|
+
const record = (path, direction, type, payload) => {
|
|
21
|
+
recorded.push({ path, type, direction, payload });
|
|
22
|
+
};
|
|
23
|
+
const http = createServer((req, res) => {
|
|
24
|
+
const path = pathOf(req.url);
|
|
25
|
+
record(path, "sent", "request", { method: req.method ?? "GET" });
|
|
26
|
+
const action = routes.get(path);
|
|
27
|
+
if (action?.kind === "reject") {
|
|
28
|
+
res.statusCode = action.code;
|
|
29
|
+
res.end();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (action?.kind === "abort") {
|
|
33
|
+
req.destroy();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
res.setHeader("content-type", "application/json");
|
|
37
|
+
if (action?.kind === "fulfill") {
|
|
38
|
+
record(path, "received", frameType(action.frame, "response"), action.frame);
|
|
39
|
+
res.end(JSON.stringify(action.frame));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
res.end("{}");
|
|
43
|
+
});
|
|
44
|
+
const wss = new WebSocketServer({ server: http });
|
|
45
|
+
wss.on("connection", (socket, req) => {
|
|
46
|
+
const path = pathOf(req.url);
|
|
47
|
+
const entry = { socket, path };
|
|
48
|
+
sockets.add(entry);
|
|
49
|
+
record(path, "sent", "open", {});
|
|
50
|
+
const action = routes.get(path);
|
|
51
|
+
if (action?.kind === "reject") {
|
|
52
|
+
socket.close(wsCloseCode(action.code), String(action.code));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (action?.kind === "abort") {
|
|
56
|
+
socket.terminate();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
socket.on("message", (data) => {
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(data.toString());
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
parsed = { raw: data.toString() };
|
|
66
|
+
}
|
|
67
|
+
const payload = parsed && typeof parsed === "object" ? parsed : {};
|
|
68
|
+
record(path, "sent", frameType(payload, "message"), payload);
|
|
69
|
+
});
|
|
70
|
+
socket.on("close", () => {
|
|
71
|
+
sockets.delete(entry);
|
|
72
|
+
});
|
|
73
|
+
if (action?.kind === "fulfill") {
|
|
74
|
+
record(path, "received", frameType(action.frame, "message"), action.frame);
|
|
75
|
+
socket.send(JSON.stringify(action.frame));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
await new Promise((resolve) => {
|
|
79
|
+
http.listen(options.port ?? 0, host, () => resolve());
|
|
80
|
+
});
|
|
81
|
+
const address = http.address();
|
|
82
|
+
const port = typeof address === "object" && address !== null ? address.port : (options.port ?? 0);
|
|
83
|
+
return {
|
|
84
|
+
url: `http://${host}:${port}`,
|
|
85
|
+
wsUrl: `ws://${host}:${port}`,
|
|
86
|
+
port,
|
|
87
|
+
async frames() {
|
|
88
|
+
return recorded.slice();
|
|
89
|
+
},
|
|
90
|
+
route(path) {
|
|
91
|
+
return {
|
|
92
|
+
fulfill: (frame) => {
|
|
93
|
+
routes.set(path, { kind: "fulfill", frame });
|
|
94
|
+
},
|
|
95
|
+
reject: (opts) => {
|
|
96
|
+
routes.set(path, { kind: "reject", code: opts.code });
|
|
97
|
+
},
|
|
98
|
+
abort: () => {
|
|
99
|
+
routes.set(path, { kind: "abort" });
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
send(path, frame) {
|
|
104
|
+
for (const entry of sockets) {
|
|
105
|
+
if (entry.path === path) {
|
|
106
|
+
record(path, "received", frameType(frame, "message"), frame);
|
|
107
|
+
entry.socket.send(JSON.stringify(frame));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
async stop() {
|
|
112
|
+
for (const entry of sockets)
|
|
113
|
+
entry.socket.terminate();
|
|
114
|
+
wss.close();
|
|
115
|
+
// Drop any lingering keep-alive sockets so http.close() actually resolves.
|
|
116
|
+
http.closeAllConnections();
|
|
117
|
+
await new Promise((resolve) => {
|
|
118
|
+
http.close(() => resolve());
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/mock.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type WaitOptions } from "./locator.js";
|
|
2
|
+
/**
|
|
3
|
+
* Backend mocking that feels like Playwright's `page.route()`.
|
|
4
|
+
*
|
|
5
|
+
* The framework owns the *contract* — observe the frames an app exchanged, and
|
|
6
|
+
* intercept a path to control its reply — while a consuming app injects the concrete
|
|
7
|
+
* backend (a mock WebSocket/REST server). With it, `expect(mock).toHaveSent(...)`
|
|
8
|
+
* replaces hand-rolled log parsing and `mock.route("/x").reject(...)` reads like
|
|
9
|
+
* network interception. The framework imports nothing app-specific.
|
|
10
|
+
*/
|
|
11
|
+
/** Direction of a frame relative to the app under test. */
|
|
12
|
+
export type FrameDirection = "sent" | "received";
|
|
13
|
+
/** One observed protocol frame (a REST call or socket message), normalised. */
|
|
14
|
+
export interface MockFrame {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly type: string;
|
|
17
|
+
readonly direction: FrameDirection;
|
|
18
|
+
readonly payload?: Readonly<Record<string, unknown>>;
|
|
19
|
+
}
|
|
20
|
+
/** A partial frame to match against: `path` / `type` plus any payload fields. */
|
|
21
|
+
export interface FrameMatch {
|
|
22
|
+
path?: string;
|
|
23
|
+
type?: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
/** Controls how an intercepted path replies — the Playwright `Route` equivalent. */
|
|
27
|
+
export interface MockRoute {
|
|
28
|
+
/** Answer with a canned frame/body. */
|
|
29
|
+
fulfill(frame: Record<string, unknown>): void;
|
|
30
|
+
/** Reject the connect/request with an error code. */
|
|
31
|
+
reject(options: {
|
|
32
|
+
code: number;
|
|
33
|
+
}): void;
|
|
34
|
+
/** Drop the connect/request entirely. */
|
|
35
|
+
abort(): void;
|
|
36
|
+
}
|
|
37
|
+
export interface MockBackend {
|
|
38
|
+
/** Every frame observed so far, in order, both directions. */
|
|
39
|
+
frames(): Promise<readonly MockFrame[]>;
|
|
40
|
+
/** Intercept a path and control its reply. */
|
|
41
|
+
route(path: string): MockRoute;
|
|
42
|
+
/** Release the backend (stop the server, close sockets). */
|
|
43
|
+
stop(): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* True if `frame` satisfies every field of `match`: `path` / `type` at the top level,
|
|
47
|
+
* every other key against the frame's payload.
|
|
48
|
+
*/
|
|
49
|
+
export declare function matchesFrame(frame: MockFrame, match: FrameMatch): boolean;
|
|
50
|
+
export declare function describeMatch(match: FrameMatch): string;
|
|
51
|
+
/** Single-shot check: does any observed frame match `match` in `direction`? */
|
|
52
|
+
export declare function frameExists(mock: MockBackend, direction: FrameDirection, match: FrameMatch): Promise<boolean>;
|
|
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>;
|
package/dist/mock.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { waitUntil } from "./locator.js";
|
|
2
|
+
/**
|
|
3
|
+
* True if `frame` satisfies every field of `match`: `path` / `type` at the top level,
|
|
4
|
+
* every other key against the frame's payload.
|
|
5
|
+
*/
|
|
6
|
+
export function matchesFrame(frame, match) {
|
|
7
|
+
if (match.path !== undefined && frame.path !== match.path)
|
|
8
|
+
return false;
|
|
9
|
+
if (match.type !== undefined && frame.type !== match.type)
|
|
10
|
+
return false;
|
|
11
|
+
for (const [key, value] of Object.entries(match)) {
|
|
12
|
+
if (key === "path" || key === "type")
|
|
13
|
+
continue;
|
|
14
|
+
if (frame.payload?.[key] !== value)
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
export function describeMatch(match) {
|
|
20
|
+
return JSON.stringify(match);
|
|
21
|
+
}
|
|
22
|
+
/** Single-shot check: does any observed frame match `match` in `direction`? */
|
|
23
|
+
export async function frameExists(mock, direction, match) {
|
|
24
|
+
const frames = await mock.frames();
|
|
25
|
+
return frames.some((frame) => frame.direction === direction && matchesFrame(frame, match));
|
|
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
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Driver } from "./driver.js";
|
|
2
|
+
import { type Locator, type Selector, type WaitOptions } from "./locator.js";
|
|
3
|
+
/**
|
|
4
|
+
* Playwright-style `getBy*` ergonomics over a {@link Driver}, so selectors are
|
|
5
|
+
* discovered, not inferred. `page(driver).getByText("Submit")` returns a {@link Locator}
|
|
6
|
+
* and you never spell out which native attribute backs it.
|
|
7
|
+
*/
|
|
8
|
+
export interface Page {
|
|
9
|
+
/** Escape hatch: a locator from a raw selector. */
|
|
10
|
+
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;
|
|
15
|
+
/**
|
|
16
|
+
* Match by accessible name. Native accessibility trees don't expose web-style roles
|
|
17
|
+
* reliably, so `role` is advisory and the dependable signal is the name (the element's
|
|
18
|
+
* accessibility label). Pass `{ name }`.
|
|
19
|
+
*/
|
|
20
|
+
getByRole(role: string, options: {
|
|
21
|
+
name: string;
|
|
22
|
+
} & WaitOptions): Locator;
|
|
23
|
+
}
|
|
24
|
+
export declare function page(driver: Driver): Page;
|
package/dist/page.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { by, locator } from "./locator.js";
|
|
2
|
+
export function page(driver) {
|
|
3
|
+
return {
|
|
4
|
+
locator: (selector, options = {}) => locator(driver, selector, options),
|
|
5
|
+
getByText: (text, options = {}) => locator(driver, by.text(text), options),
|
|
6
|
+
getByTestId: (testId, options = {}) => locator(driver, by.testId(testId), options),
|
|
7
|
+
getByLabel: (label, options = {}) => locator(driver, by.label(label), options),
|
|
8
|
+
getById: (id, options = {}) => locator(driver, by.id(id), options),
|
|
9
|
+
getByRole: (role, options) => {
|
|
10
|
+
const { name, ...wait } = options;
|
|
11
|
+
if (!name) {
|
|
12
|
+
throw new Error(`getByRole(${JSON.stringify(role)}) needs { name } on native — it matches the accessible label`);
|
|
13
|
+
}
|
|
14
|
+
return locator(driver, by.label(name), wait);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const config: Record<string, unknown>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { buildWdioConfig } from "./config.js";
|
|
3
|
+
/**
|
|
4
|
+
* The bridge the `nativeproof` CLI hands to WebdriverIO: it loads the user's
|
|
5
|
+
* `nativeproof.config.ts` (path in `NATIVEPROOF_CONFIG`) and exports the synthesised `config`.
|
|
6
|
+
* The CLI runs `wdio run <this file>` with `--import tsx`, so the TS config loads.
|
|
7
|
+
* Not part of the public API — it is the runner entry point.
|
|
8
|
+
*/
|
|
9
|
+
const configPath = process.env.NATIVEPROOF_CONFIG;
|
|
10
|
+
if (!configPath) {
|
|
11
|
+
throw new Error("NATIVEPROOF_CONFIG is not set — run NativeProof through the `nativeproof` CLI");
|
|
12
|
+
}
|
|
13
|
+
const loaded = (await import(pathToFileURL(configPath).href));
|
|
14
|
+
const userConfig = loaded.default;
|
|
15
|
+
if (!userConfig) {
|
|
16
|
+
throw new Error(`${configPath} must \`export default defineConfig(...)\``);
|
|
17
|
+
}
|
|
18
|
+
const env = {};
|
|
19
|
+
const { PLATFORM, NATIVEPROOF_PROJECT, SPEC, APPIUM_HOST, APPIUM_PORT, APPIUM_PATH } = process.env;
|
|
20
|
+
if (PLATFORM)
|
|
21
|
+
env.platform = PLATFORM;
|
|
22
|
+
if (NATIVEPROOF_PROJECT)
|
|
23
|
+
env.project = NATIVEPROOF_PROJECT;
|
|
24
|
+
if (SPEC)
|
|
25
|
+
env.spec = SPEC;
|
|
26
|
+
if (APPIUM_HOST)
|
|
27
|
+
env.appiumHost = APPIUM_HOST;
|
|
28
|
+
if (APPIUM_PORT)
|
|
29
|
+
env.appiumPort = Number(APPIUM_PORT);
|
|
30
|
+
if (APPIUM_PATH)
|
|
31
|
+
env.appiumPath = APPIUM_PATH;
|
|
32
|
+
export const config = buildWdioConfig(userConfig, env);
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
export interface BddHooks {
|
|
11
|
+
describe(title: string, fn: () => void): void;
|
|
12
|
+
before(fn: () => void | Promise<void>): void;
|
|
13
|
+
after(fn: () => void | Promise<void>): void;
|
|
14
|
+
it(title: string, fn: () => void | Promise<void>): void;
|
|
15
|
+
}
|
|
16
|
+
/** Wire an explicit BDD runner (e.g. node:test's hooks). Overrides global detection. */
|
|
17
|
+
export declare function useRunner(hooks: BddHooks): void;
|
|
18
|
+
/** The active BDD runner: the one set via {@link useRunner}, else the ambient globals. */
|
|
19
|
+
export declare function runner(): BddHooks;
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
let configured = null;
|
|
2
|
+
/** Wire an explicit BDD runner (e.g. node:test's hooks). Overrides global detection. */
|
|
3
|
+
export function useRunner(hooks) {
|
|
4
|
+
configured = hooks;
|
|
5
|
+
}
|
|
6
|
+
function fromGlobals() {
|
|
7
|
+
const globals = globalThis;
|
|
8
|
+
const { describe, before, after, it } = globals;
|
|
9
|
+
if (typeof describe === "function" &&
|
|
10
|
+
typeof before === "function" &&
|
|
11
|
+
typeof after === "function" &&
|
|
12
|
+
typeof it === "function") {
|
|
13
|
+
return {
|
|
14
|
+
describe: describe,
|
|
15
|
+
before: before,
|
|
16
|
+
after: after,
|
|
17
|
+
it: it,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/** The active BDD runner: the one set via {@link useRunner}, else the ambient globals. */
|
|
23
|
+
export function runner() {
|
|
24
|
+
const hooks = configured ?? fromGlobals();
|
|
25
|
+
if (!hooks) {
|
|
26
|
+
throw new Error("No BDD runner found. Run under Mocha (WebdriverIO's default) or call useRunner({ describe, before, after, it }) — e.g. with node:test's hooks.");
|
|
27
|
+
}
|
|
28
|
+
return hooks;
|
|
29
|
+
}
|
package/dist/screen.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
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/source.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-source geometry helpers.
|
|
3
|
+
*
|
|
4
|
+
* Android UiAutomator and iOS XCUITest both expose an XML page source with
|
|
5
|
+
* `bounds="[x1,y1][x2,y2]"` attributes. When semantic selectors are unavailable
|
|
6
|
+
* or unreliable, the screens parse those bounds to compute a tap point. Keeping
|
|
7
|
+
* this parsing in one framework module is what makes the brittle-selector problem
|
|
8
|
+
* a single, replaceable seam rather than scattered regexes.
|
|
9
|
+
*/
|
|
10
|
+
export type Bounds = {
|
|
11
|
+
x1: number;
|
|
12
|
+
y1: number;
|
|
13
|
+
x2: number;
|
|
14
|
+
y2: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
centerX: number;
|
|
18
|
+
centerY: number;
|
|
19
|
+
};
|
|
20
|
+
export declare function parseBounds(bounds: string | undefined | null): Bounds | null;
|
|
21
|
+
/** Bounds of the first element whose `bounds` follows the given attribute match. */
|
|
22
|
+
export declare function boundsForAttribute(source: string, attribute: string, value: string): Bounds | null;
|
|
23
|
+
/** Bounds of an element addressed by Android `content-desc`. */
|
|
24
|
+
export declare function boundsForContentDesc(source: string, contentDesc: string): Bounds | null;
|
|
25
|
+
/** Bounds of an element addressed by visible `text`. */
|
|
26
|
+
export declare function boundsForText(source: string, text: string): Bounds | null;
|
|
27
|
+
/**
|
|
28
|
+
* The smallest clickable ancestor that fully contains the given text node — used
|
|
29
|
+
* to turn a non-clickable Compose `TextView` into a reliable tap target (e.g. the
|
|
30
|
+
* "Members (3)" list rows).
|
|
31
|
+
*/
|
|
32
|
+
export declare function clickableAncestorBoundsForText(source: string, text: string): Bounds | null;
|
package/dist/source.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-source geometry helpers.
|
|
3
|
+
*
|
|
4
|
+
* Android UiAutomator and iOS XCUITest both expose an XML page source with
|
|
5
|
+
* `bounds="[x1,y1][x2,y2]"` attributes. When semantic selectors are unavailable
|
|
6
|
+
* or unreliable, the screens parse those bounds to compute a tap point. Keeping
|
|
7
|
+
* this parsing in one framework module is what makes the brittle-selector problem
|
|
8
|
+
* a single, replaceable seam rather than scattered regexes.
|
|
9
|
+
*/
|
|
10
|
+
export function parseBounds(bounds) {
|
|
11
|
+
const match = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(bounds ?? "");
|
|
12
|
+
if (!match)
|
|
13
|
+
return null;
|
|
14
|
+
const x1 = Number(match[1]);
|
|
15
|
+
const y1 = Number(match[2]);
|
|
16
|
+
const x2 = Number(match[3]);
|
|
17
|
+
const y2 = Number(match[4]);
|
|
18
|
+
return {
|
|
19
|
+
x1,
|
|
20
|
+
y1,
|
|
21
|
+
x2,
|
|
22
|
+
y2,
|
|
23
|
+
width: x2 - x1,
|
|
24
|
+
height: y2 - y1,
|
|
25
|
+
centerX: Math.round((x1 + x2) / 2),
|
|
26
|
+
centerY: Math.round((y1 + y2) / 2),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function escapeRegExp(value) {
|
|
30
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
31
|
+
}
|
|
32
|
+
/** Bounds of the first element whose `bounds` follows the given attribute match. */
|
|
33
|
+
export function boundsForAttribute(source, attribute, value) {
|
|
34
|
+
const match = new RegExp(`${attribute}="${escapeRegExp(value)}"[^>]*bounds="([^"]+)"`).exec(source);
|
|
35
|
+
return match ? parseBounds(match[1]) : null;
|
|
36
|
+
}
|
|
37
|
+
/** Bounds of an element addressed by Android `content-desc`. */
|
|
38
|
+
export function boundsForContentDesc(source, contentDesc) {
|
|
39
|
+
return boundsForAttribute(source, "content-desc", contentDesc);
|
|
40
|
+
}
|
|
41
|
+
/** Bounds of an element addressed by visible `text`. */
|
|
42
|
+
export function boundsForText(source, text) {
|
|
43
|
+
return boundsForAttribute(source, "text", text);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The smallest clickable ancestor that fully contains the given text node — used
|
|
47
|
+
* to turn a non-clickable Compose `TextView` into a reliable tap target (e.g. the
|
|
48
|
+
* "Members (3)" list rows).
|
|
49
|
+
*/
|
|
50
|
+
export function clickableAncestorBoundsForText(source, text) {
|
|
51
|
+
const textBounds = boundsForText(source, text);
|
|
52
|
+
if (!textBounds)
|
|
53
|
+
return null;
|
|
54
|
+
const clickable = [...source.matchAll(/clickable="true"[^>]*bounds="([^"]+)"/g)]
|
|
55
|
+
.map((m) => parseBounds(m[1]))
|
|
56
|
+
.filter((b) => b !== null)
|
|
57
|
+
.filter((b) => b.x1 <= textBounds.x1 && b.x2 >= textBounds.x2 && b.y1 <= textBounds.y1 && b.y2 >= textBounds.y2)
|
|
58
|
+
.sort((a, b) => a.width * a.height - b.width * b.height);
|
|
59
|
+
return clickable[0] ?? textBounds;
|
|
60
|
+
}
|
package/dist/test.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type BehaviourRegistrar, type ScenarioFixture } from "./fixtures.js";
|
|
2
|
+
/**
|
|
3
|
+
* The Playwright-flavoured test surface.
|
|
4
|
+
*
|
|
5
|
+
* `test.describe(title, scenario, (test) => { test("…", async ({ … }) => …) })` binds a
|
|
6
|
+
* scenario's fixture context to a block; each `test(name, body)` is one behaviour with
|
|
7
|
+
* that context injected — fully typed, with no setup/teardown in the spec. The registrar
|
|
8
|
+
* is passed into the block (rather than being a module global) so the fixture context
|
|
9
|
+
* types flow into every body; otherwise it reads like Playwright.
|
|
10
|
+
*/
|
|
11
|
+
export declare const test: {
|
|
12
|
+
describe<Ctx>(title: string, scenario: ScenarioFixture<Ctx>, define: (test: BehaviourRegistrar<Ctx>) => void): void;
|
|
13
|
+
};
|
package/dist/test.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describeScenario } from "./fixtures.js";
|
|
2
|
+
/**
|
|
3
|
+
* The Playwright-flavoured test surface.
|
|
4
|
+
*
|
|
5
|
+
* `test.describe(title, scenario, (test) => { test("…", async ({ … }) => …) })` binds a
|
|
6
|
+
* scenario's fixture context to a block; each `test(name, body)` is one behaviour with
|
|
7
|
+
* that context injected — fully typed, with no setup/teardown in the spec. The registrar
|
|
8
|
+
* is passed into the block (rather than being a module global) so the fixture context
|
|
9
|
+
* types flow into every body; otherwise it reads like Playwright.
|
|
10
|
+
*/
|
|
11
|
+
export const test = {
|
|
12
|
+
describe(title, scenario, define) {
|
|
13
|
+
describeScenario(title, scenario, define);
|
|
14
|
+
},
|
|
15
|
+
};
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
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>;
|