bynk/dev.rs
1//! `bynk dev` — build a project and serve it locally in one step.
2//!
3//! Collapses the manual recipe (compile → `cd` into the generated worker dir →
4//! `wrangler dev`) into a single command (proposal v0.57). The orchestration is
5//! **pre-flight → compile → select → serve**, and almost every piece is reused:
6//! [`compiler::resolve`](crate::compiler) for `bynkc`, the doctor `Deploy`
7//! capability for the Node + `wrangler` gate, and [`probe`] for locating
8//! `wrangler` with the same provenance ordering doctor reports.
9//!
10//! The serve step runs `wrangler dev` in **local mode** (Miniflare), which
11//! simulates KV / Durable Objects / queues keyed by *binding name* — so no
12//! namespace provisioning is needed and the generated `wrangler.toml` is served
13//! untouched (proposal §1, D4). Everything `wrangler`-specific is encapsulated
14//! here so the serve step can later be swapped for a first-party `workerd`
15//! server without touching the rest (proposal §4).
16//!
17//! Since #552 the step is **one `wrangler dev` per context**, not one per
18//! project: the processes discover each other through wrangler's dev registry
19//! and wire the emitted `[[services]]` bindings between themselves, so a
20//! cross-context call resolves locally. That makes the driver a supervisor of N
21//! children rather than a hand-off to one — hence the port allocation
22//! ([`allocate`]), the joint teardown (`terminate`, private), and the plural
23//! selection rule ([`select_contexts`]) that replaced ADR 0096 D3's ambiguity
24//! error.
25
26use std::path::{Path, PathBuf};
27use std::process::{Command, ExitCode};
28use std::time::Duration;
29
30use bynk_emit::project::{ProjectPaths, try_read_project_paths};
31
32use crate::compiler::Compiler;
33use crate::doctor::{self, Capability, Context, DoctorOptions, Report};
34use crate::probe::{self, DetectOpts, Provenance, Toolbox};
35use crate::report::{self, Format};
36use crate::shell::exit_status_byte;
37
38// The build/select/`wrangler` plumbing `dev` shares with `deploy` now lives in
39// [`crate::workers`] (it is neither command's to own). Re-exported rather than
40// merely imported, so `bynk::dev::compile_once` and `bynk::dev::select_contexts`
41// — the paths this crate's integration tests already use — keep resolving.
42pub use crate::workers::{
43 SelectError, compile_once, discover_workers, prepare_build_dir, select_contexts,
44 wrangler_command,
45};
46
47/// Parsed `bynk dev` flags (the project `PATH` is resolved into `project_root`
48/// before we get here).
49#[derive(Debug, Clone, Default)]
50pub struct DevOptions {
51 /// `--context NAME`, repeatable — which contexts' workers to serve. Empty
52 /// serves **every** context in the project, wired (ADR 0096 D3 superseded).
53 pub contexts: Vec<String>,
54 /// `--base-port N` — the first port of the per-context allocation. `None`
55 /// leaves a lone worker on wrangler's own default, so `-- --port N` keeps
56 /// working exactly as it did when `dev` served one context.
57 pub base_port: Option<u16>,
58 /// `--inspect` (slice 3): start `wrangler dev` with the V8 inspector so a
59 /// JavaScript debugger can attach; breakpoints in `.bynk` resolve through the
60 /// emitted source maps composed into the worker bundle.
61 pub inspect: bool,
62 /// Base inspector port for `--inspect` (default 9229); allocated per context
63 /// exactly as `base_port` is.
64 pub inspect_port: u16,
65 /// `--env NAME` (default `"default"`) — which `bynk.deploy.lock` section
66 /// `--remote` reads the KV id from. `dev` never provisions and never
67 /// writes the ledger (unchanged); this only selects which of `deploy`'s
68 /// environments `--remote` connects the placeholder to. Purely a `bynk`
69 /// concept — never forwarded to `wrangler dev` itself, since `dev` curates
70 /// no Wrangler-side environment config (slice 4, #837 review: a project
71 /// deployed only under a non-default `--env` previously read as
72 /// "never provisioned" here, because this always looked at `"default"`).
73 pub environment: String,
74 /// Everything after `--`, forwarded to `wrangler dev` verbatim (D5).
75 pub wrangler_args: Vec<String>,
76}
77
78/// Wrangler's own default dev port — the base of the per-context allocation
79/// when `--base-port` is not given, so a multi-context project's first worker
80/// lands where a single-context one always has.
81const DEFAULT_BASE_PORT: u16 = 8787;
82
83/// One worker to serve: its dasherised context dir, its HTTP port (`None` = let
84/// wrangler choose, the lone-worker default), and its inspector port.
85#[derive(Debug, PartialEq, Eq)]
86pub struct Serving {
87 pub worker: String,
88 pub port: Option<u16>,
89 pub inspector_port: Option<u16>,
90}
91
92/// Orchestrate a local dev session: pre-flight, compile, select the worker, and
93/// hand off to `wrangler dev`. Returns wrangler's own exit code on a clean
94/// hand-off, or a pre-flight/build failure code before serving.
95pub fn run(
96 tb: &dyn Toolbox,
97 compiler: &Compiler,
98 project_root: &Path,
99 node_floor: u32,
100 opts: &DevOptions,
101) -> ExitCode {
102 // #837 review: once `--remote` reads a ledger section by `--env`, a
103 // `-- --env`/`-- --environment` passthrough would silently diverge —
104 // `bynk` materialises one environment's KV id while Wrangler actually
105 // connects to a different one. Checked only when `--remote` is present,
106 // since `--env` is otherwise inert (nothing reads the ledger without it).
107 if opts.wrangler_args.iter().any(|arg| arg == "--remote")
108 && let Some(conflict) = crate::deploy::conflicting_env_passthrough(&opts.wrangler_args)
109 {
110 eprintln!(
111 "bynk: `--env {}` conflicts with `{conflict}` after `--` — pass one or the other, not both",
112 opts.environment
113 );
114 return ExitCode::FAILURE;
115 }
116
117 // 1. Pre-flight — reuse doctor's Deploy gate (Node + wrangler) plus the
118 // always-on compile floor. Failing here, with doctor's remedy text, beats
119 // a confusing error out of a half-built tree (proposal §2.2).
120 let ctx = Context {
121 project_root: Some(project_root.to_path_buf()),
122 in_repo: false,
123 node_floor,
124 };
125 let preflight_opts = DoctorOptions {
126 only: Some(Capability::Deploy),
127 strict: false,
128 };
129 let report = doctor::diagnose(tb, compiler, &ctx, &preflight_opts);
130 if report.exit_nonzero(&preflight_opts) {
131 eprint!("{}", preflight_failure_message(&report));
132 return ExitCode::FAILURE;
133 }
134 // 2. Compile — in-process (slice 7: the driver links the pipeline instead of
135 // shelling `bynkc`). Into the managed `.bynk/dev/` build dir (D1).
136 // Compilation is additive (never prunes), so clear `workers/` first;
137 // otherwise a renamed/deleted context would linger and spuriously trip the
138 // §2.4 ambiguity check.
139 let build_dir = project_root.join(".bynk").join("dev");
140 if let Err(e) = prepare_build_dir(project_root, &build_dir) {
141 eprintln!("bynk: could not prepare build directory: {e}");
142 return ExitCode::FAILURE;
143 }
144 // #524: compile the SAME project shape as `bynkc compile <project_root>`
145 // — the shared rooting rule over the full `[paths]` layout. `dev`
146 // previously re-rooted on the first `include` entry only, silently
147 // dropping further includes and the whole `exclude` list.
148 if !compile_once(compiler, project_root, &build_dir, true) {
149 return ExitCode::FAILURE;
150 }
151
152 // 3. Select the workers — every context by default, or the `--context`
153 // subset (#552, superseding ADR 0096 D3's select-or-default). Serving
154 // them *together* is the whole point: a cross-context call only resolves
155 // when its callee is up too, so an ambiguity error here was the feature
156 // being withheld, not a project being wrong.
157 let workers_dir = build_dir.join("workers");
158 let available = discover_workers(&workers_dir);
159 let workers = match select_contexts(&available, &opts.contexts) {
160 Ok(w) => w,
161 Err(e) => {
162 eprintln!("bynk: {e}");
163 return ExitCode::FAILURE;
164 }
165 };
166 let serving = allocate(&workers, opts.base_port, opts);
167
168 // Where the driver injects a port it owns the allocation, so the same flag
169 // arriving through `--` is a conflict — and wrangler rejects a repeated
170 // `--port` with a usage dump rather than taking the last one. Catch it here
171 // and name the driver flag that owns it.
172 for (flag, owner, injected) in [
173 (
174 "--port",
175 "--base-port",
176 serving.iter().any(|s| s.port.is_some()),
177 ),
178 (
179 "--inspector-port",
180 "--inspect-port",
181 serving.iter().any(|s| s.inspector_port.is_some()),
182 ),
183 ] {
184 if injected && passthrough_has(&opts.wrangler_args, flag) {
185 eprintln!(
186 "bynk: `{flag}` is allocated per context — pass `{owner}` to `bynk dev` instead of `-- {flag}`."
187 );
188 return ExitCode::FAILURE;
189 }
190 }
191
192 // Remote dev reads the real Cloudflare KV id, unlike Miniflare's local
193 // mode. Resolve it from the deploy ledger immediately before Wrangler
194 // runs; a never-deployed project gets an actionable error instead of
195 // sending the generated placeholder to Cloudflare.
196 if opts.wrangler_args.iter().any(|arg| arg == "--remote") {
197 for s in &serving {
198 if let Err(e) = crate::deploy::materialise_deploy_state(
199 project_root,
200 &s.worker,
201 &workers_dir.join(&s.worker).join("wrangler.toml"),
202 &opts.environment,
203 ) {
204 eprintln!("bynk: {e}");
205 return ExitCode::FAILURE;
206 }
207 }
208 }
209
210 // 4. Serve — one `wrangler dev` per context, each from inside its own worker
211 // dir (the emitted `index.ts` imports `../../runtime.js`, so cwd must be
212 // the worker dir, exactly the manual recipe's `cd`). The processes find
213 // each other through wrangler's **dev registry** and wire the generated
214 // `[[services]]` bindings between themselves — verified: a binding starts
215 // `[not connected]` and converges to `[connected]` once its callee is up,
216 // so start order does not matter and we need not stage the spawns.
217 // Resolve wrangler once with doctor's provenance ordering; an npx
218 // resolution downloads on first use, so it is a notice, never a silent
219 // green path.
220 let probe = probe::detect(
221 tb,
222 "wrangler",
223 DetectOpts {
224 project_root: Some(project_root),
225 allow_npx: true,
226 },
227 );
228 if matches!(probe.provenance, Provenance::Npx) {
229 eprintln!("bynk: wrangler resolved via npx — it will download on first run.");
230 }
231 if matches!(probe.provenance, Provenance::Missing) {
232 // The pre-flight gate should have caught this; defensive only.
233 eprintln!("bynk: wrangler not found (run `bynk doctor --only deploy`)");
234 return ExitCode::FAILURE;
235 }
236
237 // Inherited stdio (the default) keeps every session interactive. The driver
238 // and the wranglers share the terminal's foreground process group, so a
239 // Ctrl-C SIGINT reaches them all — we must not bail before reaping; we reap
240 // in the watch loop and propagate the first exit code (ADR 0096 §Exit).
241 let mut children: Vec<(String, std::process::Child)> = Vec::new();
242 for s in &serving {
243 let Some(mut cmd) = wrangler_command(&probe.provenance, "dev") else {
244 eprintln!("bynk: wrangler not found (run `bynk doctor --only deploy`)");
245 terminate(&mut children);
246 return ExitCode::FAILURE;
247 };
248 cmd.current_dir(workers_dir.join(&s.worker));
249 for arg in serve_args(s) {
250 cmd.arg(arg);
251 }
252 for arg in &opts.wrangler_args {
253 cmd.arg(arg);
254 }
255 match cmd.spawn() {
256 Ok(child) => children.push((s.worker.clone(), child)),
257 Err(e) => {
258 eprintln!("bynk: could not run wrangler for `{}`: {e}", s.worker);
259 terminate(&mut children);
260 return ExitCode::FAILURE;
261 }
262 }
263 }
264 eprint!("{}", serving_report(&serving));
265
266 // 5. Watch — #524: `bynk dev` is the edit loop, so watch the project's
267 // `.bynk` sources (the full `[paths]` layout plus `bynk.toml`) and rebuild
268 // into the same build dir on change. Each `wrangler dev` watches its own
269 // built worker files, so one rebuild hot-reloads every context that changed
270 // without a restart; a failing rebuild renders diagnostics and keeps both
271 // the watch and the last good build serving. std-only mtime polling
272 // (500ms): no native watcher dependency, and an edit-loop latency well
273 // under a keystroke-to-glance.
274 eprintln!("bynk dev: watching for source changes (edit `.bynk` files to rebuild)");
275 let mut fingerprint = watch_fingerprint(project_root);
276 loop {
277 // Any worker exiting ends the session: the survivors' bindings now
278 // point at a context that is gone, so a half-served project would fail
279 // in a way that looks like a code bug. Stop them and propagate the
280 // first exit code.
281 for i in 0..children.len() {
282 let status = match children[i].1.try_wait() {
283 Ok(status) => status,
284 Err(e) => {
285 eprintln!("bynk: could not poll wrangler: {e}");
286 terminate(&mut children);
287 return ExitCode::FAILURE;
288 }
289 };
290 if let Some(status) = status {
291 let (name, _) = children.remove(i);
292 if !children.is_empty() {
293 eprintln!("bynk dev: `{name}` exited — stopping the other contexts.");
294 }
295 terminate(&mut children);
296 return ExitCode::from(exit_status_byte(&status));
297 }
298 }
299 std::thread::sleep(Duration::from_millis(500));
300 let now = watch_fingerprint(project_root);
301 if now != fingerprint {
302 fingerprint = now;
303 eprintln!("bynk dev: change detected — rebuilding…");
304 if compile_once(compiler, project_root, &build_dir, true) {
305 eprintln!("bynk dev: rebuilt");
306 }
307 // On failure the diagnostics are already rendered; keep serving
308 // the last good build and keep watching.
309 }
310 }
311}
312
313/// Stop every remaining `wrangler dev` and reap it, so a session that ends on
314/// one worker's exit does not strand the others — each holds a port and a
315/// `workerd` child, and a stranded one makes the *next* `bynk dev` fail on a
316/// port clash. Signal them all first, then reap, so the shutdowns overlap.
317fn terminate(children: &mut Vec<(String, std::process::Child)>) {
318 for (_, child) in children.iter_mut() {
319 request_stop(child);
320 }
321 for (_, child) in children.iter_mut() {
322 reap(child);
323 }
324 children.clear();
325}
326
327/// Ask one `wrangler dev` to stop **and take its own process tree with it**.
328///
329/// SIGTERM, not [`std::process::Child::kill`]'s SIGKILL: wrangler traps SIGTERM
330/// and tears down the `node` and `workerd` processes it spawned, whereas SIGKILL
331/// is untrappable — verified, a SIGKILLed wrangler strands an orphaned `workerd
332/// serve` still holding the port. std exposes no SIGTERM, so we go through POSIX
333/// `kill(1)`; off unix, SIGKILL is the only thing std offers.
334fn request_stop(child: &mut std::process::Child) {
335 #[cfg(unix)]
336 {
337 let sent = Command::new("kill")
338 .arg("-TERM")
339 .arg(child.id().to_string())
340 .status()
341 .is_ok_and(|s| s.success());
342 if sent {
343 return;
344 }
345 // `kill` missing or the process already gone — fall through.
346 }
347 let _ = child.kill();
348}
349
350/// Reap a signalled child, giving it a moment to run wrangler's own teardown
351/// before escalating to SIGKILL. Without the escalation a wrangler wedged in
352/// shutdown would hang `bynk dev` forever; without the grace period we would be
353/// back to stranding `workerd`.
354fn reap(child: &mut std::process::Child) {
355 const GRACE: Duration = Duration::from_secs(10);
356 const TICK: Duration = Duration::from_millis(50);
357 let mut waited = Duration::ZERO;
358 while waited < GRACE {
359 match child.try_wait() {
360 Ok(Some(_)) => return,
361 Ok(None) => {}
362 Err(_) => break,
363 }
364 std::thread::sleep(TICK);
365 waited += TICK;
366 }
367 let _ = child.kill();
368 let _ = child.wait();
369}
370
371/// #524: a change fingerprint over the project's watched inputs — every
372/// `.bynk` file under the `[paths] include` roots (author `exclude` subtrees
373/// and tool/VCS directories skipped) plus `bynk.toml` itself. Hashes each
374/// file's path, mtime, and length, so an edit, add, delete, or rename all
375/// change the fingerprint. I/O errors skip the entry rather than aborting the
376/// watch.
377fn watch_fingerprint(project_root: &Path) -> u64 {
378 use std::hash::{Hash, Hasher};
379 // The watch loop's own fingerprint has no error channel to surface a
380 // malformed `bynk.toml` through — the CLI's real build/check paths
381 // (`bynk-driver::project_options`) already do that, via
382 // `try_read_project_paths_with`. This falls back to the conventional
383 // layout on any error, same as `read_project_paths`'s deleted total form
384 // (R3.8, #1113) always did for this one call site.
385 let paths = try_read_project_paths(project_root)
386 .unwrap_or_else(|_| ProjectPaths::conventional(project_root));
387 let excludes: Vec<PathBuf> = paths.exclude.iter().map(|e| project_root.join(e)).collect();
388 let mut entries: Vec<(PathBuf, std::time::SystemTime, u64)> = Vec::new();
389 let record = |path: &Path, entries: &mut Vec<(PathBuf, std::time::SystemTime, u64)>| {
390 if let Ok(meta) = std::fs::metadata(path)
391 && let Ok(mtime) = meta.modified()
392 {
393 entries.push((path.to_path_buf(), mtime, meta.len()));
394 }
395 };
396 record(&project_root.join("bynk.toml"), &mut entries);
397 for root in &paths.include {
398 collect_bynk_files(&project_root.join(root), &excludes, &mut |p| {
399 record(p, &mut entries)
400 });
401 }
402 entries.sort();
403 let mut hasher = std::hash::DefaultHasher::new();
404 for (path, mtime, len) in &entries {
405 path.hash(&mut hasher);
406 mtime.hash(&mut hasher);
407 len.hash(&mut hasher);
408 }
409 hasher.finish()
410}
411
412/// Walk `dir` recursively, calling `visit` for each `.bynk` file. Skips the
413/// author `exclude` subtrees and the tool/VCS directories a source walk never
414/// wants (`.bynk` build dir, `.git`, `node_modules`, `target`).
415fn collect_bynk_files(dir: &Path, excludes: &[PathBuf], visit: &mut dyn FnMut(&Path)) {
416 const SKIP_DIRS: [&str; 4] = [".bynk", ".git", "node_modules", "target"];
417 let Ok(read) = std::fs::read_dir(dir) else {
418 return;
419 };
420 for entry in read.flatten() {
421 let path = entry.path();
422 if path.is_dir() {
423 let name = entry.file_name();
424 if SKIP_DIRS.iter().any(|s| name == *s) {
425 continue;
426 }
427 if excludes.iter().any(|e| path.starts_with(e)) {
428 continue;
429 }
430 collect_bynk_files(&path, excludes, visit);
431 } else if path.extension().is_some_and(|e| e == "bynk") {
432 visit(&path);
433 }
434 }
435}
436
437/// The text `bynk dev` prints when the deploy pre-flight fails: a lead line plus
438/// doctor's own human report, so the remedy lines are identical to `bynk
439/// doctor`. Pure (no I/O) so this deterministic surface is pinned by a golden
440/// (§5), unlike the non-deterministic `wrangler dev` stream.
441pub fn preflight_failure_message(report: &Report) -> String {
442 format!(
443 "bynk: environment not ready for `dev` — see below.\n\n{}",
444 report::render(report, Format::Human)
445 )
446}
447
448/// Allocate a port per worker (#552): `wrangler dev` binds one port per process,
449/// so serving N contexts means N distinct ports, assigned `base + i` over the
450/// deterministic worker order.
451///
452/// The one exception preserves the pre-#552 contract: a **lone** worker with no
453/// explicit `--base-port` gets `None` — no injected `--port` at all — so it
454/// lands on wrangler's own default and `-- --port N` still works. Injecting
455/// unconditionally would break that, because a repeated `--port` is a hard
456/// wrangler error, not last-wins.
457pub fn allocate(workers: &[String], base_port: Option<u16>, opts: &DevOptions) -> Vec<Serving> {
458 let lone = workers.len() == 1 && base_port.is_none();
459 let base = base_port.unwrap_or(DEFAULT_BASE_PORT);
460 workers
461 .iter()
462 .enumerate()
463 .map(|(i, worker)| Serving {
464 worker: worker.clone(),
465 port: (!lone).then(|| base.saturating_add(i as u16)),
466 inspector_port: opts
467 .inspect
468 .then(|| opts.inspect_port.saturating_add(i as u16)),
469 })
470 .collect()
471}
472
473/// Whether the `--` passthrough carries `flag`, which the driver also injects.
474/// Wrangler rejects a repeated `--port`/`--inspector-port` outright ("expects a
475/// single value, but received multiple"), so we catch the clash ourselves and
476/// say which driver flag owns it instead of letting wrangler's usage dump land.
477fn passthrough_has(args: &[String], flag: &str) -> bool {
478 args.iter()
479 .any(|a| a == flag || a.starts_with(&format!("{flag}=")))
480}
481
482/// The `wrangler dev` flags the driver injects for one worker: the ports it
483/// allocated (#552) — `--port` when serving several contexts, `--inspector-port`
484/// under `--inspect` (slice 3, ADR 0104), so a JavaScript debugger can attach and
485/// `.bynk` breakpoints resolve through the emitted source maps.
486///
487/// Empty for a lone worker without `--base-port` or `--inspect` — byte-for-byte
488/// the pre-#552 invocation, so that path keeps its `-- --port N` passthrough.
489fn serve_args(s: &Serving) -> Vec<String> {
490 let mut args = Vec::new();
491 if let Some(port) = s.port {
492 args.push("--port".to_string());
493 args.push(port.to_string());
494 }
495 if let Some(port) = s.inspector_port {
496 args.push("--inspector-port".to_string());
497 args.push(port.to_string());
498 }
499 args
500}
501
502/// The start-up report: which context answers on which URL, plus the inspector
503/// notice under `--inspect`. Pure and deterministic, so it is golden-pinned in
504/// the style of ADR 0096 §Exit — unlike the `wrangler dev` streams it precedes.
505///
506/// A lone worker on wrangler's own default port prints no table: there is no
507/// allocation to disclose and wrangler announces its own `Ready on` line, so
508/// that session reads exactly as it did before #552.
509pub fn serving_report(serving: &[Serving]) -> String {
510 let mut out = String::new();
511 if serving.iter().any(|s| s.port.is_some()) {
512 // Only claim the wiring when there is something to wire: a subset of
513 // one has no sibling to bind to, and saying otherwise would explain a
514 // cross-context call's failure as a bug rather than as the missing
515 // context it is.
516 out.push_str(&match serving.len() {
517 1 => "bynk dev: serving 1 context.\n".to_string(),
518 n => format!(
519 "bynk dev: serving {n} contexts — service bindings between them are wired.\n"
520 ),
521 });
522 let width = serving.iter().map(|s| s.worker.len()).max().unwrap_or(0);
523 for s in serving {
524 let Some(port) = s.port else { continue };
525 out.push_str(&format!(
526 " {:width$} http://localhost:{port}\n",
527 s.worker,
528 width = width
529 ));
530 }
531 }
532 let inspected = serving
533 .iter()
534 .filter(|s| s.inspector_port.is_some())
535 .count();
536 if inspected > 0 {
537 out.push_str(&match inspected {
538 1 => "bynk dev --inspect: the worker runs with the V8 inspector enabled.\n".to_string(),
539 _ => "bynk dev --inspect: each worker runs with the V8 inspector enabled, on its own port.\n"
540 .to_string(),
541 });
542 for s in serving {
543 let Some(port) = s.inspector_port else {
544 continue;
545 };
546 out.push_str(&format!(
547 " {} — inspector on port {port} (CDP discovery: http://127.0.0.1:{port}/json)\n",
548 s.worker
549 ));
550 }
551 out.push_str(
552 " Breakpoints set in `.bynk` sources resolve through the emitted source maps.\n\
553 \x20 A hand-rolled CDP client must send an `Origin` header — VS Code's\n\
554 \x20 JavaScript debugger does this for you.\n",
555 );
556 }
557 out
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 fn names(v: &[&str]) -> Vec<String> {
565 v.iter().map(|s| s.to_string()).collect()
566 }
567
568 #[test]
569 fn a_sole_context_is_selected_without_a_flag() {
570 assert_eq!(
571 select_contexts(&names(&["links"]), &[]),
572 Ok(names(&["links"]))
573 );
574 }
575
576 #[test]
577 fn no_workers_is_its_own_error() {
578 assert_eq!(select_contexts(&[], &[]), Err(SelectError::NoneBuilt));
579 }
580
581 #[test]
582 fn exit_status_byte_maps_codes_and_signals() {
583 #[cfg(unix)]
584 {
585 use std::os::unix::process::ExitStatusExt;
586 use std::process::ExitStatus;
587 // Wait statuses: exit codes sit in the high byte; the low byte is
588 // the terminating signal.
589 assert_eq!(exit_status_byte(&ExitStatus::from_raw(0)), 0);
590 assert_eq!(exit_status_byte(&ExitStatus::from_raw(1 << 8)), 1);
591 // A shared Ctrl-C (SIGINT = 2) is a clean stop…
592 assert_eq!(exit_status_byte(&ExitStatus::from_raw(2)), 0);
593 // …but a SIGSEGV (11) or SIGKILL (9, the OOM killer) is a real
594 // failure — previously these read as passing in CI.
595 assert_eq!(exit_status_byte(&ExitStatus::from_raw(11)), 128 + 11);
596 assert_eq!(exit_status_byte(&ExitStatus::from_raw(9)), 128 + 9);
597 }
598 }
599
600 #[test]
601 fn inspect_injects_the_inspector_port() {
602 let off = DevOptions::default();
603 let lone = allocate(&names(&["links"]), None, &off);
604 assert!(
605 serve_args(&lone[0]).is_empty(),
606 "a lone worker without --inspect keeps the pre-#552 invocation"
607 );
608
609 let on = DevOptions {
610 inspect: true,
611 inspect_port: 9229,
612 ..Default::default()
613 };
614 let lone = allocate(&names(&["links"]), None, &on);
615 assert_eq!(
616 serve_args(&lone[0]),
617 vec!["--inspector-port".to_string(), "9229".to_string()]
618 );
619 }
620
621 // ---- #552: multi-context selection ------------------------------------
622
623 #[test]
624 fn no_context_flag_serves_every_context() {
625 // The defining change: several contexts is the expected shape, so the
626 // whole project is served rather than refused as ambiguous.
627 assert_eq!(
628 select_contexts(&names(&["api", "worker"]), &[]),
629 Ok(names(&["api", "worker"]))
630 );
631 }
632
633 #[test]
634 fn context_flags_narrow_to_a_subset() {
635 let avail = names(&["api", "commerce-payment", "worker"]);
636 assert_eq!(
637 select_contexts(&avail, &names(&["worker", "api"])),
638 Ok(names(&["api", "worker"])),
639 "the subset is served in the deterministic order, not the typed one"
640 );
641 // Dotted and dasherised forms both resolve, and repeats collapse.
642 assert_eq!(
643 select_contexts(&avail, &names(&["commerce.payment", "commerce-payment"])),
644 Ok(names(&["commerce-payment"]))
645 );
646 }
647
648 #[test]
649 fn selecting_many_reports_an_unknown_context() {
650 assert_eq!(
651 select_contexts(&names(&["api"]), &names(&["api", "nope"])),
652 Err(SelectError::NotFound {
653 requested: "nope".to_string(),
654 available: names(&["api"]),
655 })
656 );
657 }
658
659 #[test]
660 fn selecting_many_from_an_empty_build_is_still_none_built() {
661 assert_eq!(select_contexts(&[], &[]), Err(SelectError::NoneBuilt));
662 }
663
664 // ---- #552: port allocation --------------------------------------------
665
666 #[test]
667 fn ports_are_allocated_per_context_from_the_base() {
668 let opts = DevOptions {
669 inspect: true,
670 inspect_port: 9229,
671 ..Default::default()
672 };
673 let serving = allocate(&names(&["api", "worker"]), None, &opts);
674 assert_eq!(
675 serving.iter().map(|s| s.port).collect::<Vec<_>>(),
676 vec![Some(8787), Some(8788)],
677 "each wrangler dev binds its own port"
678 );
679 assert_eq!(
680 serving.iter().map(|s| s.inspector_port).collect::<Vec<_>>(),
681 vec![Some(9229), Some(9230)],
682 "inspector ports must not collide either"
683 );
684 assert_eq!(
685 serve_args(&serving[1]),
686 names(&["--port", "8788", "--inspector-port", "9230"])
687 );
688 }
689
690 #[test]
691 fn base_port_moves_the_whole_allocation() {
692 let serving = allocate(&names(&["a", "b"]), Some(9000), &DevOptions::default());
693 assert_eq!(
694 serving.iter().map(|s| s.port).collect::<Vec<_>>(),
695 vec![Some(9000), Some(9001)]
696 );
697 }
698
699 #[test]
700 fn a_lone_worker_keeps_wranglers_own_port() {
701 // The back-compat contract: no injected --port, so `-- --port N` still
702 // reaches wrangler (a repeated --port is a hard error, not last-wins).
703 let serving = allocate(&names(&["links"]), None, &DevOptions::default());
704 assert_eq!(serving[0].port, None);
705 assert!(serve_args(&serving[0]).is_empty());
706 // …but an explicit --base-port is honoured even for one context.
707 let pinned = allocate(&names(&["links"]), Some(8900), &DevOptions::default());
708 assert_eq!(pinned[0].port, Some(8900));
709 }
710
711 #[test]
712 fn passthrough_port_is_detected_in_both_spellings() {
713 assert!(passthrough_has(&names(&["--port", "8788"]), "--port"));
714 assert!(passthrough_has(&names(&["--port=8788"]), "--port"));
715 assert!(!passthrough_has(&names(&["--remote"]), "--port"));
716 // `--inspector-port` must not be mistaken for `--port`.
717 assert!(!passthrough_has(
718 &names(&["--inspector-port", "9229"]),
719 "--port"
720 ));
721 }
722
723 #[test]
724 fn the_serving_report_lists_context_urls() {
725 let serving = allocate(
726 &names(&["commerce-orders", "commerce-payment"]),
727 None,
728 &DevOptions::default(),
729 );
730 let report = serving_report(&serving);
731 assert!(report.contains("serving 2 contexts"), "{report}");
732 // Names are padded to the widest, so the URLs line up in a column.
733 assert!(
734 report.contains("commerce-orders http://localhost:8787"),
735 "{report}"
736 );
737 assert!(
738 report.contains("commerce-payment http://localhost:8788"),
739 "{report}"
740 );
741 // A lone default-port worker discloses no allocation — wrangler's own
742 // `Ready on` line is the announcement, exactly as before #552.
743 assert_eq!(
744 serving_report(&allocate(&names(&["links"]), None, &DevOptions::default())),
745 ""
746 );
747 }
748
749 #[test]
750 fn a_subset_of_one_claims_no_wiring() {
751 // "bindings between them are wired" would be a lie for a single
752 // context, and a misleading one: it invites reading a cross-context
753 // call's failure as a bug rather than as the context left unserved.
754 let report = serving_report(&allocate(
755 &names(&["commerce-payment"]),
756 Some(8890),
757 &DevOptions::default(),
758 ));
759 assert!(report.contains("serving 1 context."), "{report}");
760 assert!(!report.contains("bindings"), "{report}");
761 }
762}