@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,111 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
test("interactive ctx.fetch keeps its wrapped contract across module evaluation", async () => {
|
|
7
|
+
// Exercise the real eval import boundary in a separate process: the daemon
|
|
8
|
+
// changes globals and captures stdout, and APP_DIR is fixed at import time.
|
|
9
|
+
const root = await mkdtemp(join(tmpdir(), "spectest-eval-fetch-"));
|
|
10
|
+
try {
|
|
11
|
+
const daemon = new URL("./daemon.ts", import.meta.url).href;
|
|
12
|
+
const scopes = new URL("./harness/instrumentation-scope.ts", import.meta.url).href;
|
|
13
|
+
const recorder = new URL("./recorder.ts", import.meta.url).href;
|
|
14
|
+
const url = "data:application/json,%7B%22id%22%3A%22example%22%7D";
|
|
15
|
+
await writeFile(join(root, "helper.ts"), `
|
|
16
|
+
export async function read(fetcher) {
|
|
17
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
18
|
+
const response = await fetcher(${JSON.stringify(url)});
|
|
19
|
+
return (await response.json()).id.unwrap();
|
|
20
|
+
}
|
|
21
|
+
`);
|
|
22
|
+
const reproducer = `
|
|
23
|
+
const r = await ctx.fetch(${JSON.stringify(url)});
|
|
24
|
+
const j = await r.json();
|
|
25
|
+
export default {
|
|
26
|
+
statusType: typeof r.status, statusUnwrap: typeof r.status.unwrap,
|
|
27
|
+
idType: typeof j.id, idUnwrap: typeof j.id.unwrap,
|
|
28
|
+
};
|
|
29
|
+
`;
|
|
30
|
+
const readers = `
|
|
31
|
+
import { read } from "../helper.ts";
|
|
32
|
+
const r = await ctx.fetch(${JSON.stringify(url)});
|
|
33
|
+
const clone = r.clone();
|
|
34
|
+
await Promise.resolve();
|
|
35
|
+
const body = await r.json();
|
|
36
|
+
const { fetch: fetcher } = ctx;
|
|
37
|
+
export default {
|
|
38
|
+
status: r.status.unwrap(), ok: r.ok.unwrap(),
|
|
39
|
+
contentType: r.headers.get("content-type").unwrap(),
|
|
40
|
+
id: body.id.unwrap(), text: (await clone.text()).unwrap(),
|
|
41
|
+
rawStatus: r.unwrap().status, helperId: await read(fetcher),
|
|
42
|
+
};
|
|
43
|
+
`;
|
|
44
|
+
const script = `
|
|
45
|
+
import { strict as assert } from "node:assert";
|
|
46
|
+
import { METHODS } from ${JSON.stringify(daemon)};
|
|
47
|
+
import { currentInstrumentationScope, runUninstrumented } from ${JSON.stringify(scopes)};
|
|
48
|
+
import { startRecording, stopRecording } from ${JSON.stringify(recorder)};
|
|
49
|
+
const originalFetch = globalThis.fetch;
|
|
50
|
+
const url = ${JSON.stringify(url)};
|
|
51
|
+
const contentType = (await originalFetch(url)).headers.get("content-type");
|
|
52
|
+
startRecording();
|
|
53
|
+
const result = await METHODS.eval({ code: ${JSON.stringify(reproducer)} });
|
|
54
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
55
|
+
assert.deepEqual(result.result, {
|
|
56
|
+
statusType: "object", statusUnwrap: "function",
|
|
57
|
+
idType: "object", idUnwrap: "function",
|
|
58
|
+
});
|
|
59
|
+
const values = await METHODS.eval({ code: ${JSON.stringify(readers)} });
|
|
60
|
+
assert.equal(values.ok, true, JSON.stringify(values));
|
|
61
|
+
assert.deepEqual(values.result, {
|
|
62
|
+
status: 200, ok: true, contentType,
|
|
63
|
+
id: "example", text: '{"id":"example"}', rawStatus: 200,
|
|
64
|
+
helperId: "example",
|
|
65
|
+
});
|
|
66
|
+
// Fake/background work outside the eval's call must still see native fetch.
|
|
67
|
+
let releaseBackground;
|
|
68
|
+
const released = new Promise(resolve => { releaseBackground = resolve; });
|
|
69
|
+
const background = released.then(async () => {
|
|
70
|
+
const r = await fetch(url);
|
|
71
|
+
assert.equal(r.status, 200);
|
|
72
|
+
assert.equal((await r.json()).id, "example");
|
|
73
|
+
});
|
|
74
|
+
globalThis.nativeProbe = async () => {
|
|
75
|
+
releaseBackground();
|
|
76
|
+
await background;
|
|
77
|
+
await runUninstrumented(async () => {
|
|
78
|
+
await Promise.resolve();
|
|
79
|
+
const fakeResponse = await fetch(url);
|
|
80
|
+
assert.equal(fakeResponse.status, 200);
|
|
81
|
+
assert.equal((await fakeResponse.json()).id, "example");
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
const isolated = await METHODS.eval({ code: "await nativeProbe();" });
|
|
85
|
+
assert.equal(isolated.ok, true, JSON.stringify(isolated));
|
|
86
|
+
const failed = await METHODS.eval({ code: 'await ctx.fetch(' + JSON.stringify(url) + '); throw new Error("expected");' });
|
|
87
|
+
assert.equal(failed.ok, false);
|
|
88
|
+
assert.equal(failed.error.message, "expected");
|
|
89
|
+
assert.equal(globalThis.fetch, originalFetch);
|
|
90
|
+
assert.equal(currentInstrumentationScope(), undefined);
|
|
91
|
+
assert.equal((await fetch(url)).status, 200);
|
|
92
|
+
const next = await METHODS.eval({ code: ${JSON.stringify(reproducer)} });
|
|
93
|
+
assert.equal(next.ok, true, JSON.stringify(next));
|
|
94
|
+
assert.deepEqual(next.result, result.result);
|
|
95
|
+
assert.deepEqual(stopRecording(), []);
|
|
96
|
+
`;
|
|
97
|
+
const child = Bun.spawn([process.execPath, "--eval", script], {
|
|
98
|
+
env: { ...process.env, SPECTEST_APP_DIR: root },
|
|
99
|
+
stdout: "pipe",
|
|
100
|
+
stderr: "pipe",
|
|
101
|
+
});
|
|
102
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
103
|
+
child.exited,
|
|
104
|
+
new Response(child.stdout).text(),
|
|
105
|
+
new Response(child.stderr).text(),
|
|
106
|
+
]);
|
|
107
|
+
expect(code, stdout + stderr).toBe(0);
|
|
108
|
+
} finally {
|
|
109
|
+
await rm(root, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
}, 20_000);
|
package/src/harness/main.ts
CHANGED
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import net from "node:net";
|
|
37
|
+
import { initializeStickyConsumer, STICKY_CAPABILITY, type StickyConsumerConfig } from "./sticky-consumer";
|
|
37
38
|
|
|
38
39
|
import { METHODS } from "../daemon";
|
|
39
40
|
import { codeOf } from "./methods";
|
|
@@ -50,7 +51,7 @@ import {
|
|
|
50
51
|
|
|
51
52
|
/** Capabilities this build announces. Empty is fine — the slot is what
|
|
52
53
|
* matters, so a later release has something to negotiate against. */
|
|
53
|
-
const CAPABILITIES: string[] = [];
|
|
54
|
+
const CAPABILITIES: string[] = [STICKY_CAPABILITY];
|
|
54
55
|
|
|
55
56
|
type Handler = (params: Record<string, unknown>) => Promise<unknown>;
|
|
56
57
|
|
|
@@ -65,6 +66,10 @@ export function handlers(
|
|
|
65
66
|
return {
|
|
66
67
|
// Everything the environment actually does.
|
|
67
68
|
...methods,
|
|
69
|
+
stickyInitialize: async (params) => {
|
|
70
|
+
await initializeStickyConsumer(params as unknown as StickyConsumerConfig);
|
|
71
|
+
return { initialized: true };
|
|
72
|
+
},
|
|
68
73
|
// Transport-level methods, which the method table has no business
|
|
69
74
|
// knowing about: they concern this connection, not this environment.
|
|
70
75
|
// Declared after the spread so the table can never shadow them — a
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { STICKY_COMPATIBILITY, validateStickyConfig, collectStickyCache } from "./sticky-consumer";
|
|
3
|
+
|
|
4
|
+
const GiB = 1024 ** 3;
|
|
5
|
+
const config = { path: "/var/lib/spectest-sticky", capacityBytes: 256 * GiB,
|
|
6
|
+
headroomBytes: 32 * GiB, cacheTargetBytes: 64 * GiB, buildkitTargetBytes: 32 * GiB,
|
|
7
|
+
compatibility: STICKY_COMPATIBILITY };
|
|
8
|
+
|
|
9
|
+
test("sticky consumer validates independent retention and working budgets", () => {
|
|
10
|
+
expect(() => validateStickyConfig(config)).not.toThrow();
|
|
11
|
+
// Changing capacity does not require increasing either retention target.
|
|
12
|
+
expect(() => validateStickyConfig({ ...config, capacityBytes: 512 * GiB })).not.toThrow();
|
|
13
|
+
for (const override of [{ path: "/" }, { compatibility: "legacy" }, { headroomBytes: 0 },
|
|
14
|
+
{ headroomBytes: config.capacityBytes }, { capacityBytes: Number.NaN },
|
|
15
|
+
{ cacheTargetBytes: 240 * GiB }, { buildkitTargetBytes: 65 * GiB }, { cacheTargetBytes: Number.NaN }]) {
|
|
16
|
+
expect(() => validateStickyConfig({ ...config, ...override })).toThrow();
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("filesystem pressure removes oldest Docker images when BuildKit pruning frees nothing", async () => {
|
|
21
|
+
let used = 100 * GiB;
|
|
22
|
+
const calls: string[][] = [];
|
|
23
|
+
const run = async (command: string, args: string[]) => {
|
|
24
|
+
calls.push([command, ...args]);
|
|
25
|
+
if (command === "docker" && args[1] === "ls") return "new\nold\n";
|
|
26
|
+
if (command === "docker" && args[1] === "inspect") return JSON.stringify([
|
|
27
|
+
{ Id: "new", Created: "2026-09-29T10:00:00Z" }, { Id: "old", Created: "2026-09-28T10:00:00Z" }]);
|
|
28
|
+
if (command === "docker" && args[1] === "rm") used -= 40 * GiB;
|
|
29
|
+
return ""; // Model Harmony: BuildKit's own GC reports no reclaimable data.
|
|
30
|
+
};
|
|
31
|
+
await collectStickyCache(config, run, async () => ({ totalBytes: 256 * GiB, freeBytes: 256 * GiB - used }));
|
|
32
|
+
expect(calls.filter(c => c[2] === "rm")).toEqual([["docker", "image", "rm", "-f", "old"]]);
|
|
33
|
+
const prune = calls.find(c => c.includes("--free-storage"))!;
|
|
34
|
+
expect(prune[prune.indexOf("--keep-storage") + 1]).toBe(String(Math.floor(32 * GiB / 1e6)));
|
|
35
|
+
expect(prune[prune.indexOf("--free-storage") + 1]).toBe(String(Math.ceil(192 * GiB / 1e6)));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("healthy cache keeps recent images and a cache that cannot shrink is not published", async () => {
|
|
39
|
+
const calls: string[][] = [];
|
|
40
|
+
const run = async (command: string, args: string[]) => { calls.push([command, ...args]); return ""; };
|
|
41
|
+
await collectStickyCache(config, run, async () => ({ totalBytes: 256 * GiB, freeBytes: 220 * GiB }));
|
|
42
|
+
expect(calls.some(c => c[2] === "rm" || c[2] === "inspect")).toBe(false);
|
|
43
|
+
await expect(collectStickyCache(config, run, async () => ({ totalBytes: 256 * GiB, freeBytes: 5 * GiB })))
|
|
44
|
+
.rejects.toThrow("cannot meet filesystem retention target");
|
|
45
|
+
});
|
|
@@ -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
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// current recorder is `null` and the `record*` helpers are no-ops, so
|
|
9
9
|
// callers can invoke them unconditionally.
|
|
10
10
|
|
|
11
|
+
import type { AndroidStepDetails } from "./android-step.js";
|
|
11
12
|
import { clearPendingNullish } from "./inspect.js";
|
|
12
13
|
import { scopeAllowsRecording } from "./harness/instrumentation-scope.js";
|
|
13
14
|
|
|
@@ -632,7 +633,7 @@ export interface EventReservation {
|
|
|
632
633
|
class Recorder {
|
|
633
634
|
private events: TestEvent[] = [];
|
|
634
635
|
private seq = 0;
|
|
635
|
-
private readonly start = Date.now()
|
|
636
|
+
constructor(private readonly start = Date.now()) {}
|
|
636
637
|
|
|
637
638
|
/** Claim a seq + start offset now; pass the result to `push` at finish. */
|
|
638
639
|
reserve(): EventReservation {
|
|
@@ -753,8 +754,8 @@ function active(): boolean {
|
|
|
753
754
|
return current !== null && paused === 0 && scopeAllowsRecording();
|
|
754
755
|
}
|
|
755
756
|
|
|
756
|
-
export function startRecording(): void {
|
|
757
|
-
current = new Recorder();
|
|
757
|
+
export function startRecording(startedAt = Date.now()): void {
|
|
758
|
+
current = new Recorder(startedAt);
|
|
758
759
|
paused = 0;
|
|
759
760
|
}
|
|
760
761
|
|
|
@@ -911,6 +912,8 @@ export interface StepEvent extends BaseEvent {
|
|
|
911
912
|
kind: string;
|
|
912
913
|
/** The step's one-line summary in the timeline. */
|
|
913
914
|
title: string;
|
|
915
|
+
/** Native action semantics; titles are presentation only. */
|
|
916
|
+
android?: AndroidStepDetails;
|
|
914
917
|
blocks?: StepBlock[];
|
|
915
918
|
status?: "passed" | "failed" | "error";
|
|
916
919
|
durationMs?: number;
|