Skip to main content

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}