Files
aiongg 9526363152 Build each crate's integration tests as one binary (#4087)
* Build each crate's integration tests as one binary

Every tests/*.rs file is its own executable that links the whole crate
graph. Move each crate's test files into tests/it/ as modules of one
tests/it/main.rs, for apps/pdfcraft, apps/pdfcraft-cli, automation, cos,
crypt, engine, render, sign and ui-egui. ui-egui's ui_group binary and
the support modules move into tests/it/ too. Crates with a single test
file keep it. The test executables go from 150 to 52, and every crate
runs the same number of tests as before.

ui-egui perf keeps its own binary (own process): its frame-time budgets
would charge each measurement for the other tests running beside it.

What the move needs:
- include_str!/include_bytes! and #[path] paths gain one "../".
- ui-egui: one GPU lock in tests/it/main.rs replaces the five per-file
  ones (content, document_links, editing, fill_sign, view), so one
  process creates one wgpu device at a time. The drop-line test in ui.rs
  and the screenshot blocks behind PDFCRAFT_*_SHOT(S) and
  CRAFT_UI_DOCKING_DIR, which render without holding it, take it too.
- pdfcraft winit_wayland_scroll: the vendored winit source it compiles
  names crate::event, so that shim lives in main.rs.
- parity evidence names the new paths.

* Name the one-binary test paths in docs and comments

- READMEs, docs, ATTRIBUTION and source comments name tests/it/<file>.rs.
- The oracle README's example filters with --test it encryption::qpdf_agrees.
- pdfcraft's GPU lock comment points at the UI tests' shared lock in
  crates/ui-egui/tests/it/main.rs.
2026-10-22 11:28:58 -04:00

247 lines
12 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `text` must fail loudly when a page it was asked for cannot be read (#131), and when the
//! document itself cannot be opened, e.g. a password-protected file without `--password` (#132).
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
/// A PDF with `n` 200×300 pt pages reading "Page 1", "Page 2", …
fn fixture(n: usize) -> Vec<u8> {
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
for i in 0..n {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i).into_bytes());
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("pdfcraft-cli-text-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name)
}
#[test]
fn text_of_a_selected_page_that_cannot_be_read_fails() {
let path = tmp("three.pdf");
std::fs::write(&path, fixture(3)).unwrap();
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--page", "9"]).output().unwrap();
assert!(!out.status.success(), "a page that cannot be read must not exit 0");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("page 9"), "the failing page must be named: {stderr}");
}
#[test]
fn text_of_a_readable_document_succeeds() {
let path = tmp("ok.pdf");
std::fs::write(&path, fixture(3)).unwrap();
let out = Command::new(BIN).args(["text", path.to_str().unwrap()]).output().unwrap();
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Page 1") && stdout.contains("Page 3"), "{stdout}");
}
/// `three.pdf` encrypted with the open password `openme`, via `run --script`. `tag` keeps each
/// test's files apart: tests run in parallel and would otherwise overwrite each other's.
fn protected_fixture(tag: &str) -> std::path::PathBuf {
let src = tmp(&format!("to-protect-{tag}.pdf"));
std::fs::write(&src, fixture(3)).unwrap();
let out_path = tmp(&format!("protected-{tag}.pdf"));
let script = tmp(&format!("protect-{tag}.json"));
let steps = serde_json::json!([
{ "tool": "doc_open", "args": { "path": src } },
{ "tool": "doc_protect", "args": { "doc": 1, "open_password": "openme" } },
{ "tool": "doc_save", "args": { "doc": 1, "path": out_path } },
]);
std::fs::write(&script, steps.to_string()).unwrap();
let out = Command::new(BIN).args(["run", "--script", script.to_str().unwrap()]).output().unwrap();
assert!(out.status.success(), "protecting the fixture failed: {}", String::from_utf8_lossy(&out.stderr));
out_path
}
#[test]
fn text_of_a_protected_document_without_the_password_fails() {
let path = protected_fixture("no-password");
let out = Command::new(BIN).args(["text", path.to_str().unwrap()]).output().unwrap();
assert!(!out.status.success(), "a document that cannot be opened must not exit 0");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("password"), "the reason must be named: {stderr}");
assert!(out.stdout.is_empty(), "{}", String::from_utf8_lossy(&out.stdout));
}
#[test]
fn text_of_a_protected_document_with_the_password_succeeds() {
let path = protected_fixture("password");
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "openme"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Page 1") && stdout.contains("Page 3"), "{stdout}");
}
/// `three.pdf` encrypted so that the open password `openme` doesn't allow copying, and the
/// permissions password `boss` lifts that.
fn copy_protected_fixture() -> std::path::PathBuf {
let src = tmp("to-protect-no-copy.pdf");
std::fs::write(&src, fixture(3)).unwrap();
let out_path = tmp("protected-no-copy.pdf");
let script = tmp("protect-no-copy.json");
let steps = serde_json::json!([
{ "tool": "doc_open", "args": { "path": src } },
{ "tool": "doc_protect", "args": { "doc": 1, "open_password": "openme", "permissions_password": "boss", "copy": false } },
{ "tool": "doc_save", "args": { "doc": 1, "path": out_path } },
]);
std::fs::write(&script, steps.to_string()).unwrap();
let out = Command::new(BIN).args(["run", "--script", script.to_str().unwrap()]).output().unwrap();
assert!(out.status.success(), "protecting the fixture failed: {}", String::from_utf8_lossy(&out.stderr));
out_path
}
/// `text` copies text out, so it asks what `text_extract` asks (#1645): the open password alone
/// gives no text when copying isn't allowed, the permissions password does.
#[test]
fn text_respects_a_document_that_does_not_allow_copying() {
let path = copy_protected_fixture();
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "openme"]).output().unwrap();
assert!(!out.status.success(), "copying isn't allowed: {}", String::from_utf8_lossy(&out.stdout));
assert!(out.stdout.is_empty(), "{}", String::from_utf8_lossy(&out.stdout));
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("don't allow copying"), "the reason must be named: {stderr}");
assert!(stderr.contains("--password"), "and the way in: {stderr}");
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "boss"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Page 1") && stdout.contains("Page 3"), "{stdout}");
}
mod page_argument_tests {
use super::*;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
struct PageDir(PathBuf);
impl PageDir {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
for _ in 0..128 {
let serial = NEXT.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("pdfcraft-page-{}-{serial}", std::process::id()));
match std::fs::create_dir(&path) {
Ok(()) => return Self(path),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => panic!("creating page test directory: {error}"),
}
}
panic!("no unused page test directory after 128 attempts");
}
}
impl Drop for PageDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn malformed_page_values_fail_before_reading_or_printing_document_text() {
let dir = PageDir::new();
let path = dir.0.join("three.pdf");
let bytes = fixture(3);
std::fs::write(&path, &bytes).unwrap();
let missing = dir.0.join("missing.pdf");
let invalid = [
vec!["--page", "0"],
vec!["--page", ""],
vec!["--page", "bad"],
vec!["--page", "-1"],
vec!["--page", "1.5"],
vec!["--page", "18446744073709551616"],
vec!["--page"],
vec!["--page", "--password", "unused"],
vec!["--page", "2", "--page"],
];
for input in [&path, &missing] {
for args in &invalid {
let out = Command::new(BIN).arg("text").arg(input).args(args).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{args:?}: {out:?}");
assert!(out.stdout.is_empty(), "{args:?}: no text may be printed");
assert_eq!(String::from_utf8(out.stderr).unwrap().trim(), "pdfcraft-cli: bad --page: expected a positive page number");
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert!(!missing.exists());
}
}
}
#[test]
fn valid_page_values_preserve_all_page_selection_and_first_duplicate_semantics() {
let dir = PageDir::new();
let path = dir.0.join("three.pdf");
let bytes = fixture(3);
std::fs::write(&path, &bytes).unwrap();
for (args, expected) in [
(vec![], vec!["Page 1", "Page 2", "Page 3"]),
(vec!["--page", "1"], vec!["Page 1"]),
(vec!["--page", "2"], vec!["Page 2"]),
(vec!["--page", "3"], vec!["Page 3"]),
(vec!["--page", "2", "--page", "1"], vec!["Page 2"]),
] {
let out = Command::new(BIN).arg("text").arg(&path).args(args).output().unwrap();
assert!(out.status.success(), "{out:?}");
assert!(out.stderr.is_empty());
let text = String::from_utf8(out.stdout).unwrap();
assert_eq!(text.split(' ').map(str::trim).collect::<Vec<_>>(), expected);
assert_eq!(std::fs::read(&path).unwrap(), bytes);
}
}
#[test]
fn an_option_shaped_password_is_not_parsed_as_a_page_flag() {
let dir = PageDir::new();
let source = dir.0.join("three.pdf");
let bytes = fixture(3);
std::fs::write(&source, &bytes).unwrap();
let protected = dir.0.join("protected.pdf");
let script = dir.0.join("protect.json");
let steps = serde_json::json!([
{ "tool": "doc_open", "args": { "path": source } },
{ "tool": "doc_protect", "args": { "doc": 1, "open_password": "--page" } },
{ "tool": "doc_save", "args": { "doc": 1, "path": protected } },
]);
std::fs::write(&script, steps.to_string()).unwrap();
let out = Command::new(BIN).args(["run", "--script"]).arg(&script).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let protected_bytes = std::fs::read(&protected).unwrap();
for (args, expected) in [
(vec!["--password", "--page"], vec!["Page 1", "Page 2", "Page 3"]),
(vec!["--page", "2", "--password", "--page"], vec!["Page 2"]),
(vec!["--password", "--page", "--page", "2"], vec!["Page 2"]),
] {
let out = Command::new(BIN).arg("text").arg(&protected).args(args).output().unwrap();
assert!(out.status.success(), "{out:?}");
assert!(out.stderr.is_empty());
let text = String::from_utf8(out.stdout).unwrap();
assert_eq!(text.split(' ').map(str::trim).collect::<Vec<_>>(), expected);
assert_eq!(std::fs::read(&protected).unwrap(), protected_bytes);
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
}
}