@specific.dev/spectest 0.90.0 → 0.90.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/dist/android-step.d.ts +22 -0
- package/dist/android-step.js +78 -0
- package/dist/android.js +22 -17
- package/dist/daemon.js +5 -0
- package/dist/recorder.d.ts +3 -0
- package/package.json +1 -1
- package/src/android-step.test.ts +115 -0
- package/src/android-step.ts +107 -0
- package/src/android.test.ts +12 -1
- package/src/android.ts +22 -17
- package/src/daemon.ts +5 -0
- package/src/eval-fetch.test.ts +111 -0
- package/src/recorder.ts +3 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Recorded semantics, independent of Appium's wire selectors and display titles. */
|
|
2
|
+
export interface AndroidTarget {
|
|
3
|
+
kind: "text" | "id" | "accessibility" | "class" | "xpath" | "custom";
|
|
4
|
+
value: string;
|
|
5
|
+
/** Original strategy for a custom locator; never interpreted as display text. */
|
|
6
|
+
strategy?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AndroidStepDetails {
|
|
9
|
+
device: string;
|
|
10
|
+
action: "tap" | "fill" | "text" | "visible" | "wait" | "swipe" | "back" | "source" | "screenshot" | "execute";
|
|
11
|
+
target?: AndroidTarget;
|
|
12
|
+
point?: {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
};
|
|
16
|
+
direction?: "up" | "down" | "left" | "right";
|
|
17
|
+
state?: "visible" | "hidden";
|
|
18
|
+
command?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function androidTarget(using: string, value: string): AndroidTarget;
|
|
21
|
+
export declare function androidTargetLabel(target: AndroidTarget): string;
|
|
22
|
+
export declare function androidStepTitle(step: AndroidStepDetails): string;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export function androidTarget(using, value) {
|
|
2
|
+
const kind = new Map([
|
|
3
|
+
["id", "id"],
|
|
4
|
+
["accessibility id", "accessibility"],
|
|
5
|
+
["class name", "class"],
|
|
6
|
+
["xpath", "xpath"],
|
|
7
|
+
]).get(using);
|
|
8
|
+
return kind ? { kind, value } : { kind: "custom", value, strategy: using };
|
|
9
|
+
}
|
|
10
|
+
export function androidTargetLabel(target) {
|
|
11
|
+
const quoted = JSON.stringify(target.value);
|
|
12
|
+
switch (target.kind) {
|
|
13
|
+
case "text":
|
|
14
|
+
return `text ${quoted}`;
|
|
15
|
+
case "id":
|
|
16
|
+
return `id ${quoted}`;
|
|
17
|
+
case "accessibility":
|
|
18
|
+
return `accessibility label ${quoted}`;
|
|
19
|
+
case "xpath":
|
|
20
|
+
return `XPath ${quoted}`;
|
|
21
|
+
case "custom":
|
|
22
|
+
return "custom selector";
|
|
23
|
+
case "class": {
|
|
24
|
+
const classes = {
|
|
25
|
+
"android.widget.EditText": "text field",
|
|
26
|
+
"android.widget.Button": "button",
|
|
27
|
+
"android.widget.TextView": "text view",
|
|
28
|
+
"android.widget.CheckBox": "checkbox",
|
|
29
|
+
"android.widget.Switch": "switch",
|
|
30
|
+
"android.widget.ImageView": "image",
|
|
31
|
+
"android.widget.ScrollView": "scroll view",
|
|
32
|
+
"androidx.recyclerview.widget.RecyclerView": "list",
|
|
33
|
+
};
|
|
34
|
+
return Object.hasOwn(classes, target.value)
|
|
35
|
+
? classes[target.value]
|
|
36
|
+
: `class ${quoted}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function androidStepTitle(step) {
|
|
41
|
+
const target = step.target ? androidTargetLabel(step.target) : "element";
|
|
42
|
+
let label;
|
|
43
|
+
switch (step.action) {
|
|
44
|
+
case "tap":
|
|
45
|
+
label = step.point
|
|
46
|
+
? `tap (${step.point.x}, ${step.point.y})`
|
|
47
|
+
: `tap ${target}`;
|
|
48
|
+
break;
|
|
49
|
+
case "fill":
|
|
50
|
+
label = `fill ${target}`;
|
|
51
|
+
break;
|
|
52
|
+
case "text":
|
|
53
|
+
label = `read text from ${target}`;
|
|
54
|
+
break;
|
|
55
|
+
case "visible":
|
|
56
|
+
label = `check ${target} is visible`;
|
|
57
|
+
break;
|
|
58
|
+
case "wait":
|
|
59
|
+
label = `wait for ${target} to be ${step.state ?? "visible"}`;
|
|
60
|
+
break;
|
|
61
|
+
case "swipe":
|
|
62
|
+
label = `swipe ${step.direction}`;
|
|
63
|
+
break;
|
|
64
|
+
case "back":
|
|
65
|
+
label = "go back";
|
|
66
|
+
break;
|
|
67
|
+
case "source":
|
|
68
|
+
label = "read screen structure";
|
|
69
|
+
break;
|
|
70
|
+
case "screenshot":
|
|
71
|
+
label = "take screenshot";
|
|
72
|
+
break;
|
|
73
|
+
case "execute":
|
|
74
|
+
label = `run command ${JSON.stringify(step.command)}`;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
return `${step.device}: ${label}`;
|
|
78
|
+
}
|
package/dist/android.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { rawFetch } from "./harness/raw-fetch.js";
|
|
3
3
|
import { recordStep, reserveEvent } from "./recorder.js";
|
|
4
4
|
import { wrap } from "./inspect.js";
|
|
5
|
+
import { androidStepTitle, androidTarget, androidTargetLabel } from "./android-step.js";
|
|
5
6
|
export const ANDROID_APP = Symbol.for("spectest.androidApp");
|
|
6
7
|
export function androidApp(options) {
|
|
7
8
|
return { ...options, [ANDROID_APP]: true };
|
|
@@ -154,14 +155,17 @@ export function androidScope(caseId, testStartedAt, options = {}) {
|
|
|
154
155
|
list.push(marker);
|
|
155
156
|
taps.set(app.name, list);
|
|
156
157
|
}
|
|
157
|
-
async function op(
|
|
158
|
+
async function op(operation, fn) {
|
|
158
159
|
assertActive();
|
|
160
|
+
const android = { device: app.name, ...operation };
|
|
161
|
+
const title = androidStepTitle(android);
|
|
159
162
|
const reservation = reserveEvent(), started = Date.now();
|
|
160
163
|
try {
|
|
161
164
|
const result = await fn();
|
|
162
165
|
const seq = recordStep({
|
|
163
166
|
kind: "android",
|
|
164
|
-
title
|
|
167
|
+
title,
|
|
168
|
+
android,
|
|
165
169
|
durationMs: Date.now() - started,
|
|
166
170
|
status: "passed",
|
|
167
171
|
}, reservation);
|
|
@@ -170,7 +174,8 @@ export function androidScope(caseId, testStartedAt, options = {}) {
|
|
|
170
174
|
catch (error) {
|
|
171
175
|
recordStep({
|
|
172
176
|
kind: "android",
|
|
173
|
-
title
|
|
177
|
+
title,
|
|
178
|
+
android,
|
|
174
179
|
durationMs: Date.now() - started,
|
|
175
180
|
status: "failed",
|
|
176
181
|
error: String(error),
|
|
@@ -178,8 +183,8 @@ export function androidScope(caseId, testStartedAt, options = {}) {
|
|
|
178
183
|
throw error;
|
|
179
184
|
}
|
|
180
185
|
}
|
|
181
|
-
const element = (using, value) => {
|
|
182
|
-
const label =
|
|
186
|
+
const element = (using, value, target = androidTarget(using, value)) => {
|
|
187
|
+
const label = androidTargetLabel(target);
|
|
183
188
|
const find = async () => {
|
|
184
189
|
const result = await command("POST", "/element", {
|
|
185
190
|
using,
|
|
@@ -203,7 +208,7 @@ export function androidScope(caseId, testStartedAt, options = {}) {
|
|
|
203
208
|
};
|
|
204
209
|
return {
|
|
205
210
|
async tap() {
|
|
206
|
-
await op(
|
|
211
|
+
await op({ action: "tap", target }, async () => {
|
|
207
212
|
const path = await find();
|
|
208
213
|
const marker = await tapMarker(path);
|
|
209
214
|
await command("POST", `${path}/click`, {});
|
|
@@ -211,19 +216,19 @@ export function androidScope(caseId, testStartedAt, options = {}) {
|
|
|
211
216
|
});
|
|
212
217
|
},
|
|
213
218
|
async fill(text) {
|
|
214
|
-
await op(
|
|
219
|
+
await op({ action: "fill", target }, async () => {
|
|
215
220
|
const path = await find();
|
|
216
221
|
await command("POST", `${path}/clear`, {});
|
|
217
222
|
await command("POST", `${path}/value`, { text });
|
|
218
223
|
});
|
|
219
224
|
},
|
|
220
|
-
text: () => op(
|
|
221
|
-
isDisplayed: () => op(
|
|
225
|
+
text: () => op({ action: "text", target }, async () => command("GET", `${await find()}/text`)),
|
|
226
|
+
isDisplayed: () => op({ action: "visible", target }, displayed),
|
|
222
227
|
async waitFor(options = {}) {
|
|
223
228
|
const timeout = options.timeoutMs ?? 10000;
|
|
224
229
|
if (!Number.isFinite(timeout) || timeout < 0)
|
|
225
230
|
throw new Error("Invalid wait timeout");
|
|
226
|
-
await op(
|
|
231
|
+
await op({ action: "wait", target, state: options.state ?? "visible" }, async () => {
|
|
227
232
|
const end = Date.now() + timeout;
|
|
228
233
|
do {
|
|
229
234
|
if ((await displayed()) === (options.state !== "hidden"))
|
|
@@ -239,16 +244,16 @@ export function androidScope(caseId, testStartedAt, options = {}) {
|
|
|
239
244
|
locator: element,
|
|
240
245
|
getByAccessibilityId: (id) => element("accessibility id", id),
|
|
241
246
|
getById: (id) => element("id", id),
|
|
242
|
-
getByText: (text) => element("-android uiautomator", `new UiSelector().text(${JSON.stringify(text)})
|
|
247
|
+
getByText: (text) => element("-android uiautomator", `new UiSelector().text(${JSON.stringify(text)})`, { kind: "text", value: text }),
|
|
243
248
|
async tap(x, y) {
|
|
244
|
-
await op(
|
|
249
|
+
await op({ action: "tap", point: { x, y } }, async () => {
|
|
245
250
|
const marker = await tapMarker({ x, y });
|
|
246
251
|
await command("POST", "/execute/sync", { script: "mobile: clickGesture", args: [{ x, y }] });
|
|
247
252
|
saveTap(marker);
|
|
248
253
|
});
|
|
249
254
|
},
|
|
250
255
|
async swipe(direction) {
|
|
251
|
-
await op(
|
|
256
|
+
await op({ action: "swipe", direction }, async () => {
|
|
252
257
|
const rect = await command("GET", "/window/rect");
|
|
253
258
|
await command("POST", "/execute/sync", {
|
|
254
259
|
script: "mobile: swipeGesture",
|
|
@@ -266,14 +271,14 @@ export function androidScope(caseId, testStartedAt, options = {}) {
|
|
|
266
271
|
});
|
|
267
272
|
},
|
|
268
273
|
async back() {
|
|
269
|
-
await op("back", () => command("POST", "/back", {}));
|
|
274
|
+
await op({ action: "back" }, () => command("POST", "/back", {}));
|
|
270
275
|
},
|
|
271
|
-
pageSource: () => op("
|
|
276
|
+
pageSource: () => op({ action: "source" }, () => command("GET", "/source")),
|
|
272
277
|
async screenshot() {
|
|
273
|
-
const value = await op("screenshot", () => command("GET", "/screenshot"));
|
|
278
|
+
const value = await op({ action: "screenshot" }, () => command("GET", "/screenshot"));
|
|
274
279
|
return Buffer.from(value.unwrap(), "base64");
|
|
275
280
|
},
|
|
276
|
-
execute: (script, args = {}) => op(script, () => command("POST", "/execute/sync", { script, args: [args] })),
|
|
281
|
+
execute: (script, args = {}) => op({ action: "execute", command: script }, () => command("POST", "/execute/sync", { script, args: [args] })),
|
|
277
282
|
};
|
|
278
283
|
})();
|
|
279
284
|
handles.set(app.name, pending);
|
package/dist/daemon.js
CHANGED
|
@@ -5124,6 +5124,11 @@ async function evalCode(code) {
|
|
|
5124
5124
|
const base = await spectestContext();
|
|
5125
5125
|
const ctx = {
|
|
5126
5126
|
...base,
|
|
5127
|
+
// Bun does not propagate the import() caller's AsyncLocalStorage into
|
|
5128
|
+
// the snippet's module evaluation. Enter the eval scope at the ctx.fetch
|
|
5129
|
+
// call itself so top-level awaits and imported helpers get wrapped values
|
|
5130
|
+
// without enabling instrumentation for unrelated background/fake fetches.
|
|
5131
|
+
fetch: (input, init) => runInstrumented(fetchScope, () => base.fetch(input, init)),
|
|
5127
5132
|
// Wraps its result (no recorder under eval, so no provenance — but the
|
|
5128
5133
|
// wrapped type is honest at runtime, so `.unwrap()` works).
|
|
5129
5134
|
exec: execInServiceWrapped,
|
package/dist/recorder.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AndroidStepDetails } from "./android-step.js";
|
|
1
2
|
export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | EnvEvent | EmailEvent | StepEvent;
|
|
2
3
|
interface BaseEvent {
|
|
3
4
|
/** Order of *start* within the test. Reserved when an op begins (see
|
|
@@ -659,6 +660,8 @@ export interface StepEvent extends BaseEvent {
|
|
|
659
660
|
kind: string;
|
|
660
661
|
/** The step's one-line summary in the timeline. */
|
|
661
662
|
title: string;
|
|
663
|
+
/** Native action semantics; titles are presentation only. */
|
|
664
|
+
android?: AndroidStepDetails;
|
|
662
665
|
blocks?: StepBlock[];
|
|
663
666
|
status?: "passed" | "failed" | "error";
|
|
664
667
|
durationMs?: number;
|
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
androidStepTitle,
|
|
4
|
+
androidTarget,
|
|
5
|
+
type AndroidStepDetails,
|
|
6
|
+
} from "./android-step.js";
|
|
7
|
+
|
|
8
|
+
const cases: [Omit<AndroidStepDetails, "device">, string][] = [
|
|
9
|
+
[
|
|
10
|
+
{ action: "tap", target: { kind: "text", value: "SAVE LOCAL SQLITE" } },
|
|
11
|
+
'tap text "SAVE LOCAL SQLITE"',
|
|
12
|
+
],
|
|
13
|
+
[
|
|
14
|
+
{
|
|
15
|
+
action: "tap",
|
|
16
|
+
target: { kind: "text", value: 'a "quoted" label\nnext' },
|
|
17
|
+
},
|
|
18
|
+
'tap text "a \\"quoted\\" label\\nnext"',
|
|
19
|
+
],
|
|
20
|
+
[
|
|
21
|
+
{
|
|
22
|
+
action: "fill",
|
|
23
|
+
target: { kind: "class", value: "android.widget.EditText" },
|
|
24
|
+
},
|
|
25
|
+
"fill text field",
|
|
26
|
+
],
|
|
27
|
+
[
|
|
28
|
+
{
|
|
29
|
+
action: "text",
|
|
30
|
+
target: { kind: "class", value: "android.widget.EditText" },
|
|
31
|
+
},
|
|
32
|
+
"read text from text field",
|
|
33
|
+
],
|
|
34
|
+
[
|
|
35
|
+
{ action: "visible", target: { kind: "id", value: "app:id/save" } },
|
|
36
|
+
'check id "app:id/save" is visible',
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
{
|
|
40
|
+
action: "wait",
|
|
41
|
+
target: { kind: "accessibility", value: "Save" },
|
|
42
|
+
state: "hidden",
|
|
43
|
+
},
|
|
44
|
+
'wait for accessibility label "Save" to be hidden',
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
{
|
|
48
|
+
action: "wait",
|
|
49
|
+
target: { kind: "text", value: "Ready" },
|
|
50
|
+
state: "visible",
|
|
51
|
+
},
|
|
52
|
+
'wait for text "Ready" to be visible',
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
{
|
|
56
|
+
action: "tap",
|
|
57
|
+
target: { kind: "xpath", value: '//button[@text="Save"]' },
|
|
58
|
+
},
|
|
59
|
+
'tap XPath "//button[@text=\\"Save\\"]"',
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
action: "tap",
|
|
64
|
+
target: {
|
|
65
|
+
kind: "custom",
|
|
66
|
+
strategy: "-android uiautomator",
|
|
67
|
+
value: 'new UiSelector().text("Save")',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
"tap custom selector",
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
{ action: "tap", target: { kind: "class", value: "my.CustomWidget" } },
|
|
74
|
+
'tap class "my.CustomWidget"',
|
|
75
|
+
],
|
|
76
|
+
[{ action: "tap", point: { x: 12, y: 34 } }, "tap (12, 34)"],
|
|
77
|
+
[{ action: "swipe", direction: "up" }, "swipe up"],
|
|
78
|
+
[{ action: "back" }, "go back"],
|
|
79
|
+
[{ action: "source" }, "read screen structure"],
|
|
80
|
+
[{ action: "screenshot" }, "take screenshot"],
|
|
81
|
+
[
|
|
82
|
+
{ action: "execute", command: "mobile: getDeviceInfo" },
|
|
83
|
+
'run command "mobile: getDeviceInfo"',
|
|
84
|
+
],
|
|
85
|
+
];
|
|
86
|
+
for (const [action, title] of cases) {
|
|
87
|
+
test(title, () =>
|
|
88
|
+
expect(androidStepTitle({ device: "phone", ...action })).toBe(
|
|
89
|
+
`phone: ${title}`,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("custom selector expressions remain opaque structured data", () => {
|
|
95
|
+
const expression = 'new UiSelector().text("Save").enabled(true)';
|
|
96
|
+
const target = androidTarget("-android uiautomator", expression);
|
|
97
|
+
expect(target).toEqual({
|
|
98
|
+
kind: "custom",
|
|
99
|
+
strategy: "-android uiautomator",
|
|
100
|
+
value: expression,
|
|
101
|
+
});
|
|
102
|
+
expect(androidStepTitle({ device: "phone", action: "tap", target })).toBe(
|
|
103
|
+
"phone: tap custom selector",
|
|
104
|
+
);
|
|
105
|
+
for (const [using, kind] of [
|
|
106
|
+
["id", "id"],
|
|
107
|
+
["accessibility id", "accessibility"],
|
|
108
|
+
["class name", "class"],
|
|
109
|
+
["xpath", "xpath"],
|
|
110
|
+
] as const)
|
|
111
|
+
expect(androidTarget(using, expression)).toEqual({
|
|
112
|
+
kind,
|
|
113
|
+
value: expression,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Recorded semantics, independent of Appium's wire selectors and display titles. */
|
|
2
|
+
export interface AndroidTarget {
|
|
3
|
+
kind: "text" | "id" | "accessibility" | "class" | "xpath" | "custom";
|
|
4
|
+
value: string;
|
|
5
|
+
/** Original strategy for a custom locator; never interpreted as display text. */
|
|
6
|
+
strategy?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AndroidStepDetails {
|
|
9
|
+
device: string;
|
|
10
|
+
action:
|
|
11
|
+
| "tap"
|
|
12
|
+
| "fill"
|
|
13
|
+
| "text"
|
|
14
|
+
| "visible"
|
|
15
|
+
| "wait"
|
|
16
|
+
| "swipe"
|
|
17
|
+
| "back"
|
|
18
|
+
| "source"
|
|
19
|
+
| "screenshot"
|
|
20
|
+
| "execute";
|
|
21
|
+
target?: AndroidTarget;
|
|
22
|
+
point?: { x: number; y: number };
|
|
23
|
+
direction?: "up" | "down" | "left" | "right";
|
|
24
|
+
state?: "visible" | "hidden";
|
|
25
|
+
command?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function androidTarget(using: string, value: string): AndroidTarget {
|
|
29
|
+
const kind = new Map<string, AndroidTarget["kind"]>([
|
|
30
|
+
["id", "id"],
|
|
31
|
+
["accessibility id", "accessibility"],
|
|
32
|
+
["class name", "class"],
|
|
33
|
+
["xpath", "xpath"],
|
|
34
|
+
]).get(using);
|
|
35
|
+
return kind ? { kind, value } : { kind: "custom", value, strategy: using };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function androidTargetLabel(target: AndroidTarget): string {
|
|
39
|
+
const quoted = JSON.stringify(target.value);
|
|
40
|
+
switch (target.kind) {
|
|
41
|
+
case "text":
|
|
42
|
+
return `text ${quoted}`;
|
|
43
|
+
case "id":
|
|
44
|
+
return `id ${quoted}`;
|
|
45
|
+
case "accessibility":
|
|
46
|
+
return `accessibility label ${quoted}`;
|
|
47
|
+
case "xpath":
|
|
48
|
+
return `XPath ${quoted}`;
|
|
49
|
+
case "custom":
|
|
50
|
+
return "custom selector";
|
|
51
|
+
case "class": {
|
|
52
|
+
const classes = {
|
|
53
|
+
"android.widget.EditText": "text field",
|
|
54
|
+
"android.widget.Button": "button",
|
|
55
|
+
"android.widget.TextView": "text view",
|
|
56
|
+
"android.widget.CheckBox": "checkbox",
|
|
57
|
+
"android.widget.Switch": "switch",
|
|
58
|
+
"android.widget.ImageView": "image",
|
|
59
|
+
"android.widget.ScrollView": "scroll view",
|
|
60
|
+
"androidx.recyclerview.widget.RecyclerView": "list",
|
|
61
|
+
} as Record<string, string>;
|
|
62
|
+
return Object.hasOwn(classes, target.value)
|
|
63
|
+
? classes[target.value]
|
|
64
|
+
: `class ${quoted}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function androidStepTitle(step: AndroidStepDetails): string {
|
|
70
|
+
const target = step.target ? androidTargetLabel(step.target) : "element";
|
|
71
|
+
let label: string;
|
|
72
|
+
switch (step.action) {
|
|
73
|
+
case "tap":
|
|
74
|
+
label = step.point
|
|
75
|
+
? `tap (${step.point.x}, ${step.point.y})`
|
|
76
|
+
: `tap ${target}`;
|
|
77
|
+
break;
|
|
78
|
+
case "fill":
|
|
79
|
+
label = `fill ${target}`;
|
|
80
|
+
break;
|
|
81
|
+
case "text":
|
|
82
|
+
label = `read text from ${target}`;
|
|
83
|
+
break;
|
|
84
|
+
case "visible":
|
|
85
|
+
label = `check ${target} is visible`;
|
|
86
|
+
break;
|
|
87
|
+
case "wait":
|
|
88
|
+
label = `wait for ${target} to be ${step.state ?? "visible"}`;
|
|
89
|
+
break;
|
|
90
|
+
case "swipe":
|
|
91
|
+
label = `swipe ${step.direction}`;
|
|
92
|
+
break;
|
|
93
|
+
case "back":
|
|
94
|
+
label = "go back";
|
|
95
|
+
break;
|
|
96
|
+
case "source":
|
|
97
|
+
label = "read screen structure";
|
|
98
|
+
break;
|
|
99
|
+
case "screenshot":
|
|
100
|
+
label = "take screenshot";
|
|
101
|
+
break;
|
|
102
|
+
case "execute":
|
|
103
|
+
label = `run command ${JSON.stringify(step.command)}`;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
return `${step.device}: ${label}`;
|
|
107
|
+
}
|
package/src/android.test.ts
CHANGED
|
@@ -153,7 +153,18 @@ describe("Android case lifecycle", () => {
|
|
|
153
153
|
expect(bad.error).toContain("encoder failed");
|
|
154
154
|
expect(captures.size).toBe(0); // failed captures are released too
|
|
155
155
|
const events = stopRecording();
|
|
156
|
-
|
|
156
|
+
const steps = events.filter((e) => e.kind === "android");
|
|
157
|
+
expect(steps.length).toBe(7);
|
|
158
|
+
expect(steps[0]).toMatchObject({
|
|
159
|
+
title: String.raw`phone: fill text "a \"quoted\" label"`,
|
|
160
|
+
android: { device: "phone", action: "fill", target: { kind: "text", value: 'a "quoted" label' } },
|
|
161
|
+
});
|
|
162
|
+
expect(steps[1]).toMatchObject({ android: { device: "phone", action: "text", target: { kind: "id", value: "com.example.app:id/draft" } } });
|
|
163
|
+
expect(steps[2]).toMatchObject({ title: "phone: take screenshot", android: { device: "phone", action: "screenshot" } });
|
|
164
|
+
expect(steps[3]).toMatchObject({ title: 'phone: tap text "Save"', android: { device: "phone", action: "tap", target: { kind: "text", value: "Save" } } });
|
|
165
|
+
expect(steps[4]).toMatchObject({ android: { device: "phone", action: "tap", point: { x: 270, y: 1800 } } });
|
|
166
|
+
expect(steps[5]).toMatchObject({ status: "failed", android: { device: "phone", action: "tap", target: { kind: "text", value: "Gone" } } });
|
|
167
|
+
expect(JSON.stringify(steps)).not.toContain("private draft");
|
|
157
168
|
} finally {
|
|
158
169
|
stopRecording();
|
|
159
170
|
server.stop(true);
|
package/src/android.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { rawFetch } from "./harness/raw-fetch.js";
|
|
3
3
|
import { recordStep, reserveEvent } from "./recorder.js";
|
|
4
4
|
import { wrap, type Wrapped } from "./inspect.js";
|
|
5
|
+
import { androidStepTitle, androidTarget, androidTargetLabel, type AndroidStepDetails, type AndroidTarget } from "./android-step.js";
|
|
5
6
|
|
|
6
7
|
export interface AndroidRecordingOptions {
|
|
7
8
|
/** Maximum dimension of the video, default 1280. */
|
|
@@ -226,8 +227,10 @@ export function androidScope(
|
|
|
226
227
|
if (list.length < 10000) list.push(marker);
|
|
227
228
|
taps.set(app.name, list);
|
|
228
229
|
}
|
|
229
|
-
async function op<T>(
|
|
230
|
+
async function op<T>(operation: Omit<AndroidStepDetails, "device">, fn: () => Promise<T>): Promise<Wrapped<T>> {
|
|
230
231
|
assertActive();
|
|
232
|
+
const android = { device: app.name, ...operation };
|
|
233
|
+
const title = androidStepTitle(android);
|
|
231
234
|
const reservation = reserveEvent(),
|
|
232
235
|
started = Date.now();
|
|
233
236
|
try {
|
|
@@ -235,7 +238,8 @@ export function androidScope(
|
|
|
235
238
|
const seq = recordStep(
|
|
236
239
|
{
|
|
237
240
|
kind: "android",
|
|
238
|
-
title
|
|
241
|
+
title,
|
|
242
|
+
android,
|
|
239
243
|
durationMs: Date.now() - started,
|
|
240
244
|
status: "passed",
|
|
241
245
|
},
|
|
@@ -246,7 +250,8 @@ export function androidScope(
|
|
|
246
250
|
recordStep(
|
|
247
251
|
{
|
|
248
252
|
kind: "android",
|
|
249
|
-
title
|
|
253
|
+
title,
|
|
254
|
+
android,
|
|
250
255
|
durationMs: Date.now() - started,
|
|
251
256
|
status: "failed",
|
|
252
257
|
error: String(error),
|
|
@@ -256,8 +261,8 @@ export function androidScope(
|
|
|
256
261
|
throw error;
|
|
257
262
|
}
|
|
258
263
|
}
|
|
259
|
-
const element = (using: string, value: string): AndroidElement => {
|
|
260
|
-
const label =
|
|
264
|
+
const element = (using: string, value: string, target: AndroidTarget = androidTarget(using, value)): AndroidElement => {
|
|
265
|
+
const label = androidTargetLabel(target);
|
|
261
266
|
const find = async () => {
|
|
262
267
|
const result = await command<Record<string, string>>("POST", "/element", {
|
|
263
268
|
using,
|
|
@@ -281,7 +286,7 @@ export function androidScope(
|
|
|
281
286
|
};
|
|
282
287
|
return {
|
|
283
288
|
async tap() {
|
|
284
|
-
await op(
|
|
289
|
+
await op({ action: "tap", target }, async () => {
|
|
285
290
|
const path = await find();
|
|
286
291
|
const marker = await tapMarker(path);
|
|
287
292
|
await command("POST", `${path}/click`, {});
|
|
@@ -289,19 +294,19 @@ export function androidScope(
|
|
|
289
294
|
});
|
|
290
295
|
},
|
|
291
296
|
async fill(text) {
|
|
292
|
-
await op(
|
|
297
|
+
await op({ action: "fill", target }, async () => {
|
|
293
298
|
const path = await find();
|
|
294
299
|
await command("POST", `${path}/clear`, {});
|
|
295
300
|
await command("POST", `${path}/value`, { text });
|
|
296
301
|
});
|
|
297
302
|
},
|
|
298
303
|
text: () =>
|
|
299
|
-
op(
|
|
300
|
-
isDisplayed: () => op(
|
|
304
|
+
op({ action: "text", target }, async () => command<string>("GET", `${await find()}/text`)),
|
|
305
|
+
isDisplayed: () => op({ action: "visible", target }, displayed),
|
|
301
306
|
async waitFor(options = {}) {
|
|
302
307
|
const timeout = options.timeoutMs ?? 10000;
|
|
303
308
|
if (!Number.isFinite(timeout) || timeout < 0) throw new Error("Invalid wait timeout");
|
|
304
|
-
await op(
|
|
309
|
+
await op({ action: "wait", target, state: options.state ?? "visible" }, async () => {
|
|
305
310
|
const end = Date.now() + timeout;
|
|
306
311
|
do {
|
|
307
312
|
if ((await displayed()) === (options.state !== "hidden")) return;
|
|
@@ -317,16 +322,16 @@ export function androidScope(
|
|
|
317
322
|
getByAccessibilityId: (id) => element("accessibility id", id),
|
|
318
323
|
getById: (id) => element("id", id),
|
|
319
324
|
getByText: (text) =>
|
|
320
|
-
element("-android uiautomator", `new UiSelector().text(${JSON.stringify(text)})
|
|
325
|
+
element("-android uiautomator", `new UiSelector().text(${JSON.stringify(text)})`, { kind: "text", value: text }),
|
|
321
326
|
async tap(x, y) {
|
|
322
|
-
await op(
|
|
327
|
+
await op({ action: "tap", point: { x, y } }, async () => {
|
|
323
328
|
const marker = await tapMarker({ x, y });
|
|
324
329
|
await command("POST", "/execute/sync", { script: "mobile: clickGesture", args: [{ x, y }] });
|
|
325
330
|
saveTap(marker);
|
|
326
331
|
});
|
|
327
332
|
},
|
|
328
333
|
async swipe(direction) {
|
|
329
|
-
await op(
|
|
334
|
+
await op({ action: "swipe", direction }, async () => {
|
|
330
335
|
const rect = await command<{ width: number; height: number }>("GET", "/window/rect");
|
|
331
336
|
await command("POST", "/execute/sync", {
|
|
332
337
|
script: "mobile: swipeGesture",
|
|
@@ -344,15 +349,15 @@ export function androidScope(
|
|
|
344
349
|
});
|
|
345
350
|
},
|
|
346
351
|
async back() {
|
|
347
|
-
await op("back", () => command("POST", "/back", {}));
|
|
352
|
+
await op({ action: "back" }, () => command("POST", "/back", {}));
|
|
348
353
|
},
|
|
349
|
-
pageSource: () => op("
|
|
354
|
+
pageSource: () => op({ action: "source" }, () => command<string>("GET", "/source")),
|
|
350
355
|
async screenshot() {
|
|
351
|
-
const value = await op("screenshot", () => command<string>("GET", "/screenshot"));
|
|
356
|
+
const value = await op({ action: "screenshot" }, () => command<string>("GET", "/screenshot"));
|
|
352
357
|
return Buffer.from(value.unwrap(), "base64");
|
|
353
358
|
},
|
|
354
359
|
execute: <T>(script: string, args: Record<string, unknown> = {}) =>
|
|
355
|
-
op(script, () => command<T>("POST", "/execute/sync", { script, args: [args] })),
|
|
360
|
+
op({ action: "execute", command: script }, () => command<T>("POST", "/execute/sync", { script, args: [args] })),
|
|
356
361
|
} satisfies Android;
|
|
357
362
|
})();
|
|
358
363
|
handles.set(app.name, pending);
|
package/src/daemon.ts
CHANGED
|
@@ -6155,6 +6155,11 @@ async function evalCode(code: string): Promise<EvalResult> {
|
|
|
6155
6155
|
|
|
6156
6156
|
const ctx: TestContext<undefined> = {
|
|
6157
6157
|
...base,
|
|
6158
|
+
// Bun does not propagate the import() caller's AsyncLocalStorage into
|
|
6159
|
+
// the snippet's module evaluation. Enter the eval scope at the ctx.fetch
|
|
6160
|
+
// call itself so top-level awaits and imported helpers get wrapped values
|
|
6161
|
+
// without enabling instrumentation for unrelated background/fake fetches.
|
|
6162
|
+
fetch: (input, init) => runInstrumented(fetchScope, () => base.fetch(input, init)),
|
|
6158
6163
|
// Wraps its result (no recorder under eval, so no provenance — but the
|
|
6159
6164
|
// wrapped type is honest at runtime, so `.unwrap()` works).
|
|
6160
6165
|
exec: execInServiceWrapped as unknown as TestContext<undefined>["exec"],
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
test("interactive ctx.fetch keeps its wrapped contract across module evaluation", async () => {
|
|
7
|
+
// Exercise the real eval import boundary in a separate process: the daemon
|
|
8
|
+
// changes globals and captures stdout, and APP_DIR is fixed at import time.
|
|
9
|
+
const root = await mkdtemp(join(tmpdir(), "spectest-eval-fetch-"));
|
|
10
|
+
try {
|
|
11
|
+
const daemon = new URL("./daemon.ts", import.meta.url).href;
|
|
12
|
+
const scopes = new URL("./harness/instrumentation-scope.ts", import.meta.url).href;
|
|
13
|
+
const recorder = new URL("./recorder.ts", import.meta.url).href;
|
|
14
|
+
const url = "data:application/json,%7B%22id%22%3A%22example%22%7D";
|
|
15
|
+
await writeFile(join(root, "helper.ts"), `
|
|
16
|
+
export async function read(fetcher) {
|
|
17
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
18
|
+
const response = await fetcher(${JSON.stringify(url)});
|
|
19
|
+
return (await response.json()).id.unwrap();
|
|
20
|
+
}
|
|
21
|
+
`);
|
|
22
|
+
const reproducer = `
|
|
23
|
+
const r = await ctx.fetch(${JSON.stringify(url)});
|
|
24
|
+
const j = await r.json();
|
|
25
|
+
export default {
|
|
26
|
+
statusType: typeof r.status, statusUnwrap: typeof r.status.unwrap,
|
|
27
|
+
idType: typeof j.id, idUnwrap: typeof j.id.unwrap,
|
|
28
|
+
};
|
|
29
|
+
`;
|
|
30
|
+
const readers = `
|
|
31
|
+
import { read } from "../helper.ts";
|
|
32
|
+
const r = await ctx.fetch(${JSON.stringify(url)});
|
|
33
|
+
const clone = r.clone();
|
|
34
|
+
await Promise.resolve();
|
|
35
|
+
const body = await r.json();
|
|
36
|
+
const { fetch: fetcher } = ctx;
|
|
37
|
+
export default {
|
|
38
|
+
status: r.status.unwrap(), ok: r.ok.unwrap(),
|
|
39
|
+
contentType: r.headers.get("content-type").unwrap(),
|
|
40
|
+
id: body.id.unwrap(), text: (await clone.text()).unwrap(),
|
|
41
|
+
rawStatus: r.unwrap().status, helperId: await read(fetcher),
|
|
42
|
+
};
|
|
43
|
+
`;
|
|
44
|
+
const script = `
|
|
45
|
+
import { strict as assert } from "node:assert";
|
|
46
|
+
import { METHODS } from ${JSON.stringify(daemon)};
|
|
47
|
+
import { currentInstrumentationScope, runUninstrumented } from ${JSON.stringify(scopes)};
|
|
48
|
+
import { startRecording, stopRecording } from ${JSON.stringify(recorder)};
|
|
49
|
+
const originalFetch = globalThis.fetch;
|
|
50
|
+
const url = ${JSON.stringify(url)};
|
|
51
|
+
const contentType = (await originalFetch(url)).headers.get("content-type");
|
|
52
|
+
startRecording();
|
|
53
|
+
const result = await METHODS.eval({ code: ${JSON.stringify(reproducer)} });
|
|
54
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
55
|
+
assert.deepEqual(result.result, {
|
|
56
|
+
statusType: "object", statusUnwrap: "function",
|
|
57
|
+
idType: "object", idUnwrap: "function",
|
|
58
|
+
});
|
|
59
|
+
const values = await METHODS.eval({ code: ${JSON.stringify(readers)} });
|
|
60
|
+
assert.equal(values.ok, true, JSON.stringify(values));
|
|
61
|
+
assert.deepEqual(values.result, {
|
|
62
|
+
status: 200, ok: true, contentType,
|
|
63
|
+
id: "example", text: '{"id":"example"}', rawStatus: 200,
|
|
64
|
+
helperId: "example",
|
|
65
|
+
});
|
|
66
|
+
// Fake/background work outside the eval's call must still see native fetch.
|
|
67
|
+
let releaseBackground;
|
|
68
|
+
const released = new Promise(resolve => { releaseBackground = resolve; });
|
|
69
|
+
const background = released.then(async () => {
|
|
70
|
+
const r = await fetch(url);
|
|
71
|
+
assert.equal(r.status, 200);
|
|
72
|
+
assert.equal((await r.json()).id, "example");
|
|
73
|
+
});
|
|
74
|
+
globalThis.nativeProbe = async () => {
|
|
75
|
+
releaseBackground();
|
|
76
|
+
await background;
|
|
77
|
+
await runUninstrumented(async () => {
|
|
78
|
+
await Promise.resolve();
|
|
79
|
+
const fakeResponse = await fetch(url);
|
|
80
|
+
assert.equal(fakeResponse.status, 200);
|
|
81
|
+
assert.equal((await fakeResponse.json()).id, "example");
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
const isolated = await METHODS.eval({ code: "await nativeProbe();" });
|
|
85
|
+
assert.equal(isolated.ok, true, JSON.stringify(isolated));
|
|
86
|
+
const failed = await METHODS.eval({ code: 'await ctx.fetch(' + JSON.stringify(url) + '); throw new Error("expected");' });
|
|
87
|
+
assert.equal(failed.ok, false);
|
|
88
|
+
assert.equal(failed.error.message, "expected");
|
|
89
|
+
assert.equal(globalThis.fetch, originalFetch);
|
|
90
|
+
assert.equal(currentInstrumentationScope(), undefined);
|
|
91
|
+
assert.equal((await fetch(url)).status, 200);
|
|
92
|
+
const next = await METHODS.eval({ code: ${JSON.stringify(reproducer)} });
|
|
93
|
+
assert.equal(next.ok, true, JSON.stringify(next));
|
|
94
|
+
assert.deepEqual(next.result, result.result);
|
|
95
|
+
assert.deepEqual(stopRecording(), []);
|
|
96
|
+
`;
|
|
97
|
+
const child = Bun.spawn([process.execPath, "--eval", script], {
|
|
98
|
+
env: { ...process.env, SPECTEST_APP_DIR: root },
|
|
99
|
+
stdout: "pipe",
|
|
100
|
+
stderr: "pipe",
|
|
101
|
+
});
|
|
102
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
103
|
+
child.exited,
|
|
104
|
+
new Response(child.stdout).text(),
|
|
105
|
+
new Response(child.stderr).text(),
|
|
106
|
+
]);
|
|
107
|
+
expect(code, stdout + stderr).toBe(0);
|
|
108
|
+
} finally {
|
|
109
|
+
await rm(root, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
}, 20_000);
|
package/src/recorder.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// current recorder is `null` and the `record*` helpers are no-ops, so
|
|
9
9
|
// callers can invoke them unconditionally.
|
|
10
10
|
|
|
11
|
+
import type { AndroidStepDetails } from "./android-step.js";
|
|
11
12
|
import { clearPendingNullish } from "./inspect.js";
|
|
12
13
|
import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
|
|
13
14
|
|
|
@@ -911,6 +912,8 @@ export interface StepEvent extends BaseEvent {
|
|
|
911
912
|
kind: string;
|
|
912
913
|
/** The step's one-line summary in the timeline. */
|
|
913
914
|
title: string;
|
|
915
|
+
/** Native action semantics; titles are presentation only. */
|
|
916
|
+
android?: AndroidStepDetails;
|
|
914
917
|
blocks?: StepBlock[];
|
|
915
918
|
status?: "passed" | "failed" | "error";
|
|
916
919
|
durationMs?: number;
|