@specific.dev/spectest 0.125.0 → 0.127.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android-step.d.ts +7 -0
- package/dist/android.d.ts +17 -3
- package/dist/android.js +122 -26
- package/dist/components/android-recording-state.mjs +28 -0
- package/dist/components/android-runtime.mjs +5 -14
- package/dist/components/android.d.ts +2 -2
- package/dist/components/android.js +24 -3
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.js +1 -1
- package/dist/index.d.ts +21 -1
- package/dist/index.js +24 -0
- package/package.json +1 -1
- package/src/android-actions.test.ts +121 -0
- package/src/android-step.ts +2 -0
- package/src/android.test.ts +3 -0
- package/src/android.ts +107 -33
- package/src/components/android-recording-state.mjs +28 -0
- package/src/components/android-runtime.mjs +5 -14
- package/src/components/android.test.ts +60 -0
- package/src/components/android.ts +26 -4
- package/src/components/index.ts +1 -1
- package/src/index.ts +34 -3
package/dist/android-step.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ export interface AndroidTarget {
|
|
|
8
8
|
export interface AndroidStepDetails {
|
|
9
9
|
device: string;
|
|
10
10
|
action: "tap" | "fill" | "text" | "visible" | "wait" | "swipe" | "back" | "source" | "screenshot" | "execute";
|
|
11
|
+
/** Normalized screen bounds observed by this operation. */
|
|
12
|
+
bounds?: {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
};
|
|
11
18
|
target?: AndroidTarget;
|
|
12
19
|
point?: {
|
|
13
20
|
x: number;
|
package/dist/android.d.ts
CHANGED
|
@@ -24,10 +24,24 @@ export declare class AppiumError extends Error {
|
|
|
24
24
|
readonly code: string;
|
|
25
25
|
constructor(code: string, message: string);
|
|
26
26
|
}
|
|
27
|
+
export declare const ANDROID_ASSERT: unique symbol;
|
|
28
|
+
export type AndroidMatcher = "toBeVisible" | "toBeHidden" | "toHaveText" | "toContainText";
|
|
29
|
+
export interface AndroidAssertionResult {
|
|
30
|
+
sourceSeq?: number;
|
|
31
|
+
actual?: unknown;
|
|
32
|
+
error?: unknown;
|
|
33
|
+
}
|
|
27
34
|
export interface AndroidElement {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
[ANDROID_ASSERT](matcher: AndroidMatcher, negated: boolean, expected: string | RegExp | undefined, timeoutMs?: number): Promise<AndroidAssertionResult>;
|
|
36
|
+
tap(options?: {
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
fill(text: string, options?: {
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}): Promise<void>;
|
|
42
|
+
text(options?: {
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
}): Promise<Wrapped<string>>;
|
|
31
45
|
isDisplayed(): Promise<Wrapped<boolean>>;
|
|
32
46
|
waitFor(options?: {
|
|
33
47
|
state?: "visible" | "hidden";
|
package/dist/android.js
CHANGED
|
@@ -42,6 +42,10 @@ async function session(app) {
|
|
|
42
42
|
capabilities: {
|
|
43
43
|
alwaysMatch: {
|
|
44
44
|
platformName: "Android",
|
|
45
|
+
// Conditions are explicit waits. Do not stack a session-wide lookup
|
|
46
|
+
// wait or UiSelector's own 10s wait underneath their deadline.
|
|
47
|
+
timeouts: { implicit: 0 },
|
|
48
|
+
"appium:settings[waitForSelectorTimeout]": 0,
|
|
45
49
|
"appium:automationName": "UiAutomator2",
|
|
46
50
|
"appium:udid": app.serial,
|
|
47
51
|
"appium:appPackage": app.package,
|
|
@@ -65,6 +69,7 @@ async function session(app) {
|
|
|
65
69
|
}
|
|
66
70
|
return pending;
|
|
67
71
|
}
|
|
72
|
+
export const ANDROID_ASSERT = Symbol.for("spectest.androidAssert");
|
|
68
73
|
const parked = new Map();
|
|
69
74
|
let epoch = 0;
|
|
70
75
|
/** A case owns capture and handle lifetime; the Android/Appium processes outlive it. */
|
|
@@ -155,13 +160,13 @@ export function androidScope(caseId, testStartedAt, options = {}) {
|
|
|
155
160
|
list.push(marker);
|
|
156
161
|
taps.set(app.name, list);
|
|
157
162
|
}
|
|
158
|
-
async function op(operation, fn) {
|
|
163
|
+
async function op(operation, fn, settled) {
|
|
159
164
|
assertActive();
|
|
160
165
|
const android = { device: app.name, ...operation };
|
|
161
166
|
const title = androidStepTitle(android);
|
|
162
167
|
const reservation = reserveEvent(), started = Date.now();
|
|
163
168
|
try {
|
|
164
|
-
const result = await fn();
|
|
169
|
+
const result = await fn(android);
|
|
165
170
|
const seq = recordStep({
|
|
166
171
|
kind: "android",
|
|
167
172
|
title,
|
|
@@ -169,10 +174,11 @@ export function androidScope(caseId, testStartedAt, options = {}) {
|
|
|
169
174
|
durationMs: Date.now() - started,
|
|
170
175
|
status: "passed",
|
|
171
176
|
}, reservation);
|
|
177
|
+
settled?.(seq);
|
|
172
178
|
return wrap(result, seq);
|
|
173
179
|
}
|
|
174
180
|
catch (error) {
|
|
175
|
-
recordStep({
|
|
181
|
+
const seq = recordStep({
|
|
176
182
|
kind: "android",
|
|
177
183
|
title,
|
|
178
184
|
android,
|
|
@@ -180,9 +186,46 @@ export function androidScope(caseId, testStartedAt, options = {}) {
|
|
|
180
186
|
status: "failed",
|
|
181
187
|
error: String(error),
|
|
182
188
|
}, reservation);
|
|
189
|
+
settled?.(seq);
|
|
183
190
|
throw error;
|
|
184
191
|
}
|
|
185
192
|
}
|
|
193
|
+
async function bounds(path, details) {
|
|
194
|
+
delete details.bounds;
|
|
195
|
+
if (!captures.has(app.name))
|
|
196
|
+
return;
|
|
197
|
+
try {
|
|
198
|
+
if (!await command("GET", `${path}/displayed`))
|
|
199
|
+
return;
|
|
200
|
+
const screen = await command("GET", "/window/rect");
|
|
201
|
+
const rect = await command("GET", `${path}/rect`);
|
|
202
|
+
const box = { x: rect.x / screen.width, y: rect.y / screen.height, width: rect.width / screen.width, height: rect.height / screen.height };
|
|
203
|
+
if (Object.values(box).every(Number.isFinite) && box.width > 0 && box.height > 0)
|
|
204
|
+
details.bounds = box;
|
|
205
|
+
}
|
|
206
|
+
catch { /* Replay decoration must not change the operation's result. */ }
|
|
207
|
+
}
|
|
208
|
+
const transient = (error) => error instanceof AppiumError && ["no such element", "stale element reference"].includes(error.code);
|
|
209
|
+
async function until(probe, label, timeoutMs = 10000) {
|
|
210
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
211
|
+
throw new Error("Invalid wait timeout");
|
|
212
|
+
const deadline = Date.now() + timeoutMs;
|
|
213
|
+
while (true) {
|
|
214
|
+
assertActive();
|
|
215
|
+
try {
|
|
216
|
+
const value = await probe();
|
|
217
|
+
if (value !== undefined)
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (!transient(error))
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
if (Date.now() >= deadline)
|
|
225
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
226
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(100, deadline - Date.now())));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
186
229
|
const element = (using, value, target = androidTarget(using, value)) => {
|
|
187
230
|
const label = androidTargetLabel(target);
|
|
188
231
|
const find = async () => {
|
|
@@ -195,49 +238,102 @@ export function androidScope(caseId, testStartedAt, options = {}) {
|
|
|
195
238
|
throw new Error("Appium returned no element id");
|
|
196
239
|
return `/element/${encodeURIComponent(id)}`;
|
|
197
240
|
};
|
|
198
|
-
const
|
|
241
|
+
const visiblePath = async () => {
|
|
199
242
|
try {
|
|
200
|
-
|
|
243
|
+
const path = await find();
|
|
244
|
+
return await command("GET", `${path}/displayed`) ? path : undefined;
|
|
201
245
|
}
|
|
202
246
|
catch (error) {
|
|
203
|
-
if (error
|
|
204
|
-
|
|
205
|
-
return false;
|
|
247
|
+
if (transient(error))
|
|
248
|
+
return undefined;
|
|
206
249
|
throw error;
|
|
207
250
|
}
|
|
208
251
|
};
|
|
252
|
+
const ready = (timeoutMs) => until(async () => {
|
|
253
|
+
const path = await visiblePath();
|
|
254
|
+
return path && await command("GET", `${path}/enabled`) ? path : undefined;
|
|
255
|
+
}, `${label} to be visible and enabled`, timeoutMs);
|
|
209
256
|
return {
|
|
210
|
-
async tap() {
|
|
257
|
+
async tap(options = {}) {
|
|
211
258
|
await op({ action: "tap", target }, async () => {
|
|
212
|
-
const path = await
|
|
259
|
+
const path = await ready(options.timeoutMs);
|
|
213
260
|
const marker = await tapMarker(path);
|
|
261
|
+
// Never retry an input dispatch: a transport failure may occur
|
|
262
|
+
// after the app has received it.
|
|
214
263
|
await command("POST", `${path}/click`, {});
|
|
215
264
|
saveTap(marker);
|
|
216
265
|
});
|
|
217
266
|
},
|
|
218
|
-
async fill(text) {
|
|
219
|
-
await op({ action: "fill", target }, async () => {
|
|
220
|
-
const path = await
|
|
267
|
+
async fill(text, options = {}) {
|
|
268
|
+
await op({ action: "fill", target }, async (details) => {
|
|
269
|
+
const path = await ready(options.timeoutMs);
|
|
221
270
|
await command("POST", `${path}/clear`, {});
|
|
222
271
|
await command("POST", `${path}/value`, { text });
|
|
272
|
+
await bounds(path, details);
|
|
223
273
|
});
|
|
224
274
|
},
|
|
225
|
-
text: () => op({ action: "text", target }, async () =>
|
|
226
|
-
|
|
275
|
+
text: (options = {}) => op({ action: "text", target }, async (details) => {
|
|
276
|
+
return until(async () => {
|
|
277
|
+
const path = await find();
|
|
278
|
+
const value = await command("GET", `${path}/text`);
|
|
279
|
+
await bounds(path, details);
|
|
280
|
+
return value;
|
|
281
|
+
}, label, options.timeoutMs);
|
|
282
|
+
}),
|
|
283
|
+
isDisplayed: () => op({ action: "visible", target }, async (details) => {
|
|
284
|
+
const path = await visiblePath();
|
|
285
|
+
if (path)
|
|
286
|
+
await bounds(path, details);
|
|
287
|
+
return !!path;
|
|
288
|
+
}),
|
|
227
289
|
async waitFor(options = {}) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
} while (Date.now() < end);
|
|
238
|
-
throw new Error(`Timed out waiting for ${label}`);
|
|
290
|
+
await op({ action: "wait", target, state: options.state ?? "visible" }, async (details) => {
|
|
291
|
+
await until(async () => {
|
|
292
|
+
const path = await visiblePath();
|
|
293
|
+
if (path)
|
|
294
|
+
await bounds(path, details);
|
|
295
|
+
else
|
|
296
|
+
delete details.bounds;
|
|
297
|
+
return (!!path === (options.state !== "hidden")) ? true : undefined;
|
|
298
|
+
}, `${label} to be ${options.state ?? "visible"}`, options.timeoutMs);
|
|
239
299
|
});
|
|
240
300
|
},
|
|
301
|
+
async [ANDROID_ASSERT](matcher, negated, expected, timeoutMs) {
|
|
302
|
+
let sourceSeq, actual, error;
|
|
303
|
+
const visibility = matcher === "toBeVisible" || matcher === "toBeHidden";
|
|
304
|
+
try {
|
|
305
|
+
await op({ action: visibility ? "wait" : "text", target,
|
|
306
|
+
...(visibility ? { state: ((matcher === "toBeVisible") !== negated ? "visible" : "hidden") } : {}) }, async (details) => {
|
|
307
|
+
await until(async () => {
|
|
308
|
+
let path;
|
|
309
|
+
let matches;
|
|
310
|
+
if (visibility) {
|
|
311
|
+
path = await visiblePath();
|
|
312
|
+
actual = !!path;
|
|
313
|
+
matches = matcher === "toBeVisible" ? !!path : !path;
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
path = await find();
|
|
317
|
+
const text = await command("GET", `${path}/text`);
|
|
318
|
+
actual = text;
|
|
319
|
+
const normalize = (value) => value.replace(/\s+/g, " ").trim();
|
|
320
|
+
matches = expected instanceof RegExp
|
|
321
|
+
? new RegExp(expected.source, expected.flags).test(text)
|
|
322
|
+
: matcher === "toContainText" ? normalize(text).includes(normalize(expected)) : normalize(text) === normalize(expected);
|
|
323
|
+
}
|
|
324
|
+
if (path)
|
|
325
|
+
await bounds(path, details);
|
|
326
|
+
else
|
|
327
|
+
delete details.bounds;
|
|
328
|
+
return matches !== negated ? true : undefined;
|
|
329
|
+
}, `${label} ${negated ? "not " : ""}${matcher}${expected === undefined ? "" : ` ${expected}`}`, timeoutMs);
|
|
330
|
+
}, seq => { sourceSeq = seq; });
|
|
331
|
+
}
|
|
332
|
+
catch (cause) {
|
|
333
|
+
error = cause;
|
|
334
|
+
}
|
|
335
|
+
return { sourceSeq, actual, error };
|
|
336
|
+
},
|
|
241
337
|
};
|
|
242
338
|
};
|
|
243
339
|
return {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// scrcpy opens its output before receiving codec metadata or a video frame.
|
|
2
|
+
// Only the patched recorder's first-frame signal makes an immediate stop safe.
|
|
3
|
+
export const FIRST_VIDEO_FRAME = "SPECTEST_FIRST_VIDEO_FRAME";
|
|
4
|
+
export const RECORDER_CAPABILITY = "spectest-first-frame-v1";
|
|
5
|
+
|
|
6
|
+
export function requireRecorderCapability(version) {
|
|
7
|
+
if (!version.includes(RECORDER_CAPABILITY)) {
|
|
8
|
+
throw new Error("Android toolsImage needs scrcpy with the spectest-first-frame-v1 patch; rebuild it from ANDROID_TOOLS_DOCKERFILE");
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function observeRecorderOutput(record, chunk) {
|
|
13
|
+
record.log = (record.log + chunk).slice(-16000);
|
|
14
|
+
if (!record.started && record.log.includes(FIRST_VIDEO_FRAME)) {
|
|
15
|
+
record.started = true;
|
|
16
|
+
record.startedAtMs = Date.now();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function waitForRecorderFrame(record, timeoutMs = 10000) {
|
|
21
|
+
const deadline = Date.now() + timeoutMs;
|
|
22
|
+
while (true) {
|
|
23
|
+
if (record.error) throw new Error(`${record.error}: ${record.log}`);
|
|
24
|
+
if (record.started) return { startedAtMs: record.startedAtMs };
|
|
25
|
+
if (Date.now() >= deadline) throw new Error(`Recorder first video frame timed out: ${record.log}`);
|
|
26
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(50, deadline - Date.now())));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Runs inside the Android tools container, in the same snapshotted VM.
|
|
2
2
|
import http from "node:http";
|
|
3
|
+
import { requireRecorderCapability, observeRecorderOutput, waitForRecorderFrame } from "./android-recording-state.mjs";
|
|
3
4
|
import { spawn, execFile } from "node:child_process";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import { mkdir, stat, open, rm } from "node:fs/promises";
|
|
@@ -74,6 +75,7 @@ bootstrap().catch((error) => {
|
|
|
74
75
|
});
|
|
75
76
|
|
|
76
77
|
async function startRecording(body) {
|
|
78
|
+
requireRecorderCapability((await exec("scrcpy", ["--version"], { timeout: 5000 })).stdout);
|
|
77
79
|
if (!/^[a-z0-9-]{1,100}$/.test(body.id)) throw new Error("invalid recording id");
|
|
78
80
|
if (recordings.has(body.id)) throw new Error("recording already exists");
|
|
79
81
|
if ([...recordings.values()].some((r) => !r.stopped))
|
|
@@ -121,19 +123,13 @@ async function startRecording(body) {
|
|
|
121
123
|
record.error = error.message;
|
|
122
124
|
resolve(-1);
|
|
123
125
|
});
|
|
124
|
-
child.on("
|
|
126
|
+
child.on("close", (code) => {
|
|
125
127
|
if (!record.stopped) record.error = `Recorder exited early (${code})`;
|
|
126
128
|
resolve(code);
|
|
127
129
|
});
|
|
128
130
|
});
|
|
129
131
|
for (const output of [child.stdout, child.stderr])
|
|
130
|
-
output.on("data", (b) =>
|
|
131
|
-
record.log = (record.log + b).slice(-16000);
|
|
132
|
-
if (!record.started && record.log.includes("Recording started")) {
|
|
133
|
-
record.started = true;
|
|
134
|
-
record.startedAtMs = Date.now();
|
|
135
|
-
}
|
|
136
|
-
});
|
|
132
|
+
output.on("data", (b) => observeRecorderOutput(record, b));
|
|
137
133
|
record.guard = setInterval(async () => {
|
|
138
134
|
if ((await stat(path).catch(() => ({ size: 0 }))).size > maxBytes) {
|
|
139
135
|
record.error = "Recording exceeded 256 MiB";
|
|
@@ -141,12 +137,7 @@ async function startRecording(body) {
|
|
|
141
137
|
}
|
|
142
138
|
}, 1000);
|
|
143
139
|
try {
|
|
144
|
-
|
|
145
|
-
if (record.error) throw new Error(`${record.error}: ${record.log}`);
|
|
146
|
-
if (record.started) return { startedAtMs: record.startedAtMs };
|
|
147
|
-
await sleep(50);
|
|
148
|
-
}
|
|
149
|
-
throw new Error(`Recorder startup timed out: ${record.log}`);
|
|
140
|
+
return await waitForRecorderFrame(record);
|
|
150
141
|
} catch (error) {
|
|
151
142
|
clearInterval(record.guard);
|
|
152
143
|
record.stopped = true;
|
|
@@ -6,7 +6,7 @@ export interface AndroidOptions {
|
|
|
6
6
|
package: string;
|
|
7
7
|
/** Launch activity, for example .MainActivity. */
|
|
8
8
|
activity: string;
|
|
9
|
-
/** Android image; default is the
|
|
9
|
+
/** Android image; default is the pinned Android 16 64-bit-only redroid image. */
|
|
10
10
|
image?: string;
|
|
11
11
|
/** Optional prebuilt image containing the same tools as ANDROID_TOOLS_DOCKERFILE. */
|
|
12
12
|
toolsImage?: string;
|
|
@@ -19,7 +19,7 @@ export interface AndroidOptions {
|
|
|
19
19
|
recording?: false | AndroidRecordingOptions;
|
|
20
20
|
dependsOn?: string[];
|
|
21
21
|
}
|
|
22
|
-
export declare const ANDROID_TOOLS_DOCKERFILE
|
|
22
|
+
export declare const ANDROID_TOOLS_DOCKERFILE: string;
|
|
23
23
|
/** Native Android, Appium and software recording, all inside the environment VM. */
|
|
24
24
|
export declare function android(options: AndroidOptions): import("../index.js").ServiceGroup<{
|
|
25
25
|
device: {
|
|
@@ -4,9 +4,26 @@ import { serviceGroup } from "../index.js";
|
|
|
4
4
|
import { resolveExistingProjectPath } from "../project-files.js";
|
|
5
5
|
import { androidApp } from "../android.js";
|
|
6
6
|
import { ENVIRONMENT_CA_PATH } from "../environment-ca.js";
|
|
7
|
+
// Instrument the pinned recorder at the point its first real video packet is
|
|
8
|
+
// retained. This follows the MP4 header and survives an immediate SIGINT.
|
|
9
|
+
const recorderPatch = String.raw `from pathlib import Path
|
|
10
|
+
p = Path('/tmp/scrcpy-3.3.1/app/src/recorder.c')
|
|
11
|
+
s = p.read_text()
|
|
12
|
+
a = ' int64_t pts_origin = AV_NOPTS_VALUE;'
|
|
13
|
+
b = ' video_pkt_previous = video_pkt;'
|
|
14
|
+
assert s.count(a) == 1 and s.count(b) == 1
|
|
15
|
+
s = s.replace(a, a + '\n bool spectest_first_frame = true;')
|
|
16
|
+
s = s.replace(b, b + '\n if (spectest_first_frame) {\n LOGI("SPECTEST_FIRST_VIDEO_FRAME");\n spectest_first_frame = false;\n }')
|
|
17
|
+
p.write_text(s)
|
|
18
|
+
p = Path('/tmp/scrcpy-3.3.1/app/src/version.c')
|
|
19
|
+
s = p.read_text()
|
|
20
|
+
a = 'scrcpy_print_version(void) {'
|
|
21
|
+
assert s.count(a) == 1
|
|
22
|
+
p.write_text(s.replace(a, a + '\n puts("spectest-first-frame-v1");'))`;
|
|
23
|
+
const shellQuote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
7
24
|
export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
|
|
8
|
-
RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
|
|
9
|
-
RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
|
|
25
|
+
RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc python3 pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
|
|
26
|
+
RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && python3 -c ${shellQuote(recorderPatch.replaceAll("\n", ";"))} && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
|
|
10
27
|
ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
|
|
11
28
|
RUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0
|
|
12
29
|
`;
|
|
@@ -30,7 +47,7 @@ export function android(options) {
|
|
|
30
47
|
image: {
|
|
31
48
|
type: "registry",
|
|
32
49
|
reference: options.image ??
|
|
33
|
-
"redroid/redroid:
|
|
50
|
+
"redroid/redroid:16.0.0_64only-latest@sha256:7b1e389bd15f37af3bcd06138f5b2ffa7cfba4332bd5ef54c53e99c2f160a15b",
|
|
34
51
|
},
|
|
35
52
|
privileged: true,
|
|
36
53
|
// Android's netd ignores resolv.conf. Use Docker's per-container DNS,
|
|
@@ -62,6 +79,10 @@ export function android(options) {
|
|
|
62
79
|
path: "/spectest-android.mjs",
|
|
63
80
|
content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
|
|
64
81
|
},
|
|
82
|
+
{
|
|
83
|
+
path: "/android-recording-state.mjs",
|
|
84
|
+
content: readFileSync(new URL("./android-recording-state.mjs", import.meta.url), "utf8"),
|
|
85
|
+
},
|
|
65
86
|
{
|
|
66
87
|
path: "/spectest-android-trust.mjs",
|
|
67
88
|
content: readFileSync(new URL("./android-trust.mjs", import.meta.url), "utf8"),
|
|
@@ -15,4 +15,4 @@ export { microsoft, type MicrosoftOptions } from "./microsoft.js";
|
|
|
15
15
|
export { okta, type OktaOptions } from "./okta.js";
|
|
16
16
|
export { replayFake, type ReplayFakeOptions, type ReplayFakeContext, type ReplayHandler, type ReplayKey, type WebSocketKey, type ReplayWebSocketMessage, type CassetteWebSocket, type CassetteWebSocketMessage, type WebSocketTiming, type ReplayRequest, type MatchKey, type JsonValue, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
|
|
17
17
|
export { electron, type ElectronOptions } from "./electron.js";
|
|
18
|
-
export { android, type AndroidOptions } from "./android.js";
|
|
18
|
+
export { android, ANDROID_TOOLS_DOCKERFILE, type AndroidOptions } from "./android.js";
|
package/dist/components/index.js
CHANGED
|
@@ -24,4 +24,4 @@ export { microsoft } from "./microsoft.js";
|
|
|
24
24
|
export { okta } from "./okta.js";
|
|
25
25
|
export { replayFake, } from "./replayFake.js";
|
|
26
26
|
export { electron } from "./electron.js";
|
|
27
|
-
export { android } from "./android.js";
|
|
27
|
+
export { android, ANDROID_TOOLS_DOCKERFILE } from "./android.js";
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { InjectRule, SignConfig } from "./fake-upstream.js";
|
|
|
4
4
|
export type { InjectRule, InjectMatch, StringMatch, SignConfig, AwsSigV4Sign } from "./fake-upstream.js";
|
|
5
5
|
export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
|
|
6
6
|
import type { Phone, PhoneOptions } from "./phone.js";
|
|
7
|
-
import type
|
|
7
|
+
import { type Android, type AndroidApp, type AndroidElement } from "./android.js";
|
|
8
8
|
import { type InteractiveEntrypoint, type InteractiveEntrypoints } from "./interactive.js";
|
|
9
9
|
export type { InteractiveEntrypoint, InteractiveEntrypoints } from "./interactive.js";
|
|
10
10
|
export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
|
|
@@ -1959,9 +1959,29 @@ export interface BrowserAssertion extends BrowserMatchers {
|
|
|
1959
1959
|
/** Negate every matcher (retries until the negated condition holds). */
|
|
1960
1960
|
not: BrowserMatchers;
|
|
1961
1961
|
}
|
|
1962
|
+
export declare function expect(actual: AndroidElement, message?: string): AndroidAssertion;
|
|
1962
1963
|
export declare function expect(actual: Locator, message?: string): LocatorAssertion;
|
|
1963
1964
|
export declare function expect(actual: Browser, message?: string): BrowserAssertion;
|
|
1964
1965
|
export declare function expect(actual: Provenanced, message?: string): Expectation;
|
|
1966
|
+
/** Retrying native Android assertions. Timeout is in milliseconds (default 10 s;
|
|
1967
|
+
* zero performs one observation). Each assertion records one Android step. */
|
|
1968
|
+
export interface AndroidMatchers {
|
|
1969
|
+
toBeVisible(options?: {
|
|
1970
|
+
timeout?: number;
|
|
1971
|
+
}): Promise<void>;
|
|
1972
|
+
toBeHidden(options?: {
|
|
1973
|
+
timeout?: number;
|
|
1974
|
+
}): Promise<void>;
|
|
1975
|
+
toHaveText(expected: string | RegExp, options?: {
|
|
1976
|
+
timeout?: number;
|
|
1977
|
+
}): Promise<void>;
|
|
1978
|
+
toContainText(expected: string | RegExp, options?: {
|
|
1979
|
+
timeout?: number;
|
|
1980
|
+
}): Promise<void>;
|
|
1981
|
+
}
|
|
1982
|
+
export interface AndroidAssertion extends AndroidMatchers {
|
|
1983
|
+
not: AndroidMatchers;
|
|
1984
|
+
}
|
|
1965
1985
|
/**
|
|
1966
1986
|
* Assert on a value with **no provenance** — a computed number, a frame read
|
|
1967
1987
|
* off a raw `WebSocket` you opened yourself, anything that never flowed from a
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { validateServiceNetwork } from "./harness/service-network.js";
|
|
2
2
|
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
import { ANDROID_ASSERT } from "./android.js";
|
|
3
4
|
import { resolveEntrypoints } from "./interactive.js";
|
|
4
5
|
// Spectest SDK. The user's single `spectest/index.ts` calls
|
|
5
6
|
// `defineEnvironment({ name, services })` once, defines test cases via
|
|
@@ -663,12 +664,35 @@ export class ExpectationError extends Error {
|
|
|
663
664
|
}
|
|
664
665
|
}
|
|
665
666
|
export function expect(actual, message) {
|
|
667
|
+
if (actual && typeof actual === "object" && ANDROID_ASSERT in actual)
|
|
668
|
+
return buildAndroidAssertion(actual, message);
|
|
666
669
|
if (isLocator(actual))
|
|
667
670
|
return buildLocatorAssertion(actual, message);
|
|
668
671
|
if (isBrowserSession(actual))
|
|
669
672
|
return buildBrowserAssertion(actual, message);
|
|
670
673
|
return expectValue(actual, message);
|
|
671
674
|
}
|
|
675
|
+
function buildAndroidAssertion(element, message) {
|
|
676
|
+
const matchers = (negated) => {
|
|
677
|
+
const run = async (matcher, expected, timeout) => {
|
|
678
|
+
const result = await element[ANDROID_ASSERT](matcher, negated, expected, timeout);
|
|
679
|
+
const error = result.error === undefined ? undefined : new ExpectationError(String(result.error));
|
|
680
|
+
if (error)
|
|
681
|
+
error.cause = result.error;
|
|
682
|
+
recordAssertion({ matcher, negated, passed: !error, message, sourceSeq: result.sourceSeq,
|
|
683
|
+
actual: safeSerialize(result.actual), expected: expected === undefined ? undefined : safeSerialize(assertionExpected(expected)), error: error?.message });
|
|
684
|
+
if (error)
|
|
685
|
+
throw error;
|
|
686
|
+
};
|
|
687
|
+
return {
|
|
688
|
+
toBeVisible: options => run("toBeVisible", undefined, options?.timeout),
|
|
689
|
+
toBeHidden: options => run("toBeHidden", undefined, options?.timeout),
|
|
690
|
+
toHaveText: (expected, options) => run("toHaveText", expected, options?.timeout),
|
|
691
|
+
toContainText: (expected, options) => run("toContainText", expected, options?.timeout),
|
|
692
|
+
};
|
|
693
|
+
};
|
|
694
|
+
return Object.assign(matchers(false), { not: matchers(true) });
|
|
695
|
+
}
|
|
672
696
|
function expectValue(actual, message) {
|
|
673
697
|
// The first parameter is typed to the {@link Provenanced} family so a raw
|
|
674
698
|
// value (`expect(res.status === 200)`, `expect(2 + 2)`) is a *compile error* —
|
package/package.json
CHANGED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { androidApp, androidScope } from "./android.js";
|
|
3
|
+
import { expect as assert, ExpectationError } from "./index.js";
|
|
4
|
+
import { startRecording, stopRecording } from "./recorder.js";
|
|
5
|
+
|
|
6
|
+
const cleanups: (() => void)[] = [];
|
|
7
|
+
afterEach(() => { stopRecording(); cleanups.splice(0).forEach(fn => fn()); });
|
|
8
|
+
|
|
9
|
+
async function fixture() {
|
|
10
|
+
let finds = 0, clicks = 0, clears = 0, values = 0;
|
|
11
|
+
let missing = 0, invisible = 0, disabled = 0;
|
|
12
|
+
let text = "Saved", invalid = false, failClick = false;
|
|
13
|
+
const server = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(req) {
|
|
14
|
+
const path = new URL(req.url).pathname;
|
|
15
|
+
const value = (value: unknown) => Response.json({ value });
|
|
16
|
+
const error = (error: string) => Response.json({ value: { error, message: error } }, { status: 404 });
|
|
17
|
+
if (path === "/recordings") return value({ startedAtMs: Date.now() });
|
|
18
|
+
if (path === "/session") return value({ sessionId: "test" });
|
|
19
|
+
if (path.endsWith("/element")) {
|
|
20
|
+
finds++;
|
|
21
|
+
if (invalid) return error("invalid selector");
|
|
22
|
+
if (missing-- > 0) return error("no such element");
|
|
23
|
+
return value({ "element-6066-11e4-a52e-4f735466cecf": `e${finds}` });
|
|
24
|
+
}
|
|
25
|
+
if (path.endsWith("/displayed")) return value(!(invisible-- > 0));
|
|
26
|
+
if (path.endsWith("/enabled")) return value(!(disabled-- > 0));
|
|
27
|
+
if (path.endsWith("/window/rect")) return value({ width: 100, height: 200 });
|
|
28
|
+
if (path.endsWith("/rect")) return value({ x: 10, y: 40, width: 80, height: 20 });
|
|
29
|
+
if (path.endsWith("/text")) return value(text);
|
|
30
|
+
if (path.endsWith("/clear")) { clears++; return value(null); }
|
|
31
|
+
if (path.endsWith("/value")) { values++; return value(null); }
|
|
32
|
+
if (path.endsWith("/click")) { clicks++; return failClick ? error("stale element reference") : value(null); }
|
|
33
|
+
return value(null);
|
|
34
|
+
} });
|
|
35
|
+
const endpoint = `http://127.0.0.1:${server.port}`;
|
|
36
|
+
const scope = androidScope(`test-${server.port}`, Date.now());
|
|
37
|
+
cleanups.push(() => { scope.deactivate(); server.stop(true); });
|
|
38
|
+
startRecording();
|
|
39
|
+
const phone = await scope.open(androidApp({ name: "phone", endpoint, recorderEndpoint: endpoint, serial: "device", package: "app", recording: {} }));
|
|
40
|
+
return { scope, phone, stats: () => ({ finds, clicks, clears, values }),
|
|
41
|
+
configure(options: { missing?: number; invisible?: number; disabled?: number; text?: string; invalid?: boolean; failClick?: boolean }) {
|
|
42
|
+
missing = options.missing ?? 0; invisible = options.invisible ?? 0; disabled = options.disabled ?? 0;
|
|
43
|
+
text = options.text ?? text; invalid = options.invalid ?? false; failClick = options.failClick ?? false;
|
|
44
|
+
} };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test("tap and fill wait for visible enabled elements, recording one step each", async () => {
|
|
48
|
+
const f = await fixture();
|
|
49
|
+
f.configure({ missing: 1, invisible: 1, disabled: 1 });
|
|
50
|
+
await f.phone.getByText("Save").tap({ timeoutMs: 1000 });
|
|
51
|
+
f.configure({ missing: 1 });
|
|
52
|
+
await f.phone.getById("draft").fill("private text", { timeoutMs: 1000 });
|
|
53
|
+
expect(f.stats()).toMatchObject({ clicks: 1, clears: 1, values: 1 });
|
|
54
|
+
const events = stopRecording();
|
|
55
|
+
expect(events).toHaveLength(2);
|
|
56
|
+
expect(events[1]).toMatchObject({ android: { action: "fill", bounds: { x: 0.1, y: 0.2, width: 0.8, height: 0.1 } } });
|
|
57
|
+
expect(JSON.stringify(events)).not.toContain("private text");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("native assertions retry without emitting polling attempts and preserve provenance", async () => {
|
|
61
|
+
const f = await fixture();
|
|
62
|
+
f.configure({ missing: 2 });
|
|
63
|
+
await assert(f.phone.getByText("Saved"), "save completes").toBeVisible({ timeout: 1000 });
|
|
64
|
+
f.configure({ text: "Loading" });
|
|
65
|
+
const change = setTimeout(() => f.configure({ text: " Saved\n successfully " }), 150);
|
|
66
|
+
await assert(f.phone.getById("status")).toHaveText("Saved successfully", { timeout: 1000 });
|
|
67
|
+
clearTimeout(change);
|
|
68
|
+
await assert(f.phone.getById("status")).toContainText(/Saved/g);
|
|
69
|
+
await assert(f.phone.getById("status")).not.toHaveText("Loading");
|
|
70
|
+
f.configure({ missing: 10 });
|
|
71
|
+
await assert(f.phone.getById("gone")).toBeHidden({ timeout: 0 });
|
|
72
|
+
await assert(f.phone.getById("gone")).not.toBeVisible({ timeout: 0 });
|
|
73
|
+
const events = stopRecording();
|
|
74
|
+
const steps = events.filter(e => e.kind === "android");
|
|
75
|
+
const assertions = events.filter(e => e.kind === "assertion");
|
|
76
|
+
expect(steps).toHaveLength(6);
|
|
77
|
+
expect(assertions).toHaveLength(6);
|
|
78
|
+
for (let i = 0; i < steps.length; i++) expect(assertions[i]).toMatchObject({ sourceSeq: steps[i].seq, passed: true });
|
|
79
|
+
expect(steps[0]).toMatchObject({ android: { bounds: { x: 0.1, y: 0.2, width: 0.8, height: 0.1 } } });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("timeouts and invalid selectors fail once, and dispatched clicks are never retried", async () => {
|
|
83
|
+
const f = await fixture();
|
|
84
|
+
f.configure({ missing: 100 });
|
|
85
|
+
await expect(assert(f.phone.getById("missing")).toBeVisible({ timeout: 0 })).rejects.toBeInstanceOf(ExpectationError);
|
|
86
|
+
expect(f.stats().finds).toBe(1);
|
|
87
|
+
f.configure({ invalid: true });
|
|
88
|
+
await expect(f.phone.getById("bad").tap()).rejects.toThrow("invalid selector");
|
|
89
|
+
expect(f.stats().finds).toBe(2);
|
|
90
|
+
f.configure({ failClick: true });
|
|
91
|
+
await expect(f.phone.getById("save").tap()).rejects.toThrow("stale element reference");
|
|
92
|
+
expect(f.stats().clicks).toBe(1);
|
|
93
|
+
const events = stopRecording();
|
|
94
|
+
expect(events.filter(e => e.kind === "android")).toHaveLength(3);
|
|
95
|
+
expect(events.find(e => e.kind === "assertion")).toMatchObject({ passed: false, sourceSeq: events[0].seq });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("immediate visibility checks stay immediate; read waits and canceled waits stop", async () => {
|
|
99
|
+
const f = await fixture();
|
|
100
|
+
f.configure({ missing: 100 });
|
|
101
|
+
expect((await f.phone.getById("missing").isDisplayed()).unwrap()).toBe(false);
|
|
102
|
+
expect(f.stats().finds).toBe(1);
|
|
103
|
+
f.configure({ missing: 1 });
|
|
104
|
+
expect((await f.phone.getById("status").text({ timeoutMs: 500 })).unwrap()).toBe("Saved");
|
|
105
|
+
f.configure({ missing: 100 });
|
|
106
|
+
const pending = f.phone.getById("missing").waitFor();
|
|
107
|
+
setTimeout(() => f.scope.deactivate(), 30);
|
|
108
|
+
await expect(pending).rejects.toThrow("finished test");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("a completed disappearance wait clears the earlier visible target, like browser replay", async () => {
|
|
112
|
+
const f = await fixture();
|
|
113
|
+
const hide = setTimeout(() => f.configure({ missing: 100 }), 50);
|
|
114
|
+
await assert(f.phone.getById("spinner")).toBeHidden({ timeout: 1000 });
|
|
115
|
+
clearTimeout(hide);
|
|
116
|
+
const events = stopRecording();
|
|
117
|
+
const step = events.find(e => e.kind === "android")!;
|
|
118
|
+
expect(step).toMatchObject({ android: { state: "hidden" }, status: "passed" });
|
|
119
|
+
expect(step.android?.bounds).toBeUndefined();
|
|
120
|
+
expect(f.stats().finds).toBeGreaterThan(1);
|
|
121
|
+
});
|
package/src/android-step.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface AndroidStepDetails {
|
|
|
18
18
|
| "source"
|
|
19
19
|
| "screenshot"
|
|
20
20
|
| "execute";
|
|
21
|
+
/** Normalized screen bounds observed by this operation. */
|
|
22
|
+
bounds?: { x: number; y: number; width: number; height: number };
|
|
21
23
|
target?: AndroidTarget;
|
|
22
24
|
point?: { x: number; y: number };
|
|
23
25
|
direction?: "up" | "down" | "left" | "right";
|
package/src/android.test.ts
CHANGED
|
@@ -57,6 +57,7 @@ describe("Android case lifecycle", () => {
|
|
|
57
57
|
return Response.json({ value: { error: "stale element reference", message: "Gone" } }, { status: 404 });
|
|
58
58
|
if (path.endsWith("/element"))
|
|
59
59
|
return json({ value: { "element-6066-11e4-a52e-4f735466cecf": "element-1" } });
|
|
60
|
+
if (path.endsWith("/displayed") || path.endsWith("/enabled")) return json({ value: true });
|
|
60
61
|
if (path.endsWith("/text")) return json({ value: "inherited draft" });
|
|
61
62
|
if (path.endsWith("/window/rect")) return json({ value: { width: 1080, height: 2400 } });
|
|
62
63
|
if (path.endsWith("/rect")) return json({ value: { x: 100, y: 400, width: 880, height: 160 } });
|
|
@@ -135,6 +136,8 @@ describe("Android case lifecycle", () => {
|
|
|
135
136
|
const starts = requests.filter((r) => r.path === "/session");
|
|
136
137
|
expect(starts).toHaveLength(1);
|
|
137
138
|
expect(starts[0].body.capabilities.alwaysMatch).toMatchObject({
|
|
139
|
+
timeouts: { implicit: 0 },
|
|
140
|
+
"appium:settings[waitForSelectorTimeout]": 0,
|
|
138
141
|
"appium:noReset": true,
|
|
139
142
|
"appium:autoLaunch": false,
|
|
140
143
|
"appium:newCommandTimeout": 0,
|
package/src/android.ts
CHANGED
|
@@ -70,6 +70,10 @@ async function session(app: AndroidApp): Promise<string> {
|
|
|
70
70
|
capabilities: {
|
|
71
71
|
alwaysMatch: {
|
|
72
72
|
platformName: "Android",
|
|
73
|
+
// Conditions are explicit waits. Do not stack a session-wide lookup
|
|
74
|
+
// wait or UiSelector's own 10s wait underneath their deadline.
|
|
75
|
+
timeouts: { implicit: 0 },
|
|
76
|
+
"appium:settings[waitForSelectorTimeout]": 0,
|
|
73
77
|
"appium:automationName": "UiAutomator2",
|
|
74
78
|
"appium:udid": app.serial,
|
|
75
79
|
"appium:appPackage": app.package,
|
|
@@ -93,10 +97,14 @@ async function session(app: AndroidApp): Promise<string> {
|
|
|
93
97
|
return pending;
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
export const ANDROID_ASSERT: unique symbol = Symbol.for("spectest.androidAssert");
|
|
101
|
+
export type AndroidMatcher = "toBeVisible" | "toBeHidden" | "toHaveText" | "toContainText";
|
|
102
|
+
export interface AndroidAssertionResult { sourceSeq?: number; actual?: unknown; error?: unknown }
|
|
96
103
|
export interface AndroidElement {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
text
|
|
104
|
+
[ANDROID_ASSERT](matcher: AndroidMatcher, negated: boolean, expected: string | RegExp | undefined, timeoutMs?: number): Promise<AndroidAssertionResult>;
|
|
105
|
+
tap(options?: { timeoutMs?: number }): Promise<void>;
|
|
106
|
+
fill(text: string, options?: { timeoutMs?: number }): Promise<void>;
|
|
107
|
+
text(options?: { timeoutMs?: number }): Promise<Wrapped<string>>;
|
|
100
108
|
isDisplayed(): Promise<Wrapped<boolean>>;
|
|
101
109
|
waitFor(options?: { state?: "visible" | "hidden"; timeoutMs?: number }): Promise<void>;
|
|
102
110
|
}
|
|
@@ -227,14 +235,14 @@ export function androidScope(
|
|
|
227
235
|
if (list.length < 10000) list.push(marker);
|
|
228
236
|
taps.set(app.name, list);
|
|
229
237
|
}
|
|
230
|
-
async function op<T>(operation: Omit<AndroidStepDetails, "device">, fn: () => Promise<T
|
|
238
|
+
async function op<T>(operation: Omit<AndroidStepDetails, "device">, fn: (details: AndroidStepDetails) => Promise<T>, settled?: (seq: number | undefined) => void): Promise<Wrapped<T>> {
|
|
231
239
|
assertActive();
|
|
232
240
|
const android = { device: app.name, ...operation };
|
|
233
241
|
const title = androidStepTitle(android);
|
|
234
242
|
const reservation = reserveEvent(),
|
|
235
243
|
started = Date.now();
|
|
236
244
|
try {
|
|
237
|
-
const result = await fn();
|
|
245
|
+
const result = await fn(android);
|
|
238
246
|
const seq = recordStep(
|
|
239
247
|
{
|
|
240
248
|
kind: "android",
|
|
@@ -245,9 +253,10 @@ export function androidScope(
|
|
|
245
253
|
},
|
|
246
254
|
reservation,
|
|
247
255
|
);
|
|
256
|
+
settled?.(seq);
|
|
248
257
|
return wrap(result, seq) as Wrapped<T>;
|
|
249
258
|
} catch (error) {
|
|
250
|
-
recordStep(
|
|
259
|
+
const seq = recordStep(
|
|
251
260
|
{
|
|
252
261
|
kind: "android",
|
|
253
262
|
title,
|
|
@@ -258,9 +267,35 @@ export function androidScope(
|
|
|
258
267
|
},
|
|
259
268
|
reservation,
|
|
260
269
|
);
|
|
270
|
+
settled?.(seq);
|
|
261
271
|
throw error;
|
|
262
272
|
}
|
|
263
273
|
}
|
|
274
|
+
async function bounds(path: string, details: AndroidStepDetails) {
|
|
275
|
+
delete details.bounds;
|
|
276
|
+
if (!captures.has(app.name)) return;
|
|
277
|
+
try {
|
|
278
|
+
if (!await command<boolean>("GET", `${path}/displayed`)) return;
|
|
279
|
+
const screen = await command<{ width: number; height: number }>("GET", "/window/rect");
|
|
280
|
+
const rect = await command<{ x: number; y: number; width: number; height: number }>("GET", `${path}/rect`);
|
|
281
|
+
const box = { x: rect.x / screen.width, y: rect.y / screen.height, width: rect.width / screen.width, height: rect.height / screen.height };
|
|
282
|
+
if (Object.values(box).every(Number.isFinite) && box.width > 0 && box.height > 0) details.bounds = box;
|
|
283
|
+
} catch { /* Replay decoration must not change the operation's result. */ }
|
|
284
|
+
}
|
|
285
|
+
const transient = (error: unknown) => error instanceof AppiumError && ["no such element", "stale element reference"].includes(error.code);
|
|
286
|
+
async function until<T>(probe: () => Promise<T | undefined>, label: string, timeoutMs = 10000): Promise<T> {
|
|
287
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error("Invalid wait timeout");
|
|
288
|
+
const deadline = Date.now() + timeoutMs;
|
|
289
|
+
while (true) {
|
|
290
|
+
assertActive();
|
|
291
|
+
try {
|
|
292
|
+
const value = await probe();
|
|
293
|
+
if (value !== undefined) return value;
|
|
294
|
+
} catch (error) { if (!transient(error)) throw error; }
|
|
295
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${label}`);
|
|
296
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(100, deadline - Date.now())));
|
|
297
|
+
}
|
|
298
|
+
}
|
|
264
299
|
const element = (using: string, value: string, target: AndroidTarget = androidTarget(using, value)): AndroidElement => {
|
|
265
300
|
const label = androidTargetLabel(target);
|
|
266
301
|
const find = async () => {
|
|
@@ -272,49 +307,88 @@ export function androidScope(
|
|
|
272
307
|
if (!id) throw new Error("Appium returned no element id");
|
|
273
308
|
return `/element/${encodeURIComponent(id)}`;
|
|
274
309
|
};
|
|
275
|
-
const
|
|
310
|
+
const visiblePath = async () => {
|
|
276
311
|
try {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
error instanceof AppiumError &&
|
|
281
|
-
["no such element", "stale element reference"].includes(error.code)
|
|
282
|
-
)
|
|
283
|
-
return false;
|
|
284
|
-
throw error;
|
|
285
|
-
}
|
|
312
|
+
const path = await find();
|
|
313
|
+
return await command<boolean>("GET", `${path}/displayed`) ? path : undefined;
|
|
314
|
+
} catch (error) { if (transient(error)) return undefined; throw error; }
|
|
286
315
|
};
|
|
316
|
+
const ready = (timeoutMs?: number) => until(async () => {
|
|
317
|
+
const path = await visiblePath();
|
|
318
|
+
return path && await command<boolean>("GET", `${path}/enabled`) ? path : undefined;
|
|
319
|
+
}, `${label} to be visible and enabled`, timeoutMs);
|
|
287
320
|
return {
|
|
288
|
-
async tap() {
|
|
321
|
+
async tap(options = {}) {
|
|
289
322
|
await op({ action: "tap", target }, async () => {
|
|
290
|
-
const path = await
|
|
323
|
+
const path = await ready(options.timeoutMs);
|
|
291
324
|
const marker = await tapMarker(path);
|
|
325
|
+
// Never retry an input dispatch: a transport failure may occur
|
|
326
|
+
// after the app has received it.
|
|
292
327
|
await command("POST", `${path}/click`, {});
|
|
293
328
|
saveTap(marker);
|
|
294
329
|
});
|
|
295
330
|
},
|
|
296
|
-
async fill(text) {
|
|
297
|
-
await op({ action: "fill", target }, async () => {
|
|
298
|
-
const path = await
|
|
331
|
+
async fill(text, options = {}) {
|
|
332
|
+
await op({ action: "fill", target }, async (details) => {
|
|
333
|
+
const path = await ready(options.timeoutMs);
|
|
299
334
|
await command("POST", `${path}/clear`, {});
|
|
300
335
|
await command("POST", `${path}/value`, { text });
|
|
336
|
+
await bounds(path, details);
|
|
301
337
|
});
|
|
302
338
|
},
|
|
303
|
-
text: () =>
|
|
304
|
-
|
|
305
|
-
|
|
339
|
+
text: (options = {}) => op({ action: "text", target }, async (details) => {
|
|
340
|
+
return until(async () => {
|
|
341
|
+
const path = await find();
|
|
342
|
+
const value = await command<string>("GET", `${path}/text`);
|
|
343
|
+
await bounds(path, details);
|
|
344
|
+
return value;
|
|
345
|
+
}, label, options.timeoutMs);
|
|
346
|
+
}),
|
|
347
|
+
isDisplayed: () => op({ action: "visible", target }, async (details) => {
|
|
348
|
+
const path = await visiblePath();
|
|
349
|
+
if (path) await bounds(path, details);
|
|
350
|
+
return !!path;
|
|
351
|
+
}),
|
|
306
352
|
async waitFor(options = {}) {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
} while (Date.now() < end);
|
|
315
|
-
throw new Error(`Timed out waiting for ${label}`);
|
|
353
|
+
await op({ action: "wait", target, state: options.state ?? "visible" }, async (details) => {
|
|
354
|
+
await until(async () => {
|
|
355
|
+
const path = await visiblePath();
|
|
356
|
+
if (path) await bounds(path, details);
|
|
357
|
+
else delete details.bounds;
|
|
358
|
+
return (!!path === (options.state !== "hidden")) ? true : undefined;
|
|
359
|
+
}, `${label} to be ${options.state ?? "visible"}`, options.timeoutMs);
|
|
316
360
|
});
|
|
317
361
|
},
|
|
362
|
+
async [ANDROID_ASSERT](matcher, negated, expected, timeoutMs) {
|
|
363
|
+
let sourceSeq: number | undefined, actual: unknown, error: unknown;
|
|
364
|
+
const visibility = matcher === "toBeVisible" || matcher === "toBeHidden";
|
|
365
|
+
try {
|
|
366
|
+
await op({ action: visibility ? "wait" : "text", target,
|
|
367
|
+
...(visibility ? { state: ((matcher === "toBeVisible") !== negated ? "visible" : "hidden") as "visible" | "hidden" } : {}) }, async (details) => {
|
|
368
|
+
await until(async () => {
|
|
369
|
+
let path: string | undefined;
|
|
370
|
+
let matches: boolean;
|
|
371
|
+
if (visibility) {
|
|
372
|
+
path = await visiblePath();
|
|
373
|
+
actual = !!path;
|
|
374
|
+
matches = matcher === "toBeVisible" ? !!path : !path;
|
|
375
|
+
} else {
|
|
376
|
+
path = await find();
|
|
377
|
+
const text = await command<string>("GET", `${path}/text`);
|
|
378
|
+
actual = text;
|
|
379
|
+
const normalize = (value: string) => value.replace(/\s+/g, " ").trim();
|
|
380
|
+
matches = expected instanceof RegExp
|
|
381
|
+
? new RegExp(expected.source, expected.flags).test(text)
|
|
382
|
+
: matcher === "toContainText" ? normalize(text).includes(normalize(expected!)) : normalize(text) === normalize(expected!);
|
|
383
|
+
}
|
|
384
|
+
if (path) await bounds(path, details);
|
|
385
|
+
else delete details.bounds;
|
|
386
|
+
return matches !== negated ? true : undefined;
|
|
387
|
+
}, `${label} ${negated ? "not " : ""}${matcher}${expected === undefined ? "" : ` ${expected}`}`, timeoutMs);
|
|
388
|
+
}, seq => { sourceSeq = seq; });
|
|
389
|
+
} catch (cause) { error = cause; }
|
|
390
|
+
return { sourceSeq, actual, error };
|
|
391
|
+
},
|
|
318
392
|
};
|
|
319
393
|
};
|
|
320
394
|
return {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// scrcpy opens its output before receiving codec metadata or a video frame.
|
|
2
|
+
// Only the patched recorder's first-frame signal makes an immediate stop safe.
|
|
3
|
+
export const FIRST_VIDEO_FRAME = "SPECTEST_FIRST_VIDEO_FRAME";
|
|
4
|
+
export const RECORDER_CAPABILITY = "spectest-first-frame-v1";
|
|
5
|
+
|
|
6
|
+
export function requireRecorderCapability(version) {
|
|
7
|
+
if (!version.includes(RECORDER_CAPABILITY)) {
|
|
8
|
+
throw new Error("Android toolsImage needs scrcpy with the spectest-first-frame-v1 patch; rebuild it from ANDROID_TOOLS_DOCKERFILE");
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function observeRecorderOutput(record, chunk) {
|
|
13
|
+
record.log = (record.log + chunk).slice(-16000);
|
|
14
|
+
if (!record.started && record.log.includes(FIRST_VIDEO_FRAME)) {
|
|
15
|
+
record.started = true;
|
|
16
|
+
record.startedAtMs = Date.now();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function waitForRecorderFrame(record, timeoutMs = 10000) {
|
|
21
|
+
const deadline = Date.now() + timeoutMs;
|
|
22
|
+
while (true) {
|
|
23
|
+
if (record.error) throw new Error(`${record.error}: ${record.log}`);
|
|
24
|
+
if (record.started) return { startedAtMs: record.startedAtMs };
|
|
25
|
+
if (Date.now() >= deadline) throw new Error(`Recorder first video frame timed out: ${record.log}`);
|
|
26
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(50, deadline - Date.now())));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Runs inside the Android tools container, in the same snapshotted VM.
|
|
2
2
|
import http from "node:http";
|
|
3
|
+
import { requireRecorderCapability, observeRecorderOutput, waitForRecorderFrame } from "./android-recording-state.mjs";
|
|
3
4
|
import { spawn, execFile } from "node:child_process";
|
|
4
5
|
import { promisify } from "node:util";
|
|
5
6
|
import { mkdir, stat, open, rm } from "node:fs/promises";
|
|
@@ -74,6 +75,7 @@ bootstrap().catch((error) => {
|
|
|
74
75
|
});
|
|
75
76
|
|
|
76
77
|
async function startRecording(body) {
|
|
78
|
+
requireRecorderCapability((await exec("scrcpy", ["--version"], { timeout: 5000 })).stdout);
|
|
77
79
|
if (!/^[a-z0-9-]{1,100}$/.test(body.id)) throw new Error("invalid recording id");
|
|
78
80
|
if (recordings.has(body.id)) throw new Error("recording already exists");
|
|
79
81
|
if ([...recordings.values()].some((r) => !r.stopped))
|
|
@@ -121,19 +123,13 @@ async function startRecording(body) {
|
|
|
121
123
|
record.error = error.message;
|
|
122
124
|
resolve(-1);
|
|
123
125
|
});
|
|
124
|
-
child.on("
|
|
126
|
+
child.on("close", (code) => {
|
|
125
127
|
if (!record.stopped) record.error = `Recorder exited early (${code})`;
|
|
126
128
|
resolve(code);
|
|
127
129
|
});
|
|
128
130
|
});
|
|
129
131
|
for (const output of [child.stdout, child.stderr])
|
|
130
|
-
output.on("data", (b) =>
|
|
131
|
-
record.log = (record.log + b).slice(-16000);
|
|
132
|
-
if (!record.started && record.log.includes("Recording started")) {
|
|
133
|
-
record.started = true;
|
|
134
|
-
record.startedAtMs = Date.now();
|
|
135
|
-
}
|
|
136
|
-
});
|
|
132
|
+
output.on("data", (b) => observeRecorderOutput(record, b));
|
|
137
133
|
record.guard = setInterval(async () => {
|
|
138
134
|
if ((await stat(path).catch(() => ({ size: 0 }))).size > maxBytes) {
|
|
139
135
|
record.error = "Recording exceeded 256 MiB";
|
|
@@ -141,12 +137,7 @@ async function startRecording(body) {
|
|
|
141
137
|
}
|
|
142
138
|
}, 1000);
|
|
143
139
|
try {
|
|
144
|
-
|
|
145
|
-
if (record.error) throw new Error(`${record.error}: ${record.log}`);
|
|
146
|
-
if (record.started) return { startedAtMs: record.startedAtMs };
|
|
147
|
-
await sleep(50);
|
|
148
|
-
}
|
|
149
|
-
throw new Error(`Recorder startup timed out: ${record.log}`);
|
|
140
|
+
return await waitForRecorderFrame(record);
|
|
150
141
|
} catch (error) {
|
|
151
142
|
clearInterval(record.guard);
|
|
152
143
|
record.stopped = true;
|
|
@@ -32,6 +32,7 @@ test("Android expands into two VM services with an APK input and private Appium/
|
|
|
32
32
|
const env = JSON.parse(output);
|
|
33
33
|
const device = env.services["phone-device"],
|
|
34
34
|
tools = env.services.phone;
|
|
35
|
+
expect(device.image.reference).toBe("redroid/redroid:16.0.0_64only-latest@sha256:7b1e389bd15f37af3bcd06138f5b2ffa7cfba4332bd5ef54c53e99c2f160a15b");
|
|
35
36
|
expect(device.privileged).toBe(true);
|
|
36
37
|
expect(device.args).toContain("androidboot.redroid_width=1080");
|
|
37
38
|
expect(device.args).toContain("androidboot.redroid_height=2400");
|
|
@@ -68,3 +69,62 @@ test("bad dimensions and identifiers fail before image builds", () => {
|
|
|
68
69
|
android({ apk: "spectest/tests/app.apk", package: "com.example", activity: ".MainActivity" }),
|
|
69
70
|
).toThrow("invalidate snapshots");
|
|
70
71
|
});
|
|
72
|
+
|
|
73
|
+
test("recorder waits for a retained first frame before an immediate stop, including split output", async () => {
|
|
74
|
+
const { spawn } = await import("node:child_process");
|
|
75
|
+
const { observeRecorderOutput, waitForRecorderFrame } = await import("./android-recording-state.mjs");
|
|
76
|
+
// Opening the output is deliberately separate from receiving the frame.
|
|
77
|
+
// This models scrcpy's static-screen path: only SIGINT flushes the last frame.
|
|
78
|
+
const child = spawn("node", ["-e", `
|
|
79
|
+
let retained = false;
|
|
80
|
+
process.on('SIGINT', () => {
|
|
81
|
+
process.stdout.write(retained ? 'VALID_VIDEO_WITH_TRAILER' : 'INVALID_EMPTY_VIDEO');
|
|
82
|
+
process.exit(retained ? 0 : 1);
|
|
83
|
+
});
|
|
84
|
+
process.stdin.once('data', () => {
|
|
85
|
+
retained = true;
|
|
86
|
+
process.stdout.write('SPECTEST_FIRST_VIDEO_');
|
|
87
|
+
setImmediate(() => process.stdout.write('FRAME\\n'));
|
|
88
|
+
});
|
|
89
|
+
process.stdout.write('Recording started to mp4 file: fixture.mp4\\n');
|
|
90
|
+
`]);
|
|
91
|
+
const record = { log: "", error: null as string | null, started: false, startedAtMs: 0 };
|
|
92
|
+
let opened!: () => void;
|
|
93
|
+
const outputOpened = new Promise<void>(resolve => { opened = resolve; });
|
|
94
|
+
child.stdout.on("data", chunk => {
|
|
95
|
+
observeRecorderOutput(record, chunk);
|
|
96
|
+
if (record.log.includes("Recording started")) opened();
|
|
97
|
+
});
|
|
98
|
+
const closed = new Promise<number | null>(resolve => child.on("close", resolve));
|
|
99
|
+
try {
|
|
100
|
+
await outputOpened;
|
|
101
|
+
let ready = false;
|
|
102
|
+
const waiting = waitForRecorderFrame(record).then(value => { ready = true; return value; });
|
|
103
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
104
|
+
expect(ready).toBe(false);
|
|
105
|
+
expect(record.started).toBe(false);
|
|
106
|
+
const frameRequested = Date.now();
|
|
107
|
+
child.stdin.write("deliver first frame");
|
|
108
|
+
const result = await waiting;
|
|
109
|
+
expect(result.startedAtMs).toBeGreaterThanOrEqual(frameRequested);
|
|
110
|
+
child.kill("SIGINT");
|
|
111
|
+
expect(await closed).toBe(0);
|
|
112
|
+
expect(record.log).toContain("VALID_VIDEO_WITH_TRAILER");
|
|
113
|
+
expect(record.log).not.toContain("INVALID_EMPTY_VIDEO");
|
|
114
|
+
} finally {
|
|
115
|
+
child.kill("SIGKILL");
|
|
116
|
+
await closed;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("recorder rejects missing frames, early exit and unpatched custom tools explicitly", async () => {
|
|
121
|
+
const { requireRecorderCapability, observeRecorderOutput, waitForRecorderFrame } = await import("./android-recording-state.mjs");
|
|
122
|
+
expect(() => requireRecorderCapability("scrcpy 3.3.1\n")).toThrow("rebuild it from ANDROID_TOOLS_DOCKERFILE");
|
|
123
|
+
expect(() => requireRecorderCapability("scrcpy 3.3.1\nspectest-first-frame-v1\n")).not.toThrow();
|
|
124
|
+
const record = { log: "", error: null as string | null, started: false, startedAtMs: 0 };
|
|
125
|
+
observeRecorderOutput(record, "Recording started to mp4 file: empty.mp4\n");
|
|
126
|
+
await expect(waitForRecorderFrame(record, 20)).rejects.toThrow("first video frame timed out");
|
|
127
|
+
record.error = "Recorder exited early (1)";
|
|
128
|
+
observeRecorderOutput(record, "SPECTEST_FIRST_VIDEO_FRAME\n");
|
|
129
|
+
await expect(waitForRecorderFrame(record)).rejects.toThrow("Recorder exited early");
|
|
130
|
+
});
|
|
@@ -12,7 +12,7 @@ export interface AndroidOptions {
|
|
|
12
12
|
package: string;
|
|
13
13
|
/** Launch activity, for example .MainActivity. */
|
|
14
14
|
activity: string;
|
|
15
|
-
/** Android image; default is the
|
|
15
|
+
/** Android image; default is the pinned Android 16 64-bit-only redroid image. */
|
|
16
16
|
image?: string;
|
|
17
17
|
/** Optional prebuilt image containing the same tools as ANDROID_TOOLS_DOCKERFILE. */
|
|
18
18
|
toolsImage?: string;
|
|
@@ -26,9 +26,27 @@ export interface AndroidOptions {
|
|
|
26
26
|
dependsOn?: string[];
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Instrument the pinned recorder at the point its first real video packet is
|
|
30
|
+
// retained. This follows the MP4 header and survives an immediate SIGINT.
|
|
31
|
+
const recorderPatch = String.raw`from pathlib import Path
|
|
32
|
+
p = Path('/tmp/scrcpy-3.3.1/app/src/recorder.c')
|
|
33
|
+
s = p.read_text()
|
|
34
|
+
a = ' int64_t pts_origin = AV_NOPTS_VALUE;'
|
|
35
|
+
b = ' video_pkt_previous = video_pkt;'
|
|
36
|
+
assert s.count(a) == 1 and s.count(b) == 1
|
|
37
|
+
s = s.replace(a, a + '\n bool spectest_first_frame = true;')
|
|
38
|
+
s = s.replace(b, b + '\n if (spectest_first_frame) {\n LOGI("SPECTEST_FIRST_VIDEO_FRAME");\n spectest_first_frame = false;\n }')
|
|
39
|
+
p.write_text(s)
|
|
40
|
+
p = Path('/tmp/scrcpy-3.3.1/app/src/version.c')
|
|
41
|
+
s = p.read_text()
|
|
42
|
+
a = 'scrcpy_print_version(void) {'
|
|
43
|
+
assert s.count(a) == 1
|
|
44
|
+
p.write_text(s.replace(a, a + '\n puts("spectest-first-frame-v1");'))`;
|
|
45
|
+
const shellQuote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
46
|
+
|
|
29
47
|
export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
|
|
30
|
-
RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
|
|
31
|
-
RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
|
|
48
|
+
RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc python3 pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
|
|
49
|
+
RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && python3 -c ${shellQuote(recorderPatch.replaceAll("\n", ";"))} && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
|
|
32
50
|
ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
|
|
33
51
|
RUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0
|
|
34
52
|
`;
|
|
@@ -60,7 +78,7 @@ export function android(options: AndroidOptions) {
|
|
|
60
78
|
type: "registry" as const,
|
|
61
79
|
reference:
|
|
62
80
|
options.image ??
|
|
63
|
-
"redroid/redroid:
|
|
81
|
+
"redroid/redroid:16.0.0_64only-latest@sha256:7b1e389bd15f37af3bcd06138f5b2ffa7cfba4332bd5ef54c53e99c2f160a15b",
|
|
64
82
|
},
|
|
65
83
|
privileged: true,
|
|
66
84
|
// Android's netd ignores resolv.conf. Use Docker's per-container DNS,
|
|
@@ -92,6 +110,10 @@ export function android(options: AndroidOptions) {
|
|
|
92
110
|
path: "/spectest-android.mjs",
|
|
93
111
|
content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
|
|
94
112
|
},
|
|
113
|
+
{
|
|
114
|
+
path: "/android-recording-state.mjs",
|
|
115
|
+
content: readFileSync(new URL("./android-recording-state.mjs", import.meta.url), "utf8"),
|
|
116
|
+
},
|
|
95
117
|
{
|
|
96
118
|
path: "/spectest-android-trust.mjs",
|
|
97
119
|
content: readFileSync(new URL("./android-trust.mjs", import.meta.url), "utf8"),
|
package/src/components/index.ts
CHANGED
|
@@ -111,4 +111,4 @@ export {
|
|
|
111
111
|
} from "./replayFake.js";
|
|
112
112
|
export { electron, type ElectronOptions } from "./electron.js";
|
|
113
113
|
|
|
114
|
-
export { android, type AndroidOptions } from "./android.js";
|
|
114
|
+
export { android, ANDROID_TOOLS_DOCKERFILE, type AndroidOptions } from "./android.js";
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { InjectRule, SignConfig } from "./fake-upstream.js";
|
|
|
6
6
|
export type { InjectRule, InjectMatch, StringMatch, SignConfig, AwsSigV4Sign } from "./fake-upstream.js";
|
|
7
7
|
export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
|
|
8
8
|
import type { Phone, PhoneOptions } from "./phone.js";
|
|
9
|
-
import type
|
|
9
|
+
import { ANDROID_ASSERT, type Android, type AndroidApp, type AndroidElement, type AndroidMatcher } from "./android.js";
|
|
10
10
|
import { resolveEntrypoints, type InteractiveEntrypoint, type InteractiveEntrypoints } from "./interactive.js";
|
|
11
11
|
export type { InteractiveEntrypoint, InteractiveEntrypoints } from "./interactive.js";
|
|
12
12
|
export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
|
|
@@ -3030,18 +3030,49 @@ export interface BrowserAssertion extends BrowserMatchers {
|
|
|
3030
3030
|
// `expect(value)` the synchronous value matchers. The two object overloads are
|
|
3031
3031
|
// listed first so they win over `Provenanced` (neither has `unwrap`); a
|
|
3032
3032
|
// Locator and a Browser are structurally disjoint, so their order is free.
|
|
3033
|
+
export function expect(actual: AndroidElement, message?: string): AndroidAssertion;
|
|
3033
3034
|
export function expect(actual: Locator, message?: string): LocatorAssertion;
|
|
3034
3035
|
export function expect(actual: Browser, message?: string): BrowserAssertion;
|
|
3035
3036
|
export function expect(actual: Provenanced, message?: string): Expectation;
|
|
3036
3037
|
export function expect(
|
|
3037
|
-
actual: Provenanced | Locator | Browser,
|
|
3038
|
+
actual: Provenanced | Locator | Browser | AndroidElement,
|
|
3038
3039
|
message?: string,
|
|
3039
|
-
): Expectation | LocatorAssertion | BrowserAssertion {
|
|
3040
|
+
): Expectation | LocatorAssertion | BrowserAssertion | AndroidAssertion {
|
|
3041
|
+
if (actual && typeof actual === "object" && ANDROID_ASSERT in actual) return buildAndroidAssertion(actual as AndroidElement, message);
|
|
3040
3042
|
if (isLocator(actual)) return buildLocatorAssertion(actual, message);
|
|
3041
3043
|
if (isBrowserSession(actual)) return buildBrowserAssertion(actual, message);
|
|
3042
3044
|
return expectValue(actual as Provenanced, message);
|
|
3043
3045
|
}
|
|
3044
3046
|
|
|
3047
|
+
/** Retrying native Android assertions. Timeout is in milliseconds (default 10 s;
|
|
3048
|
+
* zero performs one observation). Each assertion records one Android step. */
|
|
3049
|
+
export interface AndroidMatchers {
|
|
3050
|
+
toBeVisible(options?: { timeout?: number }): Promise<void>;
|
|
3051
|
+
toBeHidden(options?: { timeout?: number }): Promise<void>;
|
|
3052
|
+
toHaveText(expected: string | RegExp, options?: { timeout?: number }): Promise<void>;
|
|
3053
|
+
toContainText(expected: string | RegExp, options?: { timeout?: number }): Promise<void>;
|
|
3054
|
+
}
|
|
3055
|
+
export interface AndroidAssertion extends AndroidMatchers { not: AndroidMatchers }
|
|
3056
|
+
function buildAndroidAssertion(element: AndroidElement, message?: string): AndroidAssertion {
|
|
3057
|
+
const matchers = (negated: boolean): AndroidMatchers => {
|
|
3058
|
+
const run = async (matcher: AndroidMatcher, expected?: string | RegExp, timeout?: number) => {
|
|
3059
|
+
const result = await element[ANDROID_ASSERT](matcher, negated, expected, timeout);
|
|
3060
|
+
const error = result.error === undefined ? undefined : new ExpectationError(String(result.error));
|
|
3061
|
+
if (error) error.cause = result.error;
|
|
3062
|
+
recordAssertion({ matcher, negated, passed: !error, message, sourceSeq: result.sourceSeq,
|
|
3063
|
+
actual: safeSerialize(result.actual), expected: expected === undefined ? undefined : safeSerialize(assertionExpected(expected)), error: error?.message });
|
|
3064
|
+
if (error) throw error;
|
|
3065
|
+
};
|
|
3066
|
+
return {
|
|
3067
|
+
toBeVisible: options => run("toBeVisible", undefined, options?.timeout),
|
|
3068
|
+
toBeHidden: options => run("toBeHidden", undefined, options?.timeout),
|
|
3069
|
+
toHaveText: (expected, options) => run("toHaveText", expected, options?.timeout),
|
|
3070
|
+
toContainText: (expected, options) => run("toContainText", expected, options?.timeout),
|
|
3071
|
+
};
|
|
3072
|
+
};
|
|
3073
|
+
return Object.assign(matchers(false), { not: matchers(true) });
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3045
3076
|
function expectValue(actual: Provenanced, message?: string): Expectation {
|
|
3046
3077
|
// The first parameter is typed to the {@link Provenanced} family so a raw
|
|
3047
3078
|
// value (`expect(res.status === 200)`, `expect(2 + 2)`) is a *compile error* —
|