@specific.dev/spectest 0.89.0 → 0.90.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.d.ts +84 -0
- package/dist/android.js +355 -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 +65 -88
- 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/harness/ws-bridge.d.ts +78 -0
- package/dist/harness/ws-bridge.js +180 -0
- package/dist/index.d.ts +4 -0
- package/dist/recorder.d.ts +1 -1
- package/dist/recorder.js +6 -3
- package/package.json +7 -3
- package/src/android.test.ts +180 -0
- package/src/android.ts +444 -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 +72 -89
- 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/harness/ws-bridge.test.ts +224 -0
- package/src/harness/ws-bridge.ts +210 -0
- package/src/index.ts +4 -0
- package/src/recorder.ts +3 -3
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
androidApp,
|
|
4
|
+
androidScope,
|
|
5
|
+
androidRecordingChunk,
|
|
6
|
+
releaseAndroidRecording,
|
|
7
|
+
} from "./android.js";
|
|
8
|
+
import { startRecording, stopRecording } from "./recorder.js";
|
|
9
|
+
|
|
10
|
+
describe("Android case lifecycle", () => {
|
|
11
|
+
test("preserves the Appium session, seals recordings per case and records idle-only children", async () => {
|
|
12
|
+
const requests: { path: string; method: string; body: any }[] = [];
|
|
13
|
+
const captures = new Map<string, number>();
|
|
14
|
+
const video = Buffer.from("fake-mp4");
|
|
15
|
+
let failStop = false;
|
|
16
|
+
let failClick = false;
|
|
17
|
+
const server = Bun.serve({
|
|
18
|
+
port: 0,
|
|
19
|
+
hostname: "127.0.0.1",
|
|
20
|
+
async fetch(req) {
|
|
21
|
+
const url = new URL(req.url),
|
|
22
|
+
path = url.pathname;
|
|
23
|
+
const body = req.method === "POST" ? await req.json().catch(() => ({})) : undefined;
|
|
24
|
+
requests.push({ path, method: req.method, body });
|
|
25
|
+
const json = (v: unknown) => Response.json(v);
|
|
26
|
+
if (path === "/recordings" && req.method === "POST") {
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
captures.set(body.id, now);
|
|
29
|
+
return json({ startedAtMs: now });
|
|
30
|
+
}
|
|
31
|
+
if (path.startsWith("/recordings/")) {
|
|
32
|
+
const id = path.split("/")[2];
|
|
33
|
+
if (path.endsWith("/stop"))
|
|
34
|
+
return failStop
|
|
35
|
+
? Response.json({ error: "encoder failed" }, { status: 500 })
|
|
36
|
+
: json({
|
|
37
|
+
bytes: video.length,
|
|
38
|
+
closedAtMs: captures.get(id)! + 60000,
|
|
39
|
+
mediaDurationMs: 1000,
|
|
40
|
+
width: 360,
|
|
41
|
+
height: 640,
|
|
42
|
+
});
|
|
43
|
+
if (req.method === "DELETE") {
|
|
44
|
+
captures.delete(id);
|
|
45
|
+
return json({ released: true });
|
|
46
|
+
}
|
|
47
|
+
const offset = Number(url.searchParams.get("offset")),
|
|
48
|
+
limit = Number(url.searchParams.get("limit"));
|
|
49
|
+
return json({
|
|
50
|
+
total: video.length,
|
|
51
|
+
offset,
|
|
52
|
+
b64: video.subarray(offset, offset + limit).toString("base64"),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (path === "/session") return json({ value: { sessionId: "kept-session" } });
|
|
56
|
+
if (path.endsWith("/click") && failClick)
|
|
57
|
+
return Response.json({ value: { error: "stale element reference", message: "Gone" } }, { status: 404 });
|
|
58
|
+
if (path.endsWith("/element"))
|
|
59
|
+
return json({ value: { "element-6066-11e4-a52e-4f735466cecf": "element-1" } });
|
|
60
|
+
if (path.endsWith("/text")) return json({ value: "inherited draft" });
|
|
61
|
+
if (path.endsWith("/window/rect")) return json({ value: { width: 1080, height: 2400 } });
|
|
62
|
+
if (path.endsWith("/rect")) return json({ value: { x: 100, y: 400, width: 880, height: 160 } });
|
|
63
|
+
if (path.endsWith("/screenshot"))
|
|
64
|
+
return json({ value: Buffer.from("PNG").toString("base64") });
|
|
65
|
+
return json({ value: null });
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
const endpoint = `http://127.0.0.1:${server.port}`;
|
|
69
|
+
const app = androidApp({
|
|
70
|
+
name: "phone",
|
|
71
|
+
package: "com.example.app",
|
|
72
|
+
endpoint,
|
|
73
|
+
recorderEndpoint: endpoint,
|
|
74
|
+
serial: "phone-device:5555",
|
|
75
|
+
recording: {},
|
|
76
|
+
});
|
|
77
|
+
startRecording();
|
|
78
|
+
try {
|
|
79
|
+
const parent = androidScope("parent", Date.now());
|
|
80
|
+
await parent.begin([app]);
|
|
81
|
+
const [a, b] = await Promise.all([parent.open(app), parent.open(app)]);
|
|
82
|
+
expect(a).toBe(b);
|
|
83
|
+
await a.getByText('a "quoted" label').fill("private draft");
|
|
84
|
+
expect((await a.getById("com.example.app:id/draft").text()).unwrap()).toBe("inherited draft");
|
|
85
|
+
expect(Buffer.from(await a.screenshot()).toString()).toBe("PNG");
|
|
86
|
+
const [recording] = await parent.finish();
|
|
87
|
+
expect(recording.error).toBeUndefined();
|
|
88
|
+
expect(recording.closedAtMs - recording.openedAtMs).toBe(60000);
|
|
89
|
+
expect(recording.mediaDurationMs).toBe(1000);
|
|
90
|
+
expect(JSON.stringify(recording)).not.toContain("b64");
|
|
91
|
+
const chunk = (await androidRecordingChunk(recording.id, 2, 3)) as {
|
|
92
|
+
offset: number;
|
|
93
|
+
b64: string;
|
|
94
|
+
};
|
|
95
|
+
expect(chunk.offset).toBe(2);
|
|
96
|
+
expect(Buffer.from(chunk.b64, "base64").toString()).toBe("ke-");
|
|
97
|
+
await expect(androidRecordingChunk(recording.id, -1, 10)).rejects.toThrow("range");
|
|
98
|
+
await expect(androidRecordingChunk(recording.id, 0, 6291457)).rejects.toThrow("range");
|
|
99
|
+
await expect(a.back()).rejects.toThrow("finished test");
|
|
100
|
+
|
|
101
|
+
// A real child inherits the registries and sessions from the snapshot.
|
|
102
|
+
// It starts a new video even when all it does is wait for another service.
|
|
103
|
+
const idle = androidScope("idle-child", Date.now());
|
|
104
|
+
await idle.begin([app]);
|
|
105
|
+
expect(captures.has(recording.id)).toBe(false);
|
|
106
|
+
const [idleVideo] = await idle.finish();
|
|
107
|
+
expect(idleVideo.id).not.toBe(recording.id);
|
|
108
|
+
expect(idleVideo.error).toBeUndefined();
|
|
109
|
+
await releaseAndroidRecording(idleVideo.id);
|
|
110
|
+
|
|
111
|
+
const child = androidScope("active-child", Date.now());
|
|
112
|
+
await child.begin([app]);
|
|
113
|
+
const phone = await child.open(app);
|
|
114
|
+
await phone.getByText("Save").tap();
|
|
115
|
+
await phone.tap(270, 1800);
|
|
116
|
+
failClick = true;
|
|
117
|
+
await expect(phone.getByText("Gone").tap()).rejects.toThrow("Gone");
|
|
118
|
+
failClick = false;
|
|
119
|
+
const [childVideo] = await child.finish();
|
|
120
|
+
expect(childVideo.taps).toHaveLength(2);
|
|
121
|
+
expect(childVideo.taps![0]).toMatchObject({ x: 0.5, y: 0.2 });
|
|
122
|
+
expect(childVideo.taps![1]).toMatchObject({ x: 0.25, y: 0.75 });
|
|
123
|
+
expect(childVideo.taps![0].offsetMs).toBeGreaterThanOrEqual(childVideo.openedAtMs);
|
|
124
|
+
expect(idleVideo.taps).toEqual([]);
|
|
125
|
+
expect(recording.taps).toEqual([]);
|
|
126
|
+
await releaseAndroidRecording(childVideo.id);
|
|
127
|
+
const hidden = androidScope("no-tap-markers", Date.now());
|
|
128
|
+
const withoutMarkers = await hidden.open({ ...app, showTouches: false });
|
|
129
|
+
const rectRequests = requests.filter(r => r.path.endsWith("/rect")).length;
|
|
130
|
+
await withoutMarkers.getByText("Save").tap();
|
|
131
|
+
const [hiddenVideo] = await hidden.finish();
|
|
132
|
+
expect(hiddenVideo.taps).toEqual([]);
|
|
133
|
+
expect(requests.filter(r => r.path.endsWith("/rect"))).toHaveLength(rectRequests);
|
|
134
|
+
await releaseAndroidRecording(hiddenVideo.id);
|
|
135
|
+
const starts = requests.filter((r) => r.path === "/session");
|
|
136
|
+
expect(starts).toHaveLength(1);
|
|
137
|
+
expect(starts[0].body.capabilities.alwaysMatch).toMatchObject({
|
|
138
|
+
"appium:noReset": true,
|
|
139
|
+
"appium:autoLaunch": false,
|
|
140
|
+
"appium:newCommandTimeout": 0,
|
|
141
|
+
});
|
|
142
|
+
expect(requests.some((r) => r.method === "DELETE" && r.path.startsWith("/session"))).toBe(
|
|
143
|
+
false,
|
|
144
|
+
);
|
|
145
|
+
expect(requests.find((r) => r.body?.using === "-android uiautomator")?.body.value).toBe(
|
|
146
|
+
'new UiSelector().text("a \\"quoted\\" label")',
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
failStop = true;
|
|
150
|
+
const failed = androidScope("failed", Date.now());
|
|
151
|
+
await failed.begin([app]);
|
|
152
|
+
const [bad] = await failed.finish();
|
|
153
|
+
expect(bad.error).toContain("encoder failed");
|
|
154
|
+
expect(captures.size).toBe(0); // failed captures are released too
|
|
155
|
+
const events = stopRecording();
|
|
156
|
+
expect(events.filter((e) => e.kind === "android").length).toBe(7);
|
|
157
|
+
} finally {
|
|
158
|
+
stopRecording();
|
|
159
|
+
server.stop(true);
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("recording can be disabled and late work cannot start after a timeout", async () => {
|
|
164
|
+
const scope = androidScope("timed-out", Date.now());
|
|
165
|
+
scope.deactivate();
|
|
166
|
+
await expect(scope.begin()).rejects.toThrow("finished test");
|
|
167
|
+
expect(await scope.finish()).toEqual([]);
|
|
168
|
+
const disabled = androidApp({
|
|
169
|
+
name: "off",
|
|
170
|
+
package: "com.example",
|
|
171
|
+
endpoint: "http://unused",
|
|
172
|
+
recorderEndpoint: "http://unused",
|
|
173
|
+
serial: "device",
|
|
174
|
+
recording: false,
|
|
175
|
+
});
|
|
176
|
+
const idle = androidScope("disabled", Date.now());
|
|
177
|
+
await idle.begin([disabled]);
|
|
178
|
+
expect(await idle.finish()).toEqual([]);
|
|
179
|
+
});
|
|
180
|
+
});
|
package/src/android.ts
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { rawFetch } from "./harness/raw-fetch.js";
|
|
3
|
+
import { recordStep, reserveEvent } from "./recorder.js";
|
|
4
|
+
import { wrap, type Wrapped } from "./inspect.js";
|
|
5
|
+
|
|
6
|
+
export interface AndroidRecordingOptions {
|
|
7
|
+
/** Maximum dimension of the video, default 1280. */
|
|
8
|
+
maxSize?: number;
|
|
9
|
+
/** Maximum frame rate, default 15. Static screens produce few frames. */
|
|
10
|
+
maxFps?: number;
|
|
11
|
+
/** H.264 bitrate target in bits/second, default 2,000,000. */
|
|
12
|
+
bitRate?: number;
|
|
13
|
+
}
|
|
14
|
+
export const ANDROID_APP: unique symbol = Symbol.for("spectest.androidApp");
|
|
15
|
+
export interface AndroidApp {
|
|
16
|
+
readonly [ANDROID_APP]: true;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly endpoint: string;
|
|
19
|
+
readonly recorderEndpoint: string;
|
|
20
|
+
readonly serial: string;
|
|
21
|
+
readonly package: string;
|
|
22
|
+
readonly recording: false | AndroidRecordingOptions;
|
|
23
|
+
readonly showTouches?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export function androidApp(options: Omit<AndroidApp, typeof ANDROID_APP>): AndroidApp {
|
|
26
|
+
return { ...options, [ANDROID_APP]: true as const };
|
|
27
|
+
}
|
|
28
|
+
export function isAndroidApp(value: unknown): value is AndroidApp {
|
|
29
|
+
return !!value && typeof value === "object" && (value as AndroidApp)[ANDROID_APP] === true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class AppiumError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
public readonly code: string,
|
|
35
|
+
message: string,
|
|
36
|
+
) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "AppiumError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function request<T>(url: string, method = "GET", body?: unknown): Promise<T> {
|
|
42
|
+
const response = await rawFetch(url, {
|
|
43
|
+
method,
|
|
44
|
+
...(body === undefined
|
|
45
|
+
? {}
|
|
46
|
+
: { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
|
|
47
|
+
signal: AbortSignal.timeout(120000),
|
|
48
|
+
});
|
|
49
|
+
const data = (await response.json()) as {
|
|
50
|
+
value?: T & { error?: string; message?: string };
|
|
51
|
+
error?: string;
|
|
52
|
+
};
|
|
53
|
+
if (!response.ok || data.value?.error || data.error)
|
|
54
|
+
throw new AppiumError(
|
|
55
|
+
data.value?.error ?? "unknown error",
|
|
56
|
+
data.value?.message ?? data.error ?? `Android request failed (${response.status})`,
|
|
57
|
+
);
|
|
58
|
+
return ("value" in data ? data.value : data) as T;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Both the Appium session and this registry are preserved by a VM snapshot.
|
|
62
|
+
// Never reset/relaunch the app or delete its session at a case boundary.
|
|
63
|
+
const sessions = new Map<string, Promise<string>>();
|
|
64
|
+
async function session(app: AndroidApp): Promise<string> {
|
|
65
|
+
const key = JSON.stringify([app.endpoint, app.serial, app.package]);
|
|
66
|
+
let pending = sessions.get(key);
|
|
67
|
+
if (!pending) {
|
|
68
|
+
pending = request<{ sessionId: string }>(`${app.endpoint}/session`, "POST", {
|
|
69
|
+
capabilities: {
|
|
70
|
+
alwaysMatch: {
|
|
71
|
+
platformName: "Android",
|
|
72
|
+
"appium:automationName": "UiAutomator2",
|
|
73
|
+
"appium:udid": app.serial,
|
|
74
|
+
"appium:appPackage": app.package,
|
|
75
|
+
"appium:noReset": true,
|
|
76
|
+
"appium:autoLaunch": false,
|
|
77
|
+
"appium:dontStopAppOnReset": true,
|
|
78
|
+
"appium:newCommandTimeout": 0,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
.then((result) => {
|
|
83
|
+
if (!result.sessionId) throw new Error("Appium returned no session id");
|
|
84
|
+
return result.sessionId;
|
|
85
|
+
})
|
|
86
|
+
.catch((error) => {
|
|
87
|
+
sessions.delete(key);
|
|
88
|
+
throw error;
|
|
89
|
+
});
|
|
90
|
+
sessions.set(key, pending);
|
|
91
|
+
}
|
|
92
|
+
return pending;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface AndroidElement {
|
|
96
|
+
tap(): Promise<void>;
|
|
97
|
+
fill(text: string): Promise<void>;
|
|
98
|
+
text(): Promise<Wrapped<string>>;
|
|
99
|
+
isDisplayed(): Promise<Wrapped<boolean>>;
|
|
100
|
+
waitFor(options?: { state?: "visible" | "hidden"; timeoutMs?: number }): Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
export interface Android {
|
|
103
|
+
getByAccessibilityId(id: string): AndroidElement;
|
|
104
|
+
getById(resourceId: string): AndroidElement;
|
|
105
|
+
getByText(text: string): AndroidElement;
|
|
106
|
+
/** Appium selector, e.g. -android uiautomator or xpath. */
|
|
107
|
+
locator(using: string, value: string): AndroidElement;
|
|
108
|
+
tap(x: number, y: number): Promise<void>;
|
|
109
|
+
swipe(direction: "up" | "down" | "left" | "right"): Promise<void>;
|
|
110
|
+
back(): Promise<void>;
|
|
111
|
+
pageSource(): Promise<Wrapped<string>>;
|
|
112
|
+
/** PNG bytes. */
|
|
113
|
+
screenshot(): Promise<Uint8Array>;
|
|
114
|
+
/** Appium extension command, e.g. mobile: getDeviceInfo. */
|
|
115
|
+
execute<T = unknown>(script: string, args?: Record<string, unknown>): Promise<Wrapped<T>>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface AndroidTap {
|
|
119
|
+
/** Milliseconds from the case start, in the same clock as the video manifest. */
|
|
120
|
+
offsetMs: number;
|
|
121
|
+
/** Normalized screen coordinates, independent of encoding resolution. */
|
|
122
|
+
x: number;
|
|
123
|
+
y: number;
|
|
124
|
+
}
|
|
125
|
+
export interface AndroidRecording {
|
|
126
|
+
id: string;
|
|
127
|
+
name: string;
|
|
128
|
+
package: string;
|
|
129
|
+
openedAtMs: number;
|
|
130
|
+
closedAtMs: number;
|
|
131
|
+
mediaDurationMs: number;
|
|
132
|
+
width: number;
|
|
133
|
+
height: number;
|
|
134
|
+
bytes: number;
|
|
135
|
+
taps?: AndroidTap[];
|
|
136
|
+
error?: string;
|
|
137
|
+
}
|
|
138
|
+
interface Parked {
|
|
139
|
+
app: AndroidApp;
|
|
140
|
+
recording: AndroidRecording;
|
|
141
|
+
}
|
|
142
|
+
const parked = new Map<string, Parked>();
|
|
143
|
+
let epoch = 0;
|
|
144
|
+
|
|
145
|
+
/** A case owns capture and handle lifetime; the Android/Appium processes outlive it. */
|
|
146
|
+
export function androidScope(
|
|
147
|
+
caseId: string,
|
|
148
|
+
testStartedAt: number,
|
|
149
|
+
options: { record?: boolean } = {},
|
|
150
|
+
) {
|
|
151
|
+
let active = true;
|
|
152
|
+
const captures = new Map<string, Promise<{ app: AndroidApp; id: string; openedAtMs: number }>>();
|
|
153
|
+
const handles = new Map<string, Promise<Android>>();
|
|
154
|
+
const taps = new Map<string, AndroidTap[]>();
|
|
155
|
+
const assertActive = () => {
|
|
156
|
+
if (!active) throw new Error("This Android handle belongs to a finished test");
|
|
157
|
+
};
|
|
158
|
+
async function capture(app: AndroidApp) {
|
|
159
|
+
assertActive();
|
|
160
|
+
if (app.recording === false || options.record === false) return;
|
|
161
|
+
let pending = captures.get(app.name);
|
|
162
|
+
if (!pending) {
|
|
163
|
+
if (captures.size >= 8) throw new Error("A case can record at most eight Android devices");
|
|
164
|
+
const id = createHash("sha256").update(`${caseId}:${app.name}:${++epoch}`).digest("hex");
|
|
165
|
+
pending = request<{ startedAtMs: number }>(`${app.recorderEndpoint}/recordings`, "POST", {
|
|
166
|
+
id,
|
|
167
|
+
...app.recording,
|
|
168
|
+
}).then((result) => ({
|
|
169
|
+
app,
|
|
170
|
+
id,
|
|
171
|
+
openedAtMs: Math.max(0, result.startedAtMs - testStartedAt),
|
|
172
|
+
}));
|
|
173
|
+
captures.set(app.name, pending);
|
|
174
|
+
}
|
|
175
|
+
await pending;
|
|
176
|
+
}
|
|
177
|
+
async function begin(apps: Iterable<AndroidApp> = []) {
|
|
178
|
+
assertActive();
|
|
179
|
+
// Previous artifacts were fetched before the VM was handed off. Forked
|
|
180
|
+
// children can discard their inherited copies without touching siblings.
|
|
181
|
+
for (const [id, previous] of parked) {
|
|
182
|
+
await request(`${previous.app.recorderEndpoint}/recordings/${id}`, "DELETE");
|
|
183
|
+
parked.delete(id);
|
|
184
|
+
}
|
|
185
|
+
for (const app of apps) await capture(app);
|
|
186
|
+
assertActive();
|
|
187
|
+
}
|
|
188
|
+
async function open(app: AndroidApp): Promise<Android> {
|
|
189
|
+
assertActive();
|
|
190
|
+
if (!isAndroidApp(app))
|
|
191
|
+
throw new Error("ctx.android(): pass the handle from an android() service");
|
|
192
|
+
let pending = handles.get(app.name);
|
|
193
|
+
if (pending) return pending;
|
|
194
|
+
pending = (async () => {
|
|
195
|
+
await capture(app);
|
|
196
|
+
assertActive();
|
|
197
|
+
const sid = await session(app);
|
|
198
|
+
assertActive();
|
|
199
|
+
const command = <T>(method: string, path: string, body?: unknown) => {
|
|
200
|
+
assertActive();
|
|
201
|
+
return request<T>(
|
|
202
|
+
`${app.endpoint}/session/${encodeURIComponent(sid)}${path}`,
|
|
203
|
+
method,
|
|
204
|
+
body,
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
async function tapMarker(point: { x: number; y: number } | string) {
|
|
208
|
+
if (!captures.has(app.name) || app.showTouches === false) return;
|
|
209
|
+
// Appium injects touches after Android's physical-touch visualizer.
|
|
210
|
+
// Keep markers alongside the video; never change tap semantics or fail
|
|
211
|
+
// a test just because optional visual feedback could not be measured.
|
|
212
|
+
try {
|
|
213
|
+
const screen = await command<{ width: number; height: number }>("GET", "/window/rect");
|
|
214
|
+
const rect = typeof point === "string"
|
|
215
|
+
? await command<{ x: number; y: number; width: number; height: number }>("GET", `${point}/rect`)
|
|
216
|
+
: { ...point, width: 0, height: 0 };
|
|
217
|
+
const x = (rect.x + rect.width / 2) / screen.width;
|
|
218
|
+
const y = (rect.y + rect.height / 2) / screen.height;
|
|
219
|
+
if (![x, y].every(v => Number.isFinite(v) && v >= 0 && v <= 1)) return;
|
|
220
|
+
return { x, y, offsetMs: Date.now() - testStartedAt };
|
|
221
|
+
} catch { return; }
|
|
222
|
+
}
|
|
223
|
+
function saveTap(marker: AndroidTap | undefined) {
|
|
224
|
+
if (!marker || !active) return;
|
|
225
|
+
const list = taps.get(app.name) ?? [];
|
|
226
|
+
if (list.length < 10000) list.push(marker);
|
|
227
|
+
taps.set(app.name, list);
|
|
228
|
+
}
|
|
229
|
+
async function op<T>(title: string, fn: () => Promise<T>): Promise<Wrapped<T>> {
|
|
230
|
+
assertActive();
|
|
231
|
+
const reservation = reserveEvent(),
|
|
232
|
+
started = Date.now();
|
|
233
|
+
try {
|
|
234
|
+
const result = await fn();
|
|
235
|
+
const seq = recordStep(
|
|
236
|
+
{
|
|
237
|
+
kind: "android",
|
|
238
|
+
title: `${app.name}: ${title}`,
|
|
239
|
+
durationMs: Date.now() - started,
|
|
240
|
+
status: "passed",
|
|
241
|
+
},
|
|
242
|
+
reservation,
|
|
243
|
+
);
|
|
244
|
+
return wrap(result, seq) as Wrapped<T>;
|
|
245
|
+
} catch (error) {
|
|
246
|
+
recordStep(
|
|
247
|
+
{
|
|
248
|
+
kind: "android",
|
|
249
|
+
title: `${app.name}: ${title}`,
|
|
250
|
+
durationMs: Date.now() - started,
|
|
251
|
+
status: "failed",
|
|
252
|
+
error: String(error),
|
|
253
|
+
},
|
|
254
|
+
reservation,
|
|
255
|
+
);
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const element = (using: string, value: string): AndroidElement => {
|
|
260
|
+
const label = `${using}=${value}`;
|
|
261
|
+
const find = async () => {
|
|
262
|
+
const result = await command<Record<string, string>>("POST", "/element", {
|
|
263
|
+
using,
|
|
264
|
+
value,
|
|
265
|
+
});
|
|
266
|
+
const id = result["element-6066-11e4-a52e-4f735466cecf"];
|
|
267
|
+
if (!id) throw new Error("Appium returned no element id");
|
|
268
|
+
return `/element/${encodeURIComponent(id)}`;
|
|
269
|
+
};
|
|
270
|
+
const displayed = async () => {
|
|
271
|
+
try {
|
|
272
|
+
return await command<boolean>("GET", `${await find()}/displayed`);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (
|
|
275
|
+
error instanceof AppiumError &&
|
|
276
|
+
["no such element", "stale element reference"].includes(error.code)
|
|
277
|
+
)
|
|
278
|
+
return false;
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
return {
|
|
283
|
+
async tap() {
|
|
284
|
+
await op(`tap ${label}`, async () => {
|
|
285
|
+
const path = await find();
|
|
286
|
+
const marker = await tapMarker(path);
|
|
287
|
+
await command("POST", `${path}/click`, {});
|
|
288
|
+
saveTap(marker);
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
async fill(text) {
|
|
292
|
+
await op(`fill ${label}`, async () => {
|
|
293
|
+
const path = await find();
|
|
294
|
+
await command("POST", `${path}/clear`, {});
|
|
295
|
+
await command("POST", `${path}/value`, { text });
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
text: () =>
|
|
299
|
+
op(`text ${label}`, async () => command<string>("GET", `${await find()}/text`)),
|
|
300
|
+
isDisplayed: () => op(`visible ${label}`, displayed),
|
|
301
|
+
async waitFor(options = {}) {
|
|
302
|
+
const timeout = options.timeoutMs ?? 10000;
|
|
303
|
+
if (!Number.isFinite(timeout) || timeout < 0) throw new Error("Invalid wait timeout");
|
|
304
|
+
await op(`wait for ${options.state ?? "visible"} ${label}`, async () => {
|
|
305
|
+
const end = Date.now() + timeout;
|
|
306
|
+
do {
|
|
307
|
+
if ((await displayed()) === (options.state !== "hidden")) return;
|
|
308
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
309
|
+
} while (Date.now() < end);
|
|
310
|
+
throw new Error(`Timed out waiting for ${label}`);
|
|
311
|
+
});
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
return {
|
|
316
|
+
locator: element,
|
|
317
|
+
getByAccessibilityId: (id) => element("accessibility id", id),
|
|
318
|
+
getById: (id) => element("id", id),
|
|
319
|
+
getByText: (text) =>
|
|
320
|
+
element("-android uiautomator", `new UiSelector().text(${JSON.stringify(text)})`),
|
|
321
|
+
async tap(x, y) {
|
|
322
|
+
await op(`tap (${x}, ${y})`, async () => {
|
|
323
|
+
const marker = await tapMarker({ x, y });
|
|
324
|
+
await command("POST", "/execute/sync", { script: "mobile: clickGesture", args: [{ x, y }] });
|
|
325
|
+
saveTap(marker);
|
|
326
|
+
});
|
|
327
|
+
},
|
|
328
|
+
async swipe(direction) {
|
|
329
|
+
await op(`swipe ${direction}`, async () => {
|
|
330
|
+
const rect = await command<{ width: number; height: number }>("GET", "/window/rect");
|
|
331
|
+
await command("POST", "/execute/sync", {
|
|
332
|
+
script: "mobile: swipeGesture",
|
|
333
|
+
args: [
|
|
334
|
+
{
|
|
335
|
+
left: rect.width * 0.1,
|
|
336
|
+
top: rect.height * 0.1,
|
|
337
|
+
width: rect.width * 0.8,
|
|
338
|
+
height: rect.height * 0.8,
|
|
339
|
+
direction,
|
|
340
|
+
percent: 0.75,
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
},
|
|
346
|
+
async back() {
|
|
347
|
+
await op("back", () => command("POST", "/back", {}));
|
|
348
|
+
},
|
|
349
|
+
pageSource: () => op("page source", () => command<string>("GET", "/source")),
|
|
350
|
+
async screenshot() {
|
|
351
|
+
const value = await op("screenshot", () => command<string>("GET", "/screenshot"));
|
|
352
|
+
return Buffer.from(value.unwrap(), "base64");
|
|
353
|
+
},
|
|
354
|
+
execute: <T>(script: string, args: Record<string, unknown> = {}) =>
|
|
355
|
+
op(script, () => command<T>("POST", "/execute/sync", { script, args: [args] })),
|
|
356
|
+
} satisfies Android;
|
|
357
|
+
})();
|
|
358
|
+
handles.set(app.name, pending);
|
|
359
|
+
return pending;
|
|
360
|
+
}
|
|
361
|
+
async function finish(): Promise<AndroidRecording[]> {
|
|
362
|
+
active = false;
|
|
363
|
+
const result: AndroidRecording[] = [];
|
|
364
|
+
for (const [name, pending] of captures) {
|
|
365
|
+
let owner: AndroidApp | undefined;
|
|
366
|
+
let recording: AndroidRecording = {
|
|
367
|
+
id: "",
|
|
368
|
+
name,
|
|
369
|
+
package: "",
|
|
370
|
+
openedAtMs: 0,
|
|
371
|
+
closedAtMs: Date.now() - testStartedAt,
|
|
372
|
+
mediaDurationMs: 0,
|
|
373
|
+
width: 0,
|
|
374
|
+
height: 0,
|
|
375
|
+
bytes: 0,
|
|
376
|
+
taps: taps.get(name) ?? [],
|
|
377
|
+
};
|
|
378
|
+
try {
|
|
379
|
+
const { app, id, openedAtMs } = await pending;
|
|
380
|
+
owner = app;
|
|
381
|
+
recording = { ...recording, id, package: app.package, openedAtMs };
|
|
382
|
+
const stopped = await request<{
|
|
383
|
+
bytes: number;
|
|
384
|
+
closedAtMs: number;
|
|
385
|
+
mediaDurationMs: number;
|
|
386
|
+
width: number;
|
|
387
|
+
height: number;
|
|
388
|
+
}>(`${app.recorderEndpoint}/recordings/${id}/stop`, "POST");
|
|
389
|
+
recording = {
|
|
390
|
+
...recording,
|
|
391
|
+
bytes: stopped.bytes,
|
|
392
|
+
width: stopped.width,
|
|
393
|
+
height: stopped.height,
|
|
394
|
+
mediaDurationMs: stopped.mediaDurationMs,
|
|
395
|
+
closedAtMs: Math.max(openedAtMs, stopped.closedAtMs - testStartedAt),
|
|
396
|
+
};
|
|
397
|
+
parked.set(id, { app, recording });
|
|
398
|
+
} catch (error) {
|
|
399
|
+
recording.error = String(error);
|
|
400
|
+
// A failed finalization still owns a file in the tools container.
|
|
401
|
+
if (recording.id) {
|
|
402
|
+
if (owner)
|
|
403
|
+
await request(`${owner.recorderEndpoint}/recordings/${recording.id}`, "DELETE").catch(
|
|
404
|
+
() => {},
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
result.push(recording);
|
|
409
|
+
}
|
|
410
|
+
return result;
|
|
411
|
+
}
|
|
412
|
+
return {
|
|
413
|
+
begin,
|
|
414
|
+
open,
|
|
415
|
+
finish,
|
|
416
|
+
deactivate: () => {
|
|
417
|
+
active = false;
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function androidRecordingChunk(id: string, offset: unknown, limit: unknown) {
|
|
423
|
+
const entry = parked.get(id);
|
|
424
|
+
if (!entry) throw new Error("Unknown Android recording");
|
|
425
|
+
const start = Number(offset ?? 0),
|
|
426
|
+
count = Number(limit ?? 6291456);
|
|
427
|
+
if (
|
|
428
|
+
!Number.isSafeInteger(start) ||
|
|
429
|
+
start < 0 ||
|
|
430
|
+
!Number.isSafeInteger(count) ||
|
|
431
|
+
count < 1 ||
|
|
432
|
+
count > 6291456
|
|
433
|
+
)
|
|
434
|
+
throw new Error("Invalid recording chunk range");
|
|
435
|
+
return request(`${entry.app.recorderEndpoint}/recordings/${id}?offset=${start}&limit=${count}`);
|
|
436
|
+
}
|
|
437
|
+
export async function releaseAndroidRecording(id: string) {
|
|
438
|
+
const entry = parked.get(id);
|
|
439
|
+
if (entry) {
|
|
440
|
+
await request(`${entry.app.recorderEndpoint}/recordings/${id}`, "DELETE");
|
|
441
|
+
parked.delete(id);
|
|
442
|
+
}
|
|
443
|
+
return { released: true };
|
|
444
|
+
}
|