@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.
- 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 +25 -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 +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 +24 -3
- 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 +3 -3
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type AndroidApp, type AndroidRecordingOptions } from "../android.js";
|
|
2
|
+
export interface AndroidOptions {
|
|
3
|
+
/** Project-relative path to an APK built for the worker's native ABI. */
|
|
4
|
+
apk: string;
|
|
5
|
+
/** Android application id, for example com.example.app. */
|
|
6
|
+
package: string;
|
|
7
|
+
/** Launch activity, for example .MainActivity. */
|
|
8
|
+
activity: string;
|
|
9
|
+
/** Android image; default is the tested Android 14 arm64-only redroid image. */
|
|
10
|
+
image?: string;
|
|
11
|
+
/** Optional prebuilt image containing the same tools as ANDROID_TOOLS_DOCKERFILE. */
|
|
12
|
+
toolsImage?: string;
|
|
13
|
+
/** Display defaults to Pixel 8 dimensions: 1080 × 2400, at 420 logical dpi. */
|
|
14
|
+
width?: number;
|
|
15
|
+
height?: number;
|
|
16
|
+
dpi?: number;
|
|
17
|
+
/** Show tap markers in replay and enable native physical-touch feedback. Default true. */
|
|
18
|
+
showTouches?: boolean;
|
|
19
|
+
recording?: false | AndroidRecordingOptions;
|
|
20
|
+
dependsOn?: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare const ANDROID_TOOLS_DOCKERFILE = "FROM node:22-bookworm-slim\nRUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*\nRUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*\nENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium\nRUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0\n";
|
|
23
|
+
/** Native Android, Appium and software recording, all inside the environment VM. */
|
|
24
|
+
export declare function android(options: AndroidOptions): import("../index.js").ServiceGroup<{
|
|
25
|
+
device: {
|
|
26
|
+
image: {
|
|
27
|
+
type: "registry";
|
|
28
|
+
reference: string;
|
|
29
|
+
};
|
|
30
|
+
privileged: true;
|
|
31
|
+
args: string[];
|
|
32
|
+
ports: number[];
|
|
33
|
+
dependsOn: string[] | undefined;
|
|
34
|
+
readyCheck: {
|
|
35
|
+
type: "exec";
|
|
36
|
+
command: string;
|
|
37
|
+
timeoutSecs: number;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
tools: {
|
|
41
|
+
image: {
|
|
42
|
+
type: "registry";
|
|
43
|
+
reference: string;
|
|
44
|
+
content?: undefined;
|
|
45
|
+
} | {
|
|
46
|
+
type: "dockerfile";
|
|
47
|
+
content: string;
|
|
48
|
+
reference?: undefined;
|
|
49
|
+
};
|
|
50
|
+
command: string;
|
|
51
|
+
files: {
|
|
52
|
+
path: string;
|
|
53
|
+
content: string;
|
|
54
|
+
}[];
|
|
55
|
+
volumes: {
|
|
56
|
+
source: string;
|
|
57
|
+
target: string;
|
|
58
|
+
readOnly: true;
|
|
59
|
+
}[];
|
|
60
|
+
inputs: string[];
|
|
61
|
+
env: {
|
|
62
|
+
ANDROID_SERIAL: string;
|
|
63
|
+
ANDROID_ACTIVITY: string;
|
|
64
|
+
ANDROID_SHOW_TOUCHES: string;
|
|
65
|
+
};
|
|
66
|
+
ports: number[];
|
|
67
|
+
readyCheck: {
|
|
68
|
+
type: "http";
|
|
69
|
+
port: number;
|
|
70
|
+
path: string;
|
|
71
|
+
timeoutSecs: number;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
}, AndroidApp, "tools">;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
import { serviceGroup } from "../index.js";
|
|
4
|
+
import { resolveExistingProjectPath } from "../project-files.js";
|
|
5
|
+
import { androidApp } from "../android.js";
|
|
6
|
+
export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
|
|
7
|
+
RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates curl gcc pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
|
|
8
|
+
RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
|
|
9
|
+
ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
|
|
10
|
+
RUN mkdir -p /opt/android/platform-tools && for tool in adb aapt aapt2 apksigner zipalign; do ln -s /usr/bin/$tool /opt/android/platform-tools/$tool; done && npm install -g appium@3.8.0 && appium driver install uiautomator2@8.7.0
|
|
11
|
+
`;
|
|
12
|
+
/** Native Android, Appium and software recording, all inside the environment VM. */
|
|
13
|
+
export function android(options) {
|
|
14
|
+
if (!options.apk ||
|
|
15
|
+
!/^[\w]+(?:\.[\w]+)+$/.test(options.package) ||
|
|
16
|
+
!/^[\w.$]+$/.test(options.activity))
|
|
17
|
+
throw new Error("android(): apk, package and activity are required");
|
|
18
|
+
const apk = posix.normalize(options.apk);
|
|
19
|
+
if (posix.isAbsolute(apk) || apk.startsWith("../") || apk.startsWith("spectest/tests/"))
|
|
20
|
+
throw new Error("android(): apk must be project-relative and outside spectest/tests so app changes invalidate snapshots");
|
|
21
|
+
const width = options.width ?? 1080, height = options.height ?? 2400, dpi = options.dpi ?? 420;
|
|
22
|
+
for (const value of [width, height, dpi])
|
|
23
|
+
if (!Number.isInteger(value) || value < 1 || value > 4096)
|
|
24
|
+
throw new Error("android(): invalid display dimensions/density");
|
|
25
|
+
return serviceGroup({
|
|
26
|
+
primary: "tools",
|
|
27
|
+
services: (g) => ({
|
|
28
|
+
device: {
|
|
29
|
+
image: {
|
|
30
|
+
type: "registry",
|
|
31
|
+
reference: options.image ??
|
|
32
|
+
"redroid/redroid:14.0.0_64only-latest@sha256:0a611199ba2e0b5d60af39b3327a517f6407231f4352114ed3bd3cbfe2be69aa",
|
|
33
|
+
},
|
|
34
|
+
privileged: true,
|
|
35
|
+
// Android's netd ignores resolv.conf. Use Docker's per-container DNS,
|
|
36
|
+
// which resolves service aliases and forwards to the in-VM resolver.
|
|
37
|
+
args: [
|
|
38
|
+
`androidboot.redroid_width=${width}`,
|
|
39
|
+
`androidboot.redroid_height=${height}`,
|
|
40
|
+
`androidboot.redroid_dpi=${dpi}`,
|
|
41
|
+
"androidboot.redroid_gpu_mode=guest",
|
|
42
|
+
"androidboot.use_memfd=1",
|
|
43
|
+
"androidboot.redroid_net_ndns=1",
|
|
44
|
+
"androidboot.redroid_net_dns1=127.0.0.11",
|
|
45
|
+
],
|
|
46
|
+
ports: [5555],
|
|
47
|
+
dependsOn: options.dependsOn,
|
|
48
|
+
readyCheck: {
|
|
49
|
+
type: "exec",
|
|
50
|
+
command: 'test "$(getprop sys.boot_completed)" = 1',
|
|
51
|
+
timeoutSecs: 180,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
tools: {
|
|
55
|
+
image: options.toolsImage
|
|
56
|
+
? { type: "registry", reference: options.toolsImage }
|
|
57
|
+
: { type: "dockerfile", content: ANDROID_TOOLS_DOCKERFILE },
|
|
58
|
+
command: "node /spectest-android.mjs",
|
|
59
|
+
files: [
|
|
60
|
+
{
|
|
61
|
+
path: "/spectest-android.mjs",
|
|
62
|
+
content: readFileSync(new URL("./android-runtime.mjs", import.meta.url), "utf8"),
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
volumes: [
|
|
66
|
+
{
|
|
67
|
+
source: resolveExistingProjectPath(apk, "Android APK"),
|
|
68
|
+
target: "/spectest/app.apk",
|
|
69
|
+
readOnly: true,
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
inputs: [apk],
|
|
73
|
+
env: {
|
|
74
|
+
ANDROID_SERIAL: `${g.key("device")}.internal:5555`,
|
|
75
|
+
ANDROID_ACTIVITY: `${options.package}/${options.activity}`,
|
|
76
|
+
ANDROID_SHOW_TOUCHES: options.showTouches === false ? "0" : "1",
|
|
77
|
+
},
|
|
78
|
+
ports: [4723, 4725],
|
|
79
|
+
readyCheck: { type: "http", port: 4725, path: "/ready", timeoutSecs: 240 },
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
helpers: ({ name }) => androidApp({
|
|
83
|
+
name,
|
|
84
|
+
endpoint: `http://${name}.internal:4723`,
|
|
85
|
+
recorderEndpoint: `http://${name}.internal:4725`,
|
|
86
|
+
serial: `${name}-device.internal:5555`,
|
|
87
|
+
package: options.package,
|
|
88
|
+
recording: options.recording ?? {},
|
|
89
|
+
showTouches: options.showTouches ?? true,
|
|
90
|
+
}),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -15,3 +15,4 @@ export { microsoft, type MicrosoftOptions } from "./microsoft.js";
|
|
|
15
15
|
export { okta, type OktaOptions } from "./okta.js";
|
|
16
16
|
export { replayFake, type ReplayFakeOptions, type ReplayMatch, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
|
|
17
17
|
export { electron, type ElectronOptions } from "./electron.js";
|
|
18
|
+
export { android, type AndroidOptions } from "./android.js";
|
package/dist/components/index.js
CHANGED
package/dist/daemon.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
|
|
1
2
|
// Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
|
|
2
3
|
// a container). Owns:
|
|
3
4
|
//
|
|
@@ -488,6 +489,12 @@ function ensureLocalBuildkitd() {
|
|
|
488
489
|
return _localBuilder;
|
|
489
490
|
}
|
|
490
491
|
async function bringUpLocalBuildkitd() {
|
|
492
|
+
if (existsSync("/run/spectest-sticky/consumer.json")) {
|
|
493
|
+
const ready = await docker(["buildx", "inspect", "--bootstrap", LOCAL_BUILDER_NAME], 30_000);
|
|
494
|
+
if (ready.code !== 0)
|
|
495
|
+
throw new Error(`sticky BuildKit unavailable: ${ready.stderr}`);
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
491
498
|
const paths = await imageCachePaths();
|
|
492
499
|
if (!paths)
|
|
493
500
|
return false;
|
|
@@ -4369,6 +4376,8 @@ function captureConsole(chunks) {
|
|
|
4369
4376
|
}
|
|
4370
4377
|
async function runOne(testCase) {
|
|
4371
4378
|
const start = Date.now();
|
|
4379
|
+
const nativeAndroid = androidScope(testCase.id, start);
|
|
4380
|
+
let androidRecordings = [];
|
|
4372
4381
|
const chunks = [];
|
|
4373
4382
|
const origStdout = process.stdout.write.bind(process.stdout);
|
|
4374
4383
|
const origStderr = process.stderr.write.bind(process.stderr);
|
|
@@ -4522,7 +4531,8 @@ async function runOne(testCase) {
|
|
|
4522
4531
|
});
|
|
4523
4532
|
return term;
|
|
4524
4533
|
};
|
|
4525
|
-
|
|
4534
|
+
// Share the case clock with native video timestamps and other artifacts.
|
|
4535
|
+
startRecording(start);
|
|
4526
4536
|
const restoreFetch = installFetchWrapper();
|
|
4527
4537
|
const fetchScope = createInstrumentationScope();
|
|
4528
4538
|
// Look up the parent's stored return value (if any). The parent ran in
|
|
@@ -4633,6 +4643,7 @@ async function runOne(testCase) {
|
|
|
4633
4643
|
openTerminal: recordedOpenTerminal,
|
|
4634
4644
|
browser: trackedOpenBrowser,
|
|
4635
4645
|
mobile: trackedOpenMobile,
|
|
4646
|
+
android: nativeAndroid.open,
|
|
4636
4647
|
desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
|
|
4637
4648
|
mcp: openMcp,
|
|
4638
4649
|
testName: testCase.name,
|
|
@@ -4654,10 +4665,10 @@ async function runOne(testCase) {
|
|
|
4654
4665
|
});
|
|
4655
4666
|
// Result events are gathered inside finally (after the final browser
|
|
4656
4667
|
// drains) so we hoist these out of the try/catch.
|
|
4657
|
-
let outcome;
|
|
4668
|
+
let outcome = { status: "passed" };
|
|
4658
4669
|
try {
|
|
4659
4670
|
const value = await Promise.race([
|
|
4660
|
-
Promise.resolve(runInstrumented(fetchScope, () => testCase.run(ctx))),
|
|
4671
|
+
Promise.resolve(runInstrumented(fetchScope, async () => { await nativeAndroid.begin(Object.values(base.svc).filter(isAndroidApp)); return testCase.run(ctx); })),
|
|
4661
4672
|
timedOut,
|
|
4662
4673
|
]);
|
|
4663
4674
|
// Stash the return value so child cases — which fork from the snapshot
|
|
@@ -4675,6 +4686,7 @@ async function runOne(testCase) {
|
|
|
4675
4686
|
finally {
|
|
4676
4687
|
if (timer)
|
|
4677
4688
|
clearTimeout(timer);
|
|
4689
|
+
nativeAndroid.deactivate();
|
|
4678
4690
|
RECORDING_EXEC = undefined;
|
|
4679
4691
|
INTERCEPTORS.endScope();
|
|
4680
4692
|
fetchScope.active = false;
|
|
@@ -4699,6 +4711,10 @@ async function runOne(testCase) {
|
|
|
4699
4711
|
}
|
|
4700
4712
|
for (const s of sessions)
|
|
4701
4713
|
s.markClosed();
|
|
4714
|
+
androidRecordings = await nativeAndroid.finish();
|
|
4715
|
+
const captureErrors = androidRecordings.flatMap(r => r.error ? [r.error] : []);
|
|
4716
|
+
if (captureErrors.length && outcome.status === "passed")
|
|
4717
|
+
outcome = { status: "failed", error: { message: captureErrors.join("\n") } };
|
|
4702
4718
|
}
|
|
4703
4719
|
const durationMs = Date.now() - start;
|
|
4704
4720
|
// Capture each service's log delta (the lines THIS case added beyond its
|
|
@@ -4757,6 +4773,7 @@ async function runOne(testCase) {
|
|
|
4757
4773
|
}
|
|
4758
4774
|
return {
|
|
4759
4775
|
status: outcome.status,
|
|
4776
|
+
androidRecordings,
|
|
4760
4777
|
durationMs,
|
|
4761
4778
|
log: chunks.join(""),
|
|
4762
4779
|
events,
|
|
@@ -4987,6 +5004,7 @@ function explainEvalExportError(code, message) {
|
|
|
4987
5004
|
}
|
|
4988
5005
|
async function evalCode(code) {
|
|
4989
5006
|
const start = Date.now();
|
|
5007
|
+
const nativeAndroid = androidScope("eval", start, { record: false });
|
|
4990
5008
|
const chunks = [];
|
|
4991
5009
|
const origStdout = process.stdout.write.bind(process.stdout);
|
|
4992
5010
|
const origStderr = process.stderr.write.bind(process.stderr);
|
|
@@ -5113,6 +5131,7 @@ async function evalCode(code) {
|
|
|
5113
5131
|
openTerminal: evalOpenTerminal,
|
|
5114
5132
|
browser: trackedOpenBrowser,
|
|
5115
5133
|
mobile: trackedOpenMobile,
|
|
5134
|
+
android: nativeAndroid.open,
|
|
5116
5135
|
desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
|
|
5117
5136
|
mcp: openMcp,
|
|
5118
5137
|
testName: "eval",
|
|
@@ -5173,6 +5192,7 @@ async function evalCode(code) {
|
|
|
5173
5192
|
}
|
|
5174
5193
|
for (const s of sessions)
|
|
5175
5194
|
s.markClosed();
|
|
5195
|
+
await nativeAndroid.finish();
|
|
5176
5196
|
if (filePath) {
|
|
5177
5197
|
fs.unlink(filePath).catch(() => {
|
|
5178
5198
|
/* best-effort cleanup */
|
|
@@ -5676,6 +5696,8 @@ export function harnessMethods(state) {
|
|
|
5676
5696
|
},
|
|
5677
5697
|
// One chunk of a parked replay bundle. Case ids are arbitrary user
|
|
5678
5698
|
// strings, so they travel in params rather than a URL path.
|
|
5699
|
+
androidRecordingChunk: async (params) => androidRecordingChunk(String(params.caseId ?? params.id), params.offset, params.limit),
|
|
5700
|
+
releaseAndroidRecording: async (params) => releaseAndroidRecording(String(params.id)),
|
|
5679
5701
|
replayChunk: async (params) => {
|
|
5680
5702
|
const caseId = requireString(params, "caseId", "caseId");
|
|
5681
5703
|
const gz = REPLAY_BUNDLES.get(caseId);
|
package/dist/harness/main.js
CHANGED
|
@@ -33,12 +33,13 @@
|
|
|
33
33
|
* transports. Two tables would each enforce the rule and together break it.
|
|
34
34
|
*/
|
|
35
35
|
import net from "node:net";
|
|
36
|
+
import { initializeStickyConsumer, STICKY_CAPABILITY } from "./sticky-consumer";
|
|
36
37
|
import { METHODS } from "../daemon";
|
|
37
38
|
import { codeOf } from "./methods";
|
|
38
39
|
import { PROTOCOL_VERSION, decodeFrame, describeThrow, encodeFrame, fail, ok, } from "./protocol";
|
|
39
40
|
/** Capabilities this build announces. Empty is fine — the slot is what
|
|
40
41
|
* matters, so a later release has something to negotiate against. */
|
|
41
|
-
const CAPABILITIES = [];
|
|
42
|
+
const CAPABILITIES = [STICKY_CAPABILITY];
|
|
42
43
|
/** Methods this build can serve. Everything else is answered with an
|
|
43
44
|
* error naming the method, which is far easier to diagnose than a
|
|
44
45
|
* request that hangs. */
|
|
@@ -46,6 +47,10 @@ export function handlers(sdkVersion, onShutdown, methods = METHODS) {
|
|
|
46
47
|
return {
|
|
47
48
|
// Everything the environment actually does.
|
|
48
49
|
...methods,
|
|
50
|
+
stickyInitialize: async (params) => {
|
|
51
|
+
await initializeStickyConsumer(params);
|
|
52
|
+
return { initialized: true };
|
|
53
|
+
},
|
|
49
54
|
// Transport-level methods, which the method table has no business
|
|
50
55
|
// knowing about: they concern this connection, not this environment.
|
|
51
56
|
// Declared after the spread so the table can never shadow them — a
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export declare const STICKY_CAPABILITY = "sticky-disks-v1";
|
|
2
|
+
export declare const STICKY_COMPATIBILITY = "docker29-ctr21-buildkit032-overlayfs-256g-v2";
|
|
3
|
+
export declare const STICKY_RUNTIME = "/run/spectest-sticky";
|
|
4
|
+
export interface StickyConsumerConfig {
|
|
5
|
+
path: string;
|
|
6
|
+
capacityBytes: number;
|
|
7
|
+
headroomBytes: number;
|
|
8
|
+
cacheTargetBytes: number;
|
|
9
|
+
buildkitTargetBytes: number;
|
|
10
|
+
compatibility: string;
|
|
11
|
+
}
|
|
12
|
+
declare function exec(program: string, args: string[], timeout?: number): Promise<string>;
|
|
13
|
+
export declare function validateStickyConfig(config: StickyConsumerConfig): void;
|
|
14
|
+
export declare function initializeStickyConsumer(config: StickyConsumerConfig): Promise<void>;
|
|
15
|
+
/** Filesystem pressure takes precedence over cache age/BuildKit record sizes.
|
|
16
|
+
* Only called after runtime containers/volumes have been removed on the
|
|
17
|
+
* isolated publication clone. Never removes files behind the daemons' backs.
|
|
18
|
+
*/
|
|
19
|
+
export declare function collectStickyCache(config: StickyConsumerConfig, run?: typeof exec, usage?: () => Promise<{
|
|
20
|
+
freeBytes: number;
|
|
21
|
+
totalBytes: number;
|
|
22
|
+
}>): Promise<void>;
|
|
23
|
+
/** Only on an isolated maintenance clone; never on the live ready environment. */
|
|
24
|
+
export declare function prepareStickyConsumer(artifact?: boolean): Promise<{
|
|
25
|
+
prepared: true;
|
|
26
|
+
freeBytes: number;
|
|
27
|
+
}>;
|
|
28
|
+
export {};
|
|
@@ -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
|
@@ -564,7 +564,7 @@ export interface EventReservation {
|
|
|
564
564
|
}
|
|
565
565
|
export declare function pauseRecording(): void;
|
|
566
566
|
export declare function resumeRecording(): void;
|
|
567
|
-
export declare function startRecording(): void;
|
|
567
|
+
export declare function startRecording(startedAt?: number): void;
|
|
568
568
|
export declare function stopRecording(): TestEvent[];
|
|
569
569
|
export declare function isRecording(): boolean;
|
|
570
570
|
/** Reserve an ordering slot at the start of an op so nested events it triggers
|
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.0",
|
|
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
|
}
|