use std::time::Duration;
use lanekeep_core::{AnalysisBudget, FilePath, TypesConfig, TypesProvider};
use super::*;
use crate::provider::{BeginRunError, TypeProvider};
fn tsc_available() -> bool {
typescript_package().join("package.json").is_file()
}
fn typescript_package() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../packages/lanekeep/node_modules/typescript")
}
fn installed_typescript_version() -> String {
let manifest = std::fs::read_to_string(typescript_package().join("package.json"))
.expect("the typescript package has a package.json");
let parsed: serde_json::Value = serde_json::from_str(&manifest).expect("parses");
parsed["version"]
.as_str()
.expect("declares a version")
.to_owned()
}
const STRICT: &str = "{\"compilerOptions\":{\"strict\":true,\"target\":\"ES2022\",\
\"module\":\"ESNext\",\"moduleResolution\":\"bundler\"},\
\"include\":[\"src\"]}\n";
fn fixture(name: &str) -> PathBuf {
fixture_with(name, STRICT, &[])
}
fn fixture_with(name: &str, tsconfig: &str, extra: &[(&str, &str)]) -> PathBuf {
let dir = std::env::temp_dir().join(format!("lanekeep-tsc-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).expect("creates the fixture");
std::fs::create_dir_all(dir.join("node_modules/dep")).expect("creates the dependency");
std::fs::write(
dir.join("package.json"),
"{\"name\":\"f\",\"private\":true}\n",
)
.expect("writes package.json");
std::fs::write(
dir.join("node_modules/dep/package.json"),
"{\"name\":\"dep\",\"version\":\"1.0.0\",\"types\":\"index.d.ts\"}\n",
)
.expect("writes the dependency manifest");
std::fs::write(
dir.join("node_modules/dep/index.d.ts"),
"export declare const d: number\n",
)
.expect("writes the dependency types");
std::fs::write(dir.join("tsconfig.json"), tsconfig).expect("writes tsconfig.json");
std::fs::write(
dir.join("src/a.ts"),
"import { d } from \"dep\"\nexport const n: number = d\n",
)
.expect("writes a source file");
for (name, contents) in extra {
std::fs::write(dir.join(name), contents).expect("writes an extra fixture file");
}
dir
}
struct RelativeFixture {
absolute: PathBuf,
top: PathBuf,
}
impl std::ops::Deref for RelativeFixture {
type Target = Path;
fn deref(&self) -> &Path {
&self.absolute
}
}
impl Drop for RelativeFixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.top);
}
}
fn relative_fixture(name: &str) -> (RelativeFixture, PathBuf) {
let relative = PathBuf::from(format!(
"../../target/lanekeep-tsc-{name}-{}/root",
std::process::id()
));
assert_eq!(
std::env::current_dir().expect("a working directory"),
Path::new(env!("CARGO_MANIFEST_DIR")),
"this test names its fixture relative to the package root"
);
let absolute = Path::new(env!("CARGO_MANIFEST_DIR")).join(&relative);
let top = absolute
.parent()
.expect("the root has a parent — it is one segment short of `absolute`")
.to_path_buf();
let _ = std::fs::remove_dir_all(&absolute);
std::fs::create_dir_all(absolute.join("src")).expect("creates the fixture");
std::fs::write(
absolute.join("package.json"),
"{\"name\":\"relative\",\"private\":true}\n",
)
.expect("writes package.json");
std::fs::write(absolute.join("tsconfig.json"), STRICT).expect("writes tsconfig.json");
std::fs::write(absolute.join("src/a.ts"), "export const n: number = 1\n")
.expect("writes a source file");
(RelativeFixture { absolute, top }, relative)
}
#[test]
fn a_relative_project_root_is_not_applied_twice() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let (_root, relative) = relative_fixture("relative-root");
let provider = TscProvider::spawn(
&relative,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar starts under a relative root");
provider
.programs(&[FilePath::new("src/a.ts")])
.expect("the programs build");
assert_ne!(provider.programs_hash(), [0; 32]);
drop(provider);
}
#[test]
#[cfg(unix)]
fn the_default_relative_typescript_resolves_against_the_project_root() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let (root, relative) = relative_fixture("default-typescript");
std::fs::create_dir_all(root.join("node_modules")).expect("creates node_modules");
std::os::unix::fs::symlink(
typescript_package()
.canonicalize()
.expect("the package is there"),
root.join("node_modules/typescript"),
)
.expect("links the typescript package into the fixture");
let provider = TscProvider::spawn(
&relative,
&TypesConfig {
provider: TypesProvider::Tsc,
command: vec!["node".to_owned()],
..TypesConfig::default()
},
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the default `./node_modules/typescript` loads");
assert_eq!(
provider.typescript_version(),
installed_typescript_version()
);
drop(provider);
}
#[test]
fn dependency_paths_answers_the_last_listings_paths() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture("dependency-paths");
let provider = TscProvider::spawn(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar starts");
assert!(
provider.dependency_paths().is_empty(),
"a fresh provider has run no `programs` yet, so it has nothing to answer"
);
provider
.begin_run(
&|| vec![FilePath::new("src/a.ts")],
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the programs build");
let paths = provider.dependency_paths();
assert!(paths.contains(&FilePath::new("src/a.ts")), "got: {paths:?}");
assert!(
paths.contains(&FilePath::new("node_modules/dep/index.d.ts")),
"an edit to the resolved declaration must wake `--watch`, or a stale answer would \
look like a rule that stopped working: got {paths:?}"
);
drop(provider);
let _ = std::fs::remove_dir_all(&root);
}
fn run_key(name: &str, tsconfig: &str, extra: &[(&str, &str)]) -> Vec<u8> {
let root = fixture_with(name, tsconfig, extra);
let provider = TscProvider::spawn(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar starts");
provider
.begin_run(
&|| vec![FilePath::new("src/a.ts")],
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the programs build")
}
#[test]
fn a_file_no_tsconfig_claims_is_reported_and_an_ordinary_project_is_not() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let claimed = fixture("notice-claimed");
let provider = TscProvider::spawn(
&claimed,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar starts");
provider
.programs(&[FilePath::new("src/a.ts")])
.expect("the programs build");
assert_eq!(
provider.notices(),
Vec::<String>::new(),
"a project whose own `tsconfig.json` claims the file has nothing to report"
);
let adhoc = fixture("notice-adhoc");
std::fs::remove_file(adhoc.join("tsconfig.json")).expect("removes the config");
let provider = TscProvider::spawn(
&adhoc,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar starts");
provider
.programs(&[FilePath::new("src/a.ts")])
.expect("the programs build");
let notices = provider.notices();
assert_eq!(notices.len(), 1, "{notices:?}");
assert!(notices[0].contains("1 file(s)"), "{notices:?}");
assert!(notices[0].contains("tsconfig.json"), "{notices:?}");
}
#[test]
fn the_adhoc_list_is_folded_into_the_key() {
let listing = serde_json::json!([["src/a.ts", "abc"], ["tsconfig.json", "def"]]);
let folded = |adhoc: serde_json::Value| {
fold_programs(&serde_json::json!({ "listing": listing, "adhoc": adhoc }))
.expect("a well-shaped answer")
};
assert_ne!(
folded(serde_json::json!([])),
folded(serde_json::json!(["src/a.ts"])),
"one listing, two configurations: without the `adhoc` fold these are one key"
);
assert_eq!(
folded(serde_json::json!(["src/a.ts"])),
folded(serde_json::json!(["src/a.ts"])),
"and the same answer twice is the same key"
);
}
#[test]
fn an_adhoc_entry_that_is_not_a_path_is_refused() {
let listing = serde_json::json!([["src/a.ts", "abc"]]);
for bad in [
serde_json::json!({ "src/a.ts": true }),
serde_json::json!("src/a.ts"),
serde_json::json!([7]),
serde_json::json!(["src/a.ts", null]),
] {
let error = fold_programs(&serde_json::json!({ "listing": listing, "adhoc": bad }))
.expect_err("unreadable");
assert!(matches!(error, ProviderError::Refused(_)), "got: {error:?}");
}
}
#[test]
fn typed_extension_admits_what_the_driver_can_parse_and_nothing_else() {
for path in [
"src/a.ts",
"src/a.tsx",
"src/a.mts",
"src/a.cts",
"src/a.js",
"src/a.jsx",
"src/a.mjs",
"src/a.cjs",
"src/a.d.ts",
"src/A.TS",
] {
assert!(typed_extension(path), "{path} is one the driver parses");
}
for path in [
"README.md",
"package.json",
"src/a.css",
"scripts/a.py",
"src/ts",
"src/a.typescript",
] {
assert!(
!typed_extension(path),
"{path} reaches no program, so asking about it is work with no answer"
);
}
}
fn tsc_config() -> TypesConfig {
TypesConfig {
provider: TypesProvider::Tsc,
command: vec!["node".to_owned()],
typescript: typescript_package()
.canonicalize()
.expect("the package is there")
.to_string_lossy()
.replace('\\', "/"),
}
}
#[test]
fn the_handshake_reports_the_projects_typescript_version() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture("hello");
let provider = TscProvider::spawn(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar starts");
assert_eq!(
provider.typescript_version(),
installed_typescript_version()
);
}
#[test]
fn a_command_that_is_not_there_is_unavailable_rather_than_a_timeout() {
let root = fixture("unavailable");
let error = TscProvider::spawn(
&root,
&TypesConfig {
provider: TypesProvider::Tsc,
command: vec!["definitely-not-node".to_owned()],
..TypesConfig::default()
},
AnalysisBudget::start(Duration::from_secs(5)),
)
.expect_err("nothing to spawn");
assert!(
matches!(error, ProviderError::Unavailable(_)),
"got: {error:?}"
);
let rendered = error.to_string();
assert!(
rendered.contains("`types.command` on PATH"),
"a command that is not there is a PATH problem: {rendered}"
);
assert!(
!rendered.contains("point `types.typescript`"),
"nothing here says anything about the package: {rendered}"
);
}
#[test]
fn the_identity_moves_with_the_typescript_version() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture("identity");
let real = TscProvider::spawn(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the real package");
let stub = root.join("stub-typescript");
std::fs::create_dir_all(stub.join("lib")).expect("creates the stub");
std::fs::write(
stub.join("package.json"),
"{\"name\":\"typescript\",\"version\":\"0.0.0-fixture\",\"main\":\"lib/typescript.js\"}\n",
)
.expect("writes the stub manifest");
std::fs::write(
stub.join("lib/typescript.js"),
"const noop = () => {};\nmodule.exports = { version: '0.0.0-fixture', \
createProgram: noop, findConfigFile: noop, readConfigFile: noop, \
parseJsonConfigFileContent: noop, resolveModuleName: noop }\n",
)
.expect("writes the stub module");
let stubbed = TscProvider::spawn(
&root,
&TypesConfig {
typescript: stub.to_string_lossy().replace('\\', "/"),
..tsc_config()
},
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the stub answers hello");
assert_eq!(stubbed.typescript_version(), "0.0.0-fixture");
assert_ne!(real.identity(), stubbed.identity());
}
#[test]
fn a_typescript_without_the_compiler_api_is_unloadable_naming_what_is_missing() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture("unsupported-api");
let stub = root.join("stub-typescript");
std::fs::create_dir_all(stub.join("lib")).expect("creates the stub");
std::fs::write(
stub.join("package.json"),
"{\"name\":\"typescript\",\"version\":\"7.0.2\",\"main\":\"lib/typescript.js\"}\n",
)
.expect("writes the stub manifest");
std::fs::write(
stub.join("lib/typescript.js"),
"module.exports = { version: '7.0.2' }\n",
)
.expect("writes the stub module");
let error = TscProvider::spawn(
&root,
&TypesConfig {
typescript: stub.to_string_lossy().replace('\\', "/"),
..tsc_config()
},
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect_err("no compiler API behind it");
let text = error.to_string();
assert!(
matches!(error, ProviderError::Unloadable(_)),
"got: {error:?}"
);
assert!(text.contains("7.0.2"), "{text}");
assert!(text.contains("createProgram"), "{text}");
assert!(text.contains("point `types.typescript`"), "{text}");
assert!(!text.contains("`types.command` on PATH"), "{text}");
}
#[test]
fn a_typescript_that_cannot_be_loaded_is_unloadable_naming_the_path() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture("unloadable");
let error = TscProvider::spawn(
&root,
&TypesConfig {
typescript: "./node_modules/typescript".to_owned(),
..tsc_config()
},
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect_err("nothing to load: the fixture has no node_modules");
let text = error.to_string();
assert!(
matches!(error, ProviderError::Unloadable(_)),
"got: {error:?}"
);
assert!(text.contains("./node_modules/typescript"), "{text}");
assert!(text.contains("pnpm"), "{text}");
assert!(text.contains("point `types.typescript`"), "{text}");
assert!(!text.contains("`types.command` on PATH"), "{text}");
}
#[test]
fn the_run_key_moves_when_only_the_tsconfig_moves() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let loose = "{\"compilerOptions\":{\"strict\":false,\"target\":\"ES2022\",\
\"module\":\"ESNext\",\"moduleResolution\":\"bundler\"},\
\"include\":[\"src\"]}\n";
let strict = run_key("strict-on", STRICT, &[]);
let again = run_key("strict-on-again", STRICT, &[]);
let relaxed = run_key("strict-off", loose, &[]);
assert_eq!(
strict, again,
"two projects with identical bytes must key identically"
);
assert_ne!(
strict, relaxed,
"`strict` decides what the compiler answers and must decide the key"
);
}
#[test]
fn the_run_key_moves_when_only_an_extended_config_moves() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let extending = "{\"extends\":\"./base.json\",\"include\":[\"src\"]}\n";
let on = "{\"compilerOptions\":{\"strict\":true,\"target\":\"ES2022\",\
\"module\":\"ESNext\",\"moduleResolution\":\"bundler\"}}\n";
let off = "{\"compilerOptions\":{\"strict\":false,\"target\":\"ES2022\",\
\"module\":\"ESNext\",\"moduleResolution\":\"bundler\"}}\n";
let strict = run_key("extends-on", extending, &[("base.json", on)]);
let relaxed = run_key("extends-off", extending, &[("base.json", off)]);
assert_ne!(
strict, relaxed,
"an `extends`ed config decides what the compiler answers and must decide the key"
);
}
#[test]
#[expect(
unsafe_code,
reason = "the only way to put a variable in the *parent's* environment, which is what \
`env_remove` has to be tested against. Sound **only** under nextest, which \
gives each test its own process: this test is the only lanekeep code in that process, \
and the one other thread — libtest's, which spawned this one and is blocked \
on the result channel — reads no environment variable while the test runs, \
so nothing in the process can observe the change. Under a plain `cargo test`, where the \
whole suite shares one process, it is a data race against any concurrent \
`getenv` — the variable is removed a few hundred milliseconds later, before \
any assertion, which narrows the window and does not close it. `just test` \
runs nextest; run this test no other way"
)]
fn the_drivers_test_only_delay_is_not_inherited_from_the_parent() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
unsafe { std::env::set_var("LANEKEEP_TSC_DRIVER_DELAY_MS", "30000") };
let root = fixture("inherited-delay");
let started = std::time::Instant::now();
let provider = TscProvider::spawn(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
);
unsafe { std::env::remove_var("LANEKEEP_TSC_DRIVER_DELAY_MS") };
let elapsed = started.elapsed();
provider.expect("the sidecar starts");
assert!(
elapsed < Duration::from_secs(10),
"`hello` took {elapsed:?}, so the parent's delay reached the child"
);
}
#[test]
fn an_answer_carrying_another_id_is_refused_rather_than_misattributed() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture("desync");
let provider = TscProvider::spawn(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar starts");
provider.write_raw("{ not json\n");
let error = provider
.programs(&[FilePath::new("src/a.ts")])
.expect_err("the stale line is not this request's answer");
let text = error.to_string();
assert!(matches!(error, ProviderError::Refused(_)), "got: {error:?}");
assert!(text.contains("answered id 0"), "{text}");
assert!(text.contains("request id 2"), "{text}");
}
#[test]
fn the_first_failure_is_kept_and_a_later_one_does_not_replace_it() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture("sticky");
let provider = TscProvider::spawn(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar starts");
assert_eq!(provider.failure(), None, "nothing has failed yet");
provider.kill_sidecar();
let first = provider
.programs(&[FilePath::new("src/a.ts")])
.expect_err("the sidecar is gone");
assert!(matches!(first, ProviderError::Refused(_)), "got: {first:?}");
assert_eq!(provider.failure(), Some(first.clone()));
let second = provider
.programs(&[FilePath::new("src/a.ts")])
.expect_err("the sidecar is still gone");
assert!(
matches!(second, ProviderError::Refused(_)),
"got: {second:?}"
);
assert_eq!(provider.failure(), Some(first));
}
#[test]
fn a_new_run_clears_the_previous_runs_failure() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture("cleared-failure");
let provider = TscProvider::spawn(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar starts");
provider.remember(&ProviderError::Timeout);
assert_eq!(
provider.failure(),
Some(ProviderError::Timeout),
"the run that timed out records it"
);
provider
.begin_run(
&|| vec![FilePath::new("src/a.ts")],
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar is healthy, so a new run begins");
assert_eq!(
provider.failure(),
None,
"a new run inherited the previous run's cancellation"
);
assert_eq!(
TypeProvider::failure(&provider),
None,
"and the engine would still be asking about the old one"
);
}
#[test]
fn a_listing_that_is_not_a_list_of_pairs_is_refused_rather_than_folded() {
let good = serde_json::json!([["src/a.ts", "abc"], ["tsconfig.json", "def"]]);
assert!(fold_programs(&good).is_ok());
for bad in [
serde_json::json!({ "src/a.ts": "abc" }),
serde_json::json!("not a list at all"),
serde_json::json!([["src/a.ts"]]),
serde_json::json!([["src/a.ts", "abc", "extra"]]),
serde_json::json!([["src/a.ts", 7]]),
serde_json::json!(["src/a.ts"]),
] {
let error = fold_programs(&bad).expect_err("unreadable");
assert!(matches!(error, ProviderError::Refused(_)), "got: {error:?}");
}
}
#[test]
fn an_empty_command_is_unavailable_and_creates_no_lanekeep_directory() {
let root = fixture("empty-command");
let error = TscProvider::spawn(
&root,
&TypesConfig {
provider: TypesProvider::Tsc,
command: Vec::new(),
..TypesConfig::default()
},
AnalysisBudget::start(Duration::from_secs(5)),
)
.expect_err("nothing to spawn");
assert!(
matches!(error, ProviderError::Unavailable(_)),
"got: {error:?}"
);
assert!(
!root.join(".lanekeep").exists(),
"a run that could never start a sidecar left a `.lanekeep/` behind"
);
}
#[test]
fn a_driver_made_to_sleep_past_the_budget_times_out() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture("timeout");
let provider = TscProvider::spawn_with_env(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
&[("LANEKEEP_TSC_DRIVER_DELAY_MS", "2000")],
)
.expect("the sidecar starts");
let error = provider
.begin_run(
&|| vec![FilePath::new("src/a.ts")],
AnalysisBudget::start(Duration::from_millis(200)),
)
.expect_err("the request outlives the run's budget");
assert!(matches!(error, BeginRunError::Timeout(_)), "got: {error:?}");
}
#[test]
fn concurrent_requests_add_service_time_rather_than_waiting_time() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture("concurrent-charge");
let budget = AnalysisBudget::start(Duration::from_mins(2));
let provider = TscProvider::spawn_with_env(
&root,
&tsc_config(),
budget.clone(),
&[("LANEKEEP_TSC_DRIVER_DELAY_MS", "100")],
)
.expect("the sidecar starts");
provider
.programs(&[FilePath::new("src/a.ts")])
.expect("the warm-up request is answered");
let before = budget.spent();
let started = std::time::Instant::now();
std::thread::scope(|scope| {
for _ in 0..4 {
scope.spawn(|| {
provider
.programs(&[FilePath::new("src/a.ts")])
.expect("the request is answered");
});
}
});
let wall = started.elapsed();
let charged = budget
.spent()
.checked_sub(before)
.expect("the accumulator only grows");
assert!(
charged >= Duration::from_millis(400),
"four answers at 100 ms charged only {charged:?}"
);
assert!(
charged <= wall + Duration::from_millis(250),
"charged {charged:?} against {wall:?} of wall clock, so waiting is being charged: \
the sum of service times cannot exceed the time the sidecar was busy"
);
}
#[test]
fn no_provider_error_renders_a_run_of_spaces() {
let errors = [
ProviderError::Unavailable("why".into()),
ProviderError::Unloadable("why".into()),
ProviderError::Unwritable("/somewhere/.lanekeep".into()),
ProviderError::Refused("why".into()),
ProviderError::Timeout,
];
for error in errors {
let rendered = error.to_string();
for line in rendered.lines() {
let sentence = line.trim_start();
assert!(
!sentence.contains(" "),
"`{rendered}` carries a run of spaces inside a sentence"
);
}
}
}
fn parse(source: &str) -> tree_sitter::Tree {
use lanekeep_lang::Language as _;
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&lanekeep_lang_js::TypeScript.grammar())
.expect("the TypeScript grammar loads");
parser.parse(source, None).expect("the source parses")
}
fn last_of<'t>(tree: &'t tree_sitter::Tree, kind: &str) -> tree_sitter::Node<'t> {
let mut best: Option<tree_sitter::Node<'t>> = None;
let mut stack = vec![tree.root_node()];
while let Some(node) = stack.pop() {
if node.kind() == kind && best.is_none_or(|b| node.start_byte() > b.start_byte()) {
best = Some(node);
}
let mut cursor = node.walk();
let children: Vec<tree_sitter::Node<'t>> = node.children(&mut cursor).collect();
stack.extend(children);
}
best.unwrap_or_else(|| panic!("no `{kind}` node in the tree"))
}
#[test]
fn a_held_providers_revalidate_answers_the_edit_begin_run_rebuilt() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture_with(
"revalidate-typeof",
STRICT,
&[("src/a.ts", "export const n: number = 1;\n")],
);
let provider = TscProvider::spawn(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar starts");
let file = FilePath::new("src/a.ts");
let files = || vec![file.clone()];
provider
.begin_run(&files, AnalysisBudget::start(Duration::from_mins(2)))
.expect("the first run's programs build");
let first_source = "export const n: number = 1;\n";
let first_tree = parse(first_source);
assert_eq!(
provider.type_of(Query {
file: &file,
tree: &first_tree,
source: first_source,
node: last_of(&first_tree, "identifier"),
files: &FileAccess::new(&root),
}),
Some(Type::Primitive(Primitive::Number)),
"the first run answers the number annotation"
);
std::fs::write(root.join("src/a.ts"), "export const n: string = \"x\";\n")
.expect("rewrites the typed source");
provider.revalidate(&FileAccess::new(&root));
provider
.begin_run(&files, AnalysisBudget::start(Duration::from_mins(2)))
.expect("the second run's programs rebuild over the edit");
let second_source = "export const n: string = \"x\";\n";
let second_tree = parse(second_source);
assert_eq!(
provider.type_of(Query {
file: &file,
tree: &second_tree,
source: second_source,
node: last_of(&second_tree, "identifier"),
files: &FileAccess::new(&root),
}),
Some(Type::Primitive(Primitive::String)),
"revalidate is a no-op, and begin_run's own program rebuild is what makes the edit \
visible — this is the claim its doc comment makes, pinned"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_held_provider_answers_an_edit_to_a_file_its_program_only_read() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let source = "import { d } from \"dep\"\nexport const n = d\n";
let root = fixture_with("revalidate-dependency", STRICT, &[("src/a.ts", source)]);
let provider = TscProvider::spawn(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar starts");
let file = FilePath::new("src/a.ts");
let files = || vec![file.clone()];
let tree = parse(source);
let ask = || {
provider.type_of(Query {
file: &file,
tree: &tree,
source,
node: last_of(&tree, "identifier"),
files: &FileAccess::new(&root),
})
};
provider
.begin_run(&files, AnalysisBudget::start(Duration::from_mins(2)))
.expect("the first run's programs build");
assert_eq!(
ask(),
Some(Type::Primitive(Primitive::Number)),
"the first run answers the declaration as it was written"
);
std::fs::write(
root.join("node_modules/dep/index.d.ts"),
"export declare const d: string\n",
)
.expect("rewrites the dependency's declaration");
provider.revalidate(&FileAccess::new(&root));
provider
.begin_run(&files, AnalysisBudget::start(Duration::from_mins(2)))
.expect("the second run's programs rebuild over the edit");
assert_eq!(
ask(),
Some(Type::Primitive(Primitive::String)),
"the held provider is still answering out of the pre-edit declaration, which no run of \
`lanekeep check` ever does — its provider is fresh and has no cached program"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_breach_leaves_the_provider_asking_to_be_rebuilt() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture("needs-rebuild");
let provider = TscProvider::spawn_with_env(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
&[("LANEKEEP_TSC_DRIVER_DELAY_MS", "2000")],
)
.expect("the sidecar starts");
assert!(
!provider.needs_rebuild(),
"a serving sidecar is not a provider to throw away"
);
let error = provider
.begin_run(
&|| vec![FilePath::new("src/a.ts")],
AnalysisBudget::start(Duration::from_millis(200)),
)
.expect_err("the request outlives the run's budget");
assert!(matches!(error, BeginRunError::Timeout(_)), "got: {error:?}");
assert!(
provider.needs_rebuild(),
"the breach killed the sidecar and the provider still offers itself for the next request"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_query_records_its_own_reads_against_the_asking_file() {
if !tsc_available() {
eprintln!("skipped: no packages/lanekeep/node_modules/typescript (CI covers this)");
return;
}
let root = fixture_with(
"query-reads",
STRICT,
&[
("src/a.ts", "export const n: number = 1;\n"),
("src/pkg", ""),
],
);
let _ = std::fs::remove_file(root.join("src/pkg"));
std::fs::create_dir_all(root.join("src/pkg")).expect("creates the package directory");
std::fs::write(
root.join("src/pkg/package.json"),
"{\"name\":\"pkg\",\"types\":\"index.d.ts\"}\n",
)
.expect("writes the package manifest");
std::fs::write(
root.join("src/pkg/index.d.ts"),
"export declare class Rate {}\n",
)
.expect("writes the package types");
let provider = TscProvider::spawn(
&root,
&tsc_config(),
AnalysisBudget::start(Duration::from_mins(2)),
)
.expect("the sidecar starts");
let file = FilePath::new("src/a.ts");
let files = || vec![file.clone()];
provider
.begin_run(&files, AnalysisBudget::start(Duration::from_mins(2)))
.expect("the first run's programs build");
let before = provider.dependency_paths();
let source = "export const n: number = 1;\n";
let tree = parse(source);
let access = FileAccess::new(&root);
provider.is_assignable_to(
Query {
file: &file,
tree: &tree,
source,
node: last_of(&tree, "identifier"),
files: &access,
},
"./pkg",
"Rate",
);
let read: Vec<String> = access
.dependencies()
.into_iter()
.map(|read| read.path.as_str().to_owned())
.collect();
assert!(
read.iter().any(|path| path == "src/pkg/package.json"),
"the question's own resolution read is not a tracked read of the file that asked it: \
{read:?}"
);
provider
.begin_run(&files, AnalysisBudget::start(Duration::from_mins(2)))
.expect("the second run's programs build");
assert_eq!(
provider.dependency_paths(),
before,
"a question the session was asked moved the listing, so the run key depends on which \
files were cache misses last request"
);
let _ = std::fs::remove_dir_all(&root);
}