//! End-to-end tests driving the real binary against a fixture cargo home. //! //! These run the command the way a user does — argument parsing, config //! loading, discovery, ageing, deletion and reporting all included — rather //! than calling into the pieces. That is the only way to check the things that //! only exist at the seams: that `cargo grunk clean` and `cargo-grunk clean` //! mean the same thing, that the config actually reaches the age policy, and //! that what `--dry-run` promises is what a clean then does. use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use filetime::FileTime; use rusqlite::Connection; use tempfile::TempDir; const DAY: Duration = Duration::from_secs(24 * 60 * 60); const INDEX: &str = "index.crates.io-1949cf8c6b5b557f"; const GIT_DB: &str = "badgebuilder-e22ab1d71dcafcb2"; /// The cache directory tag cargo writes into every target directory. const CARGO_TAG: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\ # This file is a cache directory tag created by cargo.\n"; /// A self-contained world: a cargo home, a tree of projects, and a config. struct Fixture { _tmp: TempDir, cargo_home: PathBuf, projects: PathBuf, config: PathBuf, } impl Fixture { fn new() -> Self { let tmp = TempDir::new().unwrap(); let root = tmp.path(); let cargo_home = root.join("cargo-home"); let projects = root.join("projects"); fs::create_dir_all(&cargo_home).unwrap(); fs::create_dir_all(&projects).unwrap(); let fixture = Self { cargo_home, projects, config: root.join("grunk.toml"), _tmp: tmp, }; fixture.write_config("min_age = \"3d\"\n"); fixture.init_cargo_home(); fixture } fn write_config(&self, contents: &str) { fs::write(&self.config, contents).unwrap(); } /// A cargo home holding one crate tarball, one unpacked source, and a git /// dependency — each with a last-use timestamp we choose. fn init_cargo_home(&self) { let db = Connection::open(self.cargo_home.join(".global-cache")).unwrap(); db.execute_batch(include_str!("../fixtures/global-cache-schema.sql")) .unwrap(); db.execute( "INSERT INTO registry_index (id, name, timestamp) VALUES (2, ?1, ?2)", rusqlite::params![INDEX, unix_now()], ) .unwrap(); drop(db); } fn sql(&self) -> Connection { Connection::open(self.cargo_home.join(".global-cache")).unwrap() } /// Record a crate tarball last used `age` ago. fn crate_tarball(&self, name: &str, age: Duration) -> PathBuf { let file = format!("{name}.crate"); let path = self .cargo_home .join("registry/cache") .join(INDEX) .join(&file); fs::create_dir_all(path.parent().unwrap()).unwrap(); fs::write(&path, vec![b'x'; 2048]).unwrap(); self.sql() .execute( "INSERT INTO registry_crate VALUES (2, ?1, 2048, ?2)", rusqlite::params![file, unix_ago(age)], ) .unwrap(); path } /// Record an unpacked source directory last used `age` ago. fn unpacked_src(&self, name: &str, age: Duration) -> PathBuf { let path = self.cargo_home.join("registry/src").join(INDEX).join(name); fs::create_dir_all(&path).unwrap(); fs::write(path.join("lib.rs"), vec![b'x'; 4096]).unwrap(); self.sql() .execute( "INSERT INTO registry_src VALUES (2, ?1, 4096, ?2)", rusqlite::params![name, unix_ago(age)], ) .unwrap(); path } /// Record a git clone and a working tree checked out from it. fn git_dependency(&self, db_age: Duration, checkout_age: Duration) -> (PathBuf, PathBuf) { let db_path = self.cargo_home.join("git/db").join(GIT_DB); let checkout = self .cargo_home .join("git/checkouts") .join(GIT_DB) .join("bb58e65"); fs::create_dir_all(&db_path).unwrap(); fs::create_dir_all(&checkout).unwrap(); fs::write(db_path.join("packed-refs"), vec![b'x'; 1024]).unwrap(); fs::write(checkout.join("lib.rs"), vec![b'x'; 1024]).unwrap(); let sql = self.sql(); sql.execute( "INSERT INTO git_db (id, name, timestamp) VALUES (5, ?1, ?2)", rusqlite::params![GIT_DB, unix_ago(db_age)], ) .unwrap(); sql.execute( "INSERT INTO git_checkout VALUES (5, 'bb58e65', 1024, ?1)", rusqlite::params![unix_ago(checkout_age)], ) .unwrap(); (db_path, checkout) } /// A managed project: built, then opted in the way a user would. /// /// Nothing is managed by default, so a test that wants grunk to look at a /// project has to say so — which is the whole shape of the tool. fn project(&self, name: &str, age: Duration) -> Project { let project = self.project_in(&self.projects.join(name), name, age); self.manage(&project.root); project } /// Opt a project in, as `cargo grunk add` does. fn manage(&self, root: &Path) { self.grunk_ok(&["add", root.to_str().unwrap()]); } /// A cargo project with a target directory last touched `age` ago, which /// grunk does not yet know about. /// /// The project has no dependencies, so nothing here needs the network or a /// populated registry. fn project_in(&self, root: &Path, name: &str, age: Duration) -> Project { fs::create_dir_all(root.join("src")).unwrap(); fs::write( root.join("Cargo.toml"), format!( "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n\ [dependencies]\n" ), ) .unwrap(); fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); let target = root.join("target"); fs::create_dir_all(target.join("debug")).unwrap(); fs::write(target.join("CACHEDIR.TAG"), CARGO_TAG).unwrap(); fs::write(target.join("debug").join(name), vec![b'x'; 8192]).unwrap(); let project = Project { root: root.to_path_buf(), target, }; project.age(age); project } /// Run the binary the way cargo would: as `cargo grunk `. fn grunk(&self, args: &[&str]) -> Output { let mut command = Command::new(env!("CARGO_BIN_EXE_cargo-grunk")); command .arg("grunk") .args(args) .env("CARGO_HOME", &self.cargo_home) .env("GRUNK_CONFIG", &self.config) // Point the metadata lookups at the cargo that is running these // tests, whatever CARGO_HOME now says. .env("CARGO", env!("CARGO")) .current_dir(&self.projects); command.output().unwrap() } fn grunk_ok(&self, args: &[&str]) -> String { let output = self.grunk(args); assert!( output.status.success(), "`cargo grunk {}` failed:\n{}", args.join(" "), String::from_utf8_lossy(&output.stderr) ); String::from_utf8_lossy(&output.stdout).into_owned() } fn rows(&self, table: &str) -> i64 { self.sql() .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { row.get(0) }) .unwrap() } } struct Project { root: PathBuf, target: PathBuf, } impl Project { /// Backdate every file in the target directory, as if last built `age` ago. fn age(&self, age: Duration) { let stamp = FileTime::from_system_time(SystemTime::now() - age); for entry in walk(&self.target) { filetime::set_file_mtime(&entry, stamp).unwrap(); } filetime::set_file_mtime(&self.target, stamp).unwrap(); } } fn walk(dir: &Path) -> Vec { let mut found = Vec::new(); let Ok(entries) = fs::read_dir(dir) else { return found; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { found.extend(walk(&path)); } found.push(path); } found } fn unix_now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() as i64 } fn unix_ago(age: Duration) -> i64 { (SystemTime::now() - age) .duration_since(UNIX_EPOCH) .unwrap() .as_secs() as i64 } #[test] fn status_reports_what_is_reclaimable_without_touching_it() { let fixture = Fixture::new(); let stale = fixture.project("stale", 30 * DAY); let fresh = fixture.project("fresh", Duration::from_secs(60)); let old_crate = fixture.crate_tarball("serde-1.0.0", 30 * DAY); let stdout = fixture.grunk_ok(&["status"]); assert!(stdout.contains("target"), "{stdout}"); assert!(stdout.contains("registry-crate"), "{stdout}"); // The stale target is reclaimable and named; the fresh one is held back. assert!(stdout.contains("stale"), "{stdout}"); assert!( stdout.contains("too new") || stdout.contains("TOO NEW"), "{stdout}" ); assert!(stale.target.exists(), "status must not delete anything"); assert!(fresh.target.exists()); assert!(old_crate.exists()); } #[test] fn clean_removes_what_is_stale_and_spares_what_is_not() { let fixture = Fixture::new(); let stale = fixture.project("stale", 30 * DAY); let fresh = fixture.project("fresh", Duration::from_secs(60)); fixture.grunk_ok(&["clean"]); assert!(!stale.target.exists(), "a 30-day-old target should be gone"); assert!( fresh.target.exists(), "a target built a minute ago is under the 3d threshold and must survive" ); } #[test] fn clean_never_touches_sources() { let fixture = Fixture::new(); let project = fixture.project("stale", 30 * DAY); fixture.grunk_ok(&["clean"]); assert!(!project.target.exists()); assert!(project.root.join("Cargo.toml").is_file()); assert!(project.root.join("src/main.rs").is_file()); } #[test] fn clean_ages_cargo_home_entries_by_cargos_own_timestamps() { let fixture = Fixture::new(); let old = fixture.crate_tarball("old-1.0.0", 30 * DAY); let recent = fixture.crate_tarball("recent-1.0.0", Duration::from_secs(3600)); let old_src = fixture.unpacked_src("old-1.0.0", 30 * DAY); fixture.grunk_ok(&["clean"]); assert!(!old.exists(), "a tarball unused for 30 days should be gone"); assert!( recent.exists(), "a tarball used an hour ago is under the threshold" ); assert!(!old_src.exists()); } #[test] fn clean_retires_the_tracking_row_with_the_files() { // A row left behind would tell cargo the crate is still cached, and cargo // would go looking for a file that is no longer there. let fixture = Fixture::new(); fixture.crate_tarball("old-1.0.0", 30 * DAY); fixture.unpacked_src("old-1.0.0", 30 * DAY); assert_eq!(fixture.rows("registry_crate"), 1); fixture.grunk_ok(&["clean"]); assert_eq!(fixture.rows("registry_crate"), 0); assert_eq!(fixture.rows("registry_src"), 0); // The index is never reclaimed, so its row must survive. assert_eq!(fixture.rows("registry_index"), 1); } #[test] fn a_git_clone_survives_while_a_checkout_of_it_is_in_use() { // The clone itself has not been touched in 30 days, but a working tree from // it was used an hour ago. Deleting the clone would take the checkout with // it, so neither may go. let fixture = Fixture::new(); let (db, checkout) = fixture.git_dependency(30 * DAY, Duration::from_secs(3600)); fixture.grunk_ok(&["clean"]); assert!(db.exists(), "a clone with a live checkout must survive"); assert!(checkout.exists()); assert_eq!(fixture.rows("git_db"), 1); assert_eq!(fixture.rows("git_checkout"), 1); } #[test] fn a_git_clone_goes_once_its_checkouts_are_stale_too() { let fixture = Fixture::new(); let (db, checkout) = fixture.git_dependency(30 * DAY, 30 * DAY); fixture.grunk_ok(&["clean"]); assert!(!db.exists()); assert!(!checkout.exists()); assert_eq!(fixture.rows("git_db"), 0); assert_eq!(fixture.rows("git_checkout"), 0, "rows should cascade"); } #[test] fn dry_run_reports_exactly_what_a_clean_then_does() { let fixture = Fixture::new(); let stale = fixture.project("stale", 30 * DAY); fixture.crate_tarball("old-1.0.0", 30 * DAY); let dry = fixture.grunk_ok(&["clean", "--dry-run"]); assert!(dry.contains("Nothing was deleted"), "{dry}"); assert!(stale.target.exists(), "--dry-run must not delete"); assert_eq!(fixture.rows("registry_crate"), 1); let real = fixture.grunk_ok(&["clean"]); assert!(!stale.target.exists()); assert_eq!(fixture.rows("registry_crate"), 0); // Both passes should have agreed on the byte count. let dry_bytes = extract_size(&dry, "Would reclaim "); let real_bytes = extract_size(&real, "Reclaimed "); assert_eq!( dry_bytes, real_bytes, "dry run said {dry_bytes:?} but the clean reclaimed {real_bytes:?}\n\ --- dry ---\n{dry}\n--- real ---\n{real}" ); } fn extract_size(output: &str, prefix: &str) -> Option { let start = output.find(prefix)? + prefix.len(); let rest = &output[start..]; let end = rest.find(" in ")?; Some(rest[..end].to_string()) } #[test] fn min_age_on_the_command_line_overrides_the_config() { let fixture = Fixture::new(); let fresh = fixture.project("fresh", Duration::from_secs(60)); // The config says 3d, so nothing happens by default. fixture.grunk_ok(&["clean"]); assert!(fresh.target.exists()); fixture.grunk_ok(&["clean", "--min-age", "0s"]); assert!( !fresh.target.exists(), "--min-age 0s should take everything" ); } #[test] fn a_per_category_threshold_overrides_the_blanket_one() { let fixture = Fixture::new(); fixture.write_config("min_age = \"3d\"\n\n[min_age_overrides]\ntarget = \"60d\"\n"); let project = fixture.project("stale", 30 * DAY); let tarball = fixture.crate_tarball("old-1.0.0", 30 * DAY); fixture.grunk_ok(&["clean"]); assert!( project.target.exists(), "targets are held for 60d by this config, and this one is 30d old" ); assert!(!tarball.exists(), "the tarball is still on the blanket 3d"); } #[test] fn categories_can_be_cleaned_one_at_a_time() { let fixture = Fixture::new(); let project = fixture.project("stale", 30 * DAY); let tarball = fixture.crate_tarball("old-1.0.0", 30 * DAY); fixture.grunk_ok(&["clean", "--category", "target"]); assert!(!project.target.exists()); assert!(tarball.exists(), "only targets were asked for"); } #[test] fn add_brings_in_a_project_grunk_knows_nothing_about() { let fixture = Fixture::new(); let outside = fixture._tmp.path().join("elsewhere/outsider"); let project = fixture.project_in(&outside, "outsider", 30 * DAY); // Nothing is managed until it is opted in, so nothing sees it yet. fixture.grunk_ok(&["clean"]); assert!(project.target.exists()); fixture.grunk_ok(&["add", outside.to_str().unwrap()]); let listed = fixture.grunk_ok(&["list"]); assert!(listed.contains("outsider"), "{listed}"); assert!(listed.contains("explicit"), "{listed}"); fixture.grunk_ok(&["clean"]); assert!(!project.target.exists()); } #[test] fn remove_undoes_add_and_leaves_the_rest_of_the_config_alone() { let fixture = Fixture::new(); fixture.write_config("# a comment worth keeping\nmin_age = \"3d\"\n"); let outside = fixture._tmp.path().join("elsewhere/outsider"); let project = fixture.project_in(&outside, "outsider", 30 * DAY); fixture.grunk_ok(&["add", outside.to_str().unwrap()]); fixture.grunk_ok(&["remove", outside.to_str().unwrap()]); let config = fs::read_to_string(&fixture.config).unwrap(); assert!(config.contains("# a comment worth keeping"), "{config}"); assert!(config.contains("min_age = \"3d\""), "{config}"); fixture.grunk_ok(&["clean"]); assert!(project.target.exists(), "it should no longer be managed"); } #[test] fn scan_manages_what_it_finds_and_nothing_manages_it_before_that() { // The 75% of projects `.crates.toml` cannot see: never installed, so the // only way to find them is to go looking — and grunk only goes looking when // told to. let fixture = Fixture::new(); let never_installed = fixture.project_in( &fixture.projects.join("never-installed"), "never-installed", 30 * DAY, ); fixture.grunk_ok(&["clean"]); assert!( never_installed.target.exists(), "nothing is managed until it is opted in" ); let scanned = fixture.grunk_ok(&["scan", fixture.projects.to_str().unwrap()]); assert!(scanned.contains("never-installed"), "{scanned}"); let listed = fixture.grunk_ok(&["list"]); assert!(listed.contains("never-installed"), "{listed}"); assert!( listed.contains("explicit"), "scan should opt it in:\n{listed}" ); fixture.grunk_ok(&["clean"]); assert!(!never_installed.target.exists()); } #[test] fn scan_finds_a_project_that_has_never_been_built() { // A `target/`-based scan can only see projects that have been compiled. // Scanning for manifests instead means an unbuilt project is found and // opted in now, so it is managed the moment it is first built. let fixture = Fixture::new(); let root = fixture.projects.join("unbuilt"); fs::create_dir_all(root.join("src")).unwrap(); fs::write( root.join("Cargo.toml"), "[package]\nname = \"unbuilt\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", ) .unwrap(); fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); assert!(!root.join("target").exists()); let scanned = fixture.grunk_ok(&["scan", fixture.projects.to_str().unwrap()]); assert!(scanned.contains("unbuilt"), "{scanned}"); let listed = fixture.grunk_ok(&["list"]); assert!(listed.contains("unbuilt"), "{listed}"); } #[test] fn scan_skips_projects_that_are_already_managed() { let fixture = Fixture::new(); fixture.project("managed", 30 * DAY); let scanned = fixture.grunk_ok(&["scan", fixture.projects.to_str().unwrap()]); assert!( scanned.contains("no projects that are not already managed"), "{scanned}" ); } #[test] fn scan_dry_run_changes_nothing() { let fixture = Fixture::new(); let project = fixture.project_in(&fixture.projects.join("proj"), "proj", 30 * DAY); let scanned = fixture.grunk_ok(&["scan", "--dry-run", fixture.projects.to_str().unwrap()]); assert!(scanned.contains("proj"), "{scanned}"); assert!(scanned.contains("Nothing was changed"), "{scanned}"); fixture.grunk_ok(&["clean"]); assert!( project.target.exists(), "--dry-run must not opt anything in" ); } #[test] fn scan_reports_what_each_project_is_worth() { // The size is the reason to manage a project or not, so scan pays for it. let fixture = Fixture::new(); fixture.project_in(&fixture.projects.join("proj"), "proj", 30 * DAY); let scanned = fixture.grunk_ok(&["scan", "--dry-run", fixture.projects.to_str().unwrap()]); assert!( scanned.contains("KiB") || scanned.contains("B "), "{scanned}" ); } #[test] fn a_directory_named_target_that_cargo_did_not_make_is_left_alone() { let fixture = Fixture::new(); let project = fixture.project("real", 30 * DAY); // A project whose `target` holds precious hand-made things and no tag. let impostor = fixture.projects.join("impostor"); fs::create_dir_all(impostor.join("target")).unwrap(); fs::write( impostor.join("Cargo.toml"), "[package]\nname = \"impostor\"\n", ) .unwrap(); let precious = impostor.join("target/precious.txt"); fs::write(&precious, "do not delete").unwrap(); fixture.manage(&impostor); filetime::set_file_mtime( &precious, FileTime::from_system_time(SystemTime::now() - 90 * DAY), ) .unwrap(); fixture.grunk_ok(&["clean"]); assert!(!project.target.exists()); assert!( precious.is_file(), "without cargo's tag, a `target` directory is not cargo's to delete" ); } #[test] fn an_old_target_without_cargos_tag_is_still_cleaned() { // Cargo only began tagging target directories in 1.42; builds older than // that carry `.rustc_info.json` but no `CACHEDIR.TAG`. They are just as // regenerable, and are often the stalest, largest thing on disk — so a // clean must reach them, not walk past them for want of a tag. let fixture = Fixture::new(); let root = fixture.projects.join("ancient"); let target = root.join("target"); fs::create_dir_all(root.join("src")).unwrap(); fs::create_dir_all(target.join("debug")).unwrap(); fs::write( root.join("Cargo.toml"), "[package]\nname = \"ancient\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", ) .unwrap(); fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap(); fs::write(target.join(".rustc_info.json"), "{}").unwrap(); fs::write(target.join("debug").join("ancient"), vec![b'x'; 8192]).unwrap(); assert!(!target.join("CACHEDIR.TAG").exists(), "no tag, as old cargo left it"); let old = FileTime::from_system_time(SystemTime::now() - 90 * DAY); for entry in walk(&target) { filetime::set_file_mtime(&entry, old).unwrap(); } filetime::set_file_mtime(&target, old).unwrap(); fixture.manage(&root); fixture.grunk_ok(&["clean"]); assert!( !target.exists(), "an old, untagged, long-stale target must be reclaimed" ); assert!( root.join("Cargo.toml").is_file(), "cleaning must never touch the sources" ); } #[test] fn nothing_to_clean_is_reported_rather_than_claimed_as_success() { let fixture = Fixture::new(); fixture.project("fresh", Duration::from_secs(60)); let stdout = fixture.grunk_ok(&["clean"]); assert!(stdout.contains("Nothing to clean"), "{stdout}"); assert!(stdout.contains("too new"), "{stdout}"); } #[test] fn clean_says_when_it_has_not_measured_what_it_keeps() { // A clean skips measuring targets it will not touch, which is the whole // reason a daily run is quick. It has to say so rather than print a total // that quietly leaves them out. let fixture = Fixture::new(); fixture.project("fresh", Duration::from_secs(60)); let stdout = fixture.grunk_ok(&["clean"]); assert!(stdout.contains("Nothing to clean"), "{stdout}"); assert!(stdout.contains("1 items"), "{stdout}"); assert!( !stdout.contains("GiB") && !stdout.contains("MiB") && !stdout.contains(" B "), "a clean must not claim a size it never measured:\n{stdout}" ); assert!( stdout.contains("status"), "should point at the exact number" ); } #[test] fn status_still_measures_what_is_being_kept() { let fixture = Fixture::new(); fixture.project("fresh", Duration::from_secs(60)); let stdout = fixture.grunk_ok(&["status", "--all"]); assert!(stdout.contains("Held back for being too new"), "{stdout}"); assert!(stdout.contains("fresh"), "{stdout}"); assert!( !stdout.contains("unmeasured"), "a survey measures everything:\n{stdout}" ); } #[test] fn clean_and_status_agree_on_what_may_go() { // The two do different amounts of work to reach the same verdict. If they // ever parted company, `status` would be describing some other clean. let fixture = Fixture::new(); let stale = fixture.project("stale", 30 * DAY); let fresh = fixture.project("fresh", Duration::from_secs(60)); fixture.crate_tarball("old-1.0.0", 30 * DAY); let status = fixture.grunk_ok(&["status", "--all"]); assert!(status.contains("stale/target"), "{status}"); fixture.grunk_ok(&["clean"]); assert!(!stale.target.exists(), "status called it reclaimable"); assert!(fresh.target.exists(), "status called it too new"); } #[test] fn a_target_a_package_outgrew_is_still_cleaned() { // A package built on its own, then absorbed into a workspace, keeps the // target directory it had at the time. Cargo builds elsewhere now and never // mentions the old one — so asking cargo, which is how grunk locates build // output, walks straight past several megabytes of its own leavings. let fixture = Fixture::new(); let workspace = fixture.projects.join("ws"); let member = workspace.join("member"); // The member, with the target it had back when it stood alone. let stale = fixture.project_in(&member, "member", 30 * DAY); // The workspace that has since swallowed it. Nothing has been built since, // so the workspace's own target does not exist — cargo simply reports where // it *would* go, which is somewhere other than the directory above. fs::write( workspace.join("Cargo.toml"), "[workspace]\nmembers = [\"member\"]\nresolver = \"2\"\n", ) .unwrap(); fixture.manage(&member); fixture.grunk_ok(&["clean"]); assert!( !stale.target.exists(), "the target the package outgrew is still cargo's output, and still reclaimable" ); assert!(member.join("Cargo.toml").is_file(), "sources are untouched"); } #[test] fn the_binary_works_under_both_its_names() { // Cargo re-inserts the subcommand name, so `cargo grunk list` arrives as // `cargo-grunk grunk list`. Run directly, there is no such argument. let fixture = Fixture::new(); fixture.project("proj", 30 * DAY); let direct = Command::new(env!("CARGO_BIN_EXE_cargo-grunk")) .arg("list") .env("CARGO_HOME", &fixture.cargo_home) .env("GRUNK_CONFIG", &fixture.config) .env("CARGO", env!("CARGO")) .output() .unwrap(); assert!(direct.status.success()); assert_eq!( String::from_utf8_lossy(&direct.stdout), fixture.grunk_ok(&["list"]), "both invocations should behave identically" ); }