дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(plugins): let test frameworks select tests to rerun (#2228)
* feat(plugins): let test frameworks select tests to rerun * test(smoke): await Rerun Failed Tests
Цей коміт міститься в:
1 джерело
8d5504b376
коміт
13b590f2c7
76 files changed
+693
-166
No files matched your search
@@ -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<Option<Vec<ide_plugin::PluginTestEvent>>> {
|
||||
if chunk.len() > 64 << 10 {
|
||||
return Err(IdeError::invalid("test output batch too large"));
|
||||
}
|
||||
) -> IdeResult<Option<(std::path::PathBuf, String, String)>> {
|
||||
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<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
|
||||
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::<usize>() <= 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<Option<Vec<ide_plugin::PluginTestEvent>>> {
|
||||
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();
|
||||
|
||||
@@ -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::<String>("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 {
|
||||
|
||||
@@ -76,11 +76,11 @@ impl TestWorkspace {
|
||||
|
||||
impl PluginWorkspace for TestWorkspace {
|
||||
fn run_configuration(&self, _client: &str, plugin: &str, id: &str) -> Result<ide_run::RunConfig, HostError> {
|
||||
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() {
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
invocation: Invocation,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<Vec<String>>, 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,
|
||||
|
||||
@@ -34,6 +34,8 @@ fn components() -> Vec<PathBuf> {
|
||||
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();
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
b9b06665132bd6db753aa098703f2d83c9185d756114cdd1abb986f9d6d56c06
|
||||
f8c5b18f9734fd19f65a32e5087655d75ad944dddda31780f61672b94da3da6e
|
||||
@@ -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<openidea_plugin::PluginTestEvent> {
|
||||
vec![]
|
||||
}
|
||||
fn select_tests(&mut self, config: &str, tests: &[Vec<String>]) -> Option<Vec<String>> {
|
||||
(config == "tests").then(|| tests.iter().map(|p| p.join("::")).collect())
|
||||
}
|
||||
}
|
||||
register_plugin!(Api14, test_results);
|
||||
@@ -1 +1 @@
|
||||
e986322ee0a7e8b09096deee3335e542823ad836cecdf14a4f44e2066fadf7b5
|
||||
f4ae5c053230e2a805b097d79392501ad4654f715d6d194937299273b67fda7c
|
||||
+1
-1
@@ -1 +1 @@
|
||||
cb74e35e0b0fa2bf7d46b143190d4e72d90abfa7a95962c037495b722d8c73d3
|
||||
7631939096b867c4707f7e0f929d2b75f91ef2b76da764d6e21233a5c06e8af9
|
||||
+1
-1
@@ -1 +1 @@
|
||||
7ef1de0b1eb9d70ed3f5ba1dfc8cec6ccf3445ce76ad128f10f3d2456a990c2c
|
||||
f5a2d43df242aa0ba9f358d9abf7296fd6a56b163bb526e62ce9c8b4d17e4b9a
|
||||
+1
-1
@@ -1 +1 @@
|
||||
dcca07b99ce7d6691bd501d0e7c80d6f4ce0350b32a3bd7726b13f5b315c3982
|
||||
f19481ef89309e6fc65736cb18018a4bf9b679e5a1c6c8e9f92adf39578ca4cb
|
||||
@@ -1 +1 @@
|
||||
6ab6593d3d7226a1a52bcdbe827697be39e9ed5117d238ba2366ad1a6911a623
|
||||
a6ba20c5bd531d965e64041b05f1dabfb1634c3192b06cdb61578bd2bf6f1af2
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
a410bcd828c7b43d550c67c433343d1c8d4a5310b62578fc1d7fec542f2761eb
|
||||
4c20506bb51bb9c08ebedb3ffb0c89ef153ee9565a466ac99d941a2c83ac2c9f
|
||||
@@ -89,6 +89,17 @@ impl RunConfigurations for Execution {
|
||||
}
|
||||
}
|
||||
impl TestResults for Execution {
|
||||
fn select_tests(&mut self, config: &str, tests: &[Vec<String>]) -> Option<Vec<String>> {
|
||||
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<PluginTestEvent> {
|
||||
if chunk == "trap" {
|
||||
panic!("parser failed");
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
86238b45b09b0274d59282b35295aaee650e999aa309640d9347787e7d461ec4
|
||||
110bbe7e4c0bd02d93c0c7ccae8fcb1eccbcf355587ca271d8046b588f3db898
|
||||
+1
-1
@@ -1 +1 @@
|
||||
d433104828c588baf00dece077a087dc7f4cd78097952adca5b6049e366ca944
|
||||
2c02b56def836148a1be5ac0429394d9430d468c3d3ee3682a3c742bd8c90436
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4093e1d4791c3786f4b67fd4ef76928fc17048470f858824a29aafd71855c77d
|
||||
6681327fc078ecad751627998c4b6299b6176484a61760d6f265b41bd3f42c97
|
||||
@@ -1 +1 @@
|
||||
aeb6d2f0a589731a1b7f71c9c71232b72d2535f0f555c4fbdb7101861b906d77
|
||||
a6a21264dffff392ea5e998c4545cdbe21a08bae429d2765e6bfeac9f2c9f863
|
||||
@@ -1 +1 @@
|
||||
b723a8826d256fb57f04bbe8aa4b5300c9777f79d889bed2770f6f2bd7a65aa3
|
||||
f456deae0bbdee79c22d844ac8a9db5e4b867eaed245dc9332c1708e32e01ae0
|
||||
@@ -1 +1 @@
|
||||
31f6c0067f7668235ce7f28cb14f75950549c690a8f191aa63a71fe25f8397c6
|
||||
50e77e306f196dd43f7d95789cc1da94085a2863f1ccbec83701c34916ffff41
|
||||
+1
-1
@@ -1 +1 @@
|
||||
f03ff9487293925027c5681800633d2e0a71ef908db9ea037dff5e4e0a48458e
|
||||
b9342ba2bf0107bb65074aecf726991d326a45e50ac12100a97c7a6051b01161
|
||||
@@ -1 +1 @@
|
||||
98a083b972c0a887de07b720c606ffcf13f99d835cfa64d553a1243ffc19e4b1
|
||||
b4300035602303343f8b1c15326f86a696bfe814a3f2c7e1e186f5eed5a92321
|
||||
+1
-1
@@ -1 +1 @@
|
||||
639a25cbc3aaab3080bf2894bc4710f45a1043a8a0f0f7f8c41ac20253f8bc98
|
||||
91d6f1f11a26effda7a21fd423f8ea6f42e707168ac5080033e9116c89d11d10
|
||||
@@ -1 +1 @@
|
||||
741d2909f021ec4c2f572edba0d83bcee4843a1aae4d8a79ea0367419a667640
|
||||
3edc6122e6cbba033d37f44d7855deff8473f097205846e06768888ac416742c
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1da271855c8e63d9b6873521392bb2e14cb85f9de4fddcc7bce0c80916298caf
|
||||
83ce23d58d809e9a724dfcb8bec2cccc1f73c77324d39d1c6ee2a7c855d763fd
|
||||
+1
-1
@@ -1 +1 @@
|
||||
e14bfd9a7f61a7ff44f656a148ed133e973c66edfb0859bbff4d4cda9663eb3a
|
||||
557e263c737bc5cdf15f5bc4ed4fa5d7a4f2e252b06c2eb873e936a968519c08
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1c2f975248675b935adac4ac096d23d3f45ca44a46e9e4a2ee578bb69bb35356
|
||||
884e60babc875a5971a9ee42d0949c22712b9af749a6995d6273efa191aa65b5
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2d566981f7f3aa78fbd6188e2bbdb17e33c01f6863e9b9a0b656f541371374bc
|
||||
5f2f03bbc397dde8ad7c9b1cf2a61e9a4ae5881b34d71ec2e5748fc474754d59
|
||||
@@ -1 +1 @@
|
||||
69b349e65d32c5b22f7b6d312944e0e7387d1f7b8e46a1e88fcfcd87262ea407
|
||||
253a7ab7bb1827dee5434b47c732e484976237ca5a5d637536ccc36905d84be0
|
||||
@@ -1 +1 @@
|
||||
e62e75f6e21f4d3c3797554581b6a78fc75a5362ce32f627c9858dd5f1d4edc5
|
||||
93467ab4d2a02ec72820f17df873510707dfdabd02d70c19164b8192d967bf7d
|
||||
@@ -1 +1 @@
|
||||
c4740acdb3c805e13bf9a0c2e2c2a830336a1085285c39de3dce73a301f70150
|
||||
62372338f5c0129177936ad34c7f0fc5d8cd2f14c3f895c026ca7f79f1e31391
|
||||
+1
-1
@@ -1 +1 @@
|
||||
315d592adb776916170a6c2293379e7a7a086b9ae513dcac4aefad925d91df5d
|
||||
4d1952be87c87f44e23b2420f966e23912405ba842d9928f1f9dec409dfa670e
|
||||
@@ -1 +1 @@
|
||||
7a88345173c4a77f6f94a047af53a43852adcf7e0377199f670374f296b98b19
|
||||
3992885be293901280938f5dc6498a39a310e1a3c7e34473394116a2327bb13b
|
||||
@@ -1 +1 @@
|
||||
c98b94057e62a575d6c4b6c4c92e1e88e101bcef9138683e47b0bebf262e3f63
|
||||
70926629540ab1277641aef53419ce3014f291e424502619378511735bdbb8d5
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +1 @@
|
||||
acb5f0a97061616d5bdcf7193d8fbe45c27818663dc0a9eab2e8203589492919
|
||||
c4900df449a6a323bab1f4fe5fe76ee42e2839c9197636b7c1cef733dffc683d
|
||||
@@ -1 +1 @@
|
||||
aaa3cdd1595ba8aeabae6472f7f1ef635bd3115662d64d9284152747edfb6b16
|
||||
0ea139d4a0d47072971c0b37b93085159e412554ffb45d19590bae4dc10f48b5
|
||||
@@ -1 +1 @@
|
||||
7d09b4e0e3edfbed92be8efaba47d468b7707d6164abf45b0bec495ac454f4dd
|
||||
4e4bc361395f6364e17211a9d3c32232a370ea1ee72e5197eab5f706ea465dc6
|
||||
@@ -1 +1 @@
|
||||
929b309e182c078790940244429b9872d8c7723c0bc25a07b152c83300905c47
|
||||
c39211d416f45dff0265216509070ffdecc4e0f4e738c88176cad5faaca01b65
|
||||
@@ -1 +1 @@
|
||||
5099f6ceba7936a9b54340a4ce0c9b50a49962dd96053c850c010ea5c803c6a2
|
||||
2bbb840e0bead664ccb467dbb299b269fc9a03ed4995c8d890ae195efe6e47a6
|
||||
@@ -1 +1 @@
|
||||
6deb9876934b6e21bd99c791f2a86167f1d67e808fbb1cc514c7e621514b654c
|
||||
b1fdf25b8307803b0e5e189bbe041cdb23b1e7b73abb4b76fd7a1b9ee48d87b0
|
||||
@@ -1 +1 @@
|
||||
5c2f5a254b25ba018369366df87707b423e77a573fcb73cc3c74b1f321f5aa61
|
||||
849d83a77d80e8d8a4f2c2801f4728300dd4504da411689834217b9973e582f4
|
||||
@@ -1 +1 @@
|
||||
cb4ed7e9d793df4219e92edd5d64616db65411cb77e111df862ca8237f647efb
|
||||
ff42121519f6796db56a71a504484b149592e9e28ed33c783d2e6141cfe84d6c
|
||||
@@ -1 +1 @@
|
||||
f43f1e52813a5f992c3d06cbe03aa26092dd56db45322b090906dfbb8d08b1ab
|
||||
0d5ed3d81ab5496b92e7b012ae6e1e765fb45f4f6b0613a03f2bc79029ed9397
|
||||
@@ -1 +1 @@
|
||||
656b51f57467a75b5416575871a5722e4f44be98407f6bfd0e6fa1e44f0bfa80
|
||||
dd34327c65747b1b57eef0854161837bd4e2843a59efec7730b81d8a2866a411
|
||||
@@ -1 +1 @@
|
||||
bafe2cfc88d8c1272eea289d3d804287d56159b1c5893147b73a6be88e00370f
|
||||
2141b215b141d82cc9a5cffa63c11e2b53e7bebcd31b93fb8d2db0ff5c2acd5b
|
||||
@@ -1 +1 @@
|
||||
ee5d474028c7068b06771efa5e8e2fa710cd0108d53737a36d8bf9f0f3490c08
|
||||
604a289139233c13108ff8aab448e4278604e5260d7b2485e3ace3bad9e855db
|
||||
@@ -1 +1 @@
|
||||
867fc7bba0e795155e96c39f5a05cc3a08a3b6809c4e3922437e393d65f6d829
|
||||
9efa3a6a41c05e81a50893a2cff79da6e8e5f97219ddfd54f7ed201270934bb5
|
||||
@@ -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.
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
b7aaac21c6c34d9d722aa23c13c66e61ef11028f038fa877fe67d03b03c18483
|
||||
5f05e3696671258af5474b10abafd3cab48385493d5336061a9a8679307b56b4
|
||||
@@ -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
|
||||
@@ -48,6 +48,12 @@ impl LanguageFeatures for TestRunner {
|
||||
}
|
||||
}
|
||||
impl TestResults for TestRunner {
|
||||
fn select_tests(&mut self, config: &str, tests: &[Vec<String>]) -> Option<Vec<String>> {
|
||||
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<PluginTestEvent> {
|
||||
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(_)]));
|
||||
|
||||
@@ -1 +1 @@
|
||||
66e92c09c0050dbf4c302793eea5048e21d1c91b9b44811b5f7edbae6eaa9075
|
||||
677ec8005d29d4727afbc4b8fba6afcbc26996f0db461d1e98a6e9e52b5f7fac
|
||||
@@ -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<PluginTestEvent>;
|
||||
/// Full replacement argv for test-tree name paths, or None when filtering is unsupported.
|
||||
fn select_tests(&mut self, _config: &str, _tests: &[Vec<String>]) -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::Event {
|
||||
|
||||
@@ -1101,6 +1101,12 @@ pub mod __rt {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn select_tests<T: Plugin + crate::TestResults + Default>(
|
||||
config: &str,
|
||||
tests: &[Vec<String>],
|
||||
) -> Option<Vec<String>> {
|
||||
with::<T, _>(|p| p.select_tests(config, tests))
|
||||
}
|
||||
pub fn test_results<T: Plugin + crate::TestResults + Default>(
|
||||
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<Vec<String>>) -> Option<Vec<String>> {
|
||||
$crate::__rt::select_tests::<$plugin>(&config, &tests)
|
||||
}
|
||||
}
|
||||
$crate::test_results_bindings::__export_tests_world!($component with_types_in $crate::test_results_bindings);
|
||||
};
|
||||
|
||||
@@ -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<PluginTestEvent> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
assert_eq!(Parser.select_tests("tests", &[vec!["Suite".into(), "Test".into()]]), None);
|
||||
}
|
||||
@@ -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<test-event>;
|
||||
/// 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<list<string>>) -> option<list<string>>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -514,6 +514,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
) -> IdeResult<Option<Vec<String>>> {
|
||||
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]
|
||||
|
||||
@@ -1145,6 +1145,8 @@ export const commands = {
|
||||
pluginTerminalSessionClose: (operation: string) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_terminal_session_close", { operation })),
|
||||
/** Parses one batch of an owned test configuration's output. */
|
||||
pluginParseTestResults: (config: string, chunk: string) => typedError<PluginTestEvent[] | null, IdeError>(__TAURI_INVOKE("plugin_parse_test_results", { config, chunk })),
|
||||
/** Selects test-tree paths in an owned plugin test configuration. */
|
||||
pluginSelectTests: (config: string, tests: string[][]) => typedError<string[] | null, IdeError>(__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<null, IdeError>(__TAURI_INVOKE("plugin_content_tab_action", { pluginId, action })),
|
||||
/** Re-approves a plugin's executable, globally or for this window's project. */
|
||||
|
||||
@@ -88,6 +88,7 @@ export interface PluginsBackend {
|
||||
answerPrompt(promptId: string, answer: string[] | null): Promise<void>;
|
||||
/** This window's answer to a plugin's `commitRequested`. */
|
||||
answerCommand(requestId: string, answer: CommandAnswer): Promise<void>;
|
||||
selectTests(config: string, tests: string[][]): Promise<string[] | null>;
|
||||
parseTestResults(config: string, chunk: string): Promise<import("@/bindings").PluginTestEvent[] | null>;
|
||||
closeTerminalSession(operation: string): Promise<void>;
|
||||
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 () => [],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<RunConfig[]>;
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
@@ -226,6 +255,7 @@ export function SessionPanel({
|
||||
running={running}
|
||||
selected={selectedTest}
|
||||
onSelect={setSelectedTest}
|
||||
canRunTests={(ids) => canRunSelectedTests(session.id, ids)}
|
||||
onRunTests={(ids, debug) => void runSelectedTests(session.id, ids, debug)}
|
||||
/>
|
||||
</SplitPanel>
|
||||
|
||||
@@ -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<boolean>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TestTreeView
|
||||
@@ -83,4 +86,29 @@ describe("TestTreeView", () => {
|
||||
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<boolean>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
const canRunTests = vi.fn(() => selection);
|
||||
const onRunTests = vi.fn();
|
||||
render(<Host onRunTests={onRunTests} canRunTests={canRunTests} />);
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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<boolean>;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<RunSession, "config" | "baseConfig">):
|
||||
}
|
||||
|
||||
/** 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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<RunConfig | null> {
|
||||
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<RunConfig[]> {
|
||||
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<RunConfig[]> {
|
||||
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<string, string[][]>();
|
||||
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<RunConfig | null> {
|
||||
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)) {
|
||||
|
||||
@@ -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<string[] | null>;
|
||||
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;
|
||||
|
||||
@@ -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<test-event>;
|
||||
/// 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<list<string>>) -> option<list<string>>;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача