@nativedesktop/test 0.4.1 → 0.4.3
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/package.json +3 -3
- package/src/attach.ts +157 -0
- package/src/errors.ts +121 -0
- package/src/expect.ts +274 -0
- package/src/fixtures/gestures-tree.json +291 -0
- package/src/index.ts +40 -2
- package/src/keyboard.ts +143 -0
- package/src/launch.ts +95 -1
- package/src/locator.test.ts +306 -0
- package/src/locator.ts +539 -0
- package/src/matcher.test.ts +83 -0
- package/src/matcher.ts +179 -0
- package/src/selectors.test.ts +93 -0
- package/src/selectors.ts +229 -0
- package/src/snapshot.ts +72 -0
- package/src/socket.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nativedesktop/test",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/FormalSnake/NativeDesktop#readme",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
".": "./src/index.ts"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@nativedesktop/host": "0.4.
|
|
26
|
-
"@nativedesktop/react": "0.4.
|
|
25
|
+
"@nativedesktop/host": "0.4.3",
|
|
26
|
+
"@nativedesktop/react": "0.4.3"
|
|
27
27
|
}
|
|
28
28
|
}
|
package/src/attach.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Attach to a host somebody else launched (ND_AUTOMATION_SOCKET), with the
|
|
2
|
+
// same locator surface AppHandle carries.
|
|
3
|
+
//
|
|
4
|
+
// The acceptance gates start the host from bash and hand the socket path to a
|
|
5
|
+
// drive script, so those scripts never own a process. This is the half of
|
|
6
|
+
// AppHandle that does not need one: the tree, the host-side waits, the
|
|
7
|
+
// locators, and the keyboard and mouse.
|
|
8
|
+
import type {
|
|
9
|
+
GetTreeResult,
|
|
10
|
+
ScreenshotResult,
|
|
11
|
+
SetWindowFrameParams,
|
|
12
|
+
WaitCondition,
|
|
13
|
+
WaitForResult,
|
|
14
|
+
WindowInfo,
|
|
15
|
+
WindowsResult,
|
|
16
|
+
} from "@nativedesktop/react/rpc";
|
|
17
|
+
import { AutomationClient } from "./socket.ts";
|
|
18
|
+
import { Locator, LocatorFactory, type LocatorClient, type RoleOptions, type TextOptions } from "./locator.ts";
|
|
19
|
+
import type { Keyboard, Mouse } from "./keyboard.ts";
|
|
20
|
+
import { renderWaitValue, type WaitOpts } from "./wait.ts";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
|
|
23
|
+
|
|
24
|
+
export class AttachedApp implements LocatorClient {
|
|
25
|
+
/** Deadline every locator action, reader and expect matcher inherits. */
|
|
26
|
+
actionTimeout = DEFAULT_ACTION_TIMEOUT_MS;
|
|
27
|
+
|
|
28
|
+
private readonly factory: LocatorFactory;
|
|
29
|
+
|
|
30
|
+
private constructor(readonly rpc: AutomationClient) {
|
|
31
|
+
this.factory = new LocatorFactory(this);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Connects to `path`, defaulting to ND_AUTOMATION_SOCKET. */
|
|
35
|
+
static async connect(path = process.env.ND_AUTOMATION_SOCKET): Promise<AttachedApp> {
|
|
36
|
+
return new AttachedApp(await AutomationClient.connect(path));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
tree(window?: number): Promise<GetTreeResult> {
|
|
40
|
+
return this.rpc.call("getTree", { window });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Untyped RPC entry point the Locator layer dispatches through, so one
|
|
44
|
+
* call site serves every action method. */
|
|
45
|
+
callRpc(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
|
46
|
+
return this.rpc.call(method as "click", params as never);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
windows(): Promise<WindowsResult> {
|
|
50
|
+
return this.rpc.call("windows");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The host's in-process render. Unlike AppHandle's, there is no
|
|
54
|
+
* ScreenCaptureKit rung here: this process does not know the host's pid. */
|
|
55
|
+
screenshot(path: string, opts: { window?: number } = {}): Promise<ScreenshotResult> {
|
|
56
|
+
return this.rpc.call("screenshot", { path, window: opts.window });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
isAlive(): boolean {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Resizes a window in logical units, keeping its origin. The answer is
|
|
64
|
+
* that window's updated WindowInfo, whose `geometry` is the same w/h
|
|
65
|
+
* Geometry a node carries. */
|
|
66
|
+
setWindowSize(width: number, height: number, opts: { window?: number } = {}): Promise<WindowInfo> {
|
|
67
|
+
return this.rpc.call("setWindowFrame", { window: opts.window, width, height });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Moves and/or resizes a window; omitted components keep their current
|
|
71
|
+
* value. GTK ignores x/y (client-side placement is not a Wayland
|
|
72
|
+
* capability). */
|
|
73
|
+
setWindowFrame(frame: SetWindowFrameParams): Promise<WindowInfo> {
|
|
74
|
+
return this.rpc.call("setWindowFrame", frame);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- host-side waits -------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
waitFor(condition: WaitCondition, opts: WaitOpts = {}): Promise<WaitForResult> {
|
|
80
|
+
return this.rpc.call("waitFor", { condition, timeoutMs: opts.timeoutMs, window: opts.window });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
waitForText(text: string, opts?: WaitOpts): Promise<WaitForResult> {
|
|
84
|
+
return this.waitFor({ textContains: text }, opts);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
waitForPresent(testId: string, opts?: WaitOpts): Promise<WaitForResult> {
|
|
88
|
+
return this.waitFor({ testId, state: "present" }, opts);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
waitForGone(testId: string, opts?: WaitOpts): Promise<WaitForResult> {
|
|
92
|
+
return this.waitFor({ testId, state: "gone" }, opts);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
waitForValue(testId: string, value: string | number | boolean, opts: WaitOpts = {}): Promise<WaitForResult> {
|
|
96
|
+
return this.waitFor({ testId, valueEquals: renderWaitValue(value) }, opts);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- locators --------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
get keyboard(): Keyboard {
|
|
102
|
+
return this.factory.keyboard;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get mouse(): Mouse {
|
|
106
|
+
return this.factory.mouse;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
locator(selector: string): Locator {
|
|
110
|
+
return this.factory.locator(selector);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getByTestId(testId: string): Locator {
|
|
114
|
+
return this.factory.getByTestId(testId);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getByRole(role: string, opts?: RoleOptions): Locator {
|
|
118
|
+
return this.factory.getByRole(role, opts);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
getByText(text: string | RegExp, opts?: TextOptions): Locator {
|
|
122
|
+
return this.factory.getByText(text, opts);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
getByLabel(text: string | RegExp, opts?: TextOptions): Locator {
|
|
126
|
+
return this.factory.getByLabel(text, opts);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
getByPlaceholder(text: string | RegExp, opts?: TextOptions): Locator {
|
|
130
|
+
return this.factory.getByPlaceholder(text, opts);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A locator factory bound to one window, by index in `windows()` order or
|
|
134
|
+
* by a substring of its title. */
|
|
135
|
+
async window(titleOrIndex: string | number): Promise<LocatorFactory> {
|
|
136
|
+
const { windows } = await this.windows();
|
|
137
|
+
const info =
|
|
138
|
+
typeof titleOrIndex === "number"
|
|
139
|
+
? windows[titleOrIndex]
|
|
140
|
+
: windows.find((w) => (w.title ?? "").includes(titleOrIndex));
|
|
141
|
+
if (!info) {
|
|
142
|
+
const known = windows.map((w) => JSON.stringify(w.title ?? "")).join(", ") || "(none)";
|
|
143
|
+
throw new Error(`window(${JSON.stringify(titleOrIndex)}): no such window. Open windows: ${known}`);
|
|
144
|
+
}
|
|
145
|
+
return new LocatorFactory(this, info.ref);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
close(): Promise<void> {
|
|
149
|
+
this.rpc.close();
|
|
150
|
+
return Promise.resolve();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Shorthand for AttachedApp.connect(). */
|
|
155
|
+
export function connectApp(path?: string): Promise<AttachedApp> {
|
|
156
|
+
return AttachedApp.connect(path);
|
|
157
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Locator failures, rendered so the next step is obvious from the message
|
|
2
|
+
// alone: what was waited for, how many nodes answered, and which nodes in the
|
|
3
|
+
// window came closest.
|
|
4
|
+
import { hasRealSize, nodeName, renderValue, type NdNode } from "./matcher.ts";
|
|
5
|
+
|
|
6
|
+
export class LocatorError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "LocatorError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** More than one node matched a selector used for a single-target action.
|
|
14
|
+
* Never retried: a second element is a selector bug, not a timing one. */
|
|
15
|
+
export class StrictModeError extends LocatorError {
|
|
16
|
+
constructor(
|
|
17
|
+
readonly selector: string,
|
|
18
|
+
readonly matches: NdNode[],
|
|
19
|
+
windowLabel: string,
|
|
20
|
+
) {
|
|
21
|
+
super(
|
|
22
|
+
`strict mode violation: ${selector} resolved to ${matches.length} elements in window ${windowLabel}.\n` +
|
|
23
|
+
`${renderCandidates(matches, matches.length)}\n` +
|
|
24
|
+
`Narrow it with .first(), .nth(i), or .filter({ hasText: ... }).`,
|
|
25
|
+
);
|
|
26
|
+
this.name = "StrictModeError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class TimeoutError extends LocatorError {
|
|
31
|
+
constructor(
|
|
32
|
+
readonly call: string,
|
|
33
|
+
readonly timeoutMs: number,
|
|
34
|
+
readonly callLog: string[],
|
|
35
|
+
candidates: NdNode[],
|
|
36
|
+
windowLabel: string,
|
|
37
|
+
extra?: string,
|
|
38
|
+
) {
|
|
39
|
+
const lines = [`${call}: Timeout ${timeoutMs}ms exceeded.`];
|
|
40
|
+
if (extra) lines.push(extra);
|
|
41
|
+
lines.push("Call log:");
|
|
42
|
+
for (const entry of callLog) lines.push(` - ${entry}`);
|
|
43
|
+
if (candidates.length) {
|
|
44
|
+
lines.push(`Nearest candidates in window ${windowLabel} (${candidates.length}):`);
|
|
45
|
+
lines.push(renderCandidates(candidates, MAX_CANDIDATES));
|
|
46
|
+
}
|
|
47
|
+
super(lines.join("\n"));
|
|
48
|
+
this.name = "TimeoutError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const MAX_CANDIDATES = 5;
|
|
53
|
+
|
|
54
|
+
function quote(s: string): string {
|
|
55
|
+
return JSON.stringify(s.length > 40 ? `${s.slice(0, 39)}…` : s);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One candidate line: type, role, name, testID, state flags, rectangle. */
|
|
59
|
+
export function describeNode(node: NdNode): string {
|
|
60
|
+
const bits = [node.type.padEnd(14), `role=${node.role ?? "-"}`.padEnd(18)];
|
|
61
|
+
const name = nodeName(node);
|
|
62
|
+
if (name) bits.push(`text=${quote(name)}`);
|
|
63
|
+
if (node.testID) bits.push(`testID=${node.testID}`);
|
|
64
|
+
const value = renderValue(node.value);
|
|
65
|
+
if (value && value !== name) bits.push(`value=${quote(value)}`);
|
|
66
|
+
bits.push([node.visible ? "visible" : "hidden", node.enabled ? "enabled" : "disabled"].join(" "));
|
|
67
|
+
const g = node.geometry;
|
|
68
|
+
bits.push(g ? `(${g.x},${g.y} ${g.w}x${g.h})` : "(no geometry)");
|
|
69
|
+
return ` ${bits.join(" ")}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function renderCandidates(nodes: NdNode[], limit: number): string {
|
|
73
|
+
const shown = nodes.slice(0, limit).map(describeNode);
|
|
74
|
+
if (nodes.length > limit) shown.push(` ... and ${nodes.length - limit} more`);
|
|
75
|
+
return shown.join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Levenshtein distance, capped at the longer string's length. Only used to
|
|
79
|
+
* order a handful of candidates, so the plain two-row implementation is
|
|
80
|
+
* cheaper than anything smarter. */
|
|
81
|
+
export function distance(a: string, b: string): number {
|
|
82
|
+
if (a === b) return 0;
|
|
83
|
+
if (!a.length || !b.length) return Math.max(a.length, b.length);
|
|
84
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
85
|
+
for (let i = 1; i <= a.length; i++) {
|
|
86
|
+
const row = [i];
|
|
87
|
+
for (let j = 1; j <= b.length; j++) {
|
|
88
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
89
|
+
row[j] = Math.min(row[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost);
|
|
90
|
+
}
|
|
91
|
+
prev = row;
|
|
92
|
+
}
|
|
93
|
+
return prev[b.length]!;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Orders every identifiable node in the tree by how close its testID or name
|
|
98
|
+
* is to what the selector asked for. Actionable nodes win ties, so the first
|
|
99
|
+
* suggestion is one a click would actually reach.
|
|
100
|
+
*/
|
|
101
|
+
export function rankCandidates(nodes: NdNode[], intended: string | undefined, limit = MAX_CANDIDATES): NdNode[] {
|
|
102
|
+
const identifiable = nodes.filter((n) => n.testID || nodeName(n) || n.role);
|
|
103
|
+
const want = (intended ?? "").toLowerCase();
|
|
104
|
+
const score = (node: NdNode): number => {
|
|
105
|
+
if (!want) return node.testID ? 0 : 1;
|
|
106
|
+
const keys = [node.testID ?? "", nodeName(node), node.role ?? "", node.type];
|
|
107
|
+
let best = Number.POSITIVE_INFINITY;
|
|
108
|
+
for (const key of keys) {
|
|
109
|
+
const lower = key.toLowerCase();
|
|
110
|
+
if (!lower) continue;
|
|
111
|
+
const d = lower.includes(want) || want.includes(lower) ? 0 : distance(lower, want);
|
|
112
|
+
if (d < best) best = d;
|
|
113
|
+
}
|
|
114
|
+
return best;
|
|
115
|
+
};
|
|
116
|
+
return identifiable
|
|
117
|
+
.map((node) => ({ node, score: score(node), actionable: node.visible && node.enabled && hasRealSize(node) }))
|
|
118
|
+
.sort((a, b) => a.score - b.score || Number(b.actionable) - Number(a.actionable))
|
|
119
|
+
.slice(0, limit)
|
|
120
|
+
.map((e) => e.node);
|
|
121
|
+
}
|
package/src/expect.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Polling assertions over a Locator, plus a small non-polling `expect` for
|
|
2
|
+
// plain values so a drive script needs only one assertion vocabulary.
|
|
3
|
+
//
|
|
4
|
+
// A locator matcher re-reads the tree every 100ms until it passes or the
|
|
5
|
+
// deadline runs out, so nothing in a drive has to sleep before asserting.
|
|
6
|
+
// `.not` inverts the predicate and keeps the polling: `.not.toBeVisible()`
|
|
7
|
+
// waits for the widget to go away rather than failing on the first read.
|
|
8
|
+
import { allNodes, matchText, nodeChecked, nodeName, nodePlaceholder, nodeText, renderValue, type NdNode } from "./matcher.ts";
|
|
9
|
+
import { rankCandidates, StrictModeError, TimeoutError } from "./errors.ts";
|
|
10
|
+
import { intendedName } from "./selectors.ts";
|
|
11
|
+
import { Locator } from "./locator.ts";
|
|
12
|
+
|
|
13
|
+
const POLL_MS = 100;
|
|
14
|
+
|
|
15
|
+
export interface ExpectOptions {
|
|
16
|
+
timeout?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Verdict {
|
|
20
|
+
pass: boolean;
|
|
21
|
+
actual: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sleep(ms: number): Promise<void> {
|
|
25
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function describe(expected: unknown): string {
|
|
29
|
+
if (expected instanceof RegExp) return String(expected);
|
|
30
|
+
return typeof expected === "string" ? JSON.stringify(expected) : String(expected);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function textMatches(expected: string | RegExp, actual: string, exact: boolean): boolean {
|
|
34
|
+
return matchText(expected instanceof RegExp ? { regex: expected } : { value: expected, exact }, actual);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class LocatorAssertions {
|
|
38
|
+
constructor(
|
|
39
|
+
private readonly locator: Locator,
|
|
40
|
+
private readonly negated = false,
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
get not(): LocatorAssertions {
|
|
44
|
+
return new LocatorAssertions(this.locator, !this.negated);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private async run(
|
|
48
|
+
name: string,
|
|
49
|
+
expected: string,
|
|
50
|
+
strict: boolean,
|
|
51
|
+
evaluate: (matches: NdNode[]) => Verdict,
|
|
52
|
+
opts: ExpectOptions,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const timeout = opts.timeout ?? this.locator.client.actionTimeout;
|
|
55
|
+
const deadline = Date.now() + timeout;
|
|
56
|
+
let root: NdNode | null = null;
|
|
57
|
+
let count = 0;
|
|
58
|
+
let actual = "";
|
|
59
|
+
for (;;) {
|
|
60
|
+
const snap = await this.locator.snapshot();
|
|
61
|
+
root = snap.root;
|
|
62
|
+
count = snap.matches.length;
|
|
63
|
+
if (strict && count > 1) {
|
|
64
|
+
throw new StrictModeError(this.locator.selector, snap.matches, root?.text ? JSON.stringify(root.text) : "?");
|
|
65
|
+
}
|
|
66
|
+
const verdict = evaluate(snap.matches);
|
|
67
|
+
actual = verdict.actual;
|
|
68
|
+
if (verdict.pass !== this.negated) return;
|
|
69
|
+
if (Date.now() >= deadline) break;
|
|
70
|
+
await sleep(POLL_MS);
|
|
71
|
+
}
|
|
72
|
+
const call = `expect(${this.locator.selector})${this.negated ? ".not" : ""}.${name}`;
|
|
73
|
+
const extra = `Expected: ${this.negated ? "not " : ""}${expected}\nReceived: ${actual}`;
|
|
74
|
+
const log = [`waiting for ${this.locator.selector}`, `resolved ${count} element${count === 1 ? "" : "s"}`];
|
|
75
|
+
const candidates = count === 0 && root ? rankCandidates(allNodes(root), intendedName(this.locator.parts)) : [];
|
|
76
|
+
throw new TimeoutError(call, timeout, log, candidates, root?.text ? JSON.stringify(root.text) : "?", extra);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
toBeVisible(opts: ExpectOptions = {}): Promise<void> {
|
|
80
|
+
return this.run("toBeVisible", "visible", true, (m) => ({
|
|
81
|
+
pass: m.length > 0 && m[0]!.visible,
|
|
82
|
+
actual: m.length ? (m[0]!.visible ? "visible" : "hidden") : "not attached",
|
|
83
|
+
}), opts);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
toBeHidden(opts: ExpectOptions = {}): Promise<void> {
|
|
87
|
+
return this.run("toBeHidden", "hidden", true, (m) => ({
|
|
88
|
+
pass: m.length === 0 || !m[0]!.visible,
|
|
89
|
+
actual: m.length ? (m[0]!.visible ? "visible" : "hidden") : "not attached",
|
|
90
|
+
}), opts);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
toBeAttached(opts: ExpectOptions = {}): Promise<void> {
|
|
94
|
+
return this.run("toBeAttached", "attached", false, (m) => ({
|
|
95
|
+
pass: m.length > 0,
|
|
96
|
+
actual: `${m.length} element${m.length === 1 ? "" : "s"}`,
|
|
97
|
+
}), opts);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
toBeEnabled(opts: ExpectOptions = {}): Promise<void> {
|
|
101
|
+
return this.run("toBeEnabled", "enabled", true, (m) => ({
|
|
102
|
+
pass: m.length > 0 && m[0]!.enabled,
|
|
103
|
+
actual: m.length ? (m[0]!.enabled ? "enabled" : "disabled") : "not attached",
|
|
104
|
+
}), opts);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
toBeDisabled(opts: ExpectOptions = {}): Promise<void> {
|
|
108
|
+
return this.run("toBeDisabled", "disabled", true, (m) => ({
|
|
109
|
+
pass: m.length > 0 && !m[0]!.enabled,
|
|
110
|
+
actual: m.length ? (m[0]!.enabled ? "enabled" : "disabled") : "not attached",
|
|
111
|
+
}), opts);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
toBeChecked(opts: ExpectOptions & { checked?: boolean } = {}): Promise<void> {
|
|
115
|
+
const want = opts.checked ?? true;
|
|
116
|
+
return this.run("toBeChecked", String(want), true, (m) => ({
|
|
117
|
+
pass: m.length > 0 && nodeChecked(m[0]!) === want,
|
|
118
|
+
actual: m.length ? String(nodeChecked(m[0]!)) : "not attached",
|
|
119
|
+
}), opts);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
toBeFocused(opts: ExpectOptions = {}): Promise<void> {
|
|
123
|
+
return this.run("toBeFocused", "focused", true, (m) => ({
|
|
124
|
+
pass: m.length > 0 && m[0]!.focused,
|
|
125
|
+
actual: m.length ? (m[0]!.focused ? "focused" : "not focused") : "not attached",
|
|
126
|
+
}), opts);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
toHaveText(expected: string | RegExp, opts: ExpectOptions = {}): Promise<void> {
|
|
130
|
+
return this.run("toHaveText", describe(expected), true, (m) => ({
|
|
131
|
+
pass: m.length > 0 && textMatches(expected, nodeText(m[0]!), true),
|
|
132
|
+
actual: m.length ? JSON.stringify(nodeText(m[0]!)) : "not attached",
|
|
133
|
+
}), opts);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
toContainText(expected: string | RegExp, opts: ExpectOptions = {}): Promise<void> {
|
|
137
|
+
return this.run("toContainText", describe(expected), true, (m) => ({
|
|
138
|
+
pass: m.length > 0 && textMatches(expected, nodeText(m[0]!), false),
|
|
139
|
+
actual: m.length ? JSON.stringify(nodeText(m[0]!)) : "not attached",
|
|
140
|
+
}), opts);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
toHaveValue(expected: string | number | boolean | RegExp, opts: ExpectOptions = {}): Promise<void> {
|
|
144
|
+
const want = expected instanceof RegExp ? expected : renderValue(expected);
|
|
145
|
+
return this.run("toHaveValue", describe(want), true, (m) => ({
|
|
146
|
+
pass: m.length > 0 && textMatches(want, renderValue(m[0]!.value), true),
|
|
147
|
+
actual: m.length ? JSON.stringify(renderValue(m[0]!.value)) : "not attached",
|
|
148
|
+
}), opts);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
toHaveCount(expected: number, opts: ExpectOptions = {}): Promise<void> {
|
|
152
|
+
return this.run("toHaveCount", String(expected), false, (m) => ({
|
|
153
|
+
pass: m.length === expected,
|
|
154
|
+
actual: String(m.length),
|
|
155
|
+
}), opts);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
toHaveAttribute(name: string, expected?: string | RegExp, opts: ExpectOptions = {}): Promise<void> {
|
|
159
|
+
const read = (node: NdNode): string | null => {
|
|
160
|
+
switch (name) {
|
|
161
|
+
case "testId":
|
|
162
|
+
case "testID":
|
|
163
|
+
return node.testID;
|
|
164
|
+
case "role":
|
|
165
|
+
return node.role;
|
|
166
|
+
case "type":
|
|
167
|
+
return node.type;
|
|
168
|
+
case "text":
|
|
169
|
+
return node.text;
|
|
170
|
+
case "label":
|
|
171
|
+
return nodeName(node) || null;
|
|
172
|
+
case "placeholder":
|
|
173
|
+
return nodePlaceholder(node) || null;
|
|
174
|
+
case "value":
|
|
175
|
+
return node.value === null || node.value === undefined ? null : renderValue(node.value);
|
|
176
|
+
default:
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
return this.run("toHaveAttribute", expected === undefined ? `${name} present` : `${name}=${describe(expected)}`, true, (m) => {
|
|
181
|
+
const got = m.length ? read(m[0]!) : null;
|
|
182
|
+
const pass = expected === undefined ? got !== null : got !== null && textMatches(expected, got, true);
|
|
183
|
+
return { pass, actual: got === null ? "null" : JSON.stringify(got) };
|
|
184
|
+
}, opts);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
189
|
+
if (Object.is(a, b)) return true;
|
|
190
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
191
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
192
|
+
const ka = Object.keys(a as object);
|
|
193
|
+
const kb = Object.keys(b as object);
|
|
194
|
+
if (ka.length !== kb.length) return false;
|
|
195
|
+
return ka.every((k) => deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export class ValueAssertions {
|
|
199
|
+
constructor(
|
|
200
|
+
private readonly actual: unknown,
|
|
201
|
+
private readonly negated = false,
|
|
202
|
+
) {}
|
|
203
|
+
|
|
204
|
+
get not(): ValueAssertions {
|
|
205
|
+
return new ValueAssertions(this.actual, !this.negated);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private assert(name: string, pass: boolean, expected: string): void {
|
|
209
|
+
if (pass !== this.negated) return;
|
|
210
|
+
throw new Error(
|
|
211
|
+
`expect(${describe(this.actual)})${this.negated ? ".not" : ""}.${name}\n` +
|
|
212
|
+
`Expected: ${this.negated ? "not " : ""}${expected}\nReceived: ${describe(this.actual)}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
toBe(expected: unknown): void {
|
|
217
|
+
this.assert("toBe", Object.is(this.actual, expected), describe(expected));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
toEqual(expected: unknown): void {
|
|
221
|
+
this.assert("toEqual", deepEqual(this.actual, expected), describe(expected));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
toBeTruthy(): void {
|
|
225
|
+
this.assert("toBeTruthy", Boolean(this.actual), "truthy");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
toBeFalsy(): void {
|
|
229
|
+
this.assert("toBeFalsy", !this.actual, "falsy");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
toBeNull(): void {
|
|
233
|
+
this.assert("toBeNull", this.actual === null, "null");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
toBeUndefined(): void {
|
|
237
|
+
this.assert("toBeUndefined", this.actual === undefined, "undefined");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
toContain(expected: unknown): void {
|
|
241
|
+
const pass = Array.isArray(this.actual)
|
|
242
|
+
? this.actual.includes(expected)
|
|
243
|
+
: typeof this.actual === "string" && typeof expected === "string" && this.actual.includes(expected);
|
|
244
|
+
this.assert("toContain", pass, describe(expected));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
toMatch(expected: RegExp | string): void {
|
|
248
|
+
const actual = String(this.actual);
|
|
249
|
+
const pass = expected instanceof RegExp ? expected.test(actual) : actual.includes(expected);
|
|
250
|
+
this.assert("toMatch", pass, describe(expected));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
toBeGreaterThan(expected: number): void {
|
|
254
|
+
this.assert("toBeGreaterThan", Number(this.actual) > expected, `> ${expected}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
toBeGreaterThanOrEqual(expected: number): void {
|
|
258
|
+
this.assert("toBeGreaterThanOrEqual", Number(this.actual) >= expected, `>= ${expected}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
toBeLessThan(expected: number): void {
|
|
262
|
+
this.assert("toBeLessThan", Number(this.actual) < expected, `< ${expected}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
toBeLessThanOrEqual(expected: number): void {
|
|
266
|
+
this.assert("toBeLessThanOrEqual", Number(this.actual) <= expected, `<= ${expected}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function expect(actual: Locator): LocatorAssertions;
|
|
271
|
+
export function expect(actual: unknown): ValueAssertions;
|
|
272
|
+
export function expect(actual: unknown): LocatorAssertions | ValueAssertions {
|
|
273
|
+
return actual instanceof Locator ? new LocatorAssertions(actual) : new ValueAssertions(actual);
|
|
274
|
+
}
|