@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.
- 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 +65 -88
- 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/harness/ws-bridge.d.ts +78 -0
- package/dist/harness/ws-bridge.js +180 -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 +72 -89
- 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/harness/ws-bridge.test.ts +224 -0
- package/src/harness/ws-bridge.ts +210 -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
|
//
|
|
@@ -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
|
|
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
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
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
|
|
2185
|
-
*
|
|
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
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
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
|
-
|
|
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);
|
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 {};
|