@specific.dev/spectest 0.89.0 → 0.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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";
@@ -24,3 +24,4 @@ export { microsoft } from "./microsoft.js";
24
24
  export { okta } from "./okta.js";
25
25
  export { replayFake, } from "./replayFake.js";
26
26
  export { electron } from "./electron.js";
27
+ export { android } from "./android.js";
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
  //
@@ -51,6 +52,7 @@ import { bindInstrumentationScope, createInstrumentationScope, runInstrumented,
51
52
  import { encodeRegistry } from "./harness/names-registry.js";
52
53
  import { InterceptRegistry, parseTarget, runChain, } from "./harness/intercept.js";
53
54
  import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
55
+ import { WS_BRIDGE_HANDLERS, openUpstreamWebSocket, parseSubprotocols, upstreamHandshakeHeaders, } from "./harness/ws-bridge.js";
54
56
  import { certCovers as hostmatchCertCovers, hostWithoutPort, matchRoute, selectCertName, wildcardSuffix, } from "./harness/hostmatch.js";
55
57
  import { INGRESS_HTTPS_PORT, INGRESS_HTTP_PORT, bindRoute, clearTables, emptyTables, planBind, registryTarget, routesFor, unbindRoute, } from "./harness/ingress-table.js";
56
58
  import { startTlsTerminator } from "./harness/tls-terminator.js";
@@ -487,6 +489,12 @@ function ensureLocalBuildkitd() {
487
489
  return _localBuilder;
488
490
  }
489
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
+ }
490
498
  const paths = await imageCachePaths();
491
499
  if (!paths)
492
500
  return false;
@@ -1977,8 +1985,8 @@ async function unbindRuntimeTls(hostname) {
1977
1985
  * Spin up one Bun.serve listener bound to (port, optional TLS) that
1978
1986
  * dispatches every request to the matching Route by Host header.
1979
1987
  *
1980
- * Shared by the HTTP and HTTPS branches. Also exports a `websocket`
1981
- * handler so reverse-proxy targets can transparently bridge WS
1988
+ * Shared by the HTTP and HTTPS branches. Also carries the `websocket`
1989
+ * handler set so reverse-proxy targets can transparently bridge WS
1982
1990
  * upgrades through to their upstream service.
1983
1991
  */
1984
1992
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -2016,81 +2024,9 @@ serve = { proto: "http" }) {
2016
2024
  idleTimeout: 0,
2017
2025
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2018
2026
  fetch: (req, server) => dispatchIngress(req, server, byHost, listenerLabel, proto),
2019
- websocket: {
2020
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2021
- async open(ws) {
2022
- const data = ws.data;
2023
- try {
2024
- const upstream = new WebSocket(data.upstreamUrl);
2025
- // ArrayBuffer so binary frames can be ws.send()'d to the
2026
- // downstream client verbatim — Blob would need an extra
2027
- // .arrayBuffer() round-trip on every message.
2028
- upstream.binaryType = "arraybuffer";
2029
- data.upstream = upstream;
2030
- upstream.addEventListener("open", () => {
2031
- for (const m of data.pending)
2032
- upstream.send(m);
2033
- data.pending = [];
2034
- });
2035
- upstream.addEventListener("message", (ev) => {
2036
- try {
2037
- ws.send(ev.data);
2038
- }
2039
- catch {
2040
- /* client gone */
2041
- }
2042
- });
2043
- upstream.addEventListener("close", (ev) => {
2044
- try {
2045
- ws.close(ev.code, ev.reason);
2046
- }
2047
- catch {
2048
- /* already closed */
2049
- }
2050
- });
2051
- upstream.addEventListener("error", () => {
2052
- try {
2053
- ws.close(1011, "upstream error");
2054
- }
2055
- catch {
2056
- /* already closed */
2057
- }
2058
- });
2059
- }
2060
- catch (err) {
2061
- // eslint-disable-next-line no-console
2062
- console.warn(`[ingress] ws upstream open failed for ${data.upstreamUrl}:`, err);
2063
- try {
2064
- ws.close(1011, "upstream open failed");
2065
- }
2066
- catch {
2067
- /* ignore */
2068
- }
2069
- }
2070
- },
2071
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2072
- message(ws, message) {
2073
- const data = ws.data;
2074
- const payload = typeof message === "string" ? message : new Uint8Array(message);
2075
- if (data.upstream && data.upstream.readyState === WebSocket.OPEN) {
2076
- data.upstream.send(payload);
2077
- }
2078
- else {
2079
- // Buffer until the upstream finishes its handshake.
2080
- data.pending.push(payload);
2081
- }
2082
- },
2083
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
2084
- close(ws, code, reason) {
2085
- const data = ws.data;
2086
- try {
2087
- data.upstream?.close(code, reason);
2088
- }
2089
- catch {
2090
- /* ignore */
2091
- }
2092
- },
2093
- },
2027
+ // The frame relay for bridged WebSocket upgrades; `proxyToService`
2028
+ // opens the upstream before it upgrades the client (harness/ws-bridge.ts).
2029
+ websocket: WS_BRIDGE_HANDLERS,
2094
2030
  };
2095
2031
  return Bun.serve(opts);
2096
2032
  }
@@ -2181,8 +2117,12 @@ function ingressClientIp(server, req, proto) {
2181
2117
  * Reverse-proxy a request to `http://<service>:<port>` on
2182
2118
  * `spectest-net`. Handles plain HTTP/1.1 + 2 and WebSocket upgrades:
2183
2119
  *
2184
- * - WS upgrade requests get routed through `server.upgrade()`, with
2185
- * the upstream URL stashed on `ws.data`. The shared `websocket`
2120
+ * - WS upgrade requests open the upstream WebSocket first, with the
2121
+ * client's full `Sec-WebSocket-Protocol` list and its forwardable
2122
+ * headers (cookies, authorization, origin, x-forwarded-*). Only after
2123
+ * that handshake succeeds is the client upgraded, with the protocol
2124
+ * the upstream selected; a refused upstream handshake is a 502, not a
2125
+ * 101 followed by a close. The open upstream rides on `ws.data`; the shared `websocket`
2186
2126
  * handler opens the upstream and bridges frames both ways.
2187
2127
  * - Plain requests pass through via `fetch()` with hop-by-hop
2188
2128
  * headers stripped; the response body is a ReadableStream returned
@@ -2205,16 +2145,38 @@ server, service, port, listenerLabel, proto) {
2205
2145
  const upgrade = req.headers.get("upgrade")?.toLowerCase() ?? "";
2206
2146
  if (upgrade === "websocket") {
2207
2147
  const upstreamUrl = `ws://${await proxyUpstreamHost(service)}:${port}${upstreamPath}`;
2208
- const wsData = {
2209
- upstreamUrl,
2210
- upstream: null,
2211
- pending: [],
2212
- };
2213
- const ok = server.upgrade(req, { data: wsData });
2148
+ // The upstream handshake carries what the client's did: the whole
2149
+ // subprotocol list (a console ticket often rides as the second entry)
2150
+ // and the forwardable headers. Same provenance stamps as the HTTP path.
2151
+ const protocols = parseSubprotocols(req.headers.get("sec-websocket-protocol"));
2152
+ const headers = upstreamHandshakeHeaders(req.headers, {
2153
+ service,
2154
+ port,
2155
+ proto,
2156
+ clientIp: ingressClientIp(server, req, proto),
2157
+ });
2158
+ const opened = await openUpstreamWebSocket(upstreamUrl, { protocols, headers });
2159
+ if (!opened.ok) {
2160
+ return new Response(`spectest-daemon: ${opened.reason} (${upstreamUrl}) on ${listenerLabel}\n`, { status: 502, headers: { "content-type": "text/plain" } });
2161
+ }
2162
+ const wsData = { upstreamUrl, upstream: opened.socket };
2163
+ // Echo the protocol the upstream selected; Bun would otherwise pick
2164
+ // the client's first entry by itself, which can differ.
2165
+ const selected = opened.socket.protocol;
2166
+ const ok = server.upgrade(req, {
2167
+ data: wsData,
2168
+ headers: selected ? { "sec-websocket-protocol": selected } : undefined,
2169
+ });
2214
2170
  if (ok) {
2215
2171
  // Bun has already taken over the response — return a stub.
2216
2172
  return new Response(null, { status: 101 });
2217
2173
  }
2174
+ try {
2175
+ opened.socket.close(1011, "client upgrade refused");
2176
+ }
2177
+ catch {
2178
+ /* ignore */
2179
+ }
2218
2180
  return new Response(`spectest-daemon: ws upgrade refused on ${listenerLabel}\n`, { status: 426, headers: { "content-type": "text/plain" } });
2219
2181
  }
2220
2182
  const fwdHeaders = new Headers();
@@ -4414,6 +4376,8 @@ function captureConsole(chunks) {
4414
4376
  }
4415
4377
  async function runOne(testCase) {
4416
4378
  const start = Date.now();
4379
+ const nativeAndroid = androidScope(testCase.id, start);
4380
+ let androidRecordings = [];
4417
4381
  const chunks = [];
4418
4382
  const origStdout = process.stdout.write.bind(process.stdout);
4419
4383
  const origStderr = process.stderr.write.bind(process.stderr);
@@ -4567,7 +4531,8 @@ async function runOne(testCase) {
4567
4531
  });
4568
4532
  return term;
4569
4533
  };
4570
- startRecording();
4534
+ // Share the case clock with native video timestamps and other artifacts.
4535
+ startRecording(start);
4571
4536
  const restoreFetch = installFetchWrapper();
4572
4537
  const fetchScope = createInstrumentationScope();
4573
4538
  // Look up the parent's stored return value (if any). The parent ran in
@@ -4678,6 +4643,7 @@ async function runOne(testCase) {
4678
4643
  openTerminal: recordedOpenTerminal,
4679
4644
  browser: trackedOpenBrowser,
4680
4645
  mobile: trackedOpenMobile,
4646
+ android: nativeAndroid.open,
4681
4647
  desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
4682
4648
  mcp: openMcp,
4683
4649
  testName: testCase.name,
@@ -4699,10 +4665,10 @@ async function runOne(testCase) {
4699
4665
  });
4700
4666
  // Result events are gathered inside finally (after the final browser
4701
4667
  // drains) so we hoist these out of the try/catch.
4702
- let outcome;
4668
+ let outcome = { status: "passed" };
4703
4669
  try {
4704
4670
  const value = await Promise.race([
4705
- 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); })),
4706
4672
  timedOut,
4707
4673
  ]);
4708
4674
  // Stash the return value so child cases — which fork from the snapshot
@@ -4720,6 +4686,7 @@ async function runOne(testCase) {
4720
4686
  finally {
4721
4687
  if (timer)
4722
4688
  clearTimeout(timer);
4689
+ nativeAndroid.deactivate();
4723
4690
  RECORDING_EXEC = undefined;
4724
4691
  INTERCEPTORS.endScope();
4725
4692
  fetchScope.active = false;
@@ -4744,6 +4711,10 @@ async function runOne(testCase) {
4744
4711
  }
4745
4712
  for (const s of sessions)
4746
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") } };
4747
4718
  }
4748
4719
  const durationMs = Date.now() - start;
4749
4720
  // Capture each service's log delta (the lines THIS case added beyond its
@@ -4802,6 +4773,7 @@ async function runOne(testCase) {
4802
4773
  }
4803
4774
  return {
4804
4775
  status: outcome.status,
4776
+ androidRecordings,
4805
4777
  durationMs,
4806
4778
  log: chunks.join(""),
4807
4779
  events,
@@ -5032,6 +5004,7 @@ function explainEvalExportError(code, message) {
5032
5004
  }
5033
5005
  async function evalCode(code) {
5034
5006
  const start = Date.now();
5007
+ const nativeAndroid = androidScope("eval", start, { record: false });
5035
5008
  const chunks = [];
5036
5009
  const origStdout = process.stdout.write.bind(process.stdout);
5037
5010
  const origStderr = process.stderr.write.bind(process.stderr);
@@ -5158,6 +5131,7 @@ async function evalCode(code) {
5158
5131
  openTerminal: evalOpenTerminal,
5159
5132
  browser: trackedOpenBrowser,
5160
5133
  mobile: trackedOpenMobile,
5134
+ android: nativeAndroid.open,
5161
5135
  desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
5162
5136
  mcp: openMcp,
5163
5137
  testName: "eval",
@@ -5218,6 +5192,7 @@ async function evalCode(code) {
5218
5192
  }
5219
5193
  for (const s of sessions)
5220
5194
  s.markClosed();
5195
+ await nativeAndroid.finish();
5221
5196
  if (filePath) {
5222
5197
  fs.unlink(filePath).catch(() => {
5223
5198
  /* best-effort cleanup */
@@ -5721,6 +5696,8 @@ export function harnessMethods(state) {
5721
5696
  },
5722
5697
  // One chunk of a parked replay bundle. Case ids are arbitrary user
5723
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)),
5724
5701
  replayChunk: async (params) => {
5725
5702
  const caseId = requireString(params, "caseId", "caseId");
5726
5703
  const gz = REPLAY_BUNDLES.get(caseId);
@@ -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 {};