@specific.dev/spectest 0.89.1 → 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.
@@ -0,0 +1,176 @@
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
+
5
+ export const STICKY_CAPABILITY = "sticky-disks-v1";
6
+ export const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
7
+ export const STICKY_RUNTIME = "/run/spectest-sticky";
8
+ export interface StickyConsumerConfig {
9
+ path: string;
10
+ capacityBytes: number;
11
+ headroomBytes: number;
12
+ cacheTargetBytes: number;
13
+ buildkitTargetBytes: number;
14
+ compatibility: string;
15
+ }
16
+
17
+ function exec(program: string, args: string[], timeout = 60_000): Promise<string> {
18
+ return new Promise((resolve, reject) => {
19
+ execFile(program, args, { timeout, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
20
+ if (error) reject(new Error(`${program} failed: ${stderr || stdout || error.message}`));
21
+ else resolve(stdout.trim());
22
+ });
23
+ });
24
+ }
25
+ async function ready(program: string, args: string[]): Promise<void> {
26
+ const deadline = Date.now() + 30_000;
27
+ let last: unknown;
28
+ do {
29
+ try { await exec(program, args, 2000); return; } catch (error) { last = error; }
30
+ await Bun.sleep(100);
31
+ } while (Date.now() < deadline);
32
+ throw last;
33
+ }
34
+ export function validateStickyConfig(config: StickyConsumerConfig): void {
35
+ if (config.path !== "/var/lib/spectest-sticky" || config.compatibility !== STICKY_COMPATIBILITY ||
36
+ !Number.isSafeInteger(config.capacityBytes) || config.capacityBytes <= 0 ||
37
+ !Number.isSafeInteger(config.headroomBytes) || config.headroomBytes < 16 * 1024 * 1024 ||
38
+ !Number.isSafeInteger(config.cacheTargetBytes) || config.cacheTargetBytes <= 0 ||
39
+ !Number.isSafeInteger(config.buildkitTargetBytes) || config.buildkitTargetBytes <= 0 ||
40
+ config.buildkitTargetBytes > config.cacheTargetBytes ||
41
+ config.cacheTargetBytes + config.headroomBytes >= config.capacityBytes) {
42
+ throw new Error("unsupported sticky consumer profile or budget");
43
+ }
44
+ }
45
+
46
+ export async function initializeStickyConsumer(config: StickyConsumerConfig): Promise<void> {
47
+ validateStickyConfig(config);
48
+ await exec("mountpoint", ["-q", config.path]);
49
+ // The base must have stopped these before its RAM was captured. Refuse an
50
+ // unexpected writer rather than racing it for the standard daemon sockets.
51
+ for (const unit of ["docker.service", "docker.socket", "containerd.service"]) {
52
+ const state = await exec("systemctl", ["show", unit, "-p", "ActiveState", "--value"]);
53
+ if (!["inactive", "failed"].includes(state)) throw new Error(`sticky base has active legacy consumer: ${unit}: ${state}`);
54
+ }
55
+ for (const [program, version] of [["dockerd", "29.7.2"], ["containerd", "v2.1.5"], ["buildkitd", "v0.32.2"]]) {
56
+ if (!(await exec(program!, ["--version"])).includes(version!)) throw new Error(`unsupported ${program} version for sticky profile`);
57
+ }
58
+ await mkdir(STICKY_RUNTIME, { recursive: true });
59
+ for (const child of ["docker", "containerd", "buildkit"]) 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: string, program: string, args: string[]) => {
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
+
107
+ /** Filesystem pressure takes precedence over cache age/BuildKit record sizes.
108
+ * Only called after runtime containers/volumes have been removed on the
109
+ * isolated publication clone. Never removes files behind the daemons' backs.
110
+ */
111
+ export async function collectStickyCache(
112
+ config: StickyConsumerConfig,
113
+ run: typeof exec = exec,
114
+ usage = async (): Promise<{ freeBytes: number; totalBytes: number }> => {
115
+ const fs = await statfs(config.path);
116
+ return { freeBytes: fs.bavail * fs.bsize, totalBytes: fs.blocks * fs.bsize };
117
+ },
118
+ ): Promise<void> {
119
+ validateStickyConfig(config);
120
+ const buildctl = ["--addr", "tcp://127.0.0.1:1234", "prune"];
121
+ await run("buildctl", [...buildctl, "--keep-duration", "168h"], 120_000);
122
+ await run("docker", ["image", "prune", "-af", "--filter", "until=168h"], 120_000);
123
+ const withinTarget = (fs: { freeBytes: number; totalBytes: number }) =>
124
+ fs.freeBytes >= config.headroomBytes && fs.totalBytes - fs.freeBytes <= config.cacheTargetBytes;
125
+ let fs = await usage();
126
+ // Always enforce the independent BuildKit target. Its accounting alone
127
+ // cannot bound shared images/content, so also request real filesystem space.
128
+ const pruneBuildkit = async () => {
129
+ await run("buildctl", [...buildctl, "--all", "--keep-storage",
130
+ String(Math.floor(config.buildkitTargetBytes / 1e6)), "--free-storage",
131
+ String(Math.ceil(Math.max(config.headroomBytes, fs.totalBytes - config.cacheTargetBytes) / 1e6))], 120_000);
132
+ fs = await usage();
133
+ };
134
+ await pruneBuildkit();
135
+ if (withinTarget(fs)) return;
136
+ // Pressure can be entirely in recent Docker images. Drop oldest images
137
+ // first, even within the age window, until the whole filesystem fits.
138
+ const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"])).split(/\s+/).filter(Boolean))];
139
+ const images: { Id: string; Created: string }[] = ids.length
140
+ ? JSON.parse(await run("docker", ["image", "inspect", ...ids])) : [];
141
+ images.sort((a, b) => a.Created.localeCompare(b.Created) || a.Id.localeCompare(b.Id));
142
+ for (const image of images) {
143
+ await run("docker", ["image", "rm", "-f", image.Id], 120_000);
144
+ await pruneBuildkit();
145
+ if (withinTarget(fs)) return;
146
+ }
147
+ // Containerd GC may lag reference removal; allow bounded additional passes.
148
+ for (let pass = 0; pass < 3 && !withinTarget(fs); pass++) await pruneBuildkit();
149
+ if (!withinTarget(fs)) throw new Error("sticky cache cannot meet filesystem retention target and write headroom");
150
+ }
151
+
152
+ /** Only on an isolated maintenance clone; never on the live ready environment. */
153
+ export async function prepareStickyConsumer(artifact = false): Promise<{ prepared: true; freeBytes: number }> {
154
+ const config: StickyConsumerConfig = JSON.parse(await readFile(`${STICKY_RUNTIME}/consumer.json`, "utf8"));
155
+ validateStickyConfig(config);
156
+ if (!artifact) {
157
+ const containers = (await exec("docker", ["ps", "-aq"])).split(/\s+/).filter(Boolean);
158
+ if (containers.length) await exec("docker", ["rm", "-f", ...containers]);
159
+ const volumes = (await exec("docker", ["volume", "ls", "-q"])).split(/\s+/).filter(Boolean);
160
+ if (volumes.length) await exec("docker", ["volume", "rm", ...volumes]);
161
+ await collectStickyCache(config);
162
+ }
163
+ const units = ["spectest-sticky-buildkit", "spectest-sticky-docker", "spectest-sticky-containerd"];
164
+ for (const unit of units) {
165
+ const active = await exec("systemctl", ["show", unit, "-p", "ActiveState", "--value"]);
166
+ if (active !== "active") 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") throw new Error(`unclean consumer shutdown: ${unit}: ${result}`);
170
+ }
171
+ const fs = await statfs(config.path);
172
+ const freeBytes = fs.bavail * fs.bsize;
173
+ if (freeBytes < config.headroomBytes) throw new Error("sticky publication has insufficient write headroom");
174
+ // The supervisor verifies and unmounts; only the worker seals/publishes.
175
+ return { prepared: true, freeBytes };
176
+ }
@@ -0,0 +1,7 @@
1
+ // Dedicated entry point: no daemon.ts, project modules, bootstrap or tests.
2
+ import { prepareStickyConsumer } from "./sticky-consumer";
3
+
4
+ if (import.meta.main) {
5
+ try { console.log(JSON.stringify(await prepareStickyConsumer(process.argv.includes("--artifact")))); }
6
+ catch (error) { console.error(error); process.exitCode = 1; }
7
+ }
package/src/index.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
  // Spectest SDK. The user's single `spectest/index.ts` calls
2
4
  // `defineEnvironment({ name, services })` once, defines test cases via
3
5
  // the returned `env.test(...)`, and default-exports `env.project([...])`.
@@ -1967,6 +1969,8 @@ export interface TestContext<
1967
1969
  * like named desktop browsers.
1968
1970
  */
1969
1971
  mobile(app: MobileApp, name?: string): Promise<Mobile>;
1972
+ /** Native Android via Appium. Recording is automatic for test cases; the app and session survive forks. */
1973
+ android(app: AndroidApp): Promise<Android>;
1970
1974
  /** Drive a real Electron renderer window through locators and user input.
1971
1975
  * Acquires the app started by electron(); keeps its live state across forks.
1972
1976
  * Each test gets a self-contained graphical replay. Selecting another window
package/src/recorder.ts CHANGED
@@ -632,7 +632,7 @@ export interface EventReservation {
632
632
  class Recorder {
633
633
  private events: TestEvent[] = [];
634
634
  private seq = 0;
635
- private readonly start = Date.now();
635
+ constructor(private readonly start = Date.now()) {}
636
636
 
637
637
  /** Claim a seq + start offset now; pass the result to `push` at finish. */
638
638
  reserve(): EventReservation {
@@ -753,8 +753,8 @@ function active(): boolean {
753
753
  return current !== null && paused === 0 && scopeAllowsRecording();
754
754
  }
755
755
 
756
- export function startRecording(): void {
757
- current = new Recorder();
756
+ export function startRecording(startedAt = Date.now()): void {
757
+ current = new Recorder(startedAt);
758
758
  paused = 0;
759
759
  }
760
760