oj_server 0.2.45

Dev server: on-demand compile over HTTP, WebSocket HMR channel, file watcher
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Raphael Amorim

//! The optimizer's lifecycle as the dev server drives it: the initial run
//! (plan, then commit), the warm manifest, serve-time discovery and its
//! reload-or-not commits, and the dead state a failed run leaves. The app
//! installs no Vite and no bundler: oj pre-bundles with its own rolldown.

use oj_server::optimize::{OptimizeInput, OptimizedDeps};

#[tokio::test(flavor = "multi_thread")]
async fn the_optimizer_plans_commits_and_reruns() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    std::fs::create_dir_all(root.join("node_modules")).unwrap();
    std::fs::write(root.join("package.json"), r#"{"name":"fx"}"#).unwrap();
    let dep = root.join("node_modules/plaincjs");
    std::fs::create_dir_all(&dep).unwrap();
    std::fs::write(
        dep.join("package.json"),
        r#"{"name":"plaincjs","version":"1.0.0","main":"index.js"}"#,
    )
    .unwrap();
    std::fs::write(dep.join("index.js"), "exports.a = 1;\nexports.b = 2;\n").unwrap();

    let input = || OptimizeInput {
        include: vec!["plaincjs".into()],
        // As the real callers fill them (empty lists would strip the
        // resolver's mainFields entirely).
        conditions: vec!["browser".into(), "module".into(), "development".into()],
        main_fields: vec!["browser".into(), "module".into(), "main".into()],
        extensions: vec![".mjs".into(), ".js".into(), ".ts".into(), ".json".into()],
        ..Default::default()
    };
    let deps = OptimizedDeps::prepare(root, "0.0.1", input(), None);
    // The scan's plan releases compiles first; the dep's own request waits
    // for the bundle's commit.
    let map = deps.ready().await;
    let meta = map.get("plaincjs").expect("the included dep is planned");
    assert!(meta.needs_interop, "a plain-CJS bundle needs interop");
    assert!(matches!(
        deps.await_dep(&meta.file, None).await,
        oj_server::optimize::DepServe::Ready
    ));
    let map = deps.ready().await;
    let meta = &map["plaincjs"];
    assert!(
        !meta.file_hash.is_empty(),
        "the commit carries the bundle's hash"
    );
    assert!(
        meta.url.starts_with("/@oj-deps/") && meta.url.contains("?v="),
        "versioned optimized-dep URL, got {}",
        meta.url
    );
    let bundle = std::fs::read_to_string(deps.dir().join(&meta.file)).unwrap();
    assert!(bundle.contains("export"), "an ESM pre-bundle was written");

    // The manifest makes the next boot a warm cache (no second optimizer run
    // is observable here, but the warm load must agree with the first).
    let warm = OptimizedDeps::prepare(root, "0.0.1", input(), None)
        .ready()
        .await;
    assert_eq!(
        warm.get("plaincjs").map(|m| &m.file),
        Some(&meta.file),
        "warm manifest load returns the same bundle"
    );

    // Vite's registerMissingImport: a dep the scan never saw registers and is
    // rewritten to its FUTURE optimized URL at once; the debounced rerun
    // bundles it. latedep shares nothing with plaincjs, so the committed
    // plaincjs entry is byte-identical and the commit must NOT reload: the
    // browser version stays, every served URL stays valid (Vite's needsReload
    // split).
    let write_dep = |name: &str, code: &str, esm: bool| {
        let dir = root.join("node_modules").join(name);
        std::fs::create_dir_all(&dir).unwrap();
        let module_type = if esm { r#""type":"module","# } else { "" };
        std::fs::write(
            dir.join("package.json"),
            format!(r#"{{"name":"{name}","version":"1.0.0",{module_type}"main":"index.js"}}"#),
        )
        .unwrap();
        std::fs::write(dir.join("index.js"), code).unwrap();
    };
    // An ESM dep bundles standalone: the committed plaincjs entry stays
    // byte-identical, so this commit must take the no-reload path.
    write_dep("latedep", "export const l = 1;\n", true);
    let deps = OptimizedDeps::prepare(root, "0.0.1", input(), None);
    let before = deps.ready().await;
    assert!(!before.contains_key("latedep"), "not scanned, not bundled");
    let v0 = deps.browser_version();
    let plaincjs_v0 = before["plaincjs"].url.clone();
    let provisional = deps
        .register_missing("latedep", false)
        .expect("registration returns the future meta");
    assert!(
        provisional.url.starts_with("/@oj-deps/latedep.mjs?v="),
        "the optimized URL is known before bundling: {}",
        provisional.url
    );
    let wait_for = |pred: Box<dyn Fn(&oj_server::optimize::DepMap) -> bool>| {
        let deps = &deps;
        async move {
            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
            loop {
                let m = deps.ready().await;
                if pred(&m) {
                    break m;
                }
                assert!(
                    std::time::Instant::now() < deadline,
                    "the rerun never committed"
                );
                tokio::time::sleep(std::time::Duration::from_millis(100)).await;
            }
        }
    };
    let map = wait_for(Box::new(|m| {
        m.get("latedep").is_some_and(|l| !l.file_hash.is_empty())
    }))
    .await;
    assert_eq!(
        deps.browser_version(),
        v0,
        "unchanged served entries: no version bump, no reload"
    );
    assert_eq!(
        map["latedep"].url, provisional.url,
        "the committed URL is the one importers already carry"
    );
    assert_eq!(map["plaincjs"].url, plaincjs_v0, "known URLs stay valid");
    assert!(
        !map["latedep"].needs_interop,
        "bundle agrees with the guess"
    );

    // A discovered dep that drags plaincjs into a shared chunk rewrites the
    // served plaincjs entry: that commit must take the reload path and move
    // every URL to the batch version.
    write_dep(
        "latedep2",
        "const p = require(\"plaincjs\");\nexports.both = p.a;\n",
        false,
    );
    deps.register_missing("latedep2", true)
        .expect("second registration");
    let map = wait_for(Box::new(|m| {
        m.get("latedep2").is_some_and(|l| !l.file_hash.is_empty())
    }))
    .await;
    let v2 = deps.browser_version();
    assert_ne!(v2, v0, "a changed served entry bumps the version");
    assert!(
        map["plaincjs"].url.ends_with(&format!("?v={v2}"))
            && map["latedep"].url.ends_with(&format!("?v={v2}"))
            && map["latedep2"].url.ends_with(&format!("?v={v2}")),
        "every URL moves to the batch version on reload commits"
    );

    // The next boot keeps the discovered deps and versions, no re-register.
    let after = OptimizedDeps::prepare(root, "0.0.1", input(), None);
    let warm2 = after.ready().await;
    assert!(
        warm2.contains_key("latedep") && warm2.contains_key("latedep2"),
        "discovered deps persist in the manifest"
    );
    assert_eq!(after.browser_version(), v2, "persisted browser version");

    // A reload commit moves this batch's own URLs to the batch version too,
    // exactly Vite's commitProcessing on needsReload: a request still holding
    // the provisional version is an outdated 504, and the full reload is what
    // retires it. latedep4's wrong interop guess (true for a pure-ESM dep) is
    // Vite's needsInteropMismatch, forcing the reload path; latedep3
    // registered first, so its provisional version differs from the batch's.
    let reloaded = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
    {
        let reloaded = std::sync::Arc::clone(&reloaded);
        deps.set_on_commit(Box::new(move |_| {
            reloaded.store(true, std::sync::atomic::Ordering::Release);
        }));
    }
    write_dep("latedep3", "export const t = 3;\n", true);
    write_dep("latedep4", "export const f = 4;\n", true);
    let p3 = deps
        .register_missing("latedep3", false)
        .expect("third registration");
    deps.register_missing("latedep4", true)
        .expect("fourth registration, interop guess wrong on purpose");
    let p3_version = p3
        .url
        .split("?v=")
        .nth(1)
        .expect("versioned URL")
        .to_string();
    let map = wait_for(Box::new(|m| {
        m.get("latedep4").is_some_and(|l| !l.file_hash.is_empty())
    }))
    .await;
    let batch = deps.browser_version();
    assert_ne!(
        batch, p3_version,
        "the batch version is not the provisional"
    );
    assert_eq!(
        map["latedep3"].url,
        format!("/@oj-deps/latedep3.mjs?v={batch}"),
        "a reload commit moves the discovered dep's URL to the batch version"
    );
    assert!(
        reloaded.load(std::sync::atomic::Ordering::Acquire),
        "the interop mismatch took the reload path"
    );
    assert!(
        matches!(
            deps.await_dep("latedep3.mjs", Some(&p3_version)).await,
            oj_server::optimize::DepServe::Outdated
        ),
        "the in-flight provisional request is Vite's outdated 504"
    );
    assert!(
        matches!(
            deps.await_dep("latedep3.mjs", Some(&batch)).await,
            oj_server::optimize::DepServe::Ready
        ),
        "the reloaded page's batch-version request serves"
    );
}

// A pre-bundle that cannot be built (a dep that does not parse): the initial
// run fails, so discovery must turn itself off. Registration declining is what
// keeps every bare import on the per-file path instead of a phantom /@oj-deps
// URL that 404s, and no manifest may be written, or the next boot would
// warm-load an empty map with discovery back on against the same failure.
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_initial_run_turns_discovery_off() {
    let dir = tempfile::tempdir().unwrap();
    let root = dir.path();
    let broken = root.join("node_modules/broken");
    std::fs::create_dir_all(&broken).unwrap();
    std::fs::write(root.join("package.json"), r#"{"name":"fx"}"#).unwrap();
    std::fs::write(
        broken.join("package.json"),
        r#"{"name":"broken","version":"1.0.0","main":"index.js"}"#,
    )
    .unwrap();
    std::fs::write(broken.join("index.js"), "export const = ;\n").unwrap();
    let input = OptimizeInput {
        include: vec!["broken".into()],
        ..Default::default()
    };
    let deps = OptimizedDeps::prepare(root, "0.0.1", input, None);
    // The plan names the dep first; the failed commit replaces it.
    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
    while deps.may_register("lodash") || deps.ready().await.contains_key("broken") {
        assert!(std::time::Instant::now() < deadline, "the run never failed");
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    let map = deps.ready().await;
    assert!(map.is_empty(), "nothing bundled: an empty map");
    assert!(
        deps.register_missing("lodash", false).is_none(),
        "no phantom optimized URL"
    );
    assert!(
        deps.meta_now("lodash").is_none(),
        "the live map stays untouched"
    );
    assert!(
        !deps.dir().join("manifest.json").exists(),
        "a failed run persists nothing"
    );
}