@specific.dev/spectest 0.36.1 → 0.38.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 +58 -53
- package/dist/daemon.js +268 -29
- package/dist/index.d.ts +32 -1
- package/dist/locator.d.ts +1 -1
- package/dist/locator.js +1 -1
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -61,11 +61,37 @@ function base64ByteLength(b64) {
|
|
|
61
61
|
// surfaces as an intermittent `net::ERR_NAME_NOT_RESOLVED`. getaddrinfo
|
|
62
62
|
// reads resolv.conf synchronously per lookup, so it has no startup race
|
|
63
63
|
// and always reaches the resolver. See run 878d0054 (dashboard:3000).
|
|
64
|
+
//
|
|
65
|
+
// The two media flags below make `getUserMedia` work at all. The microVM
|
|
66
|
+
// has no camera and no sound card, so without them a page that asks for one
|
|
67
|
+
// fails on `NotFoundError: Requested device not found` — an app that gates
|
|
68
|
+
// its UI on microphone access can't be driven past that point.
|
|
69
|
+
//
|
|
70
|
+
// What the app gets is Chromium's own synthetic devices: the microphone is a
|
|
71
|
+
// generated beep pattern (a live, real MediaStreamTrack — MediaRecorder,
|
|
72
|
+
// WebRTC and AudioContext analysis all work on it), the camera a rolling
|
|
73
|
+
// test pattern at 640x480. There is deliberately NO way to feed a recording
|
|
74
|
+
// in; this is device availability, not audio fixtures.
|
|
75
|
+
//
|
|
76
|
+
// --use-fake-device-for-media-stream: the synthetic camera + microphone.
|
|
77
|
+
// Also what makes `enumerateDevices()` return anything at all.
|
|
78
|
+
// --use-fake-ui-for-media-stream: auto-accept the permission prompt.
|
|
79
|
+
// Headless has no UI to accept it with, so without this `getUserMedia`
|
|
80
|
+
// fails on `NotAllowedError` even when the fake devices are present.
|
|
81
|
+
//
|
|
82
|
+
// `--autoplay-policy=no-user-gesture-required` is deliberately NOT here.
|
|
83
|
+
// WebAudio over the captured stream works without it: holding a live
|
|
84
|
+
// getUserMedia stream is itself one of the conditions under which Chromium
|
|
85
|
+
// lets an AudioContext start, so an app's level meter or VAD reads the fake
|
|
86
|
+
// device fine (measured). Adding it would also un-block ordinary media
|
|
87
|
+
// autoplay, which is a behaviour change in the app under test, not in us.
|
|
64
88
|
const CHROME_ARGV = [
|
|
65
89
|
"--no-sandbox",
|
|
66
90
|
"--disable-dev-shm-usage",
|
|
67
91
|
"--disable-features=AsyncDns,DnsOverHttps",
|
|
68
92
|
"--dns-over-https-mode=off",
|
|
93
|
+
"--use-fake-device-for-media-stream",
|
|
94
|
+
"--use-fake-ui-for-media-stream",
|
|
69
95
|
];
|
|
70
96
|
/** Default touchStart→touchEnd dwell for touch taps — see `rawTap`. */
|
|
71
97
|
const TAP_DWELL_MS = 60;
|
|
@@ -84,11 +110,13 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
|
|
|
84
110
|
// comes from the HOME-scoped NSS user DB, so neither cares that playwright
|
|
85
111
|
// runs a temp --user-data-dir.
|
|
86
112
|
let PW_BROWSER = null;
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
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.
|
|
92
120
|
function chromiumPath() {
|
|
93
121
|
return (Bun.which("chromium") ??
|
|
94
122
|
Bun.which("chromium-browser") ??
|
|
@@ -103,7 +131,6 @@ async function ensurePlaywrightBrowser() {
|
|
|
103
131
|
headless: true,
|
|
104
132
|
args: CHROME_ARGV,
|
|
105
133
|
});
|
|
106
|
-
DESKTOP_CTX = null; // contexts died with the old browser (if any)
|
|
107
134
|
return PW_BROWSER;
|
|
108
135
|
}
|
|
109
136
|
/** Context options for the mobile device preset — playwright's native
|
|
@@ -124,18 +151,20 @@ function deviceContextOptions(d) {
|
|
|
124
151
|
reducedMotion: "reduce",
|
|
125
152
|
};
|
|
126
153
|
}
|
|
127
|
-
|
|
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) {
|
|
128
157
|
const browser = await ensurePlaywrightBrowser();
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
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;
|
|
139
168
|
}
|
|
140
169
|
/**
|
|
141
170
|
* Make a user script evaluable by the page: Bun's `view.evaluate` accepts a
|
|
@@ -765,32 +794,10 @@ async function spawnPage(context) {
|
|
|
765
794
|
}
|
|
766
795
|
return { page, cdp, recordingInstalled };
|
|
767
796
|
}
|
|
768
|
-
/**
|
|
769
|
-
async function contextFor(width, height, device) {
|
|
770
|
-
if (device) {
|
|
771
|
-
const browser = await ensurePlaywrightBrowser();
|
|
772
|
-
const context = await browser.newContext(deviceContextOptions(device));
|
|
773
|
-
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
774
|
-
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
775
|
-
return { context, ownsContext: true };
|
|
776
|
-
}
|
|
777
|
-
if (width === 1280 && height === 720) {
|
|
778
|
-
return { context: await ensureDesktopContext(), ownsContext: false };
|
|
779
|
-
}
|
|
780
|
-
const browser = await ensurePlaywrightBrowser();
|
|
781
|
-
const context = await browser.newContext({
|
|
782
|
-
viewport: { width, height },
|
|
783
|
-
// Reduced-motion for replay fidelity — see deviceContextOptions.
|
|
784
|
-
reducedMotion: "reduce",
|
|
785
|
-
});
|
|
786
|
-
context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
|
|
787
|
-
context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
|
|
788
|
-
return { context, ownsContext: true };
|
|
789
|
-
}
|
|
790
|
-
/** Create a default-desktop view for the pool. */
|
|
797
|
+
/** Create a default-desktop view (plus its context) for the pool. */
|
|
791
798
|
async function createView(width, height) {
|
|
792
|
-
const
|
|
793
|
-
return spawnPage(context);
|
|
799
|
+
const context = await newViewContext(width, height, null);
|
|
800
|
+
return { context, ...(await spawnPage(context)) };
|
|
794
801
|
}
|
|
795
802
|
/**
|
|
796
803
|
* Pre-open `n` views into the pool (called by the daemon at the end of
|
|
@@ -834,8 +841,7 @@ export async function openMobileBackend(opts = {}) {
|
|
|
834
841
|
let holder;
|
|
835
842
|
if (pooled) {
|
|
836
843
|
holder = {
|
|
837
|
-
context:
|
|
838
|
-
ownsContext: false,
|
|
844
|
+
context: pooled.context,
|
|
839
845
|
page: pooled.page,
|
|
840
846
|
cdp: pooled.cdp,
|
|
841
847
|
lastTitle: "",
|
|
@@ -877,11 +883,10 @@ export async function openMobileBackend(opts = {}) {
|
|
|
877
883
|
let SHARED_BROWSER = null;
|
|
878
884
|
const SHARED_MOBILE = new Map();
|
|
879
885
|
async function newHolder(width, height, device) {
|
|
880
|
-
const
|
|
886
|
+
const context = await newViewContext(width, height, device);
|
|
881
887
|
const spawned = await spawnPage(context);
|
|
882
888
|
const holder = {
|
|
883
889
|
context,
|
|
884
|
-
ownsContext,
|
|
885
890
|
page: spawned.page,
|
|
886
891
|
cdp: spawned.cdp,
|
|
887
892
|
lastTitle: "",
|
|
@@ -1380,13 +1385,13 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1380
1385
|
catch {
|
|
1381
1386
|
/* already closed by the runtime */
|
|
1382
1387
|
}
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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) */
|
|
1390
1395
|
}
|
|
1391
1396
|
},
|
|
1392
1397
|
// ── Mobile-only primitives ──────────────────────────────────────────
|
package/dist/daemon.js
CHANGED
|
@@ -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);
|
|
@@ -2618,14 +2759,16 @@ async function bootstrapInner() {
|
|
|
2618
2759
|
// deps; this adds the image edge. The two compose: we run the moment
|
|
2619
2760
|
// both are satisfied, with no whole-graph barrier between them.
|
|
2620
2761
|
await prep.get(svc.name);
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
...(await ensureFiles(svc)),
|
|
2624
|
-
...(await ensureCertificates(svc)),
|
|
2625
|
-
];
|
|
2762
|
+
// The tag is read before the mounts are staged: a `files`/
|
|
2763
|
+
// `certificates` owner given by name is resolved against the image.
|
|
2626
2764
|
const tag = tags.get(svc.name);
|
|
2627
2765
|
if (!tag)
|
|
2628
2766
|
throw new Error(`internal: no image tag for ${svc.name}`);
|
|
2767
|
+
const flags = [
|
|
2768
|
+
...(await ensureVolumes(svc)),
|
|
2769
|
+
...(await ensureFiles(svc, tag)),
|
|
2770
|
+
...(await ensureCertificates(svc, tag)),
|
|
2771
|
+
];
|
|
2629
2772
|
const tRun = Date.now();
|
|
2630
2773
|
progressService(svc.name, { status: "starting", detail: undefined });
|
|
2631
2774
|
await runContainer(svc, tag, flags);
|
|
@@ -4025,9 +4168,86 @@ function packageName(spec) {
|
|
|
4025
4168
|
return spec.split("/")[0];
|
|
4026
4169
|
}
|
|
4027
4170
|
/**
|
|
4028
|
-
*
|
|
4029
|
-
*
|
|
4030
|
-
*
|
|
4171
|
+
* Is this package installed? Read from the disk, and deliberately not with
|
|
4172
|
+
* `Bun.resolveSync` — a resolve that misses is cached for the life of the
|
|
4173
|
+
* process (see `ensureDeps`), so asking the resolver whether a package is
|
|
4174
|
+
* missing is what makes it stay missing.
|
|
4175
|
+
*
|
|
4176
|
+
* Walks up from `APP_DIR` like a module resolver does, so a hoisted
|
|
4177
|
+
* install in an ancestor `node_modules` counts.
|
|
4178
|
+
*/
|
|
4179
|
+
async function packageInstalled(pkg) {
|
|
4180
|
+
let dir = APP_DIR;
|
|
4181
|
+
for (;;) {
|
|
4182
|
+
if (await fs.exists(path.join(dir, "node_modules", pkg, "package.json")))
|
|
4183
|
+
return true;
|
|
4184
|
+
const parent = path.dirname(dir);
|
|
4185
|
+
if (parent === dir)
|
|
4186
|
+
return false;
|
|
4187
|
+
dir = parent;
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
/**
|
|
4191
|
+
* Resolve `spec` in a throwaway `bun` process, which starts with an empty
|
|
4192
|
+
* resolver cache. Returns null if it still does not resolve — the snippet's
|
|
4193
|
+
* own import then reports the real error.
|
|
4194
|
+
*/
|
|
4195
|
+
async function resolveInNewProcess(spec) {
|
|
4196
|
+
const probe = `process.stdout.write(Bun.resolveSync(${JSON.stringify(spec)}, ${JSON.stringify(APP_DIR)}))`;
|
|
4197
|
+
const proc = Bun.spawn([BUN_BIN, "-e", probe], {
|
|
4198
|
+
cwd: APP_DIR,
|
|
4199
|
+
stdout: "pipe",
|
|
4200
|
+
stderr: "ignore",
|
|
4201
|
+
});
|
|
4202
|
+
const out = (await new Response(proc.stdout).text()).trim();
|
|
4203
|
+
const code = await proc.exited;
|
|
4204
|
+
if (code !== 0 || out.length === 0)
|
|
4205
|
+
return null;
|
|
4206
|
+
return out;
|
|
4207
|
+
}
|
|
4208
|
+
/**
|
|
4209
|
+
* Replace one import specifier in the snippet — in import positions only
|
|
4210
|
+
* (`from "x"`, `import "x"`, `import("x")`, `require("x")`), so a plain
|
|
4211
|
+
* string that is equal to the specifier stays as it is.
|
|
4212
|
+
*/
|
|
4213
|
+
function rewriteSpecifier(code, spec, target) {
|
|
4214
|
+
const escaped = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4215
|
+
const pattern = new RegExp(`((?:\\bfrom|\\bimport|\\brequire)\\s*\\(?\\s*)(["'])${escaped}\\2`, "g");
|
|
4216
|
+
// A callback, not a replacement string: an absolute path can contain `$`.
|
|
4217
|
+
return code.replace(pattern, (_match, lead, quote) => `${lead}${quote}${target}${quote}`);
|
|
4218
|
+
}
|
|
4219
|
+
/** Point each given specifier at the module's absolute path. A specifier
|
|
4220
|
+
* that still does not resolve is left as it is, so the snippet's own
|
|
4221
|
+
* import reports the real error. */
|
|
4222
|
+
async function rewriteUnreachable(code, specs) {
|
|
4223
|
+
let patched = code;
|
|
4224
|
+
for (const spec of specs) {
|
|
4225
|
+
const target = await resolveInNewProcess(spec);
|
|
4226
|
+
if (target)
|
|
4227
|
+
patched = rewriteSpecifier(patched, spec, target);
|
|
4228
|
+
}
|
|
4229
|
+
return patched;
|
|
4230
|
+
}
|
|
4231
|
+
/**
|
|
4232
|
+
* Scan the snippet's imports and `bun add` anything that isn't installed.
|
|
4233
|
+
* Skips relative paths, absolute paths, `node:`/`bun:` built-ins, and
|
|
4234
|
+
* HTTP(S)/file: URLs. Returns the snippet to actually run, plus the packages
|
|
4235
|
+
* it installed.
|
|
4236
|
+
*
|
|
4237
|
+
* A specifier the bare import cannot reach is **rewritten to the absolute
|
|
4238
|
+
* path of the module**, because Bun caches a failed resolution for the life
|
|
4239
|
+
* of the process and the daemon is long-lived: an eval that imports
|
|
4240
|
+
* `otpauth` before it is installed makes every later
|
|
4241
|
+
* `import * as OTPAuth from "otpauth"` keep failing with `Cannot find
|
|
4242
|
+
* package 'otpauth'` — even after the install put it on the disk. Nothing
|
|
4243
|
+
* clears that entry: not a later `Bun.resolveSync`, not an `onResolve`
|
|
4244
|
+
* plugin, not a new directory to import from (all measured on Bun 1.3.14,
|
|
4245
|
+
* where node 24 re-resolves and succeeds). The cache keeps hits the same
|
|
4246
|
+
* way — a package removed mid-process still resolves — so treat resolution
|
|
4247
|
+
* in this process as a snapshot taken at first ask.
|
|
4248
|
+
* An absolute path never consults the cache. The path comes from a new
|
|
4249
|
+
* `bun` process, so the package's own `exports` conditions apply exactly as
|
|
4250
|
+
* they would for the bare specifier.
|
|
4031
4251
|
*/
|
|
4032
4252
|
async function ensureDeps(code) {
|
|
4033
4253
|
let scanned;
|
|
@@ -4036,9 +4256,13 @@ async function ensureDeps(code) {
|
|
|
4036
4256
|
}
|
|
4037
4257
|
catch {
|
|
4038
4258
|
// Invalid syntax — let the import call surface the real error.
|
|
4039
|
-
return [];
|
|
4259
|
+
return { code, installed: [] };
|
|
4040
4260
|
}
|
|
4041
4261
|
const seen = new Set();
|
|
4262
|
+
// Specifiers the bare import cannot reach, and the packages to install for
|
|
4263
|
+
// them. A subpath (`otpauth/dist/…`) resolves to its own file, so these are
|
|
4264
|
+
// deduped per specifier, not per package.
|
|
4265
|
+
const unreachable = [];
|
|
4042
4266
|
const missing = [];
|
|
4043
4267
|
for (const imp of scanned) {
|
|
4044
4268
|
const p = imp.path;
|
|
@@ -4051,21 +4275,32 @@ async function ensureDeps(code) {
|
|
|
4051
4275
|
p.startsWith("file:")) {
|
|
4052
4276
|
continue;
|
|
4053
4277
|
}
|
|
4278
|
+
if (seen.has(p))
|
|
4279
|
+
continue;
|
|
4280
|
+
seen.add(p);
|
|
4054
4281
|
const pkg = packageName(p);
|
|
4055
|
-
if (
|
|
4282
|
+
if (!(await packageInstalled(pkg))) {
|
|
4283
|
+
unreachable.push(p);
|
|
4284
|
+
if (!missing.includes(pkg))
|
|
4285
|
+
missing.push(pkg);
|
|
4056
4286
|
continue;
|
|
4057
|
-
|
|
4287
|
+
}
|
|
4288
|
+
// Installed — but a poisoned cache entry from an earlier eval can still
|
|
4289
|
+
// fail the import, so ask the resolver. This can only cache a hit.
|
|
4058
4290
|
try {
|
|
4059
4291
|
Bun.resolveSync(p, APP_DIR);
|
|
4060
4292
|
}
|
|
4061
4293
|
catch {
|
|
4062
|
-
|
|
4294
|
+
unreachable.push(p);
|
|
4063
4295
|
}
|
|
4064
4296
|
}
|
|
4065
|
-
if (
|
|
4066
|
-
return [];
|
|
4297
|
+
if (unreachable.length === 0)
|
|
4298
|
+
return { code, installed: [] };
|
|
4299
|
+
if (missing.length === 0) {
|
|
4300
|
+
return { code: await rewriteUnreachable(code, unreachable), installed: [] };
|
|
4301
|
+
}
|
|
4067
4302
|
await new Promise((resolve, reject) => {
|
|
4068
|
-
execFile(
|
|
4303
|
+
execFile(BUN_BIN, ["add", ...missing], { cwd: APP_DIR, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
4069
4304
|
if (err) {
|
|
4070
4305
|
reject(new Error(`bun add ${missing.join(" ")} failed:\n${String(stderr).trim()}\n${String(stdout).trim()}`));
|
|
4071
4306
|
}
|
|
@@ -4074,7 +4309,7 @@ async function ensureDeps(code) {
|
|
|
4074
4309
|
}
|
|
4075
4310
|
});
|
|
4076
4311
|
});
|
|
4077
|
-
return missing;
|
|
4312
|
+
return { code: await rewriteUnreachable(code, unreachable), installed: missing };
|
|
4078
4313
|
}
|
|
4079
4314
|
/**
|
|
4080
4315
|
* Turn the bare parser error an `export default` misuse produces
|
|
@@ -4271,10 +4506,14 @@ async function evalCode(code, secrets) {
|
|
|
4271
4506
|
let filePath;
|
|
4272
4507
|
let outcome;
|
|
4273
4508
|
try {
|
|
4274
|
-
|
|
4509
|
+
// The snippet that runs can differ from the one the user sent: a
|
|
4510
|
+
// freshly installed import is rewritten to an absolute path (see
|
|
4511
|
+
// ensureDeps). Error messages keep using the original `code`.
|
|
4512
|
+
const prepared = await ensureDeps(code);
|
|
4513
|
+
installed = prepared.installed;
|
|
4275
4514
|
await fs.mkdir(EVAL_DIR, { recursive: true });
|
|
4276
4515
|
filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
|
|
4277
|
-
await fs.writeFile(filePath, code);
|
|
4516
|
+
await fs.writeFile(filePath, prepared.code);
|
|
4278
4517
|
const mod = (await import(pathToFileURL(filePath).href));
|
|
4279
4518
|
outcome = { ok: true, result: safeSerialize(mod.default) };
|
|
4280
4519
|
}
|
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/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
|