diff --git a/crates/ide-host/src/plugins/execution.rs b/crates/ide-host/src/plugins/execution.rs index 95328befe..0bd499d00 100644 --- a/crates/ide-host/src/plugins/execution.rs +++ b/crates/ide-host/src/plugins/execution.rs @@ -276,16 +276,12 @@ impl PluginHost { self.shared.run_configurations.write().insert((plugin, project), items); } } - /// Parses a bounded batch under the editor-provider soft/hard budgets. - pub fn parse_test_results( + /// Resolves only an owned plugin test configuration in the client's trusted project. + fn test_configuration( &self, client: &str, config: &str, - chunk: String, - ) -> IdeResult>> { - if chunk.len() > 64 << 10 { - return Err(IdeError::invalid("test output batch too large")); - } + ) -> IdeResult> { let root = self.shared.project_of(client).ok_or_else(|| IdeError::not_found("no project"))?; if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) { return Err(IdeError::invalid("project is not trusted")); @@ -302,7 +298,44 @@ impl PluginHost { { return Ok(None); } - let plugin = self.get(owner)?; + Ok(Some((root, owner.to_owned(), id.to_owned()))) + } + + /// Derives replacement argv under the parser's soft/hard call budgets. + pub fn select_tests(&self, client: &str, config: &str, tests: Vec>) -> IdeResult>> { + if tests.is_empty() + || tests.len() > 10000 + || tests.iter().any(|p| p.is_empty() || p.len() > 128) + || serde_json::to_vec(&tests).is_ok_and(|b| b.len() > 4 << 20) + { + return Ok(None); + } + let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) }; + let plugin = self.get(&owner)?; + let (actor, activated) = self.ensure_active(&plugin, None)?; + if activated { + self.changed(); + } + let invocation = ide_plugin::Invocation { workspace_root: Some(root), ..Default::default() }; + let hard = self.limits.edit_hard; + let started = Instant::now(); + let result = actor.call(Some(self.limits.edit_soft), move |rt| rt.select_tests(id, tests, invocation, hard)); + let args = self.finish_provider_call(&plugin, &actor, started, result).flatten(); + Ok(args.filter(|args| args.len() <= 10000 && args.iter().map(String::len).sum::() <= 4 << 20)) + } + + /// Parses a bounded batch under the editor-provider soft/hard budgets. + pub fn parse_test_results( + &self, + client: &str, + config: &str, + chunk: String, + ) -> IdeResult>> { + if chunk.len() > 64 << 10 { + return Err(IdeError::invalid("test output batch too large")); + } + let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) }; + let plugin = self.get(&owner)?; let (actor, activated) = self.ensure_active(&plugin, None)?; if activated { self.changed(); diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 524b387b9..7f86c7012 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -468,6 +468,16 @@ impl RemotePlugins { } }); let h = host.clone(); + d.add("plugin_select_tests", move |c| { + let h = h.clone(); + async move { + let host = alive(&h)?; + let config = c.arg::("config")?; + let tests = c.arg("tests")?; + blocking(move || host.select_tests(&c.window, &config, tests)).await + } + }); + let h = host.clone(); d.add("plugin_parse_test_results", move |c| { let h = h.clone(); async move { diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index e2b0cb996..f29f872c6 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -76,11 +76,11 @@ impl TestWorkspace { impl PluginWorkspace for TestWorkspace { fn run_configuration(&self, _client: &str, plugin: &str, id: &str) -> Result { - if plugin != "execution" || !matches!(id, "detected" | "dynamic") { + if !(plugin == "execution" && matches!(id, "detected" | "dynamic") || plugin == "api14" && id == "tests") { return Err(HostError::NotFound(id.into())); } Ok(ide_run::RunConfig { - id: format!("plugin:execution:{id}"), + id: format!("plugin:{plugin}:{id}"), name: "Tests".into(), kind: "plugin".into(), command: "execution-tool".into(), @@ -6018,6 +6018,50 @@ fn api14_stage3_configurations_test_results_and_content_actions_use_real_exports assert!(f.host.parse_test_results("main", "plugin:execution:dynamic", "trap".into()).unwrap().is_none()); } +#[test] +fn api14_fixture_exports_test_selection() { + let f = fixture(Limits::default()); + f.install(&sample("api14")); + assert_eq!( + f.host.select_tests("main", "plugin:api14:tests", vec![vec!["Suite".into(), "Test".into()]]).unwrap(), + Some(vec!["Suite::Test".into()]) + ); +} + +#[cfg(unix)] +#[test] +fn api14_test_selection_uses_owned_config_ids_and_name_paths() { + let f = execution_fixture(); + f.host.refresh_run_configurations(&f.project); + for id in ["dynamic", "detected"] { + assert_eq!( + f.host + .select_tests( + "main", + &format!("plugin:execution:{id}"), + vec![vec!["Suite".into(), "Test".into()], vec!["Other".into()]] + ) + .unwrap(), + Some(vec![id.into(), "Suite::Test".into(), "Other".into()]) + ); + } + assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![vec!["none".into()]]).unwrap(), None); + assert_eq!(f.host.select_tests("main", "cargo:test", vec![vec!["Test".into()]]).unwrap(), None); + assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![]).unwrap(), None); +} + +#[cfg(unix)] +#[test] +fn api14_test_selection_traps_and_timeouts_are_unsupported() { + for failure in ["trap", "timeout"] { + let f = execution_fixture(); + f.host.refresh_run_configurations(&f.project); + let started = Instant::now(); + assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![vec![failure.into()]]).unwrap(), None); + assert!(started.elapsed() < Duration::from_secs(5)); + } +} + #[cfg(unix)] #[test] fn api14_stage3_terminal_calls_are_denied_without_process_grants() { diff --git a/crates/ide-plugin/src/runtime.rs b/crates/ide-plugin/src/runtime.rs index 6c1bda1dc..c2c43eb90 100644 --- a/crates/ide-plugin/src/runtime.rs +++ b/crates/ide-plugin/src/runtime.rs @@ -1165,6 +1165,22 @@ impl Runtime { }) .collect()) } + /// Selects tests under the same budget as the test parser. + pub fn select_tests( + &mut self, + config: String, + tests: Vec>, + invocation: Invocation, + timeout: Duration, + ) -> Result>, CallError> { + if self.exports.test_results.is_none() { + return Err(CallError::missing_export("test-results")); + } + self.call(invocation, timeout, |e, store| { + e.test_results.as_ref().unwrap().call_select_tests(store, &config, &tests) + }) + } + /// One editor-budget batch of a plugin test parser. pub fn parse_test_results( &mut self, diff --git a/crates/ide-plugin/tests/api_compat.rs b/crates/ide-plugin/tests/api_compat.rs index 6e9778a63..95825fedb 100644 --- a/crates/ide-plugin/tests/api_compat.rs +++ b/crates/ide-plugin/tests/api_compat.rs @@ -34,6 +34,8 @@ fn components() -> Vec { repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"), repo().join("crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm"), repo().join("crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm"), + repo().join("crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm"), + repo().join("crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm"), ]; let official = repo().join("plugins/official"); let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect(); diff --git a/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm index 708ba22f1..5bdcd679c 100644 Binary files a/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm.sources index 4813269ff..01b6e7050 100644 --- a/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm.sources @@ -1 +1 @@ -b9b06665132bd6db753aa098703f2d83c9185d756114cdd1abb986f9d6d56c06 +f8c5b18f9734fd19f65a32e5087655d75ad944dddda31780f61672b94da3da6e diff --git a/crates/ide-plugin/tests/fixtures/api14/src/lib.rs b/crates/ide-plugin/tests/fixtures/api14/src/lib.rs index 659f147e3..ea6178d29 100644 --- a/crates/ide-plugin/tests/fixtures/api14/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/api14/src/lib.rs @@ -155,4 +155,12 @@ impl Plugin for Api14 { host::storage::set("events", &serde_json::to_string(&received).unwrap()).unwrap(); } } -register_plugin!(Api14); +impl openidea_plugin::TestResults for Api14 { + fn parse(&mut self, _config: &str, _chunk: &str) -> Vec { + vec![] + } + fn select_tests(&mut self, config: &str, tests: &[Vec]) -> Option> { + (config == "tests").then(|| tests.iter().map(|p| p.join("::")).collect()) + } +} +register_plugin!(Api14, test_results); diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index 1dddda010..3b08c7837 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -e986322ee0a7e8b09096deee3335e542823ad836cecdf14a4f44e2066fadf7b5 +f4ae5c053230e2a805b097d79392501ad4654f715d6d194937299273b67fda7c diff --git a/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm.sources index 71bbe995a..51acc097d 100644 --- a/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -cb74e35e0b0fa2bf7d46b143190d4e72d90abfa7a95962c037495b722d8c73d3 +7631939096b867c4707f7e0f929d2b75f91ef2b76da764d6e21233a5c06e8af9 diff --git a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources index 67c9fad7a..e5b55e534 100644 --- a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources @@ -1 +1 @@ -7ef1de0b1eb9d70ed3f5ba1dfc8cec6ccf3445ce76ad128f10f3d2456a990c2c +f5a2d43df242aa0ba9f358d9abf7296fd6a56b163bb526e62ce9c8b4d17e4b9a diff --git a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources index c5936fe68..01bab8cfe 100644 --- a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources @@ -1 +1 @@ -dcca07b99ce7d6691bd501d0e7c80d6f4ce0350b32a3bd7726b13f5b315c3982 +f19481ef89309e6fc65736cb18018a4bf9b679e5a1c6c8e9f92adf39578ca4cb diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index 270ae051b..91cd73637 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -6ab6593d3d7226a1a52bcdbe827697be39e9ed5117d238ba2366ad1a6911a623 +a6ba20c5bd531d965e64041b05f1dabfb1634c3192b06cdb61578bd2bf6f1af2 diff --git a/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm b/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm index 996f085fa..07476e17a 100644 Binary files a/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm and b/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm differ diff --git a/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm.sources index 601209570..8d00ff0c6 100644 --- a/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm.sources @@ -1 +1 @@ -a410bcd828c7b43d550c67c433343d1c8d4a5310b62578fc1d7fec542f2761eb +4c20506bb51bb9c08ebedb3ffb0c89ef153ee9565a466ac99d941a2c83ac2c9f diff --git a/crates/ide-plugin/tests/fixtures/execution/src/lib.rs b/crates/ide-plugin/tests/fixtures/execution/src/lib.rs index a9d6548c6..dee0a02ba 100644 --- a/crates/ide-plugin/tests/fixtures/execution/src/lib.rs +++ b/crates/ide-plugin/tests/fixtures/execution/src/lib.rs @@ -89,6 +89,17 @@ impl RunConfigurations for Execution { } } impl TestResults for Execution { + fn select_tests(&mut self, config: &str, tests: &[Vec]) -> Option> { + match tests.first()?.first()?.as_str() { + "none" => return None, + "trap" => panic!("selection failed"), + "timeout" => loop { + std::hint::spin_loop(); + }, + _ => {} + } + Some(std::iter::once(config.to_owned()).chain(tests.iter().map(|p| p.join("::"))).collect()) + } fn parse(&mut self, _: &str, chunk: &str) -> Vec { if chunk == "trap" { panic!("parser failed"); diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources index 154dd39da..2b7f857ab 100644 --- a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources @@ -1 +1 @@ -86238b45b09b0274d59282b35295aaee650e999aa309640d9347787e7d461ec4 +110bbe7e4c0bd02d93c0c7ccae8fcb1eccbcf355587ca271d8046b588f3db898 diff --git a/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm.sources index 326c9358d..eb8523fe1 100644 --- a/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm.sources @@ -1 +1 @@ -d433104828c588baf00dece077a087dc7f4cd78097952adca5b6049e366ca944 +2c02b56def836148a1be5ac0429394d9430d468c3d3ee3682a3c742bd8c90436 diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources index f792701c7..d0e5c3855 100644 --- a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources @@ -1 +1 @@ -4093e1d4791c3786f4b67fd4ef76928fc17048470f858824a29aafd71855c77d +6681327fc078ecad751627998c4b6299b6176484a61760d6f265b41bd3f42c97 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 02efff3b3..368e33562 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -aeb6d2f0a589731a1b7f71c9c71232b72d2535f0f555c4fbdb7101861b906d77 +a6a21264dffff392ea5e998c4545cdbe21a08bae429d2765e6bfeac9f2c9f863 diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 28d8e8fb7..98d867b4c 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -b723a8826d256fb57f04bbe8aa4b5300c9777f79d889bed2770f6f2bd7a65aa3 +f456deae0bbdee79c22d844ac8a9db5e4b867eaed245dc9332c1708e32e01ae0 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index 97ffa84d9..b1df655aa 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -31f6c0067f7668235ce7f28cb14f75950549c690a8f191aa63a71fe25f8397c6 +50e77e306f196dd43f7d95789cc1da94085a2863f1ccbec83701c34916ffff41 diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index c5e877c0e..f8b5ce5a8 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -f03ff9487293925027c5681800633d2e0a71ef908db9ea037dff5e4e0a48458e +b9342ba2bf0107bb65074aecf726991d326a45e50ac12100a97c7a6051b01161 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 44671ae8b..2f602f48c 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -98a083b972c0a887de07b720c606ffcf13f99d835cfa64d553a1243ffc19e4b1 +b4300035602303343f8b1c15326f86a696bfe814a3f2c7e1e186f5eed5a92321 diff --git a/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm.sources index dea43dfa8..68dd9dedb 100644 --- a/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm.sources @@ -1 +1 @@ -639a25cbc3aaab3080bf2894bc4710f45a1043a8a0f0f7f8c41ac20253f8bc98 +91d6f1f11a26effda7a21fd423f8ea6f42e707168ac5080033e9116c89d11d10 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 36c651b29..557220608 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -741d2909f021ec4c2f572edba0d83bcee4843a1aae4d8a79ea0367419a667640 +3edc6122e6cbba033d37f44d7855deff8473f097205846e06768888ac416742c diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources index 57e81184a..f42466716 100644 --- a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -1 +1 @@ -1da271855c8e63d9b6873521392bb2e14cb85f9de4fddcc7bce0c80916298caf +83ce23d58d809e9a724dfcb8bec2cccc1f73c77324d39d1c6ee2a7c855d763fd diff --git a/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources index de92d6fac..8993d4acb 100644 --- a/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources @@ -1 +1 @@ -e14bfd9a7f61a7ff44f656a148ed133e973c66edfb0859bbff4d4cda9663eb3a +557e263c737bc5cdf15f5bc4ed4fa5d7a4f2e252b06c2eb873e936a968519c08 diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index 9a638a03a..de9a6fc20 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -1c2f975248675b935adac4ac096d23d3f45ca44a46e9e4a2ee578bb69bb35356 +884e60babc875a5971a9ee42d0949c22712b9af749a6995d6273efa191aa65b5 diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources index 655dad638..e970f3e1d 100644 --- a/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources @@ -1 +1 @@ -2d566981f7f3aa78fbd6188e2bbdb17e33c01f6863e9b9a0b656f541371374bc +5f2f03bbc397dde8ad7c9b1cf2a61e9a4ae5881b34d71ec2e5748fc474754d59 diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index e37a46194..9d0cc6c35 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -69b349e65d32c5b22f7b6d312944e0e7387d1f7b8e46a1e88fcfcd87262ea407 +253a7ab7bb1827dee5434b47c732e484976237ca5a5d637536ccc36905d84be0 diff --git a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources index 01822bf2f..ab1331dc2 100644 --- a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources @@ -1 +1 @@ -e62e75f6e21f4d3c3797554581b6a78fc75a5362ce32f627c9858dd5f1d4edc5 +93467ab4d2a02ec72820f17df873510707dfdabd02d70c19164b8192d967bf7d diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index 8eed9241d..8421a84e7 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -c4740acdb3c805e13bf9a0c2e2c2a830336a1085285c39de3dce73a301f70150 +62372338f5c0129177936ad34c7f0fc5d8cd2f14c3f895c026ca7f79f1e31391 diff --git a/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources index a3dd95e7c..8244b0f6f 100644 --- a/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources @@ -1 +1 @@ -315d592adb776916170a6c2293379e7a7a086b9ae513dcac4aefad925d91df5d +4d1952be87c87f44e23b2420f966e23912405ba842d9928f1f9dec409dfa670e diff --git a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources index ddc072cc4..4e2868158 100644 --- a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources @@ -1 +1 @@ -7a88345173c4a77f6f94a047af53a43852adcf7e0377199f670374f296b98b19 +3992885be293901280938f5dc6498a39a310e1a3c7e34473394116a2327bb13b diff --git a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources index 22ad70b65..260ca0f32 100644 --- a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources @@ -1 +1 @@ -c98b94057e62a575d6c4b6c4c92e1e88e101bcef9138683e47b0bebf262e3f63 +70926629540ab1277641aef53419ce3014f291e424502619378511735bdbb8d5 diff --git a/plugins/README.md b/plugins/README.md index af88cd494..eafb63087 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -553,6 +553,7 @@ then run `openidea-plugin check`. | `host::reply_content_tab(request_id, reply)` | `views.reply-content-tab` | API 1.4: reply to an owned action in the originating window. No capability; commands/events only. | | `RunConfigurations::configurations(root)` | export `run-configurations.configurations` | API 1.4: optional `plugin-run-configurations` world; SDK registration `run_configurations`. Full project list, refreshed on configuration listing. | | `TestResults::parse(config, chunk)` | export `test-results.parse` | API 1.4: optional `plugin-test-results` world; SDK registration `test_results`. Bounded output batches for `framework = "plugin"`; failures use built-in parsers. | +| `TestResults::select_tests(config, tests)` | export `test-results.select-tests` | API 1.4: full replacement argv for test-tree name paths, or `None` when unsupported (the SDK default). Enables rerunning failed tests and running tree selections. | | `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. | | `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. | @@ -1628,9 +1629,10 @@ test's parent must already exist. Locations use absolute project-contained paths zero-based lines and UTF-16 columns. Passed/failed durations are milliseconds. The host uses the editor-provider soft/hard budget per batch and bounds returned events to 10,000 / 4 MiB. A missing, failed or invalid parser switches that run to -the built-in parsers; common framework commands retain their existing rerun-failed -filters. Arbitrary frameworks have no failed-test selection protocol in this -export and therefore cannot supply a custom rerun-failed filter. +the built-in parsers. Implement `select_tests(config, tests)` to derive the full +replacement argv for failed tests or test-tree selections; its default returns +`None`. See [Selecting plugin tests](#selecting-plugin-tests-api-14-unreleased) +for the name-path contract and unsupported cases. `host::open_terminal_session(key, title, &Command::new("tool"))` opens a normal Terminal tab in this window's project root. Reopening the same key focuses its @@ -2357,6 +2359,24 @@ its normal editor and project context. No capability is needed. Invalid or undeclared actions reject the whole notification. Labels are limited to 256 bytes and arguments to 64 KiB. Plain `host::notify` behaves as before. +### Selecting plugin tests (API 1.4, unreleased) + +A plugin using `framework = "plugin"` can implement `TestResults::select_tests` +(`test-results.select-tests`) alongside `parse`. It receives the same plugin-local +configuration id as `parse` and a list of test-tree paths: the names from the +plugin's `test-start` events, from parent suite to test, exactly as shown in the +tree. Return the **full replacement argument list**, including any required base +arguments, to run only those tests. `None` (the Rust SDK default), errors and +call timeouts leave test selection unsupported. An empty argument list is a +supported selection. The IDE preserves the configuration's command, environment, +working directory and run target. + +This enables **Rerun Failed Tests** and the test tree's **Run** / **Debug** entries. +Calls use the test parser's bounded background budget and run on the plugin's +host, including remote projects. See `official/test-runner` for an example. +This export is added to the existing interface while 1.4 is unreleased; released +export interfaces remain frozen under the rules above. + ### Plugin-provided run targets (API 1.4) `host::register_run_target(project, target)` registers or replaces a read-only, diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index c715f2339..efc9a0eb8 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -acb5f0a97061616d5bdcf7193d8fbe45c27818663dc0a9eab2e8203589492919 +c4900df449a6a323bab1f4fe5fe76ee42e2839c9197636b7c1cef733dffc683d diff --git a/plugins/official/blame-annotations/dist/plugin.wasm.sources b/plugins/official/blame-annotations/dist/plugin.wasm.sources index f3af33fc5..140444600 100644 --- a/plugins/official/blame-annotations/dist/plugin.wasm.sources +++ b/plugins/official/blame-annotations/dist/plugin.wasm.sources @@ -1 +1 @@ -aaa3cdd1595ba8aeabae6472f7f1ef635bd3115662d64d9284152747edfb6b16 +0ea139d4a0d47072971c0b37b93085159e412554ffb45d19590bae4dc10f48b5 diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index ef8ba4ca4..287e7bd70 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -7d09b4e0e3edfbed92be8efaba47d468b7707d6164abf45b0bec495ac454f4dd +4e4bc361395f6364e17211a9d3c32232a370ea1ee72e5197eab5f706ea465dc6 diff --git a/plugins/official/container-runner/dist/plugin.wasm.sources b/plugins/official/container-runner/dist/plugin.wasm.sources index 88f53d631..590301e25 100644 --- a/plugins/official/container-runner/dist/plugin.wasm.sources +++ b/plugins/official/container-runner/dist/plugin.wasm.sources @@ -1 +1 @@ -929b309e182c078790940244429b9872d8c7723c0bc25a07b152c83300905c47 +c39211d416f45dff0265216509070ffdecc4e0f4e738c88176cad5faaca01b65 diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index 6d851be5b..e416f9c2f 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -5099f6ceba7936a9b54340a4ce0c9b50a49962dd96053c850c010ea5c803c6a2 +2bbb840e0bead664ccb467dbb299b269fc9a03ed4995c8d890ae195efe6e47a6 diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 6b3a47871..502c85232 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -6deb9876934b6e21bd99c791f2a86167f1d67e808fbb1cc514c7e621514b654c +b1fdf25b8307803b0e5e189bbe041cdb23b1e7b73abb4b76fd7a1b9ee48d87b0 diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 6f56a0252..1a52decfc 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -5c2f5a254b25ba018369366df87707b423e77a573fcb73cc3c74b1f321f5aa61 +849d83a77d80e8d8a4f2c2801f4728300dd4504da411689834217b9973e582f4 diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 61effb082..3fb581b42 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -cb4ed7e9d793df4219e92edd5d64616db65411cb77e111df862ca8237f647efb +ff42121519f6796db56a71a504484b149592e9e28ed33c783d2e6141cfe84d6c diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index c7b41d834..563266688 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -f43f1e52813a5f992c3d06cbe03aa26092dd56db45322b090906dfbb8d08b1ab +0d5ed3d81ab5496b92e7b012ae6e1e765fb45f4f6b0613a03f2bc79029ed9397 diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index ecf858484..95a0ea2d0 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -656b51f57467a75b5416575871a5722e4f44be98407f6bfd0e6fa1e44f0bfa80 +dd34327c65747b1b57eef0854161837bd4e2843a59efec7730b81d8a2866a411 diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index 5044baccb..b4be800a5 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -bafe2cfc88d8c1272eea289d3d804287d56159b1c5893147b73a6be88e00370f +2141b215b141d82cc9a5cffa63c11e2b53e7bebcd31b93fb8d2db0ff5c2acd5b diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 49b5d5e91..60009d455 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -ee5d474028c7068b06771efa5e8e2fa710cd0108d53737a36d8bf9f0f3490c08 +604a289139233c13108ff8aab448e4278604e5260d7b2485e3ace3bad9e855db diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index 1ddc444e0..bea16abcf 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -867fc7bba0e795155e96c39f5a05cc3a08a3b6809c4e3922437e393d65f6d829 +9efa3a6a41c05e81a50893a2cff79da6e8e5f97219ddfd54f7ed201270934bb5 diff --git a/plugins/official/test-runner/README.md b/plugins/official/test-runner/README.md index 92e9902da..f14434ef7 100644 --- a/plugins/official/test-runner/README.md +++ b/plugins/official/test-runner/README.md @@ -10,7 +10,7 @@ The gutter command appends the test name as a filter to `run.start`. The fake executable prints a deliberately tiny `start NAME` / `pass NAME` protocol; `test-results.parse` retains split lines and emits stable test ids. Replace that protocol/parser and executable to integrate a real framework. This is an -API example, not a general test framework or a custom rerun-failed contract. +API example for integrating a test framework. Build from the repository root: @@ -23,3 +23,7 @@ cargo run -p ide-host --bin openidea-plugin -- check plugins/official/test-runne Install the built package folder through Settings › Plugins › Install from Disk. See the [plugin guide](../../README.md#api-14) and [official/community policy](../../README.md#81-official-and-community-plugins). + +`test-results.select-tests` returns the selected names as the complete argv for +`openidea-example-tests`, enabling Rerun Failed Tests and Run from the test tree. +It returns `None` for unknown configurations or paths this example cannot filter. diff --git a/plugins/official/test-runner/dist/plugin.wasm b/plugins/official/test-runner/dist/plugin.wasm index 57025d99e..52238d677 100644 Binary files a/plugins/official/test-runner/dist/plugin.wasm and b/plugins/official/test-runner/dist/plugin.wasm differ diff --git a/plugins/official/test-runner/dist/plugin.wasm.sources b/plugins/official/test-runner/dist/plugin.wasm.sources index 273f94c58..114cf23f1 100644 --- a/plugins/official/test-runner/dist/plugin.wasm.sources +++ b/plugins/official/test-runner/dist/plugin.wasm.sources @@ -1 +1 @@ -b7aaac21c6c34d9d722aa23c13c66e61ef11028f038fa877fe67d03b03c18483 +5f05e3696671258af5474b10abafd3cab48385493d5336061a9a8679307b56b4 diff --git a/plugins/official/test-runner/openidea-example-tests b/plugins/official/test-runner/openidea-example-tests index 0c675c486..4be1dfd0e 100755 --- a/plugins/official/test-runner/openidea-example-tests +++ b/plugins/official/test-runner/openidea-example-tests @@ -5,6 +5,12 @@ while IFS= read -r line; do 'test '*) name=${line#test } ;; *) continue ;; esac - [ -z "$1" ] || [ "$1" = "$name" ] || continue + if [ "$#" -gt 0 ]; then + selected=0 + for test in "$@"; do + [ "$test" != "$name" ] || selected=1 + done + [ "$selected" -eq 1 ] || continue + fi printf 'start %s\npass %s\n' "$name" "$name" done < example.tests diff --git a/plugins/official/test-runner/src/lib.rs b/plugins/official/test-runner/src/lib.rs index 4156d15e7..a5c5728d0 100644 --- a/plugins/official/test-runner/src/lib.rs +++ b/plugins/official/test-runner/src/lib.rs @@ -48,6 +48,12 @@ impl LanguageFeatures for TestRunner { } } impl TestResults for TestRunner { + fn select_tests(&mut self, config: &str, tests: &[Vec]) -> Option> { + if config != "tests" || tests.is_empty() || tests.iter().any(|p| p.len() != 1) { + return None; + } + Some(tests.iter().map(|p| p[0].clone()).collect()) + } fn parse(&mut self, _config: &str, chunk: &str) -> Vec { self.pending.push_str(chunk); let mut events = vec![]; @@ -92,6 +98,12 @@ mod tests { mock.set_run_configurations(plugin.configurations("/project")); plugin.run_command("test", &mock.invocation(), Some("adds")).unwrap(); assert_eq!(mock.configuration_runs()[0].2, vec!["adds"]); + assert_eq!( + plugin.select_tests("tests", &[vec!["adds".into()], vec!["subs".into()]]), + Some(vec!["adds".into(), "subs".into()]) + ); + assert_eq!(plugin.select_tests("unknown", &[vec!["adds".into()]]), None); + assert_eq!(plugin.select_tests("tests", &[vec!["suite".into(), "adds".into()]]), None); assert!(plugin.parse("tests", "sta").is_empty()); let events = plugin.parse("tests", "rt adds\npass adds\n"); assert!(matches!(&events[..], [PluginTestEvent::TestStarted(_), PluginTestEvent::Passed(_)])); diff --git a/plugins/official/todo-finder/dist/plugin.wasm.sources b/plugins/official/todo-finder/dist/plugin.wasm.sources index ef43b99b8..99cf55807 100644 --- a/plugins/official/todo-finder/dist/plugin.wasm.sources +++ b/plugins/official/todo-finder/dist/plugin.wasm.sources @@ -1 +1 @@ -66e92c09c0050dbf4c302793eea5048e21d1c91b9b44811b5f7edbae6eaa9075 +677ec8005d29d4727afbc4b8fba6afcbc26996f0db461d1e98a6e9e52b5f7fac diff --git a/sdk/rust/openidea-plugin/src/execution.rs b/sdk/rust/openidea-plugin/src/execution.rs index e8866312d..da7baa83e 100644 --- a/sdk/rust/openidea-plugin/src/execution.rs +++ b/sdk/rust/openidea-plugin/src/execution.rs @@ -158,6 +158,10 @@ pub trait RunConfigurations { /// Parses bounded test output batches; register with test_results. pub trait TestResults { fn parse(&mut self, config: &str, chunk: &str) -> Vec; + /// Full replacement argv for test-tree name paths, or None when filtering is unsupported. + fn select_tests(&mut self, _config: &str, _tests: &[Vec]) -> Option> { + None + } } impl crate::Event { diff --git a/sdk/rust/openidea-plugin/src/lib.rs b/sdk/rust/openidea-plugin/src/lib.rs index faa56f39c..1c8025ad9 100644 --- a/sdk/rust/openidea-plugin/src/lib.rs +++ b/sdk/rust/openidea-plugin/src/lib.rs @@ -1101,6 +1101,12 @@ pub mod __rt { }) .collect() } + pub fn select_tests( + config: &str, + tests: &[Vec], + ) -> Option> { + with::(|p| p.select_tests(config, tests)) + } pub fn test_results( config: &str, chunk: &str, @@ -1321,6 +1327,9 @@ macro_rules! __test_results_exports { fn parse(config: String, chunk: String) -> Vec<$crate::test_results_bindings::exports::openidea::plugin::test_results::TestEvent> { $crate::__rt::test_results::<$plugin>(&config, &chunk) } + fn select_tests(config: String, tests: Vec>) -> Option> { + $crate::__rt::select_tests::<$plugin>(&config, &tests) + } } $crate::test_results_bindings::__export_tests_world!($component with_types_in $crate::test_results_bindings); }; diff --git a/sdk/rust/openidea-plugin/tests/api14_stage3.rs b/sdk/rust/openidea-plugin/tests/api14_stage3.rs index 86f011b73..2ecd5feb3 100644 --- a/sdk/rust/openidea-plugin/tests/api14_stage3.rs +++ b/sdk/rust/openidea-plugin/tests/api14_stage3.rs @@ -53,3 +53,14 @@ fn run_targets_require_a_program_grant_and_are_project_scoped() { host::unregister_run_target(&project, "ddev"); assert!(mock.run_targets().is_empty()); } + +#[test] +fn parsers_can_keep_the_default_unsupported_test_selection() { + struct Parser; + impl TestResults for Parser { + fn parse(&mut self, _: &str, _: &str) -> Vec { + vec![] + } + } + assert_eq!(Parser.select_tests("tests", &[vec!["Suite".into(), "Test".into()]]), None); +} diff --git a/sdk/rust/openidea-plugin/wit/run.wit b/sdk/rust/openidea-plugin/wit/run.wit index c1bc79cf9..6c62534e2 100644 --- a/sdk/rust/openidea-plugin/wit/run.wit +++ b/sdk/rust/openidea-plugin/wit/run.wit @@ -47,4 +47,7 @@ interface test-results { } /// Parses one bounded output batch; failures fall back to built-in parsers. parse: func(config: string, chunk: string) -> list; + /// Selects test-tree name paths (parents then test), using the same config id as parse. + /// Returns the full replacement argument list, or none when filtering is unsupported. + select-tests: func(config: string, tests: list>) -> option>; } diff --git a/smoke/run.test.ts b/smoke/run.test.ts index 7a46ec00e..55a1945b0 100644 --- a/smoke/run.test.ts +++ b/smoke/run.test.ts @@ -220,7 +220,7 @@ async function smoke(language: string) { // Rerun Failed Tests runs that one test only. const rerunOutput: string[] = []; const before = useRun.getState().sessions.find((s) => s.id === id)!.startedAt; - expect(rerunFailed(id), "Rerun Failed Tests is offered").toBe(true); + expect(await rerunFailed(id), "Rerun Failed Tests is offered").toBe(true); const ended = () => { const s = useRun.getState().sessions.find((s) => s.id === id)!; return s.startedAt > before && s.endedAt !== null && s.endedAt >= s.startedAt; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index bcfc8a1e4..468567274 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -514,6 +514,7 @@ pub fn builder() -> tauri_specta::Builder { plugin::plugin_terminal_session_spawn, plugin::plugin_terminal_session_close, plugin::plugin_parse_test_results, + plugin::plugin_select_tests, plugin::plugin_content_tab_action, plugin::plugin_choose_program, plugin::plugin_program, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 6e288d8f2..6c14a3f44 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -963,6 +963,22 @@ pub fn plugin_content_tab_action( state.plugins.content_tab_action(window.label(), &plugin_id, action) } +/// Selects test-tree paths in an owned plugin test configuration. +#[tauri::command] +#[specta::specta] +pub async fn plugin_select_tests( + window: Window, + state: State<'_, AppState>, + config: String, + tests: Vec>, +) -> IdeResult>> { + let plugins = state.plugins.clone(); + let client = window.label().to_owned(); + tokio::task::spawn_blocking(move || plugins.select_tests(&client, &config, tests)) + .await + .map_err(|e| ide_core::IdeError::internal(e.to_string()))? +} + /// Parses one batch of an owned test configuration's output. #[tauri::command] #[specta::specta] diff --git a/src/bindings.ts b/src/bindings.ts index 9e5459d76..496ebca77 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1145,6 +1145,8 @@ export const commands = { pluginTerminalSessionClose: (operation: string) => typedError(__TAURI_INVOKE("plugin_terminal_session_close", { operation })), /** Parses one batch of an owned test configuration's output. */ pluginParseTestResults: (config: string, chunk: string) => typedError(__TAURI_INVOKE("plugin_parse_test_results", { config, chunk })), + /** Selects test-tree paths in an owned plugin test configuration. */ + pluginSelectTests: (config: string, tests: string[][]) => typedError(__TAURI_INVOKE("plugin_select_tests", { config, tests })), /** Dispatches a wasm content editor action through the owning plugin's event handler. */ pluginContentTabAction: (pluginId: string, action: ContentTabAction) => typedError(__TAURI_INVOKE("plugin_content_tab_action", { pluginId, action })), /** Re-approves a plugin's executable, globally or for this window's project. */ diff --git a/src/features/plugins/backend.ts b/src/features/plugins/backend.ts index c83ed774b..12824a7a7 100644 --- a/src/features/plugins/backend.ts +++ b/src/features/plugins/backend.ts @@ -88,6 +88,7 @@ export interface PluginsBackend { answerPrompt(promptId: string, answer: string[] | null): Promise; /** This window's answer to a plugin's `commitRequested`. */ answerCommand(requestId: string, answer: CommandAnswer): Promise; + selectTests(config: string, tests: string[][]): Promise; parseTestResults(config: string, chunk: string): Promise; closeTerminalSession(operation: string): Promise; terminalSessionSpawn( @@ -240,6 +241,7 @@ export const tauriPluginsBackend: PluginsBackend = { answerPrompt: async (promptId, answer) => { await commands.pluginAnswerPrompt(promptId, answer); }, + selectTests: (config, tests) => call(commands.pluginSelectTests(config, tests)), parseTestResults: (config, chunk) => call(commands.pluginParseTestResults(config, chunk)), closeTerminalSession: async (operation) => { await call(commands.pluginTerminalSessionClose(operation)); @@ -666,6 +668,7 @@ export function createMemoryPluginsBackend( throw new Error("Terminal unavailable"); }, parseTestResults: async () => null, + selectTests: async () => null, contentTabAction: async () => {}, answerCommit: async () => {}, commitChecks: async () => [], diff --git a/src/features/plugins/runtimeUi.tsx b/src/features/plugins/runtimeUi.tsx index 74c68c84a..13ee3d46c 100644 --- a/src/features/plugins/runtimeUi.tsx +++ b/src/features/plugins/runtimeUi.tsx @@ -4,7 +4,7 @@ * workbench events it subscribed to (`events.subscribe`), reported in * batches. */ -import { providePluginTestParser } from "@/workbench/contributions"; +import { providePluginTestParser, providePluginTestSelector } from "@/workbench/contributions"; import { contentTabData, contentTabKey, @@ -639,6 +639,7 @@ export function startRuntimeUi(): () => void { const disposers = [ startRunTargets(), stopParser, + providePluginTestSelector((config, tests) => pluginsBackend().selectTests(config, tests)), projectStatusItems(), trackWorkbenchEvents(), refreshOnProjectChange(), diff --git a/src/features/run/RunView.tsx b/src/features/run/RunView.tsx index 994e87c39..1f69388e7 100644 --- a/src/features/run/RunView.tsx +++ b/src/features/run/RunView.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import type { RunConfig } from "@/platform/ipc"; import type { RunTargetValid } from "@/platform/remoteSettings"; import { useWorkflowSettings } from "@/platform/workflowSettings"; import { EmptyState, IconButton, SplitGroup, SplitHandle, SplitPanel, Tabs, type TabItem, T } from "@/ui"; @@ -11,6 +12,8 @@ import { runDirectory } from "./navigate"; import { remoteLabel } from "./remote"; import { acceptsInput, + baseConfigOf, + canRunSelectedTests, clearConsole, closeSessionTab, closeStdin, @@ -29,6 +32,7 @@ import { type RunSession, isLive, } from "./store"; +import { rerunFailedConfigs } from "./testing/rerun"; import { TestTreeView, formatDuration } from "./TestTreeView"; import { StackTracePanel, closeStackTrace, useStackTraces, type StackTraceTab } from "./stackTrace"; import { showApplicationUpdate, useApplicationUpdates } from "./applicationUpdate"; @@ -77,6 +81,31 @@ export function SessionPanel({ const [findOpen, setFindOpen] = useState(false); const running = isLive(session); const cwd = runDirectory(session.config.cwd); + const baseConfig = useRun(() => baseConfigOf(session)); + // Test results mutate in place; their version refreshes the selection. + const failedSelection = useMemo( + () => (running ? [] : rerunFailedConfigs(baseConfig, failedTests(session.id))), + [running, baseConfig, session.id, version], // eslint-disable-line react-hooks/exhaustive-deps + ); + const [resolvedSelection, setResolvedSelection] = useState<{ + request: Promise; + supported: boolean; + } | null>(null); + useEffect(() => { + let current = true; + if (failedSelection instanceof Promise) { + void failedSelection.then((configs) => { + if (current) setResolvedSelection({ request: failedSelection, supported: configs.length > 0 }); + }); + } + return () => { + current = false; + }; + }, [failedSelection]); + const canRerunFailed = + failedSelection instanceof Promise + ? resolvedSelection?.request === failedSelection && resolvedSelection.supported + : failedSelection.length > 0; // Selecting a test with captured output shows just that output (like IDEA). const testModel = useMemo(() => { @@ -164,8 +193,8 @@ export function SessionPanel({ tone="danger" title="Rerun Failed Tests" shortcut={shortcutLabel("run.rerunFailed")} - disabled={running || failedTests(session.id).length === 0} - onClick={() => rerunFailed(session.id)} + disabled={!canRerunFailed} + onClick={() => void rerunFailed(session.id)} /> )} canRunSelectedTests(session.id, ids)} onRunTests={(ids, debug) => void runSelectedTests(session.id, ids, debug)} /> diff --git a/src/features/run/TestTreeView.test.tsx b/src/features/run/TestTreeView.test.tsx index 1a07f390b..dd8447065 100644 --- a/src/features/run/TestTreeView.test.tsx +++ b/src/features/run/TestTreeView.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import { ContextMenuHost } from "@/ui"; import { TestResults } from "./testing"; import { TestTreeView } from "./TestTreeView"; @@ -25,7 +25,10 @@ function results() { return r; } -function Host(props: { onRunTests?: (ids: string[], debug: boolean) => void }) { +function Host(props: { + onRunTests?: (ids: string[], debug: boolean) => void; + canRunTests?: (ids: string[]) => boolean | Promise; +}) { return ( <> { expect.objectContaining({ left: { text: "1", label: "Expected" }, right: { text: "2", label: "Actual" } }), ); }); + + it.each([true, false])( + "enables Run and Debug only when the async selector returns support (%s)", + async (supported) => { + let resolve!: (supported: boolean) => void; + const selection = new Promise((done) => { + resolve = done; + }); + const canRunTests = vi.fn(() => selection); + const onRunTests = vi.fn(); + render(); + fireEvent.contextMenu(screen.getByText("subs()")); + expect(screen.getByRole("menuitem", { name: "Run 'subs()'" })).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByRole("menuitem", { name: "Debug 'subs()'" })).toHaveAttribute("aria-disabled", "true"); + expect(canRunTests).toHaveBeenCalledWith(["MathTest\u0000subs()"]); + await act(async () => resolve(supported)); + for (const label of ["Run 'subs()'", "Debug 'subs()'"]) { + expect(screen.getByRole("menuitem", { name: label }).hasAttribute("aria-disabled")).toBe(!supported); + } + if (supported) { + fireEvent.click(screen.getByText("Run 'subs()'")); + expect(onRunTests).toHaveBeenCalledWith(["MathTest\u0000subs()"], false); + } + }, + ); }); diff --git a/src/features/run/TestTreeView.tsx b/src/features/run/TestTreeView.tsx index 44378442b..29f432541 100644 --- a/src/features/run/TestTreeView.tsx +++ b/src/features/run/TestTreeView.tsx @@ -1,7 +1,7 @@ import { useMemo, useState, type MouseEvent } from "react"; import clsx from "clsx"; import { executeAction } from "@/platform/actions"; -import { Icon, IconButton, showContextMenu, Spinner, Tree, type MenuItem } from "@/ui"; +import { Icon, IconButton, showContextMenu, useContextMenuStore, Spinner, Tree, type MenuItem } from "@/ui"; import { openTarget } from "./navigate"; import { nodeStatus, type TestNode, type TestResults, type TestStatus } from "./testing"; import { parseComparison } from "./testing/comparison"; @@ -106,9 +106,19 @@ export interface TestTreeViewProps { running: boolean; /** Run / Debug the selected tests (context menu); absent when the configuration can't be narrowed. */ onRunTests?: (ids: string[], debug: boolean) => void; + canRunTests?: (ids: string[]) => boolean | Promise; } -export function TestTreeView({ results, version, cwd, selected, onSelect, running, onRunTests }: TestTreeViewProps) { +export function TestTreeView({ + results, + version, + cwd, + selected, + onSelect, + running, + onRunTests, + canRunTests, +}: TestTreeViewProps) { const [filters, setFilters] = useState(loadTestFilters); const { showPassed, showIgnored } = filters; const toggle = (key: keyof TestFilters) => @@ -134,25 +144,48 @@ export function TestTreeView({ results, version, cwd, selected, onSelect, runnin const ids = sel.has(node.id) ? [...sel] : [node.id]; const name = ids.length === 1 ? (results.get(ids[0]!)?.name ?? node.name) : `${ids.length} tests`; const located = node.location ?? firstLocated(node)?.location; - const items: MenuItem[] = []; - if (onRunTests) { - items.push( - { label: tx`Run '${name}'`, verbatim: true, icon: "run", run: () => onRunTests(ids, false) }, - { label: tx`Debug '${name}'`, verbatim: true, icon: "debug", run: () => onRunTests(ids, true) }, - { kind: "separator" }, - ); - } - if (ids.length === 1 && nodeStatus(node) === "failed" && parseComparison(node.output)) - items.push({ label: "Show Diff", icon: "diff", run: () => void showTestDiff(node) }); - items.push({ - label: "Jump to Source", - icon: "external-link", - shortcut: "F4", - disabled: !located, - run: () => jumpToSource(node), - }); e.preventDefault(); - showContextMenu(e, items); + const supported = onRunTests ? (canRunTests?.(ids) ?? true) : false; + const itemsFor = (enabled: boolean) => { + const items: MenuItem[] = []; + if (onRunTests) { + items.push( + { + label: tx`Run '${name}'`, + verbatim: true, + icon: "run", + disabled: !enabled, + run: () => onRunTests(ids, false), + }, + { + label: tx`Debug '${name}'`, + verbatim: true, + icon: "debug", + disabled: !enabled, + run: () => onRunTests(ids, true), + }, + { kind: "separator" }, + ); + } + if (ids.length === 1 && nodeStatus(node) === "failed" && parseComparison(node.output)) + items.push({ label: "Show Diff", icon: "diff", run: () => void showTestDiff(node) }); + items.push({ + label: "Jump to Source", + icon: "external-link", + shortcut: "F4", + disabled: !located, + run: () => jumpToSource(node), + }); + return items; + }; + showContextMenu(e, itemsFor(supported === true)); + if (supported instanceof Promise) { + const menu = useContextMenuStore.getState().menu; + void supported.then((enabled) => { + if (useContextMenuStore.getState().menu === menu && menu) + useContextMenuStore.setState({ menu: { ...menu, items: itemsFor(enabled) } }); + }); + } }; const counts = useMemo(() => results.counts(), [results, version]); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 49ac906b8..b7c771274 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -67,6 +67,7 @@ import { setBeforeLaunchRunner, loadConfigs, failedTests, + baseConfigOf, onRunExited, rerunFailed, rerunSession, @@ -83,9 +84,43 @@ import { useRun, isLive, } from "./store"; +import { rerunFailedConfigs } from "./testing/rerun"; import { compoundTargets, selectedTarget } from "./sessionTargets"; import { tx } from "@/platform/i18n"; +function trackFailedTestSelection(): () => void { + let signature = ""; + let generation = 0; + const update = () => { + const state = useRun.getState(); + const id = + (state.activeSession && failedTests(state.activeSession).length ? state.activeSession : null) ?? + [...state.sessions].reverse().find((s) => failedTests(s.id).length > 0)?.id; + const session = state.sessions.find((s) => s.id === id); + const base = session && baseConfigOf(session); + const paths = id ? failedTests(id) : []; + const next = JSON.stringify([id, session?.status, base, paths]); + if (next === signature) return; + signature = next; + const request = ++generation; + setContext("run.canRerunFailed", false); + if (!session || isLive(session) || !base) return; + const selection = rerunFailedConfigs(base, paths); + if (selection instanceof Promise) { + void selection.then((configs) => { + if (request === generation) setContext("run.canRerunFailed", configs.length > 0); + }); + } else setContext("run.canRerunFailed", selection.length > 0); + }; + const stop = useRun.subscribe(update); + update(); + return () => { + generation++; + stop(); + setContext("run.canRerunFailed", false); + }; +} + function hasProject() { return !!useProject.getState().project; } @@ -383,6 +418,7 @@ export function activate(): () => void { activateStackTrace(), listenForApplicationDeactivation(), trackRecentConfigurations(), + trackFailedTestSelection(), useDebugRunSessions.subscribe(() => setContext("run.updatableRunning", updatableSessions().length > 0)), // A removed workspace member (or a closed project) takes its sessions, // temporary configurations and Services groups with it. @@ -563,14 +599,14 @@ export function activate(): () => void { title: "Rerun Failed Tests", category: "Run", icon: "rerun", - when: "projectOpen", + when: "projectOpen && run.canRerunFailed", keywords: ["failed tests", "rerun"], - run: () => { + run: async () => { const s = useRun.getState(); const id = (s.activeSession && failedTests(s.activeSession).length ? s.activeSession : null) ?? [...s.sessions].reverse().find((x) => failedTests(x.id).length > 0)?.id; - if (!id || !rerunFailed(id)) + if (!id || !(await rerunFailed(id))) notifications().notify({ group: "run", level: "info", diff --git a/src/features/run/session.test.tsx b/src/features/run/session.test.tsx index 39bd3ecf7..0b43a7dc2 100644 --- a/src/features/run/session.test.tsx +++ b/src/features/run/session.test.tsx @@ -5,6 +5,7 @@ import { setContext } from "@/platform/context"; import type { RunConfig } from "@/platform/ipc"; import { useDebugRunSessions, + providePluginTestSelector, provideNotificationService, provideToolWindowService, registerMenuGroup, @@ -15,7 +16,16 @@ import { ConsoleModel } from "./console"; import { ConsoleView, type ConsoleInput } from "./ConsoleView"; import { buildRows, mnemonics } from "./ConfigChooser"; import { activate } from "./index"; -import { closeSessionTab, resetRuns, runtime, startRun, useRun, type RunSession } from "./store"; +import { + closeSessionTab, + resetRuns, + runtime, + startRun, + useRun, + runSelectedTests, + rerunFailed, + type RunSession, +} from "./store"; const confirm = vi.hoisted(() => vi.fn(async () => true)); const confirmTerminate = vi.hoisted(() => vi.fn(async () => true)); @@ -265,3 +275,50 @@ describe("console input", () => { expect(input.onChange).toHaveBeenLastCalledWith("3"); }); }); + +describe("plugin test selection launches", () => { + it("keeps the owning configuration id, selected argv and original config when running tree tests", async () => { + const base = config("plugin:example:tests", { + kind: "plugin", + detected: true, + isTest: true, + params: { framework: "plugin" }, + args: ["all"], + }); + const session = (await startRun(base))!; + const tests = runtime(session.id)!.tests!; + tests.apply([{ type: "result", path: ["suite", "case"], status: "failed" }]); + const selector = vi.fn().mockResolvedValue(["--only", "suite::case"]); + const dispose = providePluginTestSelector(selector); + try { + expect(await runSelectedTests(session.id, ["suite"], false)).toBe(true); + const selected = useRun.getState().sessions.find((s) => s.id === session.id)!; + expect(selected.config.id).toBe(base.id); + expect(selected.config.args).toEqual(["--only", "suite::case"]); + expect(selected.baseConfig).toEqual(base); + expect(selector).toHaveBeenCalledWith(base.id, [["suite", "case"]]); + expect(useRun.getState().temporary).toEqual([]); + } finally { + dispose(); + } + }); + + it("does not launch when the plugin cannot filter the selection", async () => { + const base = config("plugin:example:tests", { + kind: "plugin", + detected: true, + isTest: true, + params: { framework: "plugin" }, + }); + const session = (await startRun(base))!; + runtime(session.id)!.tests!.apply([{ type: "result", path: ["case"], status: "failed" }]); + const dispose = providePluginTestSelector(async () => null); + try { + expect(await runSelectedTests(session.id, ["case"], false)).toBe(false); + expect(await rerunFailed(session.id)).toBe(false); + expect(useRun.getState().sessions[0]).toBe(session); + } finally { + dispose(); + } + }); +}); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index c66968919..410dc9adb 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -894,7 +894,12 @@ export async function startRun( if ((options.launch || launch.isTest) && launch.emulateTerminal) launch = { ...launch, emulateTerminal: false }; if (launch.emulateTerminal) rt.terminal = new TerminalOutput(); patchSession(sessionId, { terminal: !!launch.emulateTerminal }); - const started = launch.isTest ? await gradleTestLaunch(withTestReporting(launch)) : await gradleBootLaunch(launch); + const started = + launch.params?.framework === "plugin" + ? launch + : launch.isTest + ? await gradleTestLaunch(withTestReporting(launch)) + : await gradleBootLaunch(launch); if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null; const processStarted = performance.now(); const info = await startWithRemedies(started, () => call(commands.runStart(started, channel))); @@ -1178,7 +1183,7 @@ export function rerunConfig(session: Pick): } /** The session's original configuration, as saved now (see {@link rerunConfig}). */ -function baseConfigOf(session: RunSession): RunConfig { +export function baseConfigOf(session: RunSession): RunConfig { const base = session.baseConfig ?? session.config; return useRun.getState().configs.find((c) => c.id === base.id) ?? base; } @@ -1218,11 +1223,13 @@ export function failedTests(sessionId: string): string[][] { } /** "Rerun Failed Tests": reruns only the failed tests in the same tab. Returns false when unsupported. */ -export function rerunFailed(sessionId: string): boolean { +export async function rerunFailed(sessionId: string): Promise { const session = getSession(sessionId); if (!session || isLive(session)) return false; const base = baseConfigOf(session); - const derived = rerunFailedConfigs(base, failedTests(sessionId)); + const selection = rerunFailedConfigs(base, failedTests(sessionId)); + const derived = selection instanceof Promise ? await selection : selection; + if (getSession(sessionId) !== session || isLive(session)) return false; if (!derived.length) return false; derived.forEach((config, index) => void startRun(config, index === 0 ? sessionId : undefined, base)); return true; @@ -1244,19 +1251,34 @@ export async function runSelectedTests( const paths = leafTestPaths(nodes); const base = baseConfigOf(session); const name = nodes.length === 1 ? nodes[0]!.name : `${paths.length} tests`; - const derived = testsConfigs(base, paths, name); + const selection = testsConfigs(base, paths, name); + const derived = selection instanceof Promise ? await selection : selection; + if (getSession(sessionId) !== session) return false; if (!derived.length) return false; for (const [index, next] of derived.entries()) { - const config = adoptTemporary({ - ...next, - id: `context:tests:${base.id}:${index}:${paths.map((p) => p.join("/")).join("|")}`, - }); + const config = + base.params?.framework === "plugin" + ? next + : adoptTemporary({ + ...next, + id: `context:tests:${base.id}:${index}:${paths.map((p) => p.join("/")).join("|")}`, + }); if (debug) await executeAction("run.debugConfig", config); else await startRun(config, undefined, base); } return true; } +/** Whether this exact test-tree selection can be narrowed by its framework. */ +export function canRunSelectedTests(sessionId: string, nodeIds: readonly string[]): boolean | Promise { + const session = getSession(sessionId); + const tests = runtimes.get(sessionId)?.tests; + if (!session || !tests) return false; + const nodes = nodeIds.map((id) => tests.get(id)).filter((n) => !!n); + const selection = testsConfigs(baseConfigOf(session), leafTestPaths(nodes), ""); + return selection instanceof Promise ? selection.then((configs) => configs.length > 0) : selection.length > 0; +} + /** Whether the console takes typed input: the process runs and its stdin is open. */ export function acceptsInput(session: RunSession): boolean { return session.status === "running" && session.runId !== null && !session.stdinClosed; diff --git a/src/features/run/testing/rerun.test.ts b/src/features/run/testing/rerun.test.ts index 0bedd8d88..d755a7f1b 100644 --- a/src/features/run/testing/rerun.test.ts +++ b/src/features/run/testing/rerun.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi, afterEach } from "vitest"; import type { RunConfig } from "@/platform/ipc"; import { TestResults } from "./results"; import { @@ -10,6 +10,8 @@ import { testsConfigs, } from "./rerun"; +import { providePluginTestSelector } from "@/workbench/contributions"; + const cfg = (kind: string, command: string, args: string[]): RunConfig => ({ id: `${kind}:test`, name: `${kind} test`, @@ -37,7 +39,7 @@ describe("failedTestPaths", () => { }); describe("tests selected in the test tree", () => { - it("expands suites to their tests; a suite without reported tests stands for itself", () => { + it("expands suites to their tests; a suite without reported tests stands for itself", async () => { const r = new TestResults(); r.apply([ { type: "result", path: ["com.acme.ATest", "one()"], status: "passed" }, @@ -54,7 +56,7 @@ describe("tests selected in the test tree", () => { ["com.acme.ATest", "two()"], ["com.acme.BTest"], ]); - const c = testsConfig(cfg("gradle", "./gradlew", ["test"]), leafTestPaths(nodes), "3 tests")!; + const c = (await testsConfig(cfg("gradle", "./gradlew", ["test"]), leafTestPaths(nodes), "3 tests"))!; expect(c.name).toBe("3 tests"); expect(c.args).toEqual([ "test", @@ -69,20 +71,20 @@ describe("tests selected in the test tree", () => { }); describe("rerunFailedConfig", () => { - it("cargo: exact names after --", () => { - const c = rerunFailedConfig(cfg("cargo", "cargo", ["test", "--workspace"]), [ + it("cargo: exact names after --", async () => { + const c = (await rerunFailedConfig(cfg("cargo", "cargo", ["test", "--workspace"]), [ ["src/lib.rs", "tests", "adds"], ["tests/it.rs", "smoke"], ["Doc-tests app", "src/lib.rs - foo (line 3)"], - ])!; + ]))!; expect(c.args).toEqual(["test", "--workspace", "--", "tests::adds", "smoke", "--exact"]); expect(c.name).toBe("Failed: cargo test"); }); - it("cargo: merges an existing -- section without duplicating --exact", () => { - const c = rerunFailedConfig(cfg("cargo", "cargo", ["test", "--", "--nocapture", "--exact", "old"]), [ + it("cargo: merges an existing -- section without duplicating --exact", async () => { + const c = (await rerunFailedConfig(cfg("cargo", "cargo", ["test", "--", "--nocapture", "--exact", "old"]), [ ["src/lib.rs", "a"], - ])!; + ]))!; expect(c.args).toEqual(["test", "--", "--nocapture", "old", "a", "--exact"]); }); @@ -94,11 +96,11 @@ describe("rerunFailedConfig", () => { ["yarn", ["--"]], ["bun", ["--"]], ["npx", []], - ])("jest/vitest via %s: files + -t pattern", (command, separator) => { - const c = rerunFailedConfig(cfg("npm", command, ["run", "test"]), [ + ])("jest/vitest via %s: files + -t pattern", async (command, separator) => { + const c = (await rerunFailedConfig(cfg("npm", command, ["run", "test"]), [ ["src/a.test.ts", "math", "adds (1+1)"], ["src/b.test.ts"], - ])!; + ]))!; expect(c.args).toEqual([ "run", "test", @@ -113,24 +115,24 @@ describe("rerunFailedConfig", () => { it.each([ ["run", "test", "-t", "old"], ["run", "test", "--", "--testNamePattern=old"], - ])("replaces an existing pnpm test filter in %j", (...args) => { - const c = rerunFailedConfig(cfg("npm", "pnpm", args), [["src/a.test.ts", "new"]])!; + ])("replaces an existing pnpm test filter in %j", async (...args) => { + const c = (await rerunFailedConfig(cfg("npm", "pnpm", args), [["src/a.test.ts", "new"]]))!; expect(c.args).toEqual(["run", "test", ...(args.includes("--") ? ["--"] : []), "src/a.test.ts", "-t", "^(?:new)$"]); }); - it("pytest: node ids replace explicit paths", () => { - const c = rerunFailedConfig(cfg("pytest", ".venv/bin/pytest", ["-q", "tests/", "-k", "slow"]), [ + it("pytest: node ids replace explicit paths", async () => { + const c = (await rerunFailedConfig(cfg("pytest", ".venv/bin/pytest", ["-q", "tests/", "-k", "slow"]), [ ["tests/test_x.py", "TestC", "test_y[1]"], ["tests/test_z.py", "test_a"], - ])!; + ]))!; expect(c.args).toEqual(["-q", "tests/test_x.py::TestC::test_y[1]", "tests/test_z.py::test_a"]); }); - it("django: dotted labels in place of the run's target, options kept", () => { - const c = rerunFailedConfig( + it("django: dotted labels in place of the run's target, options kept", async () => { + const c = (await rerunFailedConfig( cfg("django-test", ".venv/bin/python", ["manage.py", "test", "polls", "--keepdb", "-v", "2"]), [["polls.tests", "QuestionTests", "test_a"], ["polls.tests", "BrokenSetupTests"], ["broken.tests"]], - )!; + ))!; expect(c.args).toEqual([ "manage.py", "test", @@ -146,14 +148,14 @@ describe("rerunFailedConfig", () => { ...cfg("django-test", "uv", ["run", "python", "manage.py", "test", "polls"]), params: { command: "uv", runnerArgs: "run python", target: "polls", settings: "", options: "" }, }; - expect(rerunFailedConfig(form, [["polls.tests", "QuestionTests", "test_a"]])).toMatchObject({ + expect(await rerunFailedConfig(form, [["polls.tests", "QuestionTests", "test_a"]])).toMatchObject({ args: ["run", "python", "manage.py", "test", "polls.tests.QuestionTests.test_a"], params: { target: "polls.tests.QuestionTests.test_a" }, }); }); - it("go: keeps full subtest paths separate for each package", () => { - const configs = rerunFailedConfigs(cfg("go", "go", ["test", "-v", "./..."]), [ + it("go: keeps full subtest paths separate for each package", async () => { + const configs = await rerunFailedConfigs(cfg("go", "go", ["test", "-v", "./..."]), [ ["example.com/m/pkg", "TestAdd"], ["example.com/m/pkg", "TestSub", "case_1"], ["example.com/m/other", "TestX"], @@ -165,24 +167,22 @@ describe("rerunFailedConfig", () => { expect(new Set(configs.map((config) => config.id)).size).toBe(2); }); - it("gradle and maven filters", () => { - expect(rerunFailedConfig(cfg("gradle", "./gradlew", ["test"]), [["com.a.FooTest", "bar()"]])!.args).toEqual([ - "test", - "--tests", - "com.a.FooTest.bar", - ]); + it("gradle and maven filters", async () => { + expect((await rerunFailedConfig(cfg("gradle", "./gradlew", ["test"]), [["com.a.FooTest", "bar()"]]))!.args).toEqual( + ["test", "--tests", "com.a.FooTest.bar"], + ); expect( - rerunFailedConfig(cfg("maven", "mvn", ["test"]), [ + (await rerunFailedConfig(cfg("maven", "mvn", ["test"]), [ ["com.a.FooTest", "bar"], ["com.a.FooTest", "baz"], ["com.a.BarTest"], - ])!.args, + ]))!.args, ).toEqual(["test", "-Dtest=com.a.FooTest#bar+baz,com.a.BarTest"]); }); - it("returns null when nothing failed or the framework is unknown", () => { - expect(rerunFailedConfig(cfg("cargo", "cargo", ["test"]), [])).toBeNull(); - expect(rerunFailedConfig(cfg("make", "make", ["test"]), [["x"]])).toBeNull(); + it("returns null when nothing failed or the framework is unknown", async () => { + expect(await rerunFailedConfig(cfg("cargo", "cargo", ["test"]), [])).toBeNull(); + expect(await rerunFailedConfig(cfg("make", "make", ["test"]), [["x"]])).toBeNull(); }); }); @@ -226,16 +226,16 @@ describe("withTestReporting", () => { describe("Go rerun argument boundaries", () => { it.each(["-test.v", "-test.short", "-test.benchmem", "-test.failfast", "-test.fullpath"])( "does not retain package targets after the boolean flag %s", - (flag) => { + async (flag) => { const config = cfg("go", "go", ["test", flag, "./..."]); - const rerun = rerunFailedConfig(config, [["example.com/selected", "TestOnly"]])!; + const rerun = (await rerunFailedConfig(config, [["example.com/selected", "TestOnly"]]))!; expect(rerun.args).toEqual(["test", flag, "-run", "^TestOnly$", "example.com/selected"]); }, ); - it("replaces import-path targets without losing flag values or binary arguments", () => { + it("replaces import-path targets without losing flag values or binary arguments", async () => { const config = cfg("go", "go", ["test", "-tags", "demo", "-run", "old", "example.com/old", "-args", "-test.short"]); - const rerun = rerunFailedConfig(config, [["example.com/new", "TestChild", "case"]])!; + const rerun = (await rerunFailedConfig(config, [["example.com/new", "TestChild", "case"]]))!; expect(rerun.args).toEqual([ "test", "-tags", @@ -247,24 +247,27 @@ describe("Go rerun argument boundaries", () => { "-test.short", ]); }); - it.each(["command-line-arguments", "example.com/app"])("retains file-scoped preparation for %s", (packageName) => { - const config = { - ...cfg("go", "go", ["test", "main_test.go"]), - params: { goKind: "test", scope: "file", target: "main_test.go", module: "app", pattern: "", useTags: "true" }, - }; - const rerun = rerunFailedConfig(config, [[packageName, "TestFail"]])!; - expect(rerun.params).toMatchObject({ - goKind: "test", - scope: "file", - target: "main_test.go", - pattern: "^TestFail$", - }); - expect(rerun.args).toEqual(["test", "-run", "^TestFail$", "main_test.go"]); - }); + it.each(["command-line-arguments", "example.com/app"])( + "retains file-scoped preparation for %s", + async (packageName) => { + const config = { + ...cfg("go", "go", ["test", "main_test.go"]), + params: { goKind: "test", scope: "file", target: "main_test.go", module: "app", pattern: "", useTags: "true" }, + }; + const rerun = (await rerunFailedConfig(config, [[packageName, "TestFail"]]))!; + expect(rerun.params).toMatchObject({ + goKind: "test", + scope: "file", + target: "main_test.go", + pattern: "^TestFail$", + }); + expect(rerun.args).toEqual(["test", "-run", "^TestFail$", "main_test.go"]); + }, + ); }); describe("exact Go test and benchmark selections", () => { - it("keeps benchmark selectors and removes stale selectors from binary arguments", () => { + it("keeps benchmark selectors and removes stale selectors from binary arguments", async () => { const base = cfg("go", "go", [ "test", "-tags=demo", @@ -280,7 +283,7 @@ describe("exact Go test and benchmark selections", () => { "-custom", "keep", ]); - const [config] = testsConfigs(base, [["example.com/p", "BenchmarkNew", "case"]], "selected"); + const [config] = await testsConfigs(base, [["example.com/p", "BenchmarkNew", "case"]], "selected"); expect(config!.args).toEqual([ "test", "-tags=demo", @@ -295,8 +298,8 @@ describe("exact Go test and benchmark selections", () => { ]); }); - it("keeps independent alternatives for tests of different depths", () => { - const [config] = testsConfigs( + it("keeps independent alternatives for tests of different depths", async () => { + const [config] = await testsConfigs( cfg("go", "go", ["test", "."]), [ ["p", "TestA", "one"], @@ -315,7 +318,7 @@ describe("exact Go test and benchmark selections", () => { ]); }); - it("keeps typed file benchmark flags and clears overridden binary selectors", () => { + it("keeps typed file benchmark flags and clears overridden binary selectors", async () => { const base = { ...cfg("go", "go", ["test", "chosen_test.go", "-args", "-test.run=Old", "-custom", "keep"]), params: { @@ -326,7 +329,7 @@ describe("exact Go test and benchmark selections", () => { programArgs: "-test.run=Old -custom keep", }, }; - const [config] = testsConfigs(base, [["example.com/p", "BenchmarkNew"]], "selected"); + const [config] = await testsConfigs(base, [["example.com/p", "BenchmarkNew"]], "selected"); expect(config!.params).toMatchObject({ target: "chosen_test.go", pattern: "^$", @@ -336,17 +339,81 @@ describe("exact Go test and benchmark selections", () => { expect(config!.args).toContain("chosen_test.go"); }); - it("reruns an entire failed package with its original runner options", () => { + it("reruns an entire failed package with its original runner options", async () => { const base = cfg("go", "go", ["test", "-race", "-bench", ".", "./..."]); - const [config] = testsConfigs(base, [["example.com/p"]], "selected"); + const [config] = await testsConfigs(base, [["example.com/p"]], "selected"); expect(config!.args).toEqual(["test", "-race", "-bench", ".", "example.com/p"]); }); }); -it("keeps inherited Go build tags enabled when deriving a typed package rerun", () => { +it("keeps inherited Go build tags enabled when deriving a typed package rerun", async () => { const config = { ...cfg("go", "go", ["test", "./..."]), params: { goKind: "test", scope: "package", target: "./..." }, }; - expect(rerunFailedConfig(config, [["example.com/p", "TestChosen"]])!.params?.useTags).toBe("true"); + expect((await rerunFailedConfig(config, [["example.com/p", "TestChosen"]]))!.params?.useTags).toBe("true"); +}); + +describe("plugin test selection", () => { + let dispose = () => {}; + afterEach(() => dispose()); + const pluginConfig = (): RunConfig => ({ + ...cfg("plugin", "runner", ["test", "--old-filter"]), + id: "plugin:example:tests", + params: { framework: "plugin" }, + env: { EXAMPLE: "yes" }, + cwd: "/project", + target: "plugin:example:container", + }); + + it("passes the owning config and exact failed name paths, replacing argv and preserving launch settings", async () => { + const select = vi.fn().mockResolvedValue(["test", "--only", "suite::fails"]); + dispose = providePluginTestSelector(select); + const base = pluginConfig(); + const results = new TestResults(); + results.apply([ + { type: "result", path: ["suite", "fails"], status: "failed" }, + { type: "result", path: ["suite", "passes"], status: "passed" }, + ]); + const configs = await rerunFailedConfigs(base, failedTestPaths(results)); + expect(select).toHaveBeenCalledExactlyOnceWith(base.id, [["suite", "fails"]]); + expect(configs).toEqual([{ ...base, name: "Failed: plugin test", args: ["test", "--only", "suite::fails"] }]); + expect(configs[0]!.env).not.toBe(base.env); + expect(base.args).toEqual(["test", "--old-filter"]); + }); + + it("uses the same selection for test-tree suites and accepts empty replacement argv", async () => { + const select = vi.fn().mockResolvedValue([]); + dispose = providePluginTestSelector(select); + const base = pluginConfig(); + const paths = [ + ["suite", "one"], + ["suite", "two"], + ]; + expect(await testsConfig(base, paths, "suite")).toEqual({ ...base, args: [], name: "suite" }); + expect(await testsConfigs(base, paths, "suite")).toHaveLength(1); + expect(select).toHaveBeenCalledWith(base.id, paths); + }); + + it("returns unsupported for no provider, none, errors and empty selection", async () => { + const base = pluginConfig(); + expect(await testsConfig(base, [["test"]], "selected")).toBeNull(); + const select = vi.fn().mockResolvedValue(null); + dispose = providePluginTestSelector(select); + expect(await rerunFailedConfigs(base, [["test"]])).toEqual([]); + select.mockRejectedValue(new Error("timeout")); + expect(await testsConfigs(base, [["test"]], "selected")).toEqual([]); + select.mockClear(); + expect(testsConfigs(base, [], "selected")).toEqual([]); + expect(select).not.toHaveBeenCalled(); + }); + + it("keeps built-in selection synchronous without calling plugins", () => { + const select = vi.fn(); + dispose = providePluginTestSelector(select); + const base = cfg("cargo", "cargo", ["test"]); + expect(testsConfig(base, [["src/lib.rs", "test"]], "test")).not.toBeInstanceOf(Promise); + expect(rerunFailedConfigs(base, [["src/lib.rs", "test"]])).not.toBeInstanceOf(Promise); + expect(select).not.toHaveBeenCalled(); + }); }); diff --git a/src/features/run/testing/rerun.ts b/src/features/run/testing/rerun.ts index 94ded0b5d..83e58423f 100644 --- a/src/features/run/testing/rerun.ts +++ b/src/features/run/testing/rerun.ts @@ -2,6 +2,7 @@ * "Rerun Failed Tests": derives a run configuration that runs only the failed * tests of a previous run, using each framework's own filter syntax. */ +import { selectPluginTests } from "@/workbench/contributions"; import { phpunitArgs, phpunitFilter } from "./phpunit"; import type { RunConfig } from "@/platform/ipc"; import { formatArgs } from "../configs"; @@ -149,18 +150,25 @@ export function leafTestPaths(nodes: readonly TestNode[]): string[][] { } /** Config running only `paths` (failed test paths from the results tree), or null when unsupported. */ -export function rerunFailedConfig(config: RunConfig, paths: string[][]): RunConfig | null { +export function rerunFailedConfig(config: RunConfig, paths: string[][]): RunConfig | null | Promise { const configs = rerunFailedConfigs(config, paths); - return configs.length === 1 ? configs[0]! : null; + const single = (configs: RunConfig[]) => (configs.length === 1 ? configs[0]! : null); + return configs instanceof Promise ? configs.then(single) : single(configs); } -export function rerunFailedConfigs(config: RunConfig, paths: string[][]): RunConfig[] { +export function rerunFailedConfigs(config: RunConfig, paths: string[][]): RunConfig[] | Promise { const baseName = config.name.replace(/^Failed: /, ""); return testsConfigs(config, paths, `Failed: ${baseName}`); } /** Go filters apply to every package in an invocation, so each package keeps its own selection. */ -export function testsConfigs(config: RunConfig, paths: string[][], name: string): RunConfig[] { +export function testsConfigs(config: RunConfig, paths: string[][], name: string): RunConfig[] | Promise { + if (!paths.length) return []; + if (config.params?.framework === "plugin") { + const selected = testsConfig(config, paths, name); + const list = (config: RunConfig | null) => (config ? [config] : []); + return selected instanceof Promise ? selected.then(list) : list(selected); + } const groups = new Map(); for (const path of paths) { const key = frameworkFor(config) === "go" ? (path[0] ?? "") : ""; @@ -169,14 +177,28 @@ export function testsConfigs(config: RunConfig, paths: string[][], name: string) groups.set(key, group); } return [...groups].flatMap(([pkg, selected]) => { - const derived = testsConfig(config, selected, groups.size > 1 ? `${name} (${pkg})` : name); + const derived = builtinTestsConfig(config, selected, groups.size > 1 ? `${name} (${pkg})` : name); if (!derived) return []; return [{ ...derived, id: groups.size > 1 ? `context:tests:${config.id}:${pkg}` : derived.id }]; }); } /** Config running only the tests at `paths` (test tree paths), named `name`; null when unsupported. */ -export function testsConfig(config: RunConfig, paths: string[][], name: string): RunConfig | null { +export function testsConfig( + config: RunConfig, + paths: string[][], + name: string, +): RunConfig | null | Promise { + if (!paths.length) return null; + if (config.params?.framework === "plugin") { + return selectPluginTests(config.id, paths) + .then((args) => (args === null ? null : { ...config, args, name, detected: true, env: { ...config.env } })) + .catch(() => null); + } + return builtinTestsConfig(config, paths, name); +} + +function builtinTestsConfig(config: RunConfig, paths: string[][], name: string): RunConfig | null { if (!paths.length) return null; let args: string[] | null; switch (frameworkFor(config)) { diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 50da15fb5..bd535e46c 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -1749,6 +1749,19 @@ export function parsePluginTests(config: string, chunk: string) { return pluginTestParser?.(config, chunk) ?? Promise.resolve(null); } +/** Optional selection support owned by the same host as the plugin test parser. */ +type PluginTestSelector = (config: string, tests: string[][]) => Promise; +let pluginTestSelector: PluginTestSelector | null = null; +export function providePluginTestSelector(selector: PluginTestSelector): () => void { + pluginTestSelector = selector; + return () => { + if (pluginTestSelector === selector) pluginTestSelector = null; + }; +} +export function selectPluginTests(config: string, tests: string[][]) { + return pluginTestSelector?.(config, tests) ?? Promise.resolve(null); +} + /** Read-only named snippets supplied by plugins. */ export interface ContributedSnippet { id: string; diff --git a/wit/run.wit b/wit/run.wit index c1bc79cf9..6c62534e2 100644 --- a/wit/run.wit +++ b/wit/run.wit @@ -47,4 +47,7 @@ interface test-results { } /// Parses one bounded output batch; failures fall back to built-in parsers. parse: func(config: string, chunk: string) -> list; + /// Selects test-tree name paths (parents then test), using the same config id as parse. + /// Returns the full replacement argument list, or none when filtering is unsupported. + select-tests: func(config: string, tests: list>) -> option>; }