@specific.dev/spectest 0.89.1 → 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.d.ts +84 -0
- package/dist/android.js +360 -0
- package/dist/components/android-runtime.mjs +257 -0
- package/dist/components/android.d.ts +74 -0
- package/dist/components/android.js +92 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/daemon.js +30 -3
- package/dist/harness/main.js +6 -1
- package/dist/harness/sticky-consumer.d.ts +28 -0
- package/dist/harness/sticky-consumer.js +178 -0
- package/dist/harness/sticky-maintenance.d.ts +1 -0
- package/dist/harness/sticky-maintenance.js +11 -0
- package/dist/index.d.ts +4 -0
- package/dist/recorder.d.ts +4 -1
- package/dist/recorder.js +6 -3
- package/package.json +7 -3
- package/src/android-step.test.ts +115 -0
- package/src/android-step.ts +107 -0
- package/src/android.test.ts +191 -0
- package/src/android.ts +449 -0
- package/src/components/android-runtime.mjs +257 -0
- package/src/components/android.test.ts +68 -0
- package/src/components/android.ts +123 -0
- package/src/components/index.ts +2 -0
- package/src/daemon.ts +29 -3
- package/src/eval-fetch.test.ts +111 -0
- package/src/harness/main.ts +6 -1
- package/src/harness/sticky-consumer.test.ts +45 -0
- package/src/harness/sticky-consumer.ts +176 -0
- package/src/harness/sticky-maintenance.ts +7 -0
- package/src/index.ts +4 -0
- package/src/recorder.ts +6 -3
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type Wrapped } from "./inspect.js";
|
|
2
|
+
export interface AndroidRecordingOptions {
|
|
3
|
+
/** Maximum dimension of the video, default 1280. */
|
|
4
|
+
maxSize?: number;
|
|
5
|
+
/** Maximum frame rate, default 15. Static screens produce few frames. */
|
|
6
|
+
maxFps?: number;
|
|
7
|
+
/** H.264 bitrate target in bits/second, default 2,000,000. */
|
|
8
|
+
bitRate?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const ANDROID_APP: unique symbol;
|
|
11
|
+
export interface AndroidApp {
|
|
12
|
+
readonly [ANDROID_APP]: true;
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly endpoint: string;
|
|
15
|
+
readonly recorderEndpoint: string;
|
|
16
|
+
readonly serial: string;
|
|
17
|
+
readonly package: string;
|
|
18
|
+
readonly recording: false | AndroidRecordingOptions;
|
|
19
|
+
readonly showTouches?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export declare function androidApp(options: Omit<AndroidApp, typeof ANDROID_APP>): AndroidApp;
|
|
22
|
+
export declare function isAndroidApp(value: unknown): value is AndroidApp;
|
|
23
|
+
export declare class AppiumError extends Error {
|
|
24
|
+
readonly code: string;
|
|
25
|
+
constructor(code: string, message: string);
|
|
26
|
+
}
|
|
27
|
+
export interface AndroidElement {
|
|
28
|
+
tap(): Promise<void>;
|
|
29
|
+
fill(text: string): Promise<void>;
|
|
30
|
+
text(): Promise<Wrapped<string>>;
|
|
31
|
+
isDisplayed(): Promise<Wrapped<boolean>>;
|
|
32
|
+
waitFor(options?: {
|
|
33
|
+
state?: "visible" | "hidden";
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
}): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export interface Android {
|
|
38
|
+
getByAccessibilityId(id: string): AndroidElement;
|
|
39
|
+
getById(resourceId: string): AndroidElement;
|
|
40
|
+
getByText(text: string): AndroidElement;
|
|
41
|
+
/** Appium selector, e.g. -android uiautomator or xpath. */
|
|
42
|
+
locator(using: string, value: string): AndroidElement;
|
|
43
|
+
tap(x: number, y: number): Promise<void>;
|
|
44
|
+
swipe(direction: "up" | "down" | "left" | "right"): Promise<void>;
|
|
45
|
+
back(): Promise<void>;
|
|
46
|
+
pageSource(): Promise<Wrapped<string>>;
|
|
47
|
+
/** PNG bytes. */
|
|
48
|
+
screenshot(): Promise<Uint8Array>;
|
|
49
|
+
/** Appium extension command, e.g. mobile: getDeviceInfo. */
|
|
50
|
+
execute<T = unknown>(script: string, args?: Record<string, unknown>): Promise<Wrapped<T>>;
|
|
51
|
+
}
|
|
52
|
+
export interface AndroidTap {
|
|
53
|
+
/** Milliseconds from the case start, in the same clock as the video manifest. */
|
|
54
|
+
offsetMs: number;
|
|
55
|
+
/** Normalized screen coordinates, independent of encoding resolution. */
|
|
56
|
+
x: number;
|
|
57
|
+
y: number;
|
|
58
|
+
}
|
|
59
|
+
export interface AndroidRecording {
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
package: string;
|
|
63
|
+
openedAtMs: number;
|
|
64
|
+
closedAtMs: number;
|
|
65
|
+
mediaDurationMs: number;
|
|
66
|
+
width: number;
|
|
67
|
+
height: number;
|
|
68
|
+
bytes: number;
|
|
69
|
+
taps?: AndroidTap[];
|
|
70
|
+
error?: string;
|
|
71
|
+
}
|
|
72
|
+
/** A case owns capture and handle lifetime; the Android/Appium processes outlive it. */
|
|
73
|
+
export declare function androidScope(caseId: string, testStartedAt: number, options?: {
|
|
74
|
+
record?: boolean;
|
|
75
|
+
}): {
|
|
76
|
+
begin: (apps?: Iterable<AndroidApp>) => Promise<void>;
|
|
77
|
+
open: (app: AndroidApp) => Promise<Android>;
|
|
78
|
+
finish: () => Promise<AndroidRecording[]>;
|
|
79
|
+
deactivate: () => void;
|
|
80
|
+
};
|
|
81
|
+
export declare function androidRecordingChunk(id: string, offset: unknown, limit: unknown): Promise<unknown>;
|
|
82
|
+
export declare function releaseAndroidRecording(id: string): Promise<{
|
|
83
|
+
released: boolean;
|
|
84
|
+
}>;
|
package/dist/android.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { rawFetch } from "./harness/raw-fetch.js";
|
|
3
|
+
import { recordStep, reserveEvent } from "./recorder.js";
|
|
4
|
+
import { wrap } from "./inspect.js";
|
|
5
|
+
import { androidStepTitle, androidTarget, androidTargetLabel } from "./android-step.js";
|
|
6
|
+
export const ANDROID_APP = Symbol.for("spectest.androidApp");
|
|
7
|
+
export function androidApp(options) {
|
|
8
|
+
return { ...options, [ANDROID_APP]: true };
|
|
9
|
+
}
|
|
10
|
+
export function isAndroidApp(value) {
|
|
11
|
+
return !!value && typeof value === "object" && value[ANDROID_APP] === true;
|
|
12
|
+
}
|
|
13
|
+
export class AppiumError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = "AppiumError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function request(url, method = "GET", body) {
|
|
22
|
+
const response = await rawFetch(url, {
|
|
23
|
+
method,
|
|
24
|
+
...(body === undefined
|
|
25
|
+
? {}
|
|
26
|
+
: { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
|
|
27
|
+
signal: AbortSignal.timeout(120000),
|
|
28
|
+
});
|
|
29
|
+
const data = (await response.json());
|
|
30
|
+
if (!response.ok || data.value?.error || data.error)
|
|
31
|
+
throw new AppiumError(data.value?.error ?? "unknown error", data.value?.message ?? data.error ?? `Android request failed (${response.status})`);
|
|
32
|
+
return ("value" in data ? data.value : data);
|
|
33
|
+
}
|
|
34
|
+
// Both the Appium session and this registry are preserved by a VM snapshot.
|
|
35
|
+
// Never reset/relaunch the app or delete its session at a case boundary.
|
|
36
|
+
const sessions = new Map();
|
|
37
|
+
async function session(app) {
|
|
38
|
+
const key = JSON.stringify([app.endpoint, app.serial, app.package]);
|
|
39
|
+
let pending = sessions.get(key);
|
|
40
|
+
if (!pending) {
|
|
41
|
+
pending = request(`${app.endpoint}/session`, "POST", {
|
|
42
|
+
capabilities: {
|
|
43
|
+
alwaysMatch: {
|
|
44
|
+
platformName: "Android",
|
|
45
|
+
"appium:automationName": "UiAutomator2",
|
|
46
|
+
"appium:udid": app.serial,
|
|
47
|
+
"appium:appPackage": app.package,
|
|
48
|
+
"appium:noReset": true,
|
|
49
|
+
"appium:autoLaunch": false,
|
|
50
|
+
"appium:dontStopAppOnReset": true,
|
|
51
|
+
"appium:newCommandTimeout": 0,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
.then((result) => {
|
|
56
|
+
if (!result.sessionId)
|
|
57
|
+
throw new Error("Appium returned no session id");
|
|
58
|
+
return result.sessionId;
|
|
59
|
+
})
|
|
60
|
+
.catch((error) => {
|
|
61
|
+
sessions.delete(key);
|
|
62
|
+
throw error;
|
|
63
|
+
});
|
|
64
|
+
sessions.set(key, pending);
|
|
65
|
+
}
|
|
66
|
+
return pending;
|
|
67
|
+
}
|
|
68
|
+
const parked = new Map();
|
|
69
|
+
let epoch = 0;
|
|
70
|
+
/** A case owns capture and handle lifetime; the Android/Appium processes outlive it. */
|
|
71
|
+
export function androidScope(caseId, testStartedAt, options = {}) {
|
|
72
|
+
let active = true;
|
|
73
|
+
const captures = new Map();
|
|
74
|
+
const handles = new Map();
|
|
75
|
+
const taps = new Map();
|
|
76
|
+
const assertActive = () => {
|
|
77
|
+
if (!active)
|
|
78
|
+
throw new Error("This Android handle belongs to a finished test");
|
|
79
|
+
};
|
|
80
|
+
async function capture(app) {
|
|
81
|
+
assertActive();
|
|
82
|
+
if (app.recording === false || options.record === false)
|
|
83
|
+
return;
|
|
84
|
+
let pending = captures.get(app.name);
|
|
85
|
+
if (!pending) {
|
|
86
|
+
if (captures.size >= 8)
|
|
87
|
+
throw new Error("A case can record at most eight Android devices");
|
|
88
|
+
const id = createHash("sha256").update(`${caseId}:${app.name}:${++epoch}`).digest("hex");
|
|
89
|
+
pending = request(`${app.recorderEndpoint}/recordings`, "POST", {
|
|
90
|
+
id,
|
|
91
|
+
...app.recording,
|
|
92
|
+
}).then((result) => ({
|
|
93
|
+
app,
|
|
94
|
+
id,
|
|
95
|
+
openedAtMs: Math.max(0, result.startedAtMs - testStartedAt),
|
|
96
|
+
}));
|
|
97
|
+
captures.set(app.name, pending);
|
|
98
|
+
}
|
|
99
|
+
await pending;
|
|
100
|
+
}
|
|
101
|
+
async function begin(apps = []) {
|
|
102
|
+
assertActive();
|
|
103
|
+
// Previous artifacts were fetched before the VM was handed off. Forked
|
|
104
|
+
// children can discard their inherited copies without touching siblings.
|
|
105
|
+
for (const [id, previous] of parked) {
|
|
106
|
+
await request(`${previous.app.recorderEndpoint}/recordings/${id}`, "DELETE");
|
|
107
|
+
parked.delete(id);
|
|
108
|
+
}
|
|
109
|
+
for (const app of apps)
|
|
110
|
+
await capture(app);
|
|
111
|
+
assertActive();
|
|
112
|
+
}
|
|
113
|
+
async function open(app) {
|
|
114
|
+
assertActive();
|
|
115
|
+
if (!isAndroidApp(app))
|
|
116
|
+
throw new Error("ctx.android(): pass the handle from an android() service");
|
|
117
|
+
let pending = handles.get(app.name);
|
|
118
|
+
if (pending)
|
|
119
|
+
return pending;
|
|
120
|
+
pending = (async () => {
|
|
121
|
+
await capture(app);
|
|
122
|
+
assertActive();
|
|
123
|
+
const sid = await session(app);
|
|
124
|
+
assertActive();
|
|
125
|
+
const command = (method, path, body) => {
|
|
126
|
+
assertActive();
|
|
127
|
+
return request(`${app.endpoint}/session/${encodeURIComponent(sid)}${path}`, method, body);
|
|
128
|
+
};
|
|
129
|
+
async function tapMarker(point) {
|
|
130
|
+
if (!captures.has(app.name) || app.showTouches === false)
|
|
131
|
+
return;
|
|
132
|
+
// Appium injects touches after Android's physical-touch visualizer.
|
|
133
|
+
// Keep markers alongside the video; never change tap semantics or fail
|
|
134
|
+
// a test just because optional visual feedback could not be measured.
|
|
135
|
+
try {
|
|
136
|
+
const screen = await command("GET", "/window/rect");
|
|
137
|
+
const rect = typeof point === "string"
|
|
138
|
+
? await command("GET", `${point}/rect`)
|
|
139
|
+
: { ...point, width: 0, height: 0 };
|
|
140
|
+
const x = (rect.x + rect.width / 2) / screen.width;
|
|
141
|
+
const y = (rect.y + rect.height / 2) / screen.height;
|
|
142
|
+
if (![x, y].every(v => Number.isFinite(v) && v >= 0 && v <= 1))
|
|
143
|
+
return;
|
|
144
|
+
return { x, y, offsetMs: Date.now() - testStartedAt };
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function saveTap(marker) {
|
|
151
|
+
if (!marker || !active)
|
|
152
|
+
return;
|
|
153
|
+
const list = taps.get(app.name) ?? [];
|
|
154
|
+
if (list.length < 10000)
|
|
155
|
+
list.push(marker);
|
|
156
|
+
taps.set(app.name, list);
|
|
157
|
+
}
|
|
158
|
+
async function op(operation, fn) {
|
|
159
|
+
assertActive();
|
|
160
|
+
const android = { device: app.name, ...operation };
|
|
161
|
+
const title = androidStepTitle(android);
|
|
162
|
+
const reservation = reserveEvent(), started = Date.now();
|
|
163
|
+
try {
|
|
164
|
+
const result = await fn();
|
|
165
|
+
const seq = recordStep({
|
|
166
|
+
kind: "android",
|
|
167
|
+
title,
|
|
168
|
+
android,
|
|
169
|
+
durationMs: Date.now() - started,
|
|
170
|
+
status: "passed",
|
|
171
|
+
}, reservation);
|
|
172
|
+
return wrap(result, seq);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
recordStep({
|
|
176
|
+
kind: "android",
|
|
177
|
+
title,
|
|
178
|
+
android,
|
|
179
|
+
durationMs: Date.now() - started,
|
|
180
|
+
status: "failed",
|
|
181
|
+
error: String(error),
|
|
182
|
+
}, reservation);
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const element = (using, value, target = androidTarget(using, value)) => {
|
|
187
|
+
const label = androidTargetLabel(target);
|
|
188
|
+
const find = async () => {
|
|
189
|
+
const result = await command("POST", "/element", {
|
|
190
|
+
using,
|
|
191
|
+
value,
|
|
192
|
+
});
|
|
193
|
+
const id = result["element-6066-11e4-a52e-4f735466cecf"];
|
|
194
|
+
if (!id)
|
|
195
|
+
throw new Error("Appium returned no element id");
|
|
196
|
+
return `/element/${encodeURIComponent(id)}`;
|
|
197
|
+
};
|
|
198
|
+
const displayed = async () => {
|
|
199
|
+
try {
|
|
200
|
+
return await command("GET", `${await find()}/displayed`);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error instanceof AppiumError &&
|
|
204
|
+
["no such element", "stale element reference"].includes(error.code))
|
|
205
|
+
return false;
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
return {
|
|
210
|
+
async tap() {
|
|
211
|
+
await op({ action: "tap", target }, async () => {
|
|
212
|
+
const path = await find();
|
|
213
|
+
const marker = await tapMarker(path);
|
|
214
|
+
await command("POST", `${path}/click`, {});
|
|
215
|
+
saveTap(marker);
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
async fill(text) {
|
|
219
|
+
await op({ action: "fill", target }, async () => {
|
|
220
|
+
const path = await find();
|
|
221
|
+
await command("POST", `${path}/clear`, {});
|
|
222
|
+
await command("POST", `${path}/value`, { text });
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
text: () => op({ action: "text", target }, async () => command("GET", `${await find()}/text`)),
|
|
226
|
+
isDisplayed: () => op({ action: "visible", target }, displayed),
|
|
227
|
+
async waitFor(options = {}) {
|
|
228
|
+
const timeout = options.timeoutMs ?? 10000;
|
|
229
|
+
if (!Number.isFinite(timeout) || timeout < 0)
|
|
230
|
+
throw new Error("Invalid wait timeout");
|
|
231
|
+
await op({ action: "wait", target, state: options.state ?? "visible" }, async () => {
|
|
232
|
+
const end = Date.now() + timeout;
|
|
233
|
+
do {
|
|
234
|
+
if ((await displayed()) === (options.state !== "hidden"))
|
|
235
|
+
return;
|
|
236
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
237
|
+
} while (Date.now() < end);
|
|
238
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
239
|
+
});
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
return {
|
|
244
|
+
locator: element,
|
|
245
|
+
getByAccessibilityId: (id) => element("accessibility id", id),
|
|
246
|
+
getById: (id) => element("id", id),
|
|
247
|
+
getByText: (text) => element("-android uiautomator", `new UiSelector().text(${JSON.stringify(text)})`, { kind: "text", value: text }),
|
|
248
|
+
async tap(x, y) {
|
|
249
|
+
await op({ action: "tap", point: { x, y } }, async () => {
|
|
250
|
+
const marker = await tapMarker({ x, y });
|
|
251
|
+
await command("POST", "/execute/sync", { script: "mobile: clickGesture", args: [{ x, y }] });
|
|
252
|
+
saveTap(marker);
|
|
253
|
+
});
|
|
254
|
+
},
|
|
255
|
+
async swipe(direction) {
|
|
256
|
+
await op({ action: "swipe", direction }, async () => {
|
|
257
|
+
const rect = await command("GET", "/window/rect");
|
|
258
|
+
await command("POST", "/execute/sync", {
|
|
259
|
+
script: "mobile: swipeGesture",
|
|
260
|
+
args: [
|
|
261
|
+
{
|
|
262
|
+
left: rect.width * 0.1,
|
|
263
|
+
top: rect.height * 0.1,
|
|
264
|
+
width: rect.width * 0.8,
|
|
265
|
+
height: rect.height * 0.8,
|
|
266
|
+
direction,
|
|
267
|
+
percent: 0.75,
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
async back() {
|
|
274
|
+
await op({ action: "back" }, () => command("POST", "/back", {}));
|
|
275
|
+
},
|
|
276
|
+
pageSource: () => op({ action: "source" }, () => command("GET", "/source")),
|
|
277
|
+
async screenshot() {
|
|
278
|
+
const value = await op({ action: "screenshot" }, () => command("GET", "/screenshot"));
|
|
279
|
+
return Buffer.from(value.unwrap(), "base64");
|
|
280
|
+
},
|
|
281
|
+
execute: (script, args = {}) => op({ action: "execute", command: script }, () => command("POST", "/execute/sync", { script, args: [args] })),
|
|
282
|
+
};
|
|
283
|
+
})();
|
|
284
|
+
handles.set(app.name, pending);
|
|
285
|
+
return pending;
|
|
286
|
+
}
|
|
287
|
+
async function finish() {
|
|
288
|
+
active = false;
|
|
289
|
+
const result = [];
|
|
290
|
+
for (const [name, pending] of captures) {
|
|
291
|
+
let owner;
|
|
292
|
+
let recording = {
|
|
293
|
+
id: "",
|
|
294
|
+
name,
|
|
295
|
+
package: "",
|
|
296
|
+
openedAtMs: 0,
|
|
297
|
+
closedAtMs: Date.now() - testStartedAt,
|
|
298
|
+
mediaDurationMs: 0,
|
|
299
|
+
width: 0,
|
|
300
|
+
height: 0,
|
|
301
|
+
bytes: 0,
|
|
302
|
+
taps: taps.get(name) ?? [],
|
|
303
|
+
};
|
|
304
|
+
try {
|
|
305
|
+
const { app, id, openedAtMs } = await pending;
|
|
306
|
+
owner = app;
|
|
307
|
+
recording = { ...recording, id, package: app.package, openedAtMs };
|
|
308
|
+
const stopped = await request(`${app.recorderEndpoint}/recordings/${id}/stop`, "POST");
|
|
309
|
+
recording = {
|
|
310
|
+
...recording,
|
|
311
|
+
bytes: stopped.bytes,
|
|
312
|
+
width: stopped.width,
|
|
313
|
+
height: stopped.height,
|
|
314
|
+
mediaDurationMs: stopped.mediaDurationMs,
|
|
315
|
+
closedAtMs: Math.max(openedAtMs, stopped.closedAtMs - testStartedAt),
|
|
316
|
+
};
|
|
317
|
+
parked.set(id, { app, recording });
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
recording.error = String(error);
|
|
321
|
+
// A failed finalization still owns a file in the tools container.
|
|
322
|
+
if (recording.id) {
|
|
323
|
+
if (owner)
|
|
324
|
+
await request(`${owner.recorderEndpoint}/recordings/${recording.id}`, "DELETE").catch(() => { });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
result.push(recording);
|
|
328
|
+
}
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
begin,
|
|
333
|
+
open,
|
|
334
|
+
finish,
|
|
335
|
+
deactivate: () => {
|
|
336
|
+
active = false;
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
export async function androidRecordingChunk(id, offset, limit) {
|
|
341
|
+
const entry = parked.get(id);
|
|
342
|
+
if (!entry)
|
|
343
|
+
throw new Error("Unknown Android recording");
|
|
344
|
+
const start = Number(offset ?? 0), count = Number(limit ?? 6291456);
|
|
345
|
+
if (!Number.isSafeInteger(start) ||
|
|
346
|
+
start < 0 ||
|
|
347
|
+
!Number.isSafeInteger(count) ||
|
|
348
|
+
count < 1 ||
|
|
349
|
+
count > 6291456)
|
|
350
|
+
throw new Error("Invalid recording chunk range");
|
|
351
|
+
return request(`${entry.app.recorderEndpoint}/recordings/${id}?offset=${start}&limit=${count}`);
|
|
352
|
+
}
|
|
353
|
+
export async function releaseAndroidRecording(id) {
|
|
354
|
+
const entry = parked.get(id);
|
|
355
|
+
if (entry) {
|
|
356
|
+
await request(`${entry.app.recorderEndpoint}/recordings/${id}`, "DELETE");
|
|
357
|
+
parked.delete(id);
|
|
358
|
+
}
|
|
359
|
+
return { released: true };
|
|
360
|
+
}
|