@specific.dev/spectest 0.37.0 → 0.39.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/browser.js +32 -53
- package/dist/components/k3s.js +1 -24
- package/dist/daemon.js +285 -34
- package/dist/index.d.ts +32 -1
- package/dist/inspect.d.ts +23 -0
- package/dist/inspect.js +65 -0
- package/dist/locator.d.ts +1 -1
- package/dist/locator.js +1 -1
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -110,11 +110,13 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
|
110
110
|
// comes from the HOME-scoped NSS user DB, so neither cares that playwright
|
|
111
111
|
// runs a temp --user-data-dir.
|
|
112
112
|
let PW_BROWSER = null;
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
113
|
+
// Every view gets its OWN context, desktop included. A context is the cookie
|
|
114
|
+
// jar + storage, so one-context-per-view is what makes `close()` mean what
|
|
115
|
+
// the docs promise: the browsing session is destroyed and the next
|
|
116
|
+
// `ctx.browser()` starts signed out. Desktop views used to share one context
|
|
117
|
+
// (mirroring the old one-Chrome-profile model), which left `close()` closing
|
|
118
|
+
// the page only — cookies survived and the next session was still
|
|
119
|
+
// authenticated.
|
|
118
120
|
function chromiumPath() {
|
|
119
121
|
return (Bun.which("chromium") ??
|
|
120
122
|
Bun.which("chromium-browser") ??
|
|
@@ -129,7 +131,6 @@ async function ensurePlaywrightBrowser() {
|
|
|
129
131
|
headless: true,
|
|
130
132
|
args: CHROME_ARGV,
|
|
131
133
|
});
|
|
132
|
-
DESKTOP_CTX = null; // contexts died with the old browser (if any)
|
|
133
134
|
return PW_BROWSER;
|
|
134
135
|
}
|
|
135
136
|
/** Context options for the mobile device preset — playwright's native
|
|
@@ -150,18 +151,20 @@ function deviceContextOptions(d) {
|
|
|
150
151
|
reducedMotion: "reduce",
|
|
151
152
|
};
|
|
152
153
|
}
|
|
153
|
-
|
|
154
|
+
/** Create the context one view lives in — device emulation when the view is
|
|
155
|
+
* phone-emulated, a plain viewport otherwise. */
|
|
156
|
+
async function newViewContext(width, height, device) {
|
|
154
157
|
const browser = await ensurePlaywrightBrowser();
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return
|
|
158
|
+
const context = await browser.newContext(device
|
|
159
|
+
? deviceContextOptions(device)
|
|
160
|
+
: {
|
|
161
|
+
viewport: { width, height },
|
|
162
|
+
// Reduced-motion for replay fidelity — see deviceContextOptions.
|
|
163
|
+
reducedMotion: "reduce",
|
|
164
|
+
});
|
|
165
|
+
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
166
|
+
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
167
|
+
return context;
|
|
165
168
|
}
|
|
166
169
|
/**
|
|
167
170
|
* Make a user script evaluable by the page: Bun's `view.evaluate` accepts a
|
|
@@ -791,32 +794,10 @@ async function spawnPage(context) {
|
|
|
791
794
|
}
|
|
792
795
|
return { page, cdp, recordingInstalled };
|
|
793
796
|
}
|
|
794
|
-
/**
|
|
795
|
-
async function contextFor(width, height, device) {
|
|
796
|
-
if (device) {
|
|
797
|
-
const browser = await ensurePlaywrightBrowser();
|
|
798
|
-
const context = await browser.newContext(deviceContextOptions(device));
|
|
799
|
-
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
800
|
-
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
801
|
-
return { context, ownsContext: true };
|
|
802
|
-
}
|
|
803
|
-
if (width === 1280 && height === 720) {
|
|
804
|
-
return { context: await ensureDesktopContext(), ownsContext: false };
|
|
805
|
-
}
|
|
806
|
-
const browser = await ensurePlaywrightBrowser();
|
|
807
|
-
const context = await browser.newContext({
|
|
808
|
-
viewport: { width, height },
|
|
809
|
-
// Reduced-motion for replay fidelity — see deviceContextOptions.
|
|
810
|
-
reducedMotion: "reduce",
|
|
811
|
-
});
|
|
812
|
-
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
813
|
-
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
814
|
-
return { context, ownsContext: true };
|
|
815
|
-
}
|
|
816
|
-
/** Create a default-desktop view for the pool. */
|
|
797
|
+
/** Create a default-desktop view (plus its context) for the pool. */
|
|
817
798
|
async function createView(width, height) {
|
|
818
|
-
const
|
|
819
|
-
return spawnPage(context);
|
|
799
|
+
const context = await newViewContext(width, height, null);
|
|
800
|
+
return { context, ...(await spawnPage(context)) };
|
|
820
801
|
}
|
|
821
802
|
/**
|
|
822
803
|
* Pre-open `n` views into the pool (called by the daemon at the end of
|
|
@@ -860,8 +841,7 @@ export async function openMobileBackend(opts = {}) {
|
|
|
860
841
|
let holder;
|
|
861
842
|
if (pooled) {
|
|
862
843
|
holder = {
|
|
863
|
-
context:
|
|
864
|
-
ownsContext: false,
|
|
844
|
+
context: pooled.context,
|
|
865
845
|
page: pooled.page,
|
|
866
846
|
cdp: pooled.cdp,
|
|
867
847
|
lastTitle: "",
|
|
@@ -903,11 +883,10 @@ export async function openMobileBackend(opts = {}) {
|
|
|
903
883
|
let SHARED_BROWSER = null;
|
|
904
884
|
const SHARED_MOBILE = new Map();
|
|
905
885
|
async function newHolder(width, height, device) {
|
|
906
|
-
const
|
|
886
|
+
const context = await newViewContext(width, height, device);
|
|
907
887
|
const spawned = await spawnPage(context);
|
|
908
888
|
const holder = {
|
|
909
889
|
context,
|
|
910
|
-
ownsContext,
|
|
911
890
|
page: spawned.page,
|
|
912
891
|
cdp: spawned.cdp,
|
|
913
892
|
lastTitle: "",
|
|
@@ -1406,13 +1385,13 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1406
1385
|
catch {
|
|
1407
1386
|
/* already closed by the runtime */
|
|
1408
1387
|
}
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1388
|
+
// The context, not the page, holds the cookies and storage — closing
|
|
1389
|
+
// only the page would leave the next session signed in as this one.
|
|
1390
|
+
try {
|
|
1391
|
+
await holder.context.close();
|
|
1392
|
+
}
|
|
1393
|
+
catch {
|
|
1394
|
+
/* context already gone (browser died) */
|
|
1416
1395
|
}
|
|
1417
1396
|
},
|
|
1418
1397
|
// ── Mobile-only primitives ──────────────────────────────────────────
|
package/dist/components/k3s.js
CHANGED
|
@@ -6,7 +6,7 @@ import { readFile, unlink } from "node:fs/promises";
|
|
|
6
6
|
import { AppsV1Api, BatchV1Api, CoreV1Api, KubeConfig, KubernetesObjectApi, PatchStrategy, ResponseContext, ServerConfiguration, createConfiguration, loadAllYaml, } from "@kubernetes/client-node";
|
|
7
7
|
import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
|
|
8
8
|
import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
|
|
9
|
-
import {
|
|
9
|
+
import { deepUnwrap, readTag, wrap } from "../inspect.js";
|
|
10
10
|
import { recorderAnnotate, recorderRemove } from "../recorder.js";
|
|
11
11
|
/** Port the in-cluster registry listens on (plain HTTP). */
|
|
12
12
|
const K3S_REGISTRY_PORT = 5000;
|
|
@@ -345,29 +345,6 @@ async function doFetch(request) {
|
|
|
345
345
|
binary: async () => buf,
|
|
346
346
|
});
|
|
347
347
|
}
|
|
348
|
-
/**
|
|
349
|
-
* Recursively strip the inspector's carrier/proxy wrappers from a
|
|
350
|
-
* value. Needed for arguments flowing into the kubernetes/client-node
|
|
351
|
-
* API methods — if a wrapped pod's `metadata.name` (a primitive-carrier
|
|
352
|
-
* object) reaches a URL template, the lib stringifies it to
|
|
353
|
-
* `"[object Object]"` and the request 404s.
|
|
354
|
-
*/
|
|
355
|
-
function deepUnwrap(value) {
|
|
356
|
-
if (value === null || value === undefined)
|
|
357
|
-
return value;
|
|
358
|
-
const raw = readRaw(value);
|
|
359
|
-
if (raw !== value)
|
|
360
|
-
return deepUnwrap(raw);
|
|
361
|
-
if (typeof value !== "object")
|
|
362
|
-
return value;
|
|
363
|
-
if (Array.isArray(value))
|
|
364
|
-
return value.map(deepUnwrap);
|
|
365
|
-
const out = {};
|
|
366
|
-
for (const [k, v] of Object.entries(value)) {
|
|
367
|
-
out[k] = deepUnwrap(v);
|
|
368
|
-
}
|
|
369
|
-
return out;
|
|
370
|
-
}
|
|
371
348
|
/**
|
|
372
349
|
* Wrap a `@kubernetes/client-node` Api instance so each method call
|
|
373
350
|
* runs in its own AsyncLocalStorage slot — `doFetch` writes the HTTP
|
package/dist/daemon.js
CHANGED
|
@@ -27,7 +27,7 @@ import { acquirePersistentBrowser } from "./browser.js";
|
|
|
27
27
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
28
28
|
import { openTerminal } from "./terminal.js";
|
|
29
29
|
import { recordEnv, recordExec, recordFake, recordHttp, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
|
|
30
|
-
import { wrap, wrapResponse } from "./inspect.js";
|
|
30
|
+
import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
|
|
31
31
|
import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
|
|
32
32
|
import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
|
|
33
33
|
function namedServices(cfg) {
|
|
@@ -62,6 +62,9 @@ function hostCacheGateway() {
|
|
|
62
62
|
return _hostCacheGateway;
|
|
63
63
|
}
|
|
64
64
|
const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
|
|
65
|
+
// The bun the base snapshot installs (base.rs::BASE_SETUP_SH). The daemon
|
|
66
|
+
// runs under it, and eval's dependency install shells out to it.
|
|
67
|
+
const BUN_BIN = "/usr/local/bin/bun";
|
|
65
68
|
// Root CA baked into the base snapshot at base-snapshot build time
|
|
66
69
|
// (see base.rs::BASE_SETUP_SH). Bind-mounted into every service
|
|
67
70
|
// container so apps can verify HTTPS to the daemon's fakes, and
|
|
@@ -550,6 +553,130 @@ async function ensureVolumes(svc) {
|
|
|
550
553
|
}
|
|
551
554
|
return flags;
|
|
552
555
|
}
|
|
556
|
+
/** Keyed by image ID, not tag: `spectest/<svc>:latest` is retagged onto
|
|
557
|
+
* new content every rebuild, and a stale uid is silently wrong. */
|
|
558
|
+
const IMAGE_ID_TABLES = new Map();
|
|
559
|
+
let idProbeSeq = 0;
|
|
560
|
+
/**
|
|
561
|
+
* Read an image's `/etc/passwd` and `/etc/group` so a `user`/`group` can
|
|
562
|
+
* be written as a name rather than the uid the image happens to use.
|
|
563
|
+
*
|
|
564
|
+
* Done with `docker create` + `docker cp` rather than running `id` in the
|
|
565
|
+
* image: nothing is ever started, so it works on an image with no shell
|
|
566
|
+
* (distroless) and costs no entrypoint. An image with no `/etc/passwd`
|
|
567
|
+
* (scratch) yields empty tables — only a name that isn't there fails,
|
|
568
|
+
* and a numeric id never gets this far.
|
|
569
|
+
*/
|
|
570
|
+
async function probeImageIdTables(tag) {
|
|
571
|
+
const users = new Map();
|
|
572
|
+
const groups = new Map();
|
|
573
|
+
const name = `spectest-idprobe-${idProbeSeq++}`;
|
|
574
|
+
const dir = path.join(WORKSPACE, ".spectest", "idprobe", name);
|
|
575
|
+
// Idempotent like runContainer: a leftover from a previous run would
|
|
576
|
+
// otherwise surface as a name conflict rather than the real problem.
|
|
577
|
+
await docker(["rm", "-f", name], 30_000);
|
|
578
|
+
// `--entrypoint` supplies the command `docker create` insists on for an
|
|
579
|
+
// image that declares neither ENTRYPOINT nor CMD. It is never executed.
|
|
580
|
+
const created = await docker(["create", "--name", name, "--entrypoint", "/spectest-idprobe", tag], 60_000);
|
|
581
|
+
if (created.code !== 0) {
|
|
582
|
+
throw new Error(`could not read the user/group tables of image ${tag}: ${created.stderr.trim()}`);
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
await fs.mkdir(dir, { recursive: true });
|
|
586
|
+
for (const [file, table] of [
|
|
587
|
+
["passwd", users],
|
|
588
|
+
["group", groups],
|
|
589
|
+
]) {
|
|
590
|
+
const dst = path.join(dir, file);
|
|
591
|
+
const cp = await docker(["cp", `${name}:/etc/${file}`, dst], 60_000);
|
|
592
|
+
if (cp.code !== 0)
|
|
593
|
+
continue;
|
|
594
|
+
const text = await fs.readFile(dst, "utf8").catch(() => "");
|
|
595
|
+
for (const line of text.split("\n")) {
|
|
596
|
+
// name:x:id:… — field 2 is the uid in passwd, the gid in group.
|
|
597
|
+
const fields = line.split(":");
|
|
598
|
+
const id = Number(fields[2]);
|
|
599
|
+
if (fields[0] && fields.length >= 3 && Number.isInteger(id)) {
|
|
600
|
+
table.set(fields[0], id);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
finally {
|
|
606
|
+
await docker(["rm", "-f", name], 30_000);
|
|
607
|
+
await fs.rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
608
|
+
}
|
|
609
|
+
return { users, groups };
|
|
610
|
+
}
|
|
611
|
+
async function imageIdTables(tag) {
|
|
612
|
+
const insp = await docker(["image", "inspect", "--format", "{{.Id}}", tag], 30_000);
|
|
613
|
+
const key = insp.code === 0 && insp.stdout.trim() ? insp.stdout.trim() : tag;
|
|
614
|
+
let tables = IMAGE_ID_TABLES.get(key);
|
|
615
|
+
if (!tables) {
|
|
616
|
+
// Stored before the await so concurrent services on one image probe once.
|
|
617
|
+
tables = probeImageIdTables(tag);
|
|
618
|
+
IMAGE_ID_TABLES.set(key, tables);
|
|
619
|
+
}
|
|
620
|
+
return tables;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Resolve a declared `user`/`group` against `tag`. Numeric ids are taken
|
|
624
|
+
* as-is and never touch the image; names cost one probe per image.
|
|
625
|
+
* Returns `undefined` when neither is declared — the common case, which
|
|
626
|
+
* must stay free.
|
|
627
|
+
*/
|
|
628
|
+
async function resolveOwner(svc, tag, what, user, group) {
|
|
629
|
+
if (user === undefined && group === undefined)
|
|
630
|
+
return undefined;
|
|
631
|
+
const numeric = (v) => v !== undefined && /^[0-9]+$/.test(v) ? Number(v) : undefined;
|
|
632
|
+
let uid = numeric(user);
|
|
633
|
+
let gid = numeric(group);
|
|
634
|
+
const needsTables = (user !== undefined && uid === undefined) ||
|
|
635
|
+
(group !== undefined && gid === undefined);
|
|
636
|
+
if (needsTables) {
|
|
637
|
+
const tables = await imageIdTables(tag);
|
|
638
|
+
if (user !== undefined && uid === undefined) {
|
|
639
|
+
uid = tables.users.get(user);
|
|
640
|
+
if (uid === undefined) {
|
|
641
|
+
throw new Error(`service "${svc.name}": ${what} user ${JSON.stringify(user)} is not in the image's /etc/passwd — use a numeric uid`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (group !== undefined && gid === undefined) {
|
|
645
|
+
gid = tables.groups.get(group);
|
|
646
|
+
if (gid === undefined) {
|
|
647
|
+
throw new Error(`service "${svc.name}": ${what} group ${JSON.stringify(group)} is not in the image's /etc/group — use a numeric gid`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// chown(2) reads -1 as "unchanged", so `user` alone keeps the group
|
|
652
|
+
// and vice versa — the same thing plain `chown` does.
|
|
653
|
+
return { uid: uid ?? -1, gid: gid ?? -1 };
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Apply `mode` and `owner` to a staged file, before it is bind-mounted
|
|
657
|
+
* into a container that does not exist yet.
|
|
658
|
+
*
|
|
659
|
+
* The `-1` halves are resolved against the file's current owner rather
|
|
660
|
+
* than passed through: **Bun's `fs.chown` rejects `-1` with `EPERM`**
|
|
661
|
+
* (measured on Bun 1.3.14; node and chown(2) both read it as "leave this
|
|
662
|
+
* one alone"), so handing it straight to the syscall would break exactly
|
|
663
|
+
* the common cases — a `user` with no `group`, and the reverse.
|
|
664
|
+
*/
|
|
665
|
+
async function applyFileOwnership(file, mode, owner) {
|
|
666
|
+
if (mode)
|
|
667
|
+
await fs.chmod(file, parseInt(mode, 8));
|
|
668
|
+
if (!owner)
|
|
669
|
+
return;
|
|
670
|
+
let { uid, gid } = owner;
|
|
671
|
+
if (uid < 0 || gid < 0) {
|
|
672
|
+
const st = await fs.stat(file);
|
|
673
|
+
if (uid < 0)
|
|
674
|
+
uid = st.uid;
|
|
675
|
+
if (gid < 0)
|
|
676
|
+
gid = st.gid;
|
|
677
|
+
}
|
|
678
|
+
await fs.chown(file, uid, gid);
|
|
679
|
+
}
|
|
553
680
|
// Materialize `svc.files` onto the VM host and return `--volume` flags
|
|
554
681
|
// bind-mounting each into the container (read-only). Single-file bind
|
|
555
682
|
// mounts mean the seeded config lands in place *before the container's
|
|
@@ -557,7 +684,7 @@ async function ensureVolumes(svc) {
|
|
|
557
684
|
// hook. Staging path mirrors ensureVolumes: a per-service dir derived
|
|
558
685
|
// from the in-container path, so two files never collide and the
|
|
559
686
|
// content is captured by snapshots like everything else under WORKSPACE.
|
|
560
|
-
async function ensureFiles(svc) {
|
|
687
|
+
async function ensureFiles(svc, tag) {
|
|
561
688
|
const flags = [];
|
|
562
689
|
if (!svc.files || svc.files.length === 0)
|
|
563
690
|
return flags;
|
|
@@ -574,8 +701,12 @@ async function ensureFiles(svc) {
|
|
|
574
701
|
const content = f.content.replaceAll("{{SPECTEST_SERVICE}}", svc.name);
|
|
575
702
|
const host = path.join(dir, sanitizeSegment(f.path));
|
|
576
703
|
await fs.writeFile(host, content);
|
|
577
|
-
|
|
578
|
-
|
|
704
|
+
// A bind mount carries this inode's mode and ownership into the
|
|
705
|
+
// container verbatim, and we write as root — so a `mode` that locks
|
|
706
|
+
// the file down needs `user`/`group` beside it to stay readable to
|
|
707
|
+
// whoever the container actually runs as.
|
|
708
|
+
const owner = await resolveOwner(svc, tag, `file ${f.path}`, f.user, f.group);
|
|
709
|
+
await applyFileOwnership(host, f.mode, owner);
|
|
579
710
|
flags.push(`--volume=${host}:${f.path}:ro`);
|
|
580
711
|
}
|
|
581
712
|
return flags;
|
|
@@ -595,7 +726,7 @@ async function ensureFiles(svc) {
|
|
|
595
726
|
* (~50 ms), and a stale one outliving a CA rotation would fail in a way
|
|
596
727
|
* that reads as a code bug.
|
|
597
728
|
*/
|
|
598
|
-
async function ensureCertificates(svc) {
|
|
729
|
+
async function ensureCertificates(svc, tag) {
|
|
599
730
|
const flags = [];
|
|
600
731
|
const certs = svc.certificates ?? [];
|
|
601
732
|
if (certs.length === 0)
|
|
@@ -624,11 +755,21 @@ async function ensureCertificates(svc) {
|
|
|
624
755
|
const keyHost = path.join(dir, `${i}.key`);
|
|
625
756
|
await fs.writeFile(certHost, cert);
|
|
626
757
|
await fs.writeFile(keyHost, key);
|
|
627
|
-
//
|
|
628
|
-
// carries the host inode's
|
|
629
|
-
// server that checks (postgres, ssh) refuses a lax
|
|
630
|
-
|
|
631
|
-
|
|
758
|
+
// Mode AND ownership have to be set on the staged files: a bind
|
|
759
|
+
// mount carries the host inode's straight through, and the daemon
|
|
760
|
+
// writes as root. A server that checks (postgres, ssh) refuses a lax
|
|
761
|
+
// key, but a strict root-owned one it can't open is just as fatal —
|
|
762
|
+
// which is why `mode` alone used to force an entrypoint wrapper that
|
|
763
|
+
// re-installed the key as the right user.
|
|
764
|
+
const owner = await resolveOwner(svc, tag, `certificate ${i}`, c.user, c.group);
|
|
765
|
+
// Declaring who reads the key also says what mode it wants: the
|
|
766
|
+
// strictest one that owner can still open. Only reached when
|
|
767
|
+
// `user`/`group` is set, so no existing environment changes.
|
|
768
|
+
const keyMode = c.mode ?? (owner ? (owner.uid === -1 ? "0640" : "0600") : undefined);
|
|
769
|
+
await applyFileOwnership(keyHost, keyMode, owner);
|
|
770
|
+
// The certificate is public, but it follows the key's owner so a
|
|
771
|
+
// server that insists on owning its whole TLS directory is happy.
|
|
772
|
+
await applyFileOwnership(certHost, undefined, owner);
|
|
632
773
|
flags.push(`--volume=${certHost}:${c.certPath}:ro`);
|
|
633
774
|
flags.push(`--volume=${keyHost}:${c.keyPath}:ro`);
|
|
634
775
|
if (c.caPath)
|
|
@@ -2313,8 +2454,8 @@ async function startRuntimeService(spec) {
|
|
|
2313
2454
|
const { tag } = await prepareServiceImage(svc);
|
|
2314
2455
|
const flags = [
|
|
2315
2456
|
...(await ensureVolumes(svc)),
|
|
2316
|
-
...(await ensureFiles(svc)),
|
|
2317
|
-
...(await ensureCertificates(svc)),
|
|
2457
|
+
...(await ensureFiles(svc, tag)),
|
|
2458
|
+
...(await ensureCertificates(svc, tag)),
|
|
2318
2459
|
];
|
|
2319
2460
|
await runContainer(svc, tag, flags, aliases);
|
|
2320
2461
|
await waitForReady(svc);
|
|
@@ -2387,8 +2528,9 @@ async function ensureFakeHelpers(name) {
|
|
|
2387
2528
|
fake.trackedHelpers = trackFakeHelpers(name, fake.helpers);
|
|
2388
2529
|
return fake.trackedHelpers;
|
|
2389
2530
|
}
|
|
2390
|
-
/** Wrap a fake's helpers so each call becomes a recorded `fake` event
|
|
2391
|
-
* and its return value is `wrap()`ped
|
|
2531
|
+
/** Wrap a fake's helpers so each call becomes a recorded `fake` event,
|
|
2532
|
+
* its arguments are `deepUnwrap`ped, and its return value is `wrap()`ped
|
|
2533
|
+
* for assertion provenance. Helpers
|
|
2392
2534
|
* are functions that read/mutate the fake's private state via closure;
|
|
2393
2535
|
* tests only ever see what those functions return. The proxy is built
|
|
2394
2536
|
* once and shared across tests; it consults the recorder at call time,
|
|
@@ -2415,10 +2557,21 @@ function trackFakeHelpers(fakeName, helpers) {
|
|
|
2415
2557
|
}
|
|
2416
2558
|
/** Invoke a fake helper function, recording a `fake` event and wrapping
|
|
2417
2559
|
* the return value. Handles both sync and async helpers, and records an
|
|
2418
|
-
* error event (then rethrows) if the helper throws.
|
|
2419
|
-
|
|
2560
|
+
* error event (then rethrows) if the helper throws.
|
|
2561
|
+
*
|
|
2562
|
+
* Arguments are `deepUnwrap`ped on the way in, so a helper body is plain
|
|
2563
|
+
* user code operating on plain values: a test that feeds one helper's
|
|
2564
|
+
* return (or a `ctx.fetch` field) to another passes a provenance wrapper,
|
|
2565
|
+
* and `msg.html.matchAll(...)` inside the fake would otherwise throw a
|
|
2566
|
+
* `TypeError` from a stack the author can't see. Same treatment the k8s
|
|
2567
|
+
* client's arguments get (`components/k3s.ts::withTagging`). The types
|
|
2568
|
+
* still say the value is a handle — passing a wrapped value where the
|
|
2569
|
+
* parameter type is known is a compile error asking for `.unwrap()`; this
|
|
2570
|
+
* catches the case where it isn't known (an `any` off `req.json()`). */
|
|
2571
|
+
function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
|
|
2420
2572
|
const t = Date.now();
|
|
2421
2573
|
const resv = reserveEvent();
|
|
2574
|
+
const args = callArgs.map((a) => deepUnwrap(a));
|
|
2422
2575
|
const safeArgs = args.map((a) => safeSerialize(a));
|
|
2423
2576
|
const recordResult = (value) => {
|
|
2424
2577
|
const seq = recordFake({
|
|
@@ -2618,14 +2771,16 @@ async function bootstrapInner() {
|
|
|
2618
2771
|
// deps; this adds the image edge. The two compose: we run the moment
|
|
2619
2772
|
// both are satisfied, with no whole-graph barrier between them.
|
|
2620
2773
|
await prep.get(svc.name);
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
...(await ensureFiles(svc)),
|
|
2624
|
-
...(await ensureCertificates(svc)),
|
|
2625
|
-
];
|
|
2774
|
+
// The tag is read before the mounts are staged: a `files`/
|
|
2775
|
+
// `certificates` owner given by name is resolved against the image.
|
|
2626
2776
|
const tag = tags.get(svc.name);
|
|
2627
2777
|
if (!tag)
|
|
2628
2778
|
throw new Error(`internal: no image tag for ${svc.name}`);
|
|
2779
|
+
const flags = [
|
|
2780
|
+
...(await ensureVolumes(svc)),
|
|
2781
|
+
...(await ensureFiles(svc, tag)),
|
|
2782
|
+
...(await ensureCertificates(svc, tag)),
|
|
2783
|
+
];
|
|
2629
2784
|
const tRun = Date.now();
|
|
2630
2785
|
progressService(svc.name, { status: "starting", detail: undefined });
|
|
2631
2786
|
await runContainer(svc, tag, flags);
|
|
@@ -4025,9 +4180,86 @@ function packageName(spec) {
|
|
|
4025
4180
|
return spec.split("/")[0];
|
|
4026
4181
|
}
|
|
4027
4182
|
/**
|
|
4028
|
-
*
|
|
4029
|
-
*
|
|
4030
|
-
*
|
|
4183
|
+
* Is this package installed? Read from the disk, and deliberately not with
|
|
4184
|
+
* `Bun.resolveSync` — a resolve that misses is cached for the life of the
|
|
4185
|
+
* process (see `ensureDeps`), so asking the resolver whether a package is
|
|
4186
|
+
* missing is what makes it stay missing.
|
|
4187
|
+
*
|
|
4188
|
+
* Walks up from `APP_DIR` like a module resolver does, so a hoisted
|
|
4189
|
+
* install in an ancestor `node_modules` counts.
|
|
4190
|
+
*/
|
|
4191
|
+
async function packageInstalled(pkg) {
|
|
4192
|
+
let dir = APP_DIR;
|
|
4193
|
+
for (;;) {
|
|
4194
|
+
if (await fs.exists(path.join(dir, "node_modules", pkg, "package.json")))
|
|
4195
|
+
return true;
|
|
4196
|
+
const parent = path.dirname(dir);
|
|
4197
|
+
if (parent === dir)
|
|
4198
|
+
return false;
|
|
4199
|
+
dir = parent;
|
|
4200
|
+
}
|
|
4201
|
+
}
|
|
4202
|
+
/**
|
|
4203
|
+
* Resolve `spec` in a throwaway `bun` process, which starts with an empty
|
|
4204
|
+
* resolver cache. Returns null if it still does not resolve — the snippet's
|
|
4205
|
+
* own import then reports the real error.
|
|
4206
|
+
*/
|
|
4207
|
+
async function resolveInNewProcess(spec) {
|
|
4208
|
+
const probe = `process.stdout.write(Bun.resolveSync(${JSON.stringify(spec)}, ${JSON.stringify(APP_DIR)}))`;
|
|
4209
|
+
const proc = Bun.spawn([BUN_BIN, "-e", probe], {
|
|
4210
|
+
cwd: APP_DIR,
|
|
4211
|
+
stdout: "pipe",
|
|
4212
|
+
stderr: "ignore",
|
|
4213
|
+
});
|
|
4214
|
+
const out = (await new Response(proc.stdout).text()).trim();
|
|
4215
|
+
const code = await proc.exited;
|
|
4216
|
+
if (code !== 0 || out.length === 0)
|
|
4217
|
+
return null;
|
|
4218
|
+
return out;
|
|
4219
|
+
}
|
|
4220
|
+
/**
|
|
4221
|
+
* Replace one import specifier in the snippet — in import positions only
|
|
4222
|
+
* (`from "x"`, `import "x"`, `import("x")`, `require("x")`), so a plain
|
|
4223
|
+
* string that is equal to the specifier stays as it is.
|
|
4224
|
+
*/
|
|
4225
|
+
function rewriteSpecifier(code, spec, target) {
|
|
4226
|
+
const escaped = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4227
|
+
const pattern = new RegExp(`((?:\\bfrom|\\bimport|\\brequire)\\s*\\(?\\s*)(["'])${escaped}\\2`, "g");
|
|
4228
|
+
// A callback, not a replacement string: an absolute path can contain `$`.
|
|
4229
|
+
return code.replace(pattern, (_match, lead, quote) => `${lead}${quote}${target}${quote}`);
|
|
4230
|
+
}
|
|
4231
|
+
/** Point each given specifier at the module's absolute path. A specifier
|
|
4232
|
+
* that still does not resolve is left as it is, so the snippet's own
|
|
4233
|
+
* import reports the real error. */
|
|
4234
|
+
async function rewriteUnreachable(code, specs) {
|
|
4235
|
+
let patched = code;
|
|
4236
|
+
for (const spec of specs) {
|
|
4237
|
+
const target = await resolveInNewProcess(spec);
|
|
4238
|
+
if (target)
|
|
4239
|
+
patched = rewriteSpecifier(patched, spec, target);
|
|
4240
|
+
}
|
|
4241
|
+
return patched;
|
|
4242
|
+
}
|
|
4243
|
+
/**
|
|
4244
|
+
* Scan the snippet's imports and `bun add` anything that isn't installed.
|
|
4245
|
+
* Skips relative paths, absolute paths, `node:`/`bun:` built-ins, and
|
|
4246
|
+
* HTTP(S)/file: URLs. Returns the snippet to actually run, plus the packages
|
|
4247
|
+
* it installed.
|
|
4248
|
+
*
|
|
4249
|
+
* A specifier the bare import cannot reach is **rewritten to the absolute
|
|
4250
|
+
* path of the module**, because Bun caches a failed resolution for the life
|
|
4251
|
+
* of the process and the daemon is long-lived: an eval that imports
|
|
4252
|
+
* `otpauth` before it is installed makes every later
|
|
4253
|
+
* `import * as OTPAuth from "otpauth"` keep failing with `Cannot find
|
|
4254
|
+
* package 'otpauth'` — even after the install put it on the disk. Nothing
|
|
4255
|
+
* clears that entry: not a later `Bun.resolveSync`, not an `onResolve`
|
|
4256
|
+
* plugin, not a new directory to import from (all measured on Bun 1.3.14,
|
|
4257
|
+
* where node 24 re-resolves and succeeds). The cache keeps hits the same
|
|
4258
|
+
* way — a package removed mid-process still resolves — so treat resolution
|
|
4259
|
+
* in this process as a snapshot taken at first ask.
|
|
4260
|
+
* An absolute path never consults the cache. The path comes from a new
|
|
4261
|
+
* `bun` process, so the package's own `exports` conditions apply exactly as
|
|
4262
|
+
* they would for the bare specifier.
|
|
4031
4263
|
*/
|
|
4032
4264
|
async function ensureDeps(code) {
|
|
4033
4265
|
let scanned;
|
|
@@ -4036,9 +4268,13 @@ async function ensureDeps(code) {
|
|
|
4036
4268
|
}
|
|
4037
4269
|
catch {
|
|
4038
4270
|
// Invalid syntax — let the import call surface the real error.
|
|
4039
|
-
return [];
|
|
4271
|
+
return { code, installed: [] };
|
|
4040
4272
|
}
|
|
4041
4273
|
const seen = new Set();
|
|
4274
|
+
// Specifiers the bare import cannot reach, and the packages to install for
|
|
4275
|
+
// them. A subpath (`otpauth/dist/…`) resolves to its own file, so these are
|
|
4276
|
+
// deduped per specifier, not per package.
|
|
4277
|
+
const unreachable = [];
|
|
4042
4278
|
const missing = [];
|
|
4043
4279
|
for (const imp of scanned) {
|
|
4044
4280
|
const p = imp.path;
|
|
@@ -4051,21 +4287,32 @@ async function ensureDeps(code) {
|
|
|
4051
4287
|
p.startsWith("file:")) {
|
|
4052
4288
|
continue;
|
|
4053
4289
|
}
|
|
4290
|
+
if (seen.has(p))
|
|
4291
|
+
continue;
|
|
4292
|
+
seen.add(p);
|
|
4054
4293
|
const pkg = packageName(p);
|
|
4055
|
-
if (
|
|
4294
|
+
if (!(await packageInstalled(pkg))) {
|
|
4295
|
+
unreachable.push(p);
|
|
4296
|
+
if (!missing.includes(pkg))
|
|
4297
|
+
missing.push(pkg);
|
|
4056
4298
|
continue;
|
|
4057
|
-
|
|
4299
|
+
}
|
|
4300
|
+
// Installed — but a poisoned cache entry from an earlier eval can still
|
|
4301
|
+
// fail the import, so ask the resolver. This can only cache a hit.
|
|
4058
4302
|
try {
|
|
4059
4303
|
Bun.resolveSync(p, APP_DIR);
|
|
4060
4304
|
}
|
|
4061
4305
|
catch {
|
|
4062
|
-
|
|
4306
|
+
unreachable.push(p);
|
|
4063
4307
|
}
|
|
4064
4308
|
}
|
|
4065
|
-
if (
|
|
4066
|
-
return [];
|
|
4309
|
+
if (unreachable.length === 0)
|
|
4310
|
+
return { code, installed: [] };
|
|
4311
|
+
if (missing.length === 0) {
|
|
4312
|
+
return { code: await rewriteUnreachable(code, unreachable), installed: [] };
|
|
4313
|
+
}
|
|
4067
4314
|
await new Promise((resolve, reject) => {
|
|
4068
|
-
execFile(
|
|
4315
|
+
execFile(BUN_BIN, ["add", ...missing], { cwd: APP_DIR, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
4069
4316
|
if (err) {
|
|
4070
4317
|
reject(new Error(`bun add ${missing.join(" ")} failed:\n${String(stderr).trim()}\n${String(stdout).trim()}`));
|
|
4071
4318
|
}
|
|
@@ -4074,7 +4321,7 @@ async function ensureDeps(code) {
|
|
|
4074
4321
|
}
|
|
4075
4322
|
});
|
|
4076
4323
|
});
|
|
4077
|
-
return missing;
|
|
4324
|
+
return { code: await rewriteUnreachable(code, unreachable), installed: missing };
|
|
4078
4325
|
}
|
|
4079
4326
|
/**
|
|
4080
4327
|
* Turn the bare parser error an `export default` misuse produces
|
|
@@ -4271,10 +4518,14 @@ async function evalCode(code, secrets) {
|
|
|
4271
4518
|
let filePath;
|
|
4272
4519
|
let outcome;
|
|
4273
4520
|
try {
|
|
4274
|
-
|
|
4521
|
+
// The snippet that runs can differ from the one the user sent: a
|
|
4522
|
+
// freshly installed import is rewritten to an absolute path (see
|
|
4523
|
+
// ensureDeps). Error messages keep using the original `code`.
|
|
4524
|
+
const prepared = await ensureDeps(code);
|
|
4525
|
+
installed = prepared.installed;
|
|
4275
4526
|
await fs.mkdir(EVAL_DIR, { recursive: true });
|
|
4276
4527
|
filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
|
|
4277
|
-
await fs.writeFile(filePath, code);
|
|
4528
|
+
await fs.writeFile(filePath, prepared.code);
|
|
4278
4529
|
const mod = (await import(pathToFileURL(filePath).href));
|
|
4279
4530
|
outcome = { ok: true, result: safeSerialize(mod.default) };
|
|
4280
4531
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -684,6 +684,18 @@ export interface FileMount {
|
|
|
684
684
|
* file before it's bind-mounted. Defaults to the writer's umask.
|
|
685
685
|
*/
|
|
686
686
|
mode?: string;
|
|
687
|
+
/**
|
|
688
|
+
* Owner of the staged file — a user name from the image's
|
|
689
|
+
* `/etc/passwd` (resolved against the image, so `"postgres"` works),
|
|
690
|
+
* or a numeric uid. A bind mount carries the staged file's ownership
|
|
691
|
+
* straight through, so a file written by the daemon lands as `root`:
|
|
692
|
+
* pair a restrictive `mode` with `user` or the container's own
|
|
693
|
+
* process can't read it.
|
|
694
|
+
*/
|
|
695
|
+
user?: string;
|
|
696
|
+
/** Group of the staged file — a group name from the image's
|
|
697
|
+
* `/etc/group`, or a numeric gid. */
|
|
698
|
+
group?: string;
|
|
687
699
|
}
|
|
688
700
|
/** PEM material returned by `ctx.certificate(hostnames)`. */
|
|
689
701
|
export interface CertificateMaterial {
|
|
@@ -713,9 +725,28 @@ export interface CertificateMount {
|
|
|
713
725
|
/**
|
|
714
726
|
* Optional octal mode (e.g. `"0600"`) applied to the staged key.
|
|
715
727
|
* Servers that refuse a group/world-readable key (postgres, ssh) need
|
|
716
|
-
* this
|
|
728
|
+
* this. The default is the writer's umask — or, once `user`/`group`
|
|
729
|
+
* says who reads the key, the strictest mode that owner can still
|
|
730
|
+
* read (`0600`, or `0640` when only `group` is given).
|
|
717
731
|
*/
|
|
718
732
|
mode?: string;
|
|
733
|
+
/**
|
|
734
|
+
* Owner of the staged key and certificate — a user name from the
|
|
735
|
+
* image's `/etc/passwd` (resolved against the image, so
|
|
736
|
+
* `"postgres"` works), or a numeric uid.
|
|
737
|
+
*
|
|
738
|
+
* A bind mount carries the staged file's ownership through, and the
|
|
739
|
+
* daemon writes as `root`, so a strict `mode` alone gives a
|
|
740
|
+
* non-root server a key it cannot open — postgres reports
|
|
741
|
+
* `could not access private key file`. `user` is what makes the
|
|
742
|
+
* pair work, with no entrypoint wrapper and no custom image.
|
|
743
|
+
*/
|
|
744
|
+
user?: string;
|
|
745
|
+
/** Group of the staged key and certificate — a group name from the
|
|
746
|
+
* image's `/etc/group`, or a numeric gid. Enough on its own for a
|
|
747
|
+
* server that accepts a root-owned key readable by its group
|
|
748
|
+
* (postgres does, at `0640`). */
|
|
749
|
+
group?: string;
|
|
719
750
|
}
|
|
720
751
|
export type ReadyCheck = {
|
|
721
752
|
type: "tcp";
|
package/dist/inspect.d.ts
CHANGED
|
@@ -22,6 +22,29 @@ export declare function clearPendingNullish(): void;
|
|
|
22
22
|
export declare function adoptNullishTag<T>(value: T): T;
|
|
23
23
|
/** If x is wrapped, return the raw value; otherwise return x. */
|
|
24
24
|
export declare function readRaw<T>(x: T): T;
|
|
25
|
+
/**
|
|
26
|
+
* Strip provenance wrappers from a value AND from anything nested inside a
|
|
27
|
+
* plain object/array it holds. The inbound counterpart of {@link wrap}: for
|
|
28
|
+
* values crossing *back* into code that expects raw data — a fake's `helpers`,
|
|
29
|
+
* a k8s client method's arguments. Without it a helper that takes a value some
|
|
30
|
+
* earlier op produced gets a `Carrier`/proxy, and the first `.matchAll(...)` /
|
|
31
|
+
* typed-client call inside it throws a `TypeError` from deep in the callee,
|
|
32
|
+
* far from the call site that passed the wrapper. Coercion sinks
|
|
33
|
+
* ({@link makeCarrier}) rescue only the interpolation cases; a callee that
|
|
34
|
+
* calls a method on the value, or checks its `typeof`, still sees an object.
|
|
35
|
+
*
|
|
36
|
+
* One {@link readRaw} finishes a wrapped value: a wrapper's target is raw all
|
|
37
|
+
* the way down (the proxy wraps children lazily, on read), so there is nothing
|
|
38
|
+
* left to walk underneath it. The recursion is for the other shape — a
|
|
39
|
+
* container the *caller* built around wrapped leaves: `{ to: msg.to }`,
|
|
40
|
+
* `[row.id, row.email]`.
|
|
41
|
+
*
|
|
42
|
+
* Rebuilds only what changed, and only plain objects/arrays: a `Date`,
|
|
43
|
+
* `Buffer`, `Map`, `Response` or class instance is returned untouched rather
|
|
44
|
+
* than flattened into a plain object. Cyclic containers are left in place at
|
|
45
|
+
* the point the cycle closes.
|
|
46
|
+
*/
|
|
47
|
+
export declare function deepUnwrap<T>(value: T): T;
|
|
25
48
|
/** The raw type behind a wrapper: a `Carrier`/`WrappedObject`/`WrappedArray`/
|
|
26
49
|
* `WrappedResponse` resolves to its `unwrap()` return type; anything else is
|
|
27
50
|
* already raw and passes through unchanged. */
|
package/dist/inspect.js
CHANGED
|
@@ -136,6 +136,71 @@ export function readRaw(x) {
|
|
|
136
136
|
}
|
|
137
137
|
return cur;
|
|
138
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Strip provenance wrappers from a value AND from anything nested inside a
|
|
141
|
+
* plain object/array it holds. The inbound counterpart of {@link wrap}: for
|
|
142
|
+
* values crossing *back* into code that expects raw data — a fake's `helpers`,
|
|
143
|
+
* a k8s client method's arguments. Without it a helper that takes a value some
|
|
144
|
+
* earlier op produced gets a `Carrier`/proxy, and the first `.matchAll(...)` /
|
|
145
|
+
* typed-client call inside it throws a `TypeError` from deep in the callee,
|
|
146
|
+
* far from the call site that passed the wrapper. Coercion sinks
|
|
147
|
+
* ({@link makeCarrier}) rescue only the interpolation cases; a callee that
|
|
148
|
+
* calls a method on the value, or checks its `typeof`, still sees an object.
|
|
149
|
+
*
|
|
150
|
+
* One {@link readRaw} finishes a wrapped value: a wrapper's target is raw all
|
|
151
|
+
* the way down (the proxy wraps children lazily, on read), so there is nothing
|
|
152
|
+
* left to walk underneath it. The recursion is for the other shape — a
|
|
153
|
+
* container the *caller* built around wrapped leaves: `{ to: msg.to }`,
|
|
154
|
+
* `[row.id, row.email]`.
|
|
155
|
+
*
|
|
156
|
+
* Rebuilds only what changed, and only plain objects/arrays: a `Date`,
|
|
157
|
+
* `Buffer`, `Map`, `Response` or class instance is returned untouched rather
|
|
158
|
+
* than flattened into a plain object. Cyclic containers are left in place at
|
|
159
|
+
* the point the cycle closes.
|
|
160
|
+
*/
|
|
161
|
+
export function deepUnwrap(value) {
|
|
162
|
+
return deepUnwrapInner(value, new Set());
|
|
163
|
+
}
|
|
164
|
+
function deepUnwrapInner(value, seen) {
|
|
165
|
+
if (value === null || value === undefined)
|
|
166
|
+
return value;
|
|
167
|
+
const raw = readRaw(value);
|
|
168
|
+
// It was wrapped, so it's now fully raw — and recursing into it would be
|
|
169
|
+
// wrong anyway (the raw form may be an exotic object we must not rebuild).
|
|
170
|
+
if (raw !== value)
|
|
171
|
+
return raw;
|
|
172
|
+
if (typeof raw !== "object")
|
|
173
|
+
return raw;
|
|
174
|
+
const obj = raw;
|
|
175
|
+
if (seen.has(obj))
|
|
176
|
+
return obj;
|
|
177
|
+
if (Array.isArray(obj)) {
|
|
178
|
+
seen.add(obj);
|
|
179
|
+
let changed = false;
|
|
180
|
+
const out = obj.map((el) => {
|
|
181
|
+
const next = deepUnwrapInner(el, seen);
|
|
182
|
+
if (next !== el)
|
|
183
|
+
changed = true;
|
|
184
|
+
return next;
|
|
185
|
+
});
|
|
186
|
+
seen.delete(obj);
|
|
187
|
+
return changed ? out : obj;
|
|
188
|
+
}
|
|
189
|
+
const proto = Object.getPrototypeOf(obj);
|
|
190
|
+
if (proto !== Object.prototype && proto !== null)
|
|
191
|
+
return obj;
|
|
192
|
+
seen.add(obj);
|
|
193
|
+
let changed = false;
|
|
194
|
+
const out = {};
|
|
195
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
196
|
+
const next = deepUnwrapInner(v, seen);
|
|
197
|
+
if (next !== v)
|
|
198
|
+
changed = true;
|
|
199
|
+
out[k] = next;
|
|
200
|
+
}
|
|
201
|
+
seen.delete(obj);
|
|
202
|
+
return changed ? out : obj;
|
|
203
|
+
}
|
|
139
204
|
/**
|
|
140
205
|
* Wrap a value so reads through it carry an `OpTag`. Recursion is lazy:
|
|
141
206
|
* a property read on an object Proxy wraps its child on demand.
|
package/dist/locator.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { Wrapped } from "./inspect.js";
|
|
|
5
5
|
* Playwright's own default is 30s — far too slow-failing for tests; 5s
|
|
6
6
|
* matches the pre-Playwright behavior. A per-call `{ timeout }` overrides it;
|
|
7
7
|
* `undefined` falls through to the context default (also set to this in
|
|
8
|
-
* browser.ts's `
|
|
8
|
+
* browser.ts's `newViewContext`). Navigations keep a longer deadline. */
|
|
9
9
|
export declare const DEFAULT_ACTION_TIMEOUT_MS = 5000;
|
|
10
10
|
export interface GetByTextOptions {
|
|
11
11
|
/** Whole-string, case-sensitive match instead of the default
|
package/dist/locator.js
CHANGED
|
@@ -26,7 +26,7 @@ import { truncateUtf8 } from "./recorder.js";
|
|
|
26
26
|
* Playwright's own default is 30s — far too slow-failing for tests; 5s
|
|
27
27
|
* matches the pre-Playwright behavior. A per-call `{ timeout }` overrides it;
|
|
28
28
|
* `undefined` falls through to the context default (also set to this in
|
|
29
|
-
* browser.ts's `
|
|
29
|
+
* browser.ts's `newViewContext`). Navigations keep a longer deadline. */
|
|
30
30
|
export const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
|
|
31
31
|
// Brand + chain carrier. Both are `Symbol.for` keys so `JSON.stringify` drops
|
|
32
32
|
// them (locators are never serialized) while runtime code can still detect a
|