1mod assets;
8mod availability;
9mod config_edit;
10mod install;
11mod model;
12mod opencode_config;
13mod paths;
14pub mod resume;
15mod status;
16
17pub use availability::is_available;
18pub use install::install;
19pub use model::{
20 InstallResult, InstallStatus, IntegrationMetadata, IntegrationTarget, LifecycleCapability,
21};
22pub use status::{metadata, scan};
23
24#[cfg(test)]
25mod tests {
26 use super::*;
27 use serde_json::Value;
28 use std::fs;
29 use std::path::PathBuf;
30
31 fn test_root(name: &str) -> PathBuf {
32 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
33 .join("../../target/wsx-core-integration-tests")
34 .join(format!(
35 "{name}-{}-{}",
36 std::process::id(),
37 std::time::SystemTime::now()
38 .duration_since(std::time::UNIX_EPOCH)
39 .unwrap()
40 .as_nanos()
41 ));
42 fs::create_dir_all(&path).unwrap();
43 path
44 }
45
46 #[test]
47 fn enum_is_complete() {
48 assert_eq!(IntegrationTarget::ALL.len(), 17);
49 assert_eq!(
50 IntegrationTarget::ALL.map(IntegrationTarget::cli_value),
51 [
52 "pi",
53 "omp",
54 "claude",
55 "codex",
56 "copilot",
57 "devin",
58 "droid",
59 "kimi",
60 "opencode",
61 "kilo",
62 "hermes",
63 "qodercli",
64 "qwen",
65 "cursor",
66 "mastracode",
67 "antigravity-cli",
68 "grok"
69 ]
70 );
71 }
72 #[test]
73 fn availability_and_status_are_path_injected() {
74 let root = test_root("availability");
75 let executable = root.join("claude");
76 fs::write(&executable, "#!/bin/sh\n").unwrap();
77 #[cfg(unix)]
78 {
79 use std::os::unix::fs::PermissionsExt;
80 fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
81 }
82 assert!(availability::available_on_path_for_test(
83 IntegrationTarget::Claude,
84 &root
85 ));
86 assert_eq!(
87 status::metadata_for_test(IntegrationTarget::Claude, &root)
88 .unwrap()
89 .install_status,
90 InstallStatus::Missing
91 );
92 fs::remove_dir_all(root).unwrap();
93 }
94
95 #[test]
96 fn safe_idempotent_install() {
97 let root = test_root("pi");
98 let first = install::install_for_test(IntegrationTarget::Pi, &root).unwrap();
99 let before = fs::read(&first.paths[0]).unwrap();
100 let second = install::install_for_test(IntegrationTarget::Pi, &root).unwrap();
101 assert_eq!(first.paths, second.paths);
102 assert_eq!(before, fs::read(&second.paths[0]).unwrap());
103 assert_eq!(
104 status::metadata_for_test(IntegrationTarget::Pi, &root)
105 .unwrap()
106 .install_status,
107 InstallStatus::Current
108 );
109 fs::remove_dir_all(root).unwrap();
110 }
111
112 #[test]
113 fn every_target_installs_idempotently_in_isolation() {
114 let parent = test_root("all-targets");
115 for target in IntegrationTarget::ALL {
116 let root = parent.join(target.cli_value());
117 fs::create_dir_all(&root).unwrap();
118 let first = install::install_for_test(target, &root).unwrap();
119 let second = install::install_for_test(target, &root).unwrap();
120 assert_eq!(first.paths, second.paths, "{target}");
121 assert_eq!(
122 status::metadata_for_test(target, &root)
123 .unwrap()
124 .install_status,
125 InstallStatus::Current,
126 "{target}"
127 );
128 }
129 let grok_hooks = fs::read_to_string(parent.join("grok/hooks/wsx.json")).unwrap();
130 assert!(grok_hooks.contains("SessionEnd") && grok_hooks.contains("detached"));
131 fs::remove_dir_all(parent).unwrap();
132 }
133
134 #[test]
135 fn omp_rejects_the_pi_extension_directory() {
136 let shared = test_root("shared-agent");
137 let omp = paths::asset_path_in(&shared, IntegrationTarget::Omp);
138 let pi = paths::asset_path_in(&shared, IntegrationTarget::Pi);
139
140 let error = install::validate_omp_directories_for_test(&omp, &pi).unwrap_err();
141
142 assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
143 fs::remove_dir_all(shared).unwrap();
144 }
145
146 #[test]
147 fn invalid_config_does_not_publish_a_current_version_marker() {
148 let root = test_root("invalid-config");
149 fs::write(root.join("settings.json"), "[]\n").unwrap();
150
151 assert!(install::install_for_test(IntegrationTarget::Claude, &root).is_err());
152 assert!(!paths::asset_path_in(&root, IntegrationTarget::Claude).exists());
153 assert_eq!(
154 status::metadata_for_test(IntegrationTarget::Claude, &root)
155 .unwrap()
156 .install_status,
157 InstallStatus::Missing
158 );
159 fs::remove_dir_all(root).unwrap();
160 }
161
162 #[cfg(unix)]
163 #[test]
164 fn rejects_unsafe_destinations() {
165 use std::os::unix::fs::{symlink, PermissionsExt};
166 let dir = test_root("unsafe");
167 let target = dir.join("target");
168 fs::write(&target, "x").unwrap();
169 let link = dir.join("link");
170 symlink(&target, &link).unwrap();
171 assert_eq!(
172 install::atomic_write_for_test(&link, "x")
173 .unwrap_err()
174 .kind(),
175 std::io::ErrorKind::PermissionDenied
176 );
177 let bad = dir.join("bad");
178 fs::write(&bad, "x").unwrap();
179 fs::set_permissions(&bad, fs::Permissions::from_mode(0o666)).unwrap();
180 assert_eq!(
181 install::atomic_write_for_test(&bad, "x")
182 .unwrap_err()
183 .kind(),
184 std::io::ErrorKind::PermissionDenied
185 );
186 fs::remove_dir_all(dir).unwrap();
187 }
188 #[test]
189 fn representative_config_shapes() {
190 let p = PathBuf::from("target/wsx hook.sh");
191 let cfg = PathBuf::from("x.json");
192 let nested = config_edit::json_config(
193 IntegrationTarget::Claude,
194 r#"{"keep":1,"hooks":{"PermissionRequest":[{"hooks":[{"type":"command","command":"keep-permission-hook"},{"type":"command","command":"'target/wsx hook.sh' blocked"}]}],"StopFailure":[{"matcher":"custom","hooks":[{"type":"command","command":"keep-stop-failure"}]},{"matcher":"*","hooks":[{"type":"command","command":"'target/wsx hook.sh' working"}]}]}}"#,
195 &cfg,
196 &p,
197 )
198 .unwrap();
199 assert!(nested.contains("SessionStart") && nested.contains("\"keep\": 1"));
200 assert!(nested.contains("keep-permission-hook"));
201 assert!(nested.contains("keep-stop-failure"));
202 let claude: Value = serde_json::from_str(&nested).unwrap();
203 let stop_failures = claude["hooks"]["StopFailure"].as_array().unwrap();
204 assert_eq!(stop_failures.len(), 3);
205 for (matcher, action) in [
206 ("rate_limit", "blocked"),
207 (
208 "overloaded|authentication_failed|oauth_org_not_allowed|account_on_hold|billing_error|invalid_request|model_not_found|server_error|max_output_tokens|unknown",
209 "error",
210 ),
211 ] {
212 assert!(stop_failures.iter().any(|entry| {
213 entry["matcher"] == matcher
214 && entry["hooks"].as_array().is_some_and(|hooks| {
215 hooks.iter().any(|hook| {
216 hook["command"] == config_edit::command(&p, action)
217 })
218 })
219 }));
220 }
221 assert!(!stop_failures.iter().any(|entry| {
222 entry["hooks"].as_array().is_some_and(|hooks| {
223 hooks
224 .iter()
225 .any(|hook| hook["command"] == config_edit::command(&p, "working"))
226 })
227 }));
228 for (event, action) in [
229 ("SessionStart", "idle"),
230 ("SessionEnd", "detached"),
231 ("UserPromptSubmit", "working"),
232 ("Stop", "done"),
233 ] {
234 assert!(nested.contains(event), "missing Claude event {event}");
235 assert!(
236 nested.contains(&config_edit::command(&p, action)),
237 "missing Claude action {action}"
238 );
239 }
240 assert_eq!(
241 config_edit::json_config(IntegrationTarget::Claude, &nested, &cfg, &p).unwrap(),
242 nested
243 );
244 assert!(config_edit::json_config(IntegrationTarget::Claude, "[]", &cfg, &p).is_err());
245 let codex_hooks = config_edit::json_config(
246 IntegrationTarget::Codex,
247 r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"keep-me"},{"type":"command","command":"'target/wsx hook.sh' session"}]}]}}"#,
248 &cfg,
249 &p,
250 )
251 .unwrap();
252 for (event, action) in [
253 ("SessionStart", "idle"),
254 ("UserPromptSubmit", "working"),
255 ("PreToolUse", "working"),
256 ("PermissionRequest", "blocked"),
257 ("PostToolUse", "working"),
258 ("Stop", "done"),
259 ("Interrupt", "idle"),
260 ] {
261 assert!(codex_hooks.contains(event), "missing Codex event {event}");
262 assert!(codex_hooks.contains(&config_edit::command(&p, action)));
263 }
264 assert!(codex_hooks.contains("keep-me"));
265 assert!(!codex_hooks.contains("'target/wsx hook.sh' session"));
266 assert_eq!(
267 config_edit::json_config(IntegrationTarget::Codex, &codex_hooks, &cfg, &p).unwrap(),
268 codex_hooks
269 );
270 let direct = config_edit::json_config(IntegrationTarget::Copilot, "{}", &cfg, &p).unwrap();
271 assert!(direct.contains("\"bash\""));
272 let simple = config_edit::json_config(IntegrationTarget::Cursor, "{}", &cfg, &p).unwrap();
273 assert!(simple.contains("sessionStart"));
274 let flat = config_edit::json_config(IntegrationTarget::Mastracode, "{}", &cfg, &p).unwrap();
275 assert!(flat.contains("PermissionRequest"));
276 let named = config_edit::json_config(
277 IntegrationTarget::AntigravityCli,
278 r#"{"other":{}}"#,
279 &cfg,
280 &p,
281 )
282 .unwrap();
283 assert!(named.contains("PreInvocation") && named.contains("other"));
284 let codex = config_edit::codex_toml("model = \"x\"\n");
285 assert!(codex.contains("[features]\nhooks = true"));
286 let kimi = config_edit::kimi_toml("model = \"x\"\n", &p);
287 assert!(kimi.contains("PermissionRequest") && kimi.contains("model"));
288 let yaml = config_edit::hermes_yaml("theme: dark\n");
289 assert!(yaml.contains("wsx-agent-status") && yaml.contains("theme"));
290 }
291
292 #[test]
293 fn primary_assets_report_version_and_native_session_ids() {
294 for target in IntegrationTarget::ALL {
295 let asset = assets::primary(target);
296 let marker = format!("WSX_INTEGRATION_VERSION={}", target.expected_version());
297 assert!(asset.contains(&marker), "{target}: missing {marker}");
298 assert!(
299 asset.contains("--session-id"),
300 "{target}: missing --session-id"
301 );
302 assert!(
303 !asset.contains("--conversation-id"),
304 "{target}: unexpected --conversation-id"
305 );
306 }
307 }
308
309 #[test]
310 fn lifecycle_capabilities_match_native_authoritative_adapters() {
311 assert!(assets::primary(IntegrationTarget::Claude)
312 .contains("[ \"claude\" = \"claude\" ] && set -- \"$@\" --escape-interrupts"));
313 let authoritative = IntegrationTarget::ALL
314 .into_iter()
315 .filter(|target| target.lifecycle() == LifecycleCapability::Authoritative)
316 .collect::<Vec<_>>();
317 assert_eq!(
318 authoritative,
319 [
320 IntegrationTarget::Pi,
321 IntegrationTarget::Omp,
322 IntegrationTarget::Claude,
323 IntegrationTarget::Codex,
324 IntegrationTarget::Kimi,
325 IntegrationTarget::Opencode,
326 IntegrationTarget::Kilo,
327 IntegrationTarget::Mastracode,
328 ]
329 );
330 }
331
332 #[test]
333 fn authoritative_assets_and_configs_report_completion() {
334 let pi = assets::primary(IntegrationTarget::Pi);
335 assert!(pi.contains("SETTLEMENT_DELAY_MS = 25"));
336 assert!(pi.contains("BLOCKING_UI_METHODS"));
337 assert!(pi.contains("observeBlockingUi(ctx.ui, updateBlocked)"));
338 assert!(
339 pi.contains("Promise.resolve(Reflect.apply(original, uiValue, args)).finally(release)")
340 );
341 assert!(pi.contains("restoreBlockingUi?.();"));
342 assert!(pi.contains("clearPendingSettlement();"));
343 assert!(pi.contains("ctx.isIdle() === false"));
344 assert!(pi.contains("report(settledRunAborted ? \"idle\" : \"done\", settledSessionRef)"));
345 assert!(pi.contains("stopReason === \"aborted\""));
346 assert!(pi.contains("pi.on(\"session_shutdown\""));
347 assert!(pi.contains("await flushReports();"));
348 assert!(pi.contains("--detached"));
349 let omp = assets::primary(IntegrationTarget::Omp);
350 assert!(omp.contains("report(\"done\", ctx)"));
351 assert!(omp.contains("pi.on(\"session_shutdown\""));
352 assert!(omp.contains("await flushReports();"));
353 assert!(omp.contains("--detached"));
354 for target in [IntegrationTarget::Opencode, IntegrationTarget::Kilo] {
355 assert!(
356 assets::primary(target).contains("activeSessions.has(id) ? \"done\" : \"idle\"")
357 );
358 }
359 let hook = PathBuf::from("wsx-agent-status.sh");
360 let kimi = config_edit::kimi_toml("", &hook);
361 assert!(kimi.contains("'done'") || kimi.contains(" done"));
362 let codex = config_edit::json_config(
363 IntegrationTarget::Codex,
364 "{}",
365 PathBuf::from("hooks.json").as_path(),
366 &hook,
367 )
368 .unwrap();
369 assert!(codex.contains("blocked") && codex.contains("done"));
370 let mastra = config_edit::json_config(
371 IntegrationTarget::Mastracode,
372 "{}",
373 PathBuf::from("hooks.json").as_path(),
374 &hook,
375 )
376 .unwrap();
377 assert!(mastra.contains("done"));
378 }
379
380 #[test]
381 fn teardown_capable_adapters_report_detached() {
382 let hook = PathBuf::from("wsx-agent-status.sh");
383 for target in [
384 IntegrationTarget::Claude,
385 IntegrationTarget::Codex,
386 IntegrationTarget::Copilot,
387 IntegrationTarget::Devin,
388 IntegrationTarget::Droid,
389 IntegrationTarget::Qodercli,
390 IntegrationTarget::Qwen,
391 IntegrationTarget::Cursor,
392 IntegrationTarget::Mastracode,
393 ] {
394 let config = config_edit::json_config(
395 target,
396 "{}",
397 PathBuf::from("hooks.json").as_path(),
398 &hook,
399 )
400 .unwrap();
401 assert!(config.contains("detached"), "{target}: missing detach hook");
402 }
403 assert!(config_edit::kimi_toml("", &hook).contains("detached"));
404 assert!(assets::primary(IntegrationTarget::Hermes).contains("on_session_finalize"));
405 }
406
407 #[test]
408 fn working_adapters_renew_wake_authority() {
409 for target in [
410 IntegrationTarget::Pi,
411 IntegrationTarget::Omp,
412 IntegrationTarget::Opencode,
413 IntegrationTarget::Kilo,
414 ] {
415 let asset = assets::primary(target);
416 assert!(
417 asset.contains("300_000"),
418 "{target}: missing wake heartbeat"
419 );
420 assert!(
421 asset.contains("sendInFlight"),
422 "{target}: reports must serialize"
423 );
424 assert!(
425 asset.contains("report(\"working\""),
426 "{target}: heartbeat must renew Working"
427 );
428 }
429 }
430
431 #[test]
432 fn pi_and_omp_primary_assets_prefer_session_paths_and_report_lifecycle() {
433 for target in [IntegrationTarget::Pi, IntegrationTarget::Omp] {
434 let asset = assets::primary(target);
435 let path = asset
436 .find("--session-path")
437 .expect("session path assertion requires a path branch");
438 let id = asset
439 .find("--session-id")
440 .expect("session path assertion requires an ID branch");
441 assert!(path < id, "{target}: path branch must precede ID branch");
442 assert!(
443 asset.contains(&format!("\"--provider\", \"{}\"", target.cli_value())),
444 "{target}: missing exact provider reporting"
445 );
446 assert!(
447 asset.contains("--lifecycle"),
448 "{target}: missing lifecycle reporting"
449 );
450 }
451 }
452
453 #[test]
454 fn opencode_assets_use_tui_routing_and_argv_reporting() {
455 assert!(assets::OPENCODE_TUI.contains("api.route.current"));
456 assert!(assets::OPENCODE_TUI.contains("execFile"));
457 assert!(!assets::OPENCODE_TUI.contains("createConnection"));
458 let updated = opencode_config::register_tui("{\n // keep\n \"plugin\": []\n}\n").unwrap();
459 assert!(updated.contains("// keep"));
460 assert!(updated.contains("./wsx-tui-session.js"));
461 }
462}