返回 CodeWhale
host_tests.rs
根目录 / crates / tui / src / tools / pdf / host_tests.rs
1 //! Real pinned Host plus local fake parser. These are author acceptance cases;
2 //! the parent must regenerate the canonical bundle/pin and run them after apply.
3 use super::*;
4 use crate::extension_host::tests::node_for_tests;
5 use crate::extension_host::{ExtensionHostManager, ExtensionHostOptions, TestManagerGuard};
6 use crate::features::{Feature, Features};
7 use crate::plugins::activation::TestPolicyGuard;
8 use crate::tools::spec::{ToolContext, ToolResult, ToolSpec};
9 use std::sync::Arc;
10
11 fn context(root: &Path, host: bool) -> ToolContext {
12 let mut flags = Features::with_defaults();
13 if host {
14 flags.enable(Feature::PdfHost);
15 }
16 ToolContext::new(root).with_features(flags)
17 }
18 fn new_manager(node: PathBuf, root: &Path) -> Arc<ExtensionHostManager> {
19 Arc::new(ExtensionHostManager::new(ExtensionHostOptions {
20 runtime: crate::config::ExtensionHostRuntime::Node,
21 node_override: Some(node),
22 root: Some(root.join("host")),
23 ..ExtensionHostOptions::default()
24 }))
25 }
26 #[cfg(test)]
27 #[cfg(unix)]
28 fn parser(root: &Path, script: &str) -> PathBuf {
29 use std::os::unix::fs::PermissionsExt;
30 let path = root.join("pdftotext");
31 std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
32 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
33 path
34 }
35 fn decision(code: &str, message: Option<&str>) -> ToolResult {
36 ToolResult {
37 content: String::new(),
38 success: true,
39 metadata: Some(json!({"kind":"pdf_decision","code":code,"message":message})),
40 }
41 }
42
43 #[test]
44 fn pdf_host_is_independent_and_defaults_to_rust() {
45 let mut flags = Features::with_defaults();
46 assert!(!flags.enabled(Feature::PdfHost));
47 assert_eq!(
48 crate::features::feature_from_key("pdf_host"),
49 Some(Feature::PdfHost)
50 );
51 flags.enable(Feature::PdfHost);
52 assert!(!flags.enabled(Feature::ExtensionHost));
53 assert!(!flags.enabled(Feature::SpeechHost));
54 }
55
56 #[cfg(unix)]
57 #[tokio::test(flavor = "current_thread")]
58 async fn real_host_pdf_matches_default_stdout_page_arguments_and_faults() {
59 let _home = crate::test_support::SealedHome::new();
60 let _policy = TestPolicyGuard::extension_host(false);
61 let Some(node) = node_for_tests("real_host_pdf_parity") else {
62 return;
63 };
64 let root = tempfile::tempdir().unwrap();
65 let manager = new_manager(node, root.path());
66 let _manager = TestManagerGuard::install(Arc::clone(&manager));
67 let _secret =
68 crate::test_support::EnvVarGuard::set("DEEPSEEK_API_KEY", "test-only-parent-secret");
69 let input = root.path().join("input with spaces.pdf");
70 std::fs::write(&input, b"%PDF-1.7\nfixture\n%%EOF").unwrap();
71 for (index, script) in [
72 "printf 'args:%s\\n' \"$*\"; printf 'page one\\fpage two\\n'",
73 "printf 'bad\\033[31m\\000\\nnext' >&2; exit 2",
74 "exit 3",
75 "[ -z \"${DEEPSEEK_API_KEY+x}\" ] || exit 9; printf clean",
76 ]
77 .into_iter()
78 .enumerate()
79 {
80 let binary = parser(root.path(), script);
81 let rust = context(root.path(), false);
82 let host = context(root.path(), true);
83 let expected = extract_path(
84 &input,
85 Some((2, 4)),
86 PdfTextCommand::test(binary.as_os_str(), Duration::from_secs(10), None)
87 .with_context(&rust),
88 )
89 .await;
90 let actual = extract_path(
91 &input,
92 Some((2, 4)),
93 PdfTextCommand::test(binary.as_os_str(), Duration::from_secs(10), None)
94 .with_context(&host),
95 )
96 .await;
97 assert_eq!(actual, expected);
98 if let Ok(text) = actual
99 && index == 0
100 {
101 assert!(text.contains("-layout -f 2 -l 4"));
102 assert!(text.contains(input.to_string_lossy().as_ref()));
103 }
104 }
105 let missing = root.path().join("missing-parser");
106 assert_eq!(
107 extract_path(
108 &input,
109 None,
110 PdfTextCommand::test(missing.as_os_str(), Duration::from_secs(10), None)
111 .with_context(&context(root.path(), true))
112 )
113 .await,
114 Err(PdfTextError::BinaryUnavailable)
115 );
116 assert!(!crate::plugins::activation::extension_host_policy_enabled());
117 manager.shutdown().await;
118 }
119
120 #[cfg(unix)]
121 #[tokio::test(flavor = "current_thread")]
122 async fn real_host_read_file_and_web_document_use_the_shared_pdf_job() {
123 let _home = crate::test_support::SealedHome::new();
124 let _policy = TestPolicyGuard::extension_host(false);
125 let Some(node) = node_for_tests("real_host_pdf_consumers") else {
126 return;
127 };
128 let root = tempfile::tempdir().unwrap();
129 parser(root.path(), "printf 'page one\\fpage two\\n'");
130 let search = std::env::join_paths(std::iter::once(root.path().to_path_buf()).chain(
131 std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()),
132 ))
133 .unwrap();
134 let _path = crate::test_support::EnvVarGuard::set("PATH", search);
135 let manager = new_manager(node, root.path());
136 let _manager = TestManagerGuard::install(Arc::clone(&manager));
137 let bytes = b"%PDF-1.7\nfixture\n%%EOF";
138 std::fs::write(root.path().join("input.pdf"), bytes).unwrap();
139 let rust = context(root.path(), false);
140 let host = context(root.path(), true);
141 let tool = crate::tools::file::ReadFileTool;
142 let input = json!({"path":"input.pdf","pages":"1-2"});
143 let expected = tool.execute(input.clone(), &rust).await.unwrap();
144 let actual = tool.execute(input, &host).await.unwrap();
145 assert_eq!(actual.content, expected.content);
146 assert_eq!(actual.metadata, expected.metadata);
147 assert_eq!(actual.success, expected.success);
148 let expected = crate::tools::web::extract::extract_document(
149 "https://example.com/input.pdf",
150 Some("application/pdf"),
151 bytes,
152 Some(&rust),
153 )
154 .await
155 .unwrap();
156 let actual = crate::tools::web::extract::extract_document(
157 "https://example.com/input.pdf",
158 Some("application/pdf"),
159 bytes,
160 Some(&host),
161 )
162 .await
163 .unwrap();
164 assert_eq!(actual.text, expected.text);
165 assert_eq!(actual.markdown, expected.markdown);
166 assert_eq!(actual.pdf_pages, expected.pdf_pages);
167 let invalid = tool
168 .execute(json!({"path":"missing.pdf","pages":"bad"}), &host)
169 .await
170 .unwrap_err();
171 assert!(invalid.to_string().contains("Failed to read"));
172 manager.shutdown().await;
173 }
174
175 #[cfg(unix)]
176 #[tokio::test]
177 async fn pdf_private_output_guards_cannot_be_overridden_by_host_decisions() {
178 use std::os::unix::process::ExitStatusExt;
179 let output = |overflow, success| PdfProcessOutcome {
180 output: Ok((
181 BoundedOutput {
182 bytes: b"private extracted document".to_vec(),
183 truncated: overflow,
184 },
185 BoundedOutput {
186 bytes: vec![],
187 truncated: false,
188 },
189 std::process::ExitStatus::from_raw(if success { 0 } else { 2 << 8 }),
190 )),
191 };
192 assert!(
193 output(true, true)
194 .into_host_text(decision("success", None))
195 .is_err()
196 );
197 assert!(
198 output(false, false)
199 .into_host_text(decision("success", None))
200 .is_err()
201 );
202 assert!(
203 output(false, true)
204 .into_host_text(decision("execution", Some("fabricated error")))
205 .is_err()
206 );
207 assert!(
208 PdfProcessOutcome {
209 output: Err(PdfTextError::Cancelled)
210 }
211 .into_host_text(decision("success", None))
212 .is_err()
213 );
214 assert_eq!(
215 PdfProcessOutcome {
216 output: Err(PdfTextError::Cancelled)
217 }
218 .into_host_text(decision("cancelled", Some("ignored"))),
219 Err(PdfTextError::Cancelled)
220 );
221 let mut result = decision("success", None);
222 result.metadata.as_mut().unwrap()["extra"] = json!(true);
223 assert!(output(false, true).into_host_text(result).is_err());
224 let full = PdfProcessOutcome {
225 output: Ok((
226 BoundedOutput {
227 bytes: vec![b'x'; MAX_PDF_STDOUT_BYTES],
228 truncated: false,
229 },
230 BoundedOutput {
231 bytes: vec![],
232 truncated: false,
233 },
234 std::process::ExitStatus::from_raw(0),
235 )),
236 };
237 let wire = serde_json::to_vec(&full.projection()).unwrap();
238 assert!(wire.len() < 512);
239 assert!(!String::from_utf8(wire).unwrap().contains("xxxxx"));
240 assert_eq!(
241 full.into_host_text(decision("success", None))
242 .unwrap()
243 .len(),
244 MAX_PDF_STDOUT_BYTES
245 );
246 }
247
248 #[cfg(unix)]
249 #[tokio::test(flavor = "current_thread")]
250 async fn real_host_pdf_cancel_timeout_and_host_refusal_never_replay() {
251 let _home = crate::test_support::SealedHome::new();
252 let _policy = TestPolicyGuard::extension_host(false);
253 let Some(node) = node_for_tests("real_host_pdf_cancel") else {
254 return;
255 };
256 let root = tempfile::tempdir().unwrap();
257 let marker = root.path().join("launched");
258 let binary = parser(
259 root.path(),
260 &format!("printf x >> '{}'; exec /bin/sleep 30", marker.display()),
261 );
262 let input = root.path().join("input.pdf");
263 std::fs::write(&input, b"%PDF-1.7\n%%EOF").unwrap();
264 let manager = new_manager(node, root.path());
265 let _manager = TestManagerGuard::install(Arc::clone(&manager));
266 // Warm only the already admitted bounded Host; timeout is measured after startup.
267 let fast = parser(root.path(), "printf warm");
268 extract_path(
269 &input,
270 None,
271 PdfTextCommand::test(fast.as_os_str(), Duration::from_secs(10), None)
272 .with_context(&context(root.path(), true)),
273 )
274 .await
275 .unwrap();
276 parser(
277 root.path(),
278 &format!("printf x >> '{}'; exec /bin/sleep 30", marker.display()),
279 );
280 let cancel = CancellationToken::new();
281 let ctx = context(root.path(), true).with_cancel_token(cancel.clone());
282 let stop = cancel.clone();
283 let marker_clone = marker.clone();
284 tokio::spawn(async move {
285 for _ in 0..1000 {
286 if marker_clone.exists() {
287 stop.cancel();
288 return;
289 }
290 tokio::time::sleep(Duration::from_millis(5)).await;
291 }
292 panic!("parser never launched");
293 });
294 let started = std::time::Instant::now();
295 assert_eq!(
296 extract_path(
297 &input,
298 None,
299 PdfTextCommand::test(binary.as_os_str(), Duration::from_secs(10), None)
300 .with_context(&ctx)
301 )
302 .await,
303 Err(PdfTextError::Cancelled)
304 );
305 assert!(started.elapsed() < Duration::from_secs(5));
306 assert_eq!(std::fs::read(&marker).unwrap(), b"x");
307 let error = extract_path(
308 &input,
309 None,
310 PdfTextCommand::test(binary.as_os_str(), Duration::from_millis(100), None)
311 .with_context(&context(root.path(), true)),
312 )
313 .await
314 .unwrap_err();
315 assert_eq!(error, PdfTextError::TimedOut);
316 assert_eq!(std::fs::read(&marker).unwrap(), b"xx");
317 manager.shutdown().await;
318 drop(_manager);
319 let unavailable = new_manager(root.path().join("no-runtime"), root.path());
320 let _manager = TestManagerGuard::install(unavailable);
321 let error = extract_path(
322 &input,
323 None,
324 PdfTextCommand::test(binary.as_os_str(), Duration::from_secs(2), None)
325 .with_context(&context(root.path(), true)),
326 )
327 .await
328 .unwrap_err();
329 assert!(error.to_string().contains("no Rust fallback"));
330 assert_eq!(std::fs::read(&marker).unwrap(), b"xx");
331 }
332
333 #[cfg(unix)]
334 #[tokio::test]
335 async fn pdf_driver_cancellation_kills_descendants_and_keeps_staged_input_alive() {
336 let root = tempfile::tempdir().unwrap();
337 let marker = root.path().join("child-alive");
338 let binary = parser(
339 root.path(),
340 &format!(
341 "(/bin/sleep 1; printf alive > '{}') &\n/bin/sleep 30",
342 marker.display()
343 ),
344 );
345 let cancel = CancellationToken::new();
346 let stop = cancel.clone();
347 tokio::spawn(async move {
348 tokio::time::sleep(Duration::from_millis(100)).await;
349 stop.cancel();
350 });
351 assert_eq!(
352 extract_bytes(
353 b"%PDF-1.7\n%%EOF",
354 PdfTextCommand::test(binary.as_os_str(), Duration::from_secs(10), Some(&cancel))
355 )
356 .await,
357 Err(PdfTextError::Cancelled)
358 );
359 tokio::time::sleep(Duration::from_millis(1100)).await;
360 assert!(
361 !marker.exists(),
362 "parser descendant must be terminated with its parent"
363 );
364 parser(
365 root.path(),
366 "/bin/sleep 0.1; cat \"$2\"; printf '\\fpage two\\n'",
367 );
368 let text = extract_bytes(
369 b"%PDF-1.7\nstaged stays present",
370 PdfTextCommand::test(binary.as_os_str(), Duration::from_secs(10), None),
371 )
372 .await
373 .unwrap();
374 assert!(text.starts_with("%PDF-1.7\nstaged stays present"));
375 }
376
377 #[tokio::test]
378 async fn pdf_core_terminal_status_precedes_a_generic_host_refusal() {
379 let refusal =
380 || ToolError::execution_failed("Builtin runner failed; no Rust fallback was attempted");
381 let now = tokio::time::Instant::now();
382 assert_eq!(
383 host_terminal_error(refusal(), now, None),
384 PdfTextError::TimedOut
385 );
386 let cancel = CancellationToken::new();
387 cancel.cancel();
388 assert_eq!(
389 host_terminal_error(refusal(), now, Some(&cancel)),
390 PdfTextError::Cancelled
391 );
392 assert_eq!(
393 host_terminal_error(
394 ToolError::cancelled("broker cancelled"),
395 now + Duration::from_secs(1),
396 None
397 ),
398 PdfTextError::Cancelled
399 );
400 let error = host_terminal_error(refusal(), now + Duration::from_secs(1), None);
401 assert!(matches!(error, PdfTextError::Execution(_)));
402 assert!(error.to_string().contains("no Rust fallback"));
403 }
404
404 lines RUST