@specific.dev/spectest 0.89.1 → 0.90.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android-step.d.ts +22 -0
- package/dist/android-step.js +78 -0
- package/dist/android.d.ts +84 -0
- package/dist/android.js +360 -0
- package/dist/components/android-runtime.mjs +257 -0
- package/dist/components/android.d.ts +74 -0
- package/dist/components/android.js +92 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/daemon.js +30 -3
- package/dist/harness/main.js +6 -1
- package/dist/harness/sticky-consumer.d.ts +28 -0
- package/dist/harness/sticky-consumer.js +178 -0
- package/dist/harness/sticky-maintenance.d.ts +1 -0
- package/dist/harness/sticky-maintenance.js +11 -0
- package/dist/index.d.ts +4 -0
- package/dist/recorder.d.ts +4 -1
- package/dist/recorder.js +6 -3
- package/package.json +7 -3
- package/src/android-step.test.ts +115 -0
- package/src/android-step.ts +107 -0
- package/src/android.test.ts +191 -0
- package/src/android.ts +449 -0
- package/src/components/android-runtime.mjs +257 -0
- package/src/components/android.test.ts +68 -0
- package/src/components/android.ts +123 -0
- package/src/components/index.ts +2 -0
- package/src/daemon.ts +29 -3
- package/src/eval-fetch.test.ts +111 -0
- package/src/harness/main.ts +6 -1
- package/src/harness/sticky-consumer.test.ts +45 -0
- package/src/harness/sticky-consumer.ts +176 -0
- package/src/harness/sticky-maintenance.ts +7 -0
- package/src/index.ts +4 -0
- package/src/recorder.ts +6 -3
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/** Docker/BuildKit policy for the generic sticky disk. No host publication API. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdir, readFile, statfs, writeFile } from "node:fs/promises";
|
|
4
|
+
export const STICKY_CAPABILITY = "sticky-disks-v1";
|
|
5
|
+
export const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
|
|
6
|
+
export const STICKY_RUNTIME = "/run/spectest-sticky";
|
|
7
|
+
function exec(program, args, timeout = 60_000) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
execFile(program, args, { timeout, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
10
|
+
if (error)
|
|
11
|
+
reject(new Error(`${program} failed: ${stderr || stdout || error.message}`));
|
|
12
|
+
else
|
|
13
|
+
resolve(stdout.trim());
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
async function ready(program, args) {
|
|
18
|
+
const deadline = Date.now() + 30_000;
|
|
19
|
+
let last;
|
|
20
|
+
do {
|
|
21
|
+
try {
|
|
22
|
+
await exec(program, args, 2000);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
last = error;
|
|
27
|
+
}
|
|
28
|
+
await Bun.sleep(100);
|
|
29
|
+
} while (Date.now() < deadline);
|
|
30
|
+
throw last;
|
|
31
|
+
}
|
|
32
|
+
export function validateStickyConfig(config) {
|
|
33
|
+
if (config.path !== "/var/lib/spectest-sticky" || config.compatibility !== STICKY_COMPATIBILITY ||
|
|
34
|
+
!Number.isSafeInteger(config.capacityBytes) || config.capacityBytes <= 0 ||
|
|
35
|
+
!Number.isSafeInteger(config.headroomBytes) || config.headroomBytes < 16 * 1024 * 1024 ||
|
|
36
|
+
!Number.isSafeInteger(config.cacheTargetBytes) || config.cacheTargetBytes <= 0 ||
|
|
37
|
+
!Number.isSafeInteger(config.buildkitTargetBytes) || config.buildkitTargetBytes <= 0 ||
|
|
38
|
+
config.buildkitTargetBytes > config.cacheTargetBytes ||
|
|
39
|
+
config.cacheTargetBytes + config.headroomBytes >= config.capacityBytes) {
|
|
40
|
+
throw new Error("unsupported sticky consumer profile or budget");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function initializeStickyConsumer(config) {
|
|
44
|
+
validateStickyConfig(config);
|
|
45
|
+
await exec("mountpoint", ["-q", config.path]);
|
|
46
|
+
// The base must have stopped these before its RAM was captured. Refuse an
|
|
47
|
+
// unexpected writer rather than racing it for the standard daemon sockets.
|
|
48
|
+
for (const unit of ["docker.service", "docker.socket", "containerd.service"]) {
|
|
49
|
+
const state = await exec("systemctl", ["show", unit, "-p", "ActiveState", "--value"]);
|
|
50
|
+
if (!["inactive", "failed"].includes(state))
|
|
51
|
+
throw new Error(`sticky base has active legacy consumer: ${unit}: ${state}`);
|
|
52
|
+
}
|
|
53
|
+
for (const [program, version] of [["dockerd", "29.7.2"], ["containerd", "v2.1.5"], ["buildkitd", "v0.32.2"]]) {
|
|
54
|
+
if (!(await exec(program, ["--version"])).includes(version))
|
|
55
|
+
throw new Error(`unsupported ${program} version for sticky profile`);
|
|
56
|
+
}
|
|
57
|
+
await mkdir(STICKY_RUNTIME, { recursive: true });
|
|
58
|
+
for (const child of ["docker", "containerd", "buildkit"])
|
|
59
|
+
await mkdir(`${config.path}/${child}`, { recursive: true });
|
|
60
|
+
await writeFile(`${STICKY_RUNTIME}/consumer.json`, JSON.stringify(config));
|
|
61
|
+
await writeFile(`${STICKY_RUNTIME}/containerd.toml`, `version = 2
|
|
62
|
+
root = "${config.path}/containerd"
|
|
63
|
+
state = "/run/containerd"
|
|
64
|
+
disabled_plugins = ["io.containerd.grpc.v1.cri"]
|
|
65
|
+
[grpc]
|
|
66
|
+
address = "/run/containerd/containerd.sock"
|
|
67
|
+
`);
|
|
68
|
+
const dockerConfig = JSON.parse(await readFile("/etc/docker/daemon.json", "utf8"));
|
|
69
|
+
dockerConfig["data-root"] = `${config.path}/docker`;
|
|
70
|
+
dockerConfig.features = { "containerd-snapshotter": true };
|
|
71
|
+
dockerConfig["storage-driver"] = "overlayfs";
|
|
72
|
+
await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
|
|
73
|
+
await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, `root = "${config.path}/buildkit"
|
|
74
|
+
[grpc]
|
|
75
|
+
address = ["tcp://127.0.0.1:1234"]
|
|
76
|
+
[worker.oci]
|
|
77
|
+
enabled = false
|
|
78
|
+
[worker.containerd]
|
|
79
|
+
enabled = true
|
|
80
|
+
address = "/run/containerd/containerd.sock"
|
|
81
|
+
namespace = "moby"
|
|
82
|
+
snapshotter = "overlayfs"
|
|
83
|
+
gc = true
|
|
84
|
+
reservedSpace = "1GB"
|
|
85
|
+
maxUsedSpace = ${config.buildkitTargetBytes}
|
|
86
|
+
minFreeSpace = ${config.headroomBytes}
|
|
87
|
+
`);
|
|
88
|
+
const start = async (unit, program, args) => {
|
|
89
|
+
await exec("systemd-run", ["--quiet", "--unit", unit, "-p", "TimeoutStopSec=30",
|
|
90
|
+
// Pinned BuildKit 0.32.2 returns 1 after its normal first-SIGTERM
|
|
91
|
+
// shutdown (util/appcontext). A timeout/signal still fails the unit.
|
|
92
|
+
...(unit === "spectest-sticky-buildkit" ? ["-p", "SuccessExitStatus=1"] : []),
|
|
93
|
+
program, ...args]);
|
|
94
|
+
};
|
|
95
|
+
await start("spectest-sticky-containerd", "containerd", ["--config", `${STICKY_RUNTIME}/containerd.toml`]);
|
|
96
|
+
await ready("ctr", ["version"]);
|
|
97
|
+
await start("spectest-sticky-docker", "dockerd", ["--config-file", `${STICKY_RUNTIME}/docker.json`,
|
|
98
|
+
"--host", "unix:///run/docker.sock", "--exec-root", "/run/docker", "--pidfile", "/run/docker.pid",
|
|
99
|
+
"--containerd", "/run/containerd/containerd.sock", "--containerd-namespace", "moby"]);
|
|
100
|
+
await ready("docker", ["info"]);
|
|
101
|
+
await start("spectest-sticky-buildkit", "buildkitd", ["--config", `${STICKY_RUNTIME}/buildkit.toml`]);
|
|
102
|
+
await ready("buildctl", ["--addr", "tcp://127.0.0.1:1234", "debug", "workers"]);
|
|
103
|
+
await exec("docker", ["buildx", "create", "--name", "spectest-local", "--driver", "remote", "tcp://127.0.0.1:1234"]);
|
|
104
|
+
await exec("docker", ["buildx", "inspect", "--bootstrap", "spectest-local"]);
|
|
105
|
+
}
|
|
106
|
+
/** Filesystem pressure takes precedence over cache age/BuildKit record sizes.
|
|
107
|
+
* Only called after runtime containers/volumes have been removed on the
|
|
108
|
+
* isolated publication clone. Never removes files behind the daemons' backs.
|
|
109
|
+
*/
|
|
110
|
+
export async function collectStickyCache(config, run = exec, usage = async () => {
|
|
111
|
+
const fs = await statfs(config.path);
|
|
112
|
+
return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
|
|
113
|
+
}) {
|
|
114
|
+
validateStickyConfig(config);
|
|
115
|
+
const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
|
|
116
|
+
await run("buildctl", [...buildctl, "--keep-duration", "168h"], 120_000);
|
|
117
|
+
await run("docker", ["image", "prune", "-af", "--filter", "until=168h"], 120_000);
|
|
118
|
+
const withinTarget = (fs) => fs.freeBytes >= config.headroomBytes && fs.totalBytes - fs.freeBytes <= config.cacheTargetBytes;
|
|
119
|
+
let fs = await usage();
|
|
120
|
+
// Always enforce the independent BuildKit target. Its accounting alone
|
|
121
|
+
// cannot bound shared images/content, so also request real filesystem space.
|
|
122
|
+
const pruneBuildkit = async () => {
|
|
123
|
+
await run("buildctl", [...buildctl, "--all", "--keep-storage",
|
|
124
|
+
String(Math.floor(config.buildkitTargetBytes / 1e6)), "--free-storage",
|
|
125
|
+
String(Math.ceil(Math.max(config.headroomBytes, fs.totalBytes - config.cacheTargetBytes) / 1e6))], 120_000);
|
|
126
|
+
fs = await usage();
|
|
127
|
+
};
|
|
128
|
+
await pruneBuildkit();
|
|
129
|
+
if (withinTarget(fs))
|
|
130
|
+
return;
|
|
131
|
+
// Pressure can be entirely in recent Docker images. Drop oldest images
|
|
132
|
+
// first, even within the age window, until the whole filesystem fits.
|
|
133
|
+
const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"])).split(/\s+/).filter(Boolean))];
|
|
134
|
+
const images = ids.length
|
|
135
|
+
? JSON.parse(await run("docker", ["image", "inspect", ...ids])) : [];
|
|
136
|
+
images.sort((a, b) => a.Created.localeCompare(b.Created) || a.Id.localeCompare(b.Id));
|
|
137
|
+
for (const image of images) {
|
|
138
|
+
await run("docker", ["image", "rm", "-f", image.Id], 120_000);
|
|
139
|
+
await pruneBuildkit();
|
|
140
|
+
if (withinTarget(fs))
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// Containerd GC may lag reference removal; allow bounded additional passes.
|
|
144
|
+
for (let pass = 0; pass < 3 && !withinTarget(fs); pass++)
|
|
145
|
+
await pruneBuildkit();
|
|
146
|
+
if (!withinTarget(fs))
|
|
147
|
+
throw new Error("sticky cache cannot meet filesystem retention target and write headroom");
|
|
148
|
+
}
|
|
149
|
+
/** Only on an isolated maintenance clone; never on the live ready environment. */
|
|
150
|
+
export async function prepareStickyConsumer(artifact = false) {
|
|
151
|
+
const config = JSON.parse(await readFile(`${STICKY_RUNTIME}/consumer.json`, "utf8"));
|
|
152
|
+
validateStickyConfig(config);
|
|
153
|
+
if (!artifact) {
|
|
154
|
+
const containers = (await exec("docker", ["ps", "-aq"])).split(/\s+/).filter(Boolean);
|
|
155
|
+
if (containers.length)
|
|
156
|
+
await exec("docker", ["rm", "-f", ...containers]);
|
|
157
|
+
const volumes = (await exec("docker", ["volume", "ls", "-q"])).split(/\s+/).filter(Boolean);
|
|
158
|
+
if (volumes.length)
|
|
159
|
+
await exec("docker", ["volume", "rm", ...volumes]);
|
|
160
|
+
await collectStickyCache(config);
|
|
161
|
+
}
|
|
162
|
+
const units = ["spectest-sticky-buildkit", "spectest-sticky-docker", "spectest-sticky-containerd"];
|
|
163
|
+
for (const unit of units) {
|
|
164
|
+
const active = await exec("systemctl", ["show", unit, "-p", "ActiveState", "--value"]);
|
|
165
|
+
if (active !== "active")
|
|
166
|
+
throw new Error(`consumer exited before sealing: ${unit}: ${active}`);
|
|
167
|
+
await exec("systemctl", ["stop", unit], 45_000);
|
|
168
|
+
const result = await exec("systemctl", ["show", unit, "-p", "Result", "--value"]);
|
|
169
|
+
if (result !== "success")
|
|
170
|
+
throw new Error(`unclean consumer shutdown: ${unit}: ${result}`);
|
|
171
|
+
}
|
|
172
|
+
const fs = await statfs(config.path);
|
|
173
|
+
const freeBytes = fs.bavail * fs.bsize;
|
|
174
|
+
if (freeBytes < config.headroomBytes)
|
|
175
|
+
throw new Error("sticky publication has insufficient write headroom");
|
|
176
|
+
// The supervisor verifies and unmounts; only the worker seals/publishes.
|
|
177
|
+
return { prepared: true, freeBytes };
|
|
178
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Dedicated entry point: no daemon.ts, project modules, bootstrap or tests.
|
|
2
|
+
import { prepareStickyConsumer } from "./sticky-consumer";
|
|
3
|
+
if (import.meta.main) {
|
|
4
|
+
try {
|
|
5
|
+
console.log(JSON.stringify(await prepareStickyConsumer(process.argv.includes("--artifact"))));
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
console.error(error);
|
|
9
|
+
process.exitCode = 1;
|
|
10
|
+
}
|
|
11
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Android, AndroidApp } from "./android.js";
|
|
2
|
+
export type { Android, AndroidApp, AndroidElement, AndroidRecordingOptions } from "./android.js";
|
|
1
3
|
import { strict as nodeAssert } from "node:assert";
|
|
2
4
|
export type { Carrier, Wrapped, WrappedObject, WrappedArray, WrappedHeaders, WrappedBuffer, WrappedBinaryView, WrappedBytes, WrappedBlob, WrappedFile, WrappedFormData, WrappedStream, WrappedReader, WrappedBYOBReader, WrappedReadResult, WrappedResponse, Provenanced, SpectestFetch, SpectestRequestInit, Unwrap, } from "./inspect.js";
|
|
3
5
|
import type { Provenanced, SpectestFetch, Wrapped } from "./inspect.js";
|
|
@@ -1315,6 +1317,8 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
|
|
|
1315
1317
|
* like named desktop browsers.
|
|
1316
1318
|
*/
|
|
1317
1319
|
mobile(app: MobileApp, name?: string): Promise<Mobile>;
|
|
1320
|
+
/** Native Android via Appium. Recording is automatic for test cases; the app and session survive forks. */
|
|
1321
|
+
android(app: AndroidApp): Promise<Android>;
|
|
1318
1322
|
/** Drive a real Electron renderer window through locators and user input.
|
|
1319
1323
|
* Acquires the app started by electron(); keeps its live state across forks.
|
|
1320
1324
|
* Each test gets a self-contained graphical replay. Selecting another window
|
package/dist/recorder.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { AndroidStepDetails } from "./android-step.js";
|
|
1
2
|
export type TestEvent = ExecEvent | AssertionEvent | HttpEvent | KubeEvent | BrowserEvent | DbEvent | RedisEvent | S3Event | TerminalEvent | TerminalStepEvent | WaitEvent | FakeEvent | EnvEvent | EmailEvent | StepEvent;
|
|
2
3
|
interface BaseEvent {
|
|
3
4
|
/** Order of *start* within the test. Reserved when an op begins (see
|
|
@@ -564,7 +565,7 @@ export interface EventReservation {
|
|
|
564
565
|
}
|
|
565
566
|
export declare function pauseRecording(): void;
|
|
566
567
|
export declare function resumeRecording(): void;
|
|
567
|
-
export declare function startRecording(): void;
|
|
568
|
+
export declare function startRecording(startedAt?: number): void;
|
|
568
569
|
export declare function stopRecording(): TestEvent[];
|
|
569
570
|
export declare function isRecording(): boolean;
|
|
570
571
|
/** Reserve an ordering slot at the start of an op so nested events it triggers
|
|
@@ -659,6 +660,8 @@ export interface StepEvent extends BaseEvent {
|
|
|
659
660
|
kind: string;
|
|
660
661
|
/** The step's one-line summary in the timeline. */
|
|
661
662
|
title: string;
|
|
663
|
+
/** Native action semantics; titles are presentation only. */
|
|
664
|
+
android?: AndroidStepDetails;
|
|
662
665
|
blocks?: StepBlock[];
|
|
663
666
|
status?: "passed" | "failed" | "error";
|
|
664
667
|
durationMs?: number;
|
package/dist/recorder.js
CHANGED
|
@@ -11,9 +11,12 @@ import { clearPendingNullish } from "./inspect.js";
|
|
|
11
11
|
import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
|
|
12
12
|
const OUTPUT_SNIPPET_BYTES = 256 * 1024;
|
|
13
13
|
class Recorder {
|
|
14
|
+
start;
|
|
14
15
|
events = [];
|
|
15
16
|
seq = 0;
|
|
16
|
-
start = Date.now()
|
|
17
|
+
constructor(start = Date.now()) {
|
|
18
|
+
this.start = start;
|
|
19
|
+
}
|
|
17
20
|
/** Claim a seq + start offset now; pass the result to `push` at finish. */
|
|
18
21
|
reserve() {
|
|
19
22
|
return { seq: this.seq++, tOffsetMs: Date.now() - this.start };
|
|
@@ -120,8 +123,8 @@ export function resumeRecording() {
|
|
|
120
123
|
function active() {
|
|
121
124
|
return current !== null && paused === 0 && scopeAllowsRecording();
|
|
122
125
|
}
|
|
123
|
-
export function startRecording() {
|
|
124
|
-
current = new Recorder();
|
|
126
|
+
export function startRecording(startedAt = Date.now()) {
|
|
127
|
+
current = new Recorder(startedAt);
|
|
125
128
|
paused = 0;
|
|
126
129
|
}
|
|
127
130
|
export function stopRecording() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specific.dev/spectest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.90.1",
|
|
4
4
|
"description": "Spectest SDK for defining test environments in TypeScript.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"registry": "https://registry.npmjs.org",
|
|
52
|
-
"access": "public"
|
|
52
|
+
"access": "public",
|
|
53
|
+
"tag": "latest"
|
|
53
54
|
},
|
|
54
55
|
"dependencies": {
|
|
55
56
|
"@kubernetes/client-node": "^1.0.0",
|
|
@@ -61,5 +62,8 @@
|
|
|
61
62
|
"@types/dns-packet": "^5.6.5",
|
|
62
63
|
"@types/bun": "^1.3.0",
|
|
63
64
|
"typescript": "^5.6.3"
|
|
64
|
-
}
|
|
65
|
+
},
|
|
66
|
+
"spectestCapabilities": [
|
|
67
|
+
"sticky-disks-v1"
|
|
68
|
+
]
|
|
65
69
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
androidStepTitle,
|
|
4
|
+
androidTarget,
|
|
5
|
+
type AndroidStepDetails,
|
|
6
|
+
} from "./android-step.js";
|
|
7
|
+
|
|
8
|
+
const cases: [Omit<AndroidStepDetails, "device">, string][] = [
|
|
9
|
+
[
|
|
10
|
+
{ action: "tap", target: { kind: "text", value: "SAVE LOCAL SQLITE" } },
|
|
11
|
+
'tap text "SAVE LOCAL SQLITE"',
|
|
12
|
+
],
|
|
13
|
+
[
|
|
14
|
+
{
|
|
15
|
+
action: "tap",
|
|
16
|
+
target: { kind: "text", value: 'a "quoted" label\nnext' },
|
|
17
|
+
},
|
|
18
|
+
'tap text "a \\"quoted\\" label\\nnext"',
|
|
19
|
+
],
|
|
20
|
+
[
|
|
21
|
+
{
|
|
22
|
+
action: "fill",
|
|
23
|
+
target: { kind: "class", value: "android.widget.EditText" },
|
|
24
|
+
},
|
|
25
|
+
"fill text field",
|
|
26
|
+
],
|
|
27
|
+
[
|
|
28
|
+
{
|
|
29
|
+
action: "text",
|
|
30
|
+
target: { kind: "class", value: "android.widget.EditText" },
|
|
31
|
+
},
|
|
32
|
+
"read text from text field",
|
|
33
|
+
],
|
|
34
|
+
[
|
|
35
|
+
{ action: "visible", target: { kind: "id", value: "app:id/save" } },
|
|
36
|
+
'check id "app:id/save" is visible',
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
{
|
|
40
|
+
action: "wait",
|
|
41
|
+
target: { kind: "accessibility", value: "Save" },
|
|
42
|
+
state: "hidden",
|
|
43
|
+
},
|
|
44
|
+
'wait for accessibility label "Save" to be hidden',
|
|
45
|
+
],
|
|
46
|
+
[
|
|
47
|
+
{
|
|
48
|
+
action: "wait",
|
|
49
|
+
target: { kind: "text", value: "Ready" },
|
|
50
|
+
state: "visible",
|
|
51
|
+
},
|
|
52
|
+
'wait for text "Ready" to be visible',
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
{
|
|
56
|
+
action: "tap",
|
|
57
|
+
target: { kind: "xpath", value: '//button[@text="Save"]' },
|
|
58
|
+
},
|
|
59
|
+
'tap XPath "//button[@text=\\"Save\\"]"',
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
action: "tap",
|
|
64
|
+
target: {
|
|
65
|
+
kind: "custom",
|
|
66
|
+
strategy: "-android uiautomator",
|
|
67
|
+
value: 'new UiSelector().text("Save")',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
"tap custom selector",
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
{ action: "tap", target: { kind: "class", value: "my.CustomWidget" } },
|
|
74
|
+
'tap class "my.CustomWidget"',
|
|
75
|
+
],
|
|
76
|
+
[{ action: "tap", point: { x: 12, y: 34 } }, "tap (12, 34)"],
|
|
77
|
+
[{ action: "swipe", direction: "up" }, "swipe up"],
|
|
78
|
+
[{ action: "back" }, "go back"],
|
|
79
|
+
[{ action: "source" }, "read screen structure"],
|
|
80
|
+
[{ action: "screenshot" }, "take screenshot"],
|
|
81
|
+
[
|
|
82
|
+
{ action: "execute", command: "mobile: getDeviceInfo" },
|
|
83
|
+
'run command "mobile: getDeviceInfo"',
|
|
84
|
+
],
|
|
85
|
+
];
|
|
86
|
+
for (const [action, title] of cases) {
|
|
87
|
+
test(title, () =>
|
|
88
|
+
expect(androidStepTitle({ device: "phone", ...action })).toBe(
|
|
89
|
+
`phone: ${title}`,
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
test("custom selector expressions remain opaque structured data", () => {
|
|
95
|
+
const expression = 'new UiSelector().text("Save").enabled(true)';
|
|
96
|
+
const target = androidTarget("-android uiautomator", expression);
|
|
97
|
+
expect(target).toEqual({
|
|
98
|
+
kind: "custom",
|
|
99
|
+
strategy: "-android uiautomator",
|
|
100
|
+
value: expression,
|
|
101
|
+
});
|
|
102
|
+
expect(androidStepTitle({ device: "phone", action: "tap", target })).toBe(
|
|
103
|
+
"phone: tap custom selector",
|
|
104
|
+
);
|
|
105
|
+
for (const [using, kind] of [
|
|
106
|
+
["id", "id"],
|
|
107
|
+
["accessibility id", "accessibility"],
|
|
108
|
+
["class name", "class"],
|
|
109
|
+
["xpath", "xpath"],
|
|
110
|
+
] as const)
|
|
111
|
+
expect(androidTarget(using, expression)).toEqual({
|
|
112
|
+
kind,
|
|
113
|
+
value: expression,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Recorded semantics, independent of Appium's wire selectors and display titles. */
|
|
2
|
+
export interface AndroidTarget {
|
|
3
|
+
kind: "text" | "id" | "accessibility" | "class" | "xpath" | "custom";
|
|
4
|
+
value: string;
|
|
5
|
+
/** Original strategy for a custom locator; never interpreted as display text. */
|
|
6
|
+
strategy?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface AndroidStepDetails {
|
|
9
|
+
device: string;
|
|
10
|
+
action:
|
|
11
|
+
| "tap"
|
|
12
|
+
| "fill"
|
|
13
|
+
| "text"
|
|
14
|
+
| "visible"
|
|
15
|
+
| "wait"
|
|
16
|
+
| "swipe"
|
|
17
|
+
| "back"
|
|
18
|
+
| "source"
|
|
19
|
+
| "screenshot"
|
|
20
|
+
| "execute";
|
|
21
|
+
target?: AndroidTarget;
|
|
22
|
+
point?: { x: number; y: number };
|
|
23
|
+
direction?: "up" | "down" | "left" | "right";
|
|
24
|
+
state?: "visible" | "hidden";
|
|
25
|
+
command?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function androidTarget(using: string, value: string): AndroidTarget {
|
|
29
|
+
const kind = new Map<string, AndroidTarget["kind"]>([
|
|
30
|
+
["id", "id"],
|
|
31
|
+
["accessibility id", "accessibility"],
|
|
32
|
+
["class name", "class"],
|
|
33
|
+
["xpath", "xpath"],
|
|
34
|
+
]).get(using);
|
|
35
|
+
return kind ? { kind, value } : { kind: "custom", value, strategy: using };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function androidTargetLabel(target: AndroidTarget): string {
|
|
39
|
+
const quoted = JSON.stringify(target.value);
|
|
40
|
+
switch (target.kind) {
|
|
41
|
+
case "text":
|
|
42
|
+
return `text ${quoted}`;
|
|
43
|
+
case "id":
|
|
44
|
+
return `id ${quoted}`;
|
|
45
|
+
case "accessibility":
|
|
46
|
+
return `accessibility label ${quoted}`;
|
|
47
|
+
case "xpath":
|
|
48
|
+
return `XPath ${quoted}`;
|
|
49
|
+
case "custom":
|
|
50
|
+
return "custom selector";
|
|
51
|
+
case "class": {
|
|
52
|
+
const classes = {
|
|
53
|
+
"android.widget.EditText": "text field",
|
|
54
|
+
"android.widget.Button": "button",
|
|
55
|
+
"android.widget.TextView": "text view",
|
|
56
|
+
"android.widget.CheckBox": "checkbox",
|
|
57
|
+
"android.widget.Switch": "switch",
|
|
58
|
+
"android.widget.ImageView": "image",
|
|
59
|
+
"android.widget.ScrollView": "scroll view",
|
|
60
|
+
"androidx.recyclerview.widget.RecyclerView": "list",
|
|
61
|
+
} as Record<string, string>;
|
|
62
|
+
return Object.hasOwn(classes, target.value)
|
|
63
|
+
? classes[target.value]
|
|
64
|
+
: `class ${quoted}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function androidStepTitle(step: AndroidStepDetails): string {
|
|
70
|
+
const target = step.target ? androidTargetLabel(step.target) : "element";
|
|
71
|
+
let label: string;
|
|
72
|
+
switch (step.action) {
|
|
73
|
+
case "tap":
|
|
74
|
+
label = step.point
|
|
75
|
+
? `tap (${step.point.x}, ${step.point.y})`
|
|
76
|
+
: `tap ${target}`;
|
|
77
|
+
break;
|
|
78
|
+
case "fill":
|
|
79
|
+
label = `fill ${target}`;
|
|
80
|
+
break;
|
|
81
|
+
case "text":
|
|
82
|
+
label = `read text from ${target}`;
|
|
83
|
+
break;
|
|
84
|
+
case "visible":
|
|
85
|
+
label = `check ${target} is visible`;
|
|
86
|
+
break;
|
|
87
|
+
case "wait":
|
|
88
|
+
label = `wait for ${target} to be ${step.state ?? "visible"}`;
|
|
89
|
+
break;
|
|
90
|
+
case "swipe":
|
|
91
|
+
label = `swipe ${step.direction}`;
|
|
92
|
+
break;
|
|
93
|
+
case "back":
|
|
94
|
+
label = "go back";
|
|
95
|
+
break;
|
|
96
|
+
case "source":
|
|
97
|
+
label = "read screen structure";
|
|
98
|
+
break;
|
|
99
|
+
case "screenshot":
|
|
100
|
+
label = "take screenshot";
|
|
101
|
+
break;
|
|
102
|
+
case "execute":
|
|
103
|
+
label = `run command ${JSON.stringify(step.command)}`;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
return `${step.device}: ${label}`;
|
|
107
|
+
}
|