@specific.dev/spectest 0.28.0 → 0.28.2
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/browser.d.ts +2 -2
- package/dist/browser.js +1 -1
- package/dist/components/k3s.js +16 -30
- package/dist/components/replayFake.js +2 -2
- package/dist/daemon.js +62 -3
- package/dist/index.d.ts +5 -5
- package/dist/terminal.js +1 -1
- package/package.json +1 -1
package/dist/browser.d.ts
CHANGED
|
@@ -154,8 +154,8 @@ export interface Browser {
|
|
|
154
154
|
/**
|
|
155
155
|
* Capture a PNG screenshot of the viewport and upload it as a downloadable
|
|
156
156
|
* **artifact**. Resolves to the artifact's `art_…` id — fetch it locally
|
|
157
|
-
* with `spectest artifact download <id>`. Eval-only (`
|
|
158
|
-
*
|
|
157
|
+
* with `spectest artifact download <id>`. Eval-only (`spectest env eval`);
|
|
158
|
+
* throws with a clear message during test runs.
|
|
159
159
|
*/
|
|
160
160
|
screenshot(): Promise<string>;
|
|
161
161
|
/**
|
package/dist/browser.js
CHANGED
|
@@ -1187,7 +1187,7 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1187
1187
|
const register = recorder?.registerArtifact;
|
|
1188
1188
|
if (!register) {
|
|
1189
1189
|
throw new Error("screenshot() uploads the capture as a downloadable artifact and is " +
|
|
1190
|
-
"currently only available inside an eval (
|
|
1190
|
+
"currently only available inside an eval (`spectest env eval`) — " +
|
|
1191
1191
|
"it cannot be used in test runs.");
|
|
1192
1192
|
}
|
|
1193
1193
|
// Raw CDP rather than page.screenshot(): captures the viewport
|
package/dist/components/k3s.js
CHANGED
|
@@ -595,10 +595,9 @@ const ZOT_MIRRORS = [
|
|
|
595
595
|
/**
|
|
596
596
|
* Discover the host-side image cache gateway by reading the same
|
|
597
597
|
* `registry-mirrors` entry the in-VM dockerd already uses (baked into
|
|
598
|
-
* the
|
|
598
|
+
* the golden rootfs's `/etc/docker/daemon.json`). Returns the
|
|
599
599
|
* gateway host (`"10.42.0.1"`) when present, or `null` when there's no
|
|
600
|
-
* host cache
|
|
601
|
-
* image direct. Runs inside the daemon (VM) at `index.ts` load time, so
|
|
600
|
+
* host cache, in which case the cluster pulls every image direct. Runs inside the daemon (VM) at `index.ts` load time, so
|
|
602
601
|
* the result is stable per host and never poisons the warm-template
|
|
603
602
|
* cache.
|
|
604
603
|
*/
|
|
@@ -618,7 +617,7 @@ function detectHostMirrorGateway() {
|
|
|
618
617
|
* to be seeded via `files` (a pre-start bind mount) rather than a
|
|
619
618
|
* `setup` hook. Two jobs:
|
|
620
619
|
* 1. Mirror the cluster's image pulls through the host `zot` cache
|
|
621
|
-
* (
|
|
620
|
+
* (omitted when there's no host cache).
|
|
622
621
|
* 2. Trust the in-cluster registry, addressed as `<key>.internal:5000`
|
|
623
622
|
* (the `{{SPECTEST_SERVICE}}` token is expanded to the cluster's
|
|
624
623
|
* service key when the file is written). Image *references* use
|
|
@@ -872,30 +871,18 @@ async function collectTraefikDiagnostics(helpers) {
|
|
|
872
871
|
* the k3s container's IP (via systest-resolver), lands on Traefik,
|
|
873
872
|
* and gets dispatched to the matching Ingress rule's backend pods.
|
|
874
873
|
*
|
|
875
|
-
* **
|
|
876
|
-
*
|
|
877
|
-
*
|
|
874
|
+
* **kube-proxy runs in `nftables` mode** (`--kube-proxy-arg=proxy-mode
|
|
875
|
+
* =nftables`), not the iptables default. This started as a workaround
|
|
876
|
+
* for a stock kernel with no `xt_comment` match — kube-proxy's iptables
|
|
877
|
+
* rules carry `-m comment`, and the kernel rejected every one of them,
|
|
878
|
+
* breaking pod→ClusterIP routing and any pod talking to the in-cluster
|
|
879
|
+
* API (helm-install Jobs, CoreDNS, …). We build the guest kernel
|
|
880
|
+
* ourselves now and it has the match, but nftables mode is the better
|
|
881
|
+
* path regardless, so it stays. It is GA in k8s 1.32, which is what
|
|
882
|
+
* pins `DEFAULT_K3S_VERSION`.
|
|
878
883
|
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
* breaking pod→ClusterIP routing and every pod that talks to the
|
|
882
|
-
* in-cluster API (helm-install Jobs, CoreDNS, …). Fixed by
|
|
883
|
-
* `--kube-proxy-arg=proxy-mode=nftables`: kube-proxy emits
|
|
884
|
-
* native nftables rules where comments are a first-class
|
|
885
|
-
* construct, no xt_comment dependency. nftables proxy mode is
|
|
886
|
-
* GA in k8s 1.32, which is why we pin that.
|
|
887
|
-
*
|
|
888
|
-
* 2. *CNI portmap plugin* (used by klipper-lb's hostPort to expose
|
|
889
|
-
* LoadBalancer ports on the host) still uses iptables-nft and
|
|
890
|
-
* hits the same xt_comment failure — there's no equivalent
|
|
891
|
-
* flag to switch it to native nftables. Workaround: disable the
|
|
892
|
-
* bundled traefik + ServiceLB and run Traefik with
|
|
893
|
-
* `hostNetwork: true` ourselves. hostNetwork pods don't go
|
|
894
|
-
* through portmap at all (they share the node's netns directly),
|
|
895
|
-
* so the broken plugin is never invoked.
|
|
896
|
-
*
|
|
897
|
-
* Flannel uses the `host-gw` backend because Freestyle's stock kernel
|
|
898
|
-
* lacks the VXLAN module — fine for single-node clusters.
|
|
884
|
+
* Flannel uses the `host-gw` backend: single-node clusters never route
|
|
885
|
+
* pod traffic off the node, so VXLAN encapsulation is pure overhead.
|
|
899
886
|
*/
|
|
900
887
|
/**
|
|
901
888
|
* Default k3s docker image tag.
|
|
@@ -912,8 +899,7 @@ async function collectTraefikDiagnostics(helpers) {
|
|
|
912
899
|
*
|
|
913
900
|
* **Why v1.32.x:** kube-proxy's `nftables` proxy mode is GA in k8s 1.32
|
|
914
901
|
* (beta in 1.31, alpha-gated in 1.30). The component runs kube-proxy in
|
|
915
|
-
*
|
|
916
|
-
* extension; dropping below 1.31 reintroduces the broken iptables path.
|
|
902
|
+
* that mode; dropping below 1.31 falls back to the iptables path.
|
|
917
903
|
*/
|
|
918
904
|
const DEFAULT_K3S_VERSION = "v1.32.1-k3s1";
|
|
919
905
|
export function k3s(opts = {}) {
|
|
@@ -960,7 +946,7 @@ export function k3s(opts = {}) {
|
|
|
960
946
|
// Services get an address and work. It was disabled for years
|
|
961
947
|
// because klipper-lb binds its ports with a CNI portmap hostPort,
|
|
962
948
|
// and portmap's iptables-nft rules need the `xt_comment` netfilter
|
|
963
|
-
// match — absent from the
|
|
949
|
+
// match — absent from the stock kernel we used to run on, which made
|
|
964
950
|
// every LoadBalancer hang at <pending> with no svclb DaemonSet. We
|
|
965
951
|
// build the guest kernel ourselves now and it carries
|
|
966
952
|
// CONFIG_NETFILTER_XT_MATCH_COMMENT (see
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// - RECORD (MITM): the request is forwarded to the REAL host and the
|
|
20
20
|
// request/response pair is captured (decoded, redacted), and the real
|
|
21
21
|
// response is returned to the app so a manual session behaves like
|
|
22
|
-
// production. This runs under `
|
|
22
|
+
// production. This runs under `spectest env eval` against a manual env.
|
|
23
23
|
//
|
|
24
24
|
// Because the fake's hostname IS the real host, the in-VM resolver points
|
|
25
25
|
// that name at the daemon — so a naive `fetch("https://api.stripe.com")`
|
|
@@ -509,7 +509,7 @@ export function replayFake(opts) {
|
|
|
509
509
|
if (missing.length > 0) {
|
|
510
510
|
const refs = [...new Set(missing)];
|
|
511
511
|
return new Response(`replayFake(${opts.name}): secret(s) ${JSON.stringify(refs)} were not supplied ` +
|
|
512
|
-
`(configure them on the project's Secrets page, and record via
|
|
512
|
+
`(configure them on the project's Secrets page, and record via \`spectest env eval\`).\n`, { status: 599, headers: { "content-type": "text/plain" } });
|
|
513
513
|
}
|
|
514
514
|
// Resolve the REAL host's IP via an external resolver so we don't loop
|
|
515
515
|
// back into the daemon (the in-VM resolver answers our own gateway for
|
package/dist/daemon.js
CHANGED
|
@@ -68,6 +68,17 @@ const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
|
|
|
68
68
|
// referenced when we layer it into each image's system trust store.
|
|
69
69
|
const CA_PATH = process.env.SPECTEST_CA_PATH ?? "/etc/spectest/ca.crt";
|
|
70
70
|
const CA_KEY_PATH = process.env.SPECTEST_CA_KEY_PATH ?? "/etc/spectest/ca.key";
|
|
71
|
+
// Public roots ⧺ the spectest CA, materialized by `ensureCaBundle` and
|
|
72
|
+
// bind-mounted into every container. `SSL_CERT_FILE`/`REQUESTS_CA_BUNDLE`/
|
|
73
|
+
// `AWS_CA_BUNDLE` REPLACE the trust store rather than adding to it, so
|
|
74
|
+
// pointing them at the bare CA leaves a container able to verify our fakes
|
|
75
|
+
// and nothing else — public HTTPS then fails to verify from anything using
|
|
76
|
+
// OpenSSL, Python requests, or an AWS SDK. (`NODE_EXTRA_CA_CERTS` appends,
|
|
77
|
+
// which is why Node services never saw this.)
|
|
78
|
+
const CA_BUNDLE_PATH = process.env.SPECTEST_CA_BUNDLE_PATH ?? "/etc/spectest/ca-bundle.crt";
|
|
79
|
+
// The guest's own trust store. BASE_SETUP_SH runs `update-ca-certificates`
|
|
80
|
+
// after generating the CA, so this normally already carries both halves.
|
|
81
|
+
const SYSTEM_CA_BUNDLE = process.env.SPECTEST_SYSTEM_CA_BUNDLE ?? "/etc/ssl/certs/ca-certificates.crt";
|
|
71
82
|
let loaded = null;
|
|
72
83
|
function casesMetadata(suite) {
|
|
73
84
|
if (!suite)
|
|
@@ -902,6 +913,46 @@ RUN if command -v update-ca-certificates >/dev/null 2>&1; then \\
|
|
|
902
913
|
console.warn(`[ca-trust] could not layer spectest CA into ${serviceName} (${tag}); env-var fallback only:\n${build.stderr.trim() || build.stdout.trim()}`);
|
|
903
914
|
}
|
|
904
915
|
}
|
|
916
|
+
/** Memoized across containers — the CA never rotates within a VM's life. */
|
|
917
|
+
let caBundlePromise = null;
|
|
918
|
+
/**
|
|
919
|
+
* Materialize {@link CA_BUNDLE_PATH} (public roots ⧺ the spectest CA) and
|
|
920
|
+
* return its path, or null when there's no CA to trust (daemon running
|
|
921
|
+
* outside a base-snapshot VM).
|
|
922
|
+
*
|
|
923
|
+
* This is what the replace-semantics trust env vars must point at. Falling
|
|
924
|
+
* back to the bare CA when the guest bundle is unreadable keeps the old
|
|
925
|
+
* behaviour — fakes verify, public HTTPS doesn't — which is strictly better
|
|
926
|
+
* than dropping the CA and breaking the fakes everything else depends on.
|
|
927
|
+
*/
|
|
928
|
+
async function ensureCaBundle() {
|
|
929
|
+
caBundlePromise ??= (async () => {
|
|
930
|
+
if (!existsSync(CA_PATH))
|
|
931
|
+
return null;
|
|
932
|
+
const ca = await fs.readFile(CA_PATH, "utf8");
|
|
933
|
+
let system = "";
|
|
934
|
+
try {
|
|
935
|
+
system = await fs.readFile(SYSTEM_CA_BUNDLE, "utf8");
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
// eslint-disable-next-line no-console
|
|
939
|
+
console.warn(`[ca-trust] no system CA bundle at ${SYSTEM_CA_BUNDLE} (${err}); containers will trust the spectest CA only`);
|
|
940
|
+
return CA_PATH;
|
|
941
|
+
}
|
|
942
|
+
// update-ca-certificates already folds our CA into the system bundle;
|
|
943
|
+
// appending again is harmless but pointless.
|
|
944
|
+
const parts = system.includes(ca.trim()) ? [system] : [system, ca];
|
|
945
|
+
const bundle = parts.map((p) => p.trimEnd()).join("\n") + "\n";
|
|
946
|
+
await fs.writeFile(CA_BUNDLE_PATH, bundle);
|
|
947
|
+
await fs.chmod(CA_BUNDLE_PATH, 0o644);
|
|
948
|
+
return CA_BUNDLE_PATH;
|
|
949
|
+
})().catch((err) => {
|
|
950
|
+
// eslint-disable-next-line no-console
|
|
951
|
+
console.warn(`[ca-trust] could not write ${CA_BUNDLE_PATH} (${err}); falling back to ${CA_PATH}`);
|
|
952
|
+
return existsSync(CA_PATH) ? CA_PATH : null;
|
|
953
|
+
});
|
|
954
|
+
return caBundlePromise;
|
|
955
|
+
}
|
|
905
956
|
async function runContainer(svc, tag, volumeFlags,
|
|
906
957
|
// Extra `--network-alias`es beyond the lowered `aliasesByService`. Used by
|
|
907
958
|
// runtime `startService` (whose service isn't in LOWERED) to give the new
|
|
@@ -970,10 +1021,18 @@ extraAliases = []) {
|
|
|
970
1021
|
// for images where the layer step couldn't run (no
|
|
971
1022
|
// update-ca-certificates).
|
|
972
1023
|
args.push(`--volume=${CA_PATH}:${CA_PATH}:ro`);
|
|
1024
|
+
// Appends to the image's roots, so it takes the bare CA.
|
|
973
1025
|
args.push("-e", `NODE_EXTRA_CA_CERTS=${CA_PATH}`);
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1026
|
+
// These three REPLACE the roots — they must get the combined bundle or
|
|
1027
|
+
// the container loses every public CA (see CA_BUNDLE_PATH).
|
|
1028
|
+
const caBundle = await ensureCaBundle();
|
|
1029
|
+
if (caBundle) {
|
|
1030
|
+
if (caBundle !== CA_PATH)
|
|
1031
|
+
args.push(`--volume=${caBundle}:${caBundle}:ro`);
|
|
1032
|
+
args.push("-e", `SSL_CERT_FILE=${caBundle}`);
|
|
1033
|
+
args.push("-e", `REQUESTS_CA_BUNDLE=${caBundle}`);
|
|
1034
|
+
args.push("-e", `AWS_CA_BUNDLE=${caBundle}`);
|
|
1035
|
+
}
|
|
977
1036
|
if (svc.env) {
|
|
978
1037
|
for (const [k, v] of Object.entries(svc.env)) {
|
|
979
1038
|
args.push("-e", `${k}=${v}`);
|
package/dist/index.d.ts
CHANGED
|
@@ -612,7 +612,7 @@ export interface FakeContext {
|
|
|
612
612
|
* is strongly typed.
|
|
613
613
|
*/
|
|
614
614
|
export interface TestCase<T = unknown, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
|
|
615
|
-
/** Stable id (slug of name). Used in
|
|
615
|
+
/** Stable id (slug of name). Used in run results and CLI flags. */
|
|
616
616
|
readonly id: string;
|
|
617
617
|
readonly name: string;
|
|
618
618
|
/** Parent test, if any. Single-parent for now. */
|
|
@@ -693,9 +693,9 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
|
|
|
693
693
|
* UI the same way a one-shot `terminal(...)` is.
|
|
694
694
|
*
|
|
695
695
|
* Terminals are NOT auto-closed when the test ends. A docker exec
|
|
696
|
-
* subprocess is cheap to keep alive and
|
|
697
|
-
* cleanly
|
|
698
|
-
*
|
|
696
|
+
* subprocess is cheap to keep alive and the VM snapshot captures it
|
|
697
|
+
* cleanly along with the rest of the container, so leaking the handle
|
|
698
|
+
* past test end is fine. Call `.close()`
|
|
699
699
|
* explicitly if you want a `close` step in the timeline; otherwise
|
|
700
700
|
* `await term.exited` (after the program self-terminates) is the
|
|
701
701
|
* natural way to assert on the exit code.
|
|
@@ -747,7 +747,7 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
|
|
|
747
747
|
readonly testName: string;
|
|
748
748
|
/**
|
|
749
749
|
* Value returned by the parent test. Carried across the fork in the
|
|
750
|
-
* daemon's own memory (
|
|
750
|
+
* daemon's own memory (a VM snapshot is memory + filesystem), so
|
|
751
751
|
* any JS value works — including Maps, Sets, class instances, and live
|
|
752
752
|
* connections that survive the fork. `undefined` for root tests or when
|
|
753
753
|
* the parent returned nothing.
|
package/dist/terminal.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// a PTY for CMD; we set the slave's size with `stty rows R cols C` inside
|
|
25
25
|
// the wrapped command so the container app sees the cols/rows the caller
|
|
26
26
|
// asked for. `script(1)` ships in util-linux which is essential on Debian,
|
|
27
|
-
// so it's present in the
|
|
27
|
+
// so it's present in the base image without extra apt deps.
|
|
28
28
|
import { Terminal as XtermHeadless } from "@xterm/headless";
|
|
29
29
|
import { recordTerminalStep, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
30
30
|
import { wrap } from "./inspect.js";
|