mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-14 21:16:36 +03:00
f37dc73829
* feat(skill): add anydoc core content and references
Add the anydoc skill content tree: SKILL.md (progressive-disclosure index
with frontmatter per ALLOWED_FIELDS), human-facing README, the five reference
files (formats, cli-reference, errors, workflows, sources), 24 committed
fixtures (valid + error cases), and a fixture-grounded eval manifest with 8
cases. Every documented behavior, exit code, and error message was verified
against the real pinned CLI (npx -y @firecrawl/anydoc@0.1.6); verbatim --help
and error transcripts are reproduced character-for-character.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* feat(skill): add anydoc wrapper script and unit tests
Implements scripts/anydoc, a stdlib-only Python wrapper around the pinned
@firecrawl/anydoc@0.1.6 CLI: convert/batch/info subcommands, global
--json/--dry-run, input and output pre-validation, friendly hints for the
no-OCR/encrypted/malformed/unsupported error classes, Node >= 20 and npx
availability checks, deterministic batch output naming with documented
duplicate/collision behavior, and exit codes 0/1/2. Adds offline unittest
suite (46 tests, real-CLI tests skip when npx is unavailable) and keeps the
wrapper contract documented in cli-reference.md and errors.md.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* feat(skill): ratchet anydoc evals to 14 grounded cases
Verify the pre-authored 8-case manifest and extend it with six
high-signal cases (PDF lower-fidelity pipeline, legacy .ppt table
flattening, ODP same-serializer, RTF, EPUB, CSV header promotion),
each grounded in real pinned-CLI runs against the committed fixtures.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* feat(skill): integrate anydoc into repo catalog and artifacts
Add the sorted anydoc catalog entry to README.md (between agent-skills
and api-design-and-evolution), regenerate the tracked catalog artifacts
(.claude-plugin/marketplace.json, .codex-plugin/plugin.json,
.agents/plugins/marketplace.json, llms.txt) with the ruby generators,
and add a routing note to documents/SKILL.md pointing office-document
to-markdown conversion at the anydoc skill.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
* fix(skill): polish anydoc wrapper timeout, JSON shape, and docs
- run_cli raises CliTimeoutError on the 120s timeout; convert/batch with
--json now emit one parseable JSON error envelope (error_class "timeout")
on stdout before exiting, so --json always yields exactly one JSON doc
- batch JSON failure entries (pre-validation and CLI) now carry error_class
("io" for missing/dir inputs, mapped classes for CLI failures), so all
batch failure entries share the same shape
- build_cli_command places -o/-f before the -- separator for dash-leading
filenames, so `convert -f csv -- -weird` converts instead of misparsing
("unexpected second input"); absolute-path inputs unchanged
- workflows.md vault-ingestion recipe globs notes/* instead of docs/* and
warns to run from a temp/vault dir, never touching repo-root docs/
- unit tests: +6 (timeout envelope x4, batch error_class shape,
dash-leading filename); suite grows 46 -> 52
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
---------
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
811 lines
32 KiB
Python
811 lines
32 KiB
Python
#!/usr/bin/env python3
|
|
"""Unit tests for the anydoc wrapper (`anydoc/scripts/anydoc`).
|
|
|
|
Offline by design: the core tests need no node, no npx, and no network.
|
|
Real-CLI tests (converting the committed fixtures through the pinned CLI)
|
|
are opt-in and skip gracefully when the toolchain is unavailable.
|
|
"""
|
|
|
|
import importlib.machinery
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stderr, redirect_stdout
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
ROOT = Path(__file__).resolve().parents[1] # anydoc/
|
|
SCRIPT = ROOT / "scripts" / "anydoc"
|
|
FIXTURES = ROOT / "fixtures"
|
|
|
|
DOCX = FIXTURES / "fixture-handmade-outline.docx"
|
|
CSV = FIXTURES / "fixture-sheet.csv"
|
|
SCANNED = FIXTURES / "scanned-image-only.pdf"
|
|
ENCRYPTED = FIXTURES / "encrypted--errors.odt"
|
|
MALFORMED = FIXTURES / "empty--errors.docx"
|
|
UNSUPPORTED = FIXTURES / "unsupported.xyz"
|
|
TABLES = FIXTURES / "fixture-handmade-tables.docx"
|
|
|
|
PINNED = "@firecrawl/anydoc@0.1.6"
|
|
|
|
cli = importlib.machinery.SourceFileLoader("anydoc_wrapper", str(SCRIPT)).load_module()
|
|
|
|
|
|
def run_in_process(arguments):
|
|
"""Run cli.main() in-process; return (code, stdout, stderr)."""
|
|
stdout, stderr = io.StringIO(), io.StringIO()
|
|
with redirect_stdout(stdout), redirect_stderr(stderr):
|
|
try:
|
|
code = cli.main(arguments)
|
|
except SystemExit as exc:
|
|
code = exc.code if exc.code is not None else 0
|
|
return code, stdout.getvalue(), stderr.getvalue()
|
|
|
|
|
|
def run_script(arguments, env=None, cwd=None, input_bytes=None, timeout=120):
|
|
"""Run the wrapper as a subprocess; return CompletedProcess."""
|
|
return subprocess.run(
|
|
[sys.executable, str(SCRIPT)] + arguments,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
env=env,
|
|
cwd=cwd,
|
|
input=input_bytes,
|
|
timeout=timeout,
|
|
)
|
|
|
|
|
|
def minimal_path_env():
|
|
"""A PATH containing only a python3 symlink (no node, no npx)."""
|
|
tmp = Path(tempfile.mkdtemp())
|
|
bindir = tmp / "bin"
|
|
bindir.mkdir()
|
|
os.symlink(sys.executable, bindir / "python3")
|
|
env = os.environ.copy()
|
|
env["PATH"] = str(bindir)
|
|
return tmp, env
|
|
|
|
|
|
def node_shim_env(version_line):
|
|
"""A PATH whose `node` is a shim printing `version_line`."""
|
|
tmp, env = minimal_path_env()
|
|
bindir = tmp / "bin2"
|
|
bindir.mkdir()
|
|
shim = bindir / "node"
|
|
shim.write_text("#!/bin/sh\n%s\n" % version_line)
|
|
shim.chmod(0o755)
|
|
env["PATH"] = str(bindir) + os.pathsep + env["PATH"]
|
|
return tmp, env
|
|
|
|
|
|
class WrapperCoreTests(unittest.TestCase):
|
|
"""Offline wrapper behavior: help, usage errors, pre-validation, plans."""
|
|
|
|
def test_script_is_executable_and_has_shebang(self):
|
|
mode = stat.S_IMODE(SCRIPT.stat().st_mode)
|
|
self.assertTrue(mode & stat.S_IXUSR, "scripts/anydoc must be executable")
|
|
with SCRIPT.open("rb") as handle:
|
|
first = handle.readline().decode("utf-8", "replace").strip()
|
|
self.assertEqual(first, "#!/usr/bin/env python3")
|
|
|
|
def test_direct_execution_via_shebang(self):
|
|
result = subprocess.run(
|
|
[str(SCRIPT), "info"], capture_output=True, text=True, timeout=60
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("anydoc", result.stdout)
|
|
self.assertIn("0.1.6", result.stdout)
|
|
|
|
def test_help_exits_zero_with_usage_and_examples(self):
|
|
for arguments in (
|
|
["--help"],
|
|
["convert", "--help"],
|
|
["batch", "--help"],
|
|
["info", "--help"],
|
|
):
|
|
with self.subTest(arguments=arguments):
|
|
code, stdout, stderr = run_in_process(arguments)
|
|
self.assertEqual(code, 0, stderr)
|
|
self.assertIn("usage", stdout.lower())
|
|
self.assertIn("Example", stdout)
|
|
self.assertEqual(stderr, "")
|
|
|
|
def test_batch_help_documents_exit_semantics(self):
|
|
_, stdout, _ = run_in_process(["batch", "--help"])
|
|
self.assertIn("1 when any", stdout)
|
|
self.assertIn("input failed", stdout)
|
|
|
|
def test_help_works_without_node_on_path(self):
|
|
tmp, env = minimal_path_env()
|
|
try:
|
|
for arguments in (
|
|
["--help"],
|
|
["convert", "--help"],
|
|
["batch", "--help"],
|
|
["info", "--help"],
|
|
):
|
|
with self.subTest(arguments=arguments):
|
|
result = run_script(arguments, env=env)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("usage", result.stdout.lower())
|
|
self.assertIn("Example", result.stdout)
|
|
self.assertEqual(result.stderr, "")
|
|
finally:
|
|
shutil.rmtree(tmp)
|
|
|
|
def test_usage_errors_exit_2(self):
|
|
# Each usage error must exit 2 on stderr and name the offending token
|
|
# OR the missing input (argparse names the missing subcommand for an
|
|
# unknown root option).
|
|
cases = (
|
|
(["--bogus"], "command"),
|
|
(["convert", str(DOCX), "--bogus"], "--bogus"),
|
|
(["convert"], "required"),
|
|
(["batch"], "required"),
|
|
)
|
|
for arguments, needle in cases:
|
|
with self.subTest(arguments=arguments):
|
|
code, stdout, stderr = run_in_process(arguments)
|
|
self.assertEqual(code, 2)
|
|
self.assertEqual(stdout, "")
|
|
self.assertIn(needle, stderr)
|
|
self.assertIn("usage", stderr.lower())
|
|
self.assertNotIn("Traceback", stderr)
|
|
|
|
def test_info_reports_tool_and_version(self):
|
|
code, stdout, stderr = run_in_process(["info"])
|
|
self.assertEqual(code, 0, stderr)
|
|
self.assertIn("anydoc", stdout)
|
|
self.assertIn("0.1.6", stdout)
|
|
self.assertEqual(stderr, "")
|
|
|
|
def test_info_version_prints_exact_version(self):
|
|
code, stdout, stderr = run_in_process(["info", "--version"])
|
|
self.assertEqual(code, 0, stderr)
|
|
self.assertEqual(stdout.strip(), "0.1.6")
|
|
self.assertEqual(stderr, "")
|
|
|
|
def test_convert_missing_input_prevalidation(self):
|
|
code, stdout, stderr = run_in_process(
|
|
["convert", "/nonexistent/anydoc-input.docx"]
|
|
)
|
|
self.assertEqual(code, 1)
|
|
self.assertEqual(stdout, "")
|
|
self.assertIn("/nonexistent/anydoc-input.docx", stderr)
|
|
self.assertNotIn("Traceback", stderr)
|
|
|
|
def test_convert_directory_input_prevalidation(self):
|
|
code, stdout, stderr = run_in_process(["convert", str(FIXTURES)])
|
|
self.assertEqual(code, 1)
|
|
self.assertEqual(stdout, "")
|
|
self.assertIn(str(FIXTURES), stderr)
|
|
self.assertIn("directory", stderr)
|
|
self.assertNotIn("Traceback", stderr)
|
|
|
|
def test_convert_output_path_is_directory(self):
|
|
code, stdout, stderr = run_in_process(
|
|
["convert", str(DOCX), "-o", str(FIXTURES)]
|
|
)
|
|
self.assertEqual(code, 1)
|
|
self.assertEqual(stdout, "")
|
|
self.assertIn("directory", stderr)
|
|
self.assertNotIn("Traceback", stderr)
|
|
|
|
def test_convert_invalid_format_exit_2(self):
|
|
code, stdout, stderr = run_in_process(
|
|
["convert", str(DOCX), "-f", "bogus"]
|
|
)
|
|
self.assertEqual(code, 2)
|
|
self.assertEqual(stdout, "")
|
|
self.assertIn("invalid format 'bogus'", stderr)
|
|
self.assertNotIn("Traceback", stderr)
|
|
|
|
def test_convert_dry_run_plans_without_executing(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "out.md"
|
|
code, stdout, stderr = run_in_process(
|
|
["convert", str(DOCX), "-o", str(out), "--dry-run"]
|
|
)
|
|
self.assertEqual(code, 0, stderr)
|
|
self.assertIn("npx -y " + PINNED, stdout)
|
|
self.assertIn(str(DOCX), stdout)
|
|
self.assertEqual(stderr, "")
|
|
self.assertFalse(out.exists(), "dry-run must not create outputs")
|
|
|
|
def test_convert_dry_run_json(self):
|
|
code, stdout, stderr = run_in_process(
|
|
["convert", str(DOCX), "--dry-run", "--json"]
|
|
)
|
|
self.assertEqual(code, 0, stderr)
|
|
doc = json.loads(stdout)
|
|
self.assertTrue(doc["dry_run"])
|
|
self.assertEqual(doc["command"], "convert")
|
|
self.assertIn("npx -y " + PINNED, doc["command_line"])
|
|
self.assertEqual(stderr, "")
|
|
|
|
def test_batch_dry_run_json_plan_and_no_output_dir(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out_dir = Path(tmp) / "out"
|
|
code, stdout, stderr = run_in_process(
|
|
[
|
|
"batch",
|
|
str(DOCX),
|
|
str(CSV),
|
|
"--out-dir",
|
|
str(out_dir),
|
|
"--dry-run",
|
|
"--json",
|
|
]
|
|
)
|
|
self.assertEqual(code, 0, stderr)
|
|
doc = json.loads(stdout)
|
|
self.assertTrue(doc["dry_run"])
|
|
self.assertEqual(doc["command"], "batch")
|
|
self.assertEqual(len(doc["plan"]), 2)
|
|
for entry in doc["plan"]:
|
|
self.assertIn("input", entry)
|
|
self.assertIn("output", entry)
|
|
self.assertIn("command", entry)
|
|
self.assertIn("npx -y " + PINNED, entry["command"])
|
|
self.assertEqual(stderr, "")
|
|
self.assertFalse(out_dir.exists(), "dry-run must not create out-dir")
|
|
|
|
def test_batch_dry_run_marks_invalid_inputs(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
missing = Path(tmp) / "missing.docx"
|
|
code, stdout, stderr = run_in_process(
|
|
[
|
|
"batch",
|
|
str(DOCX),
|
|
str(missing),
|
|
"--out-dir",
|
|
str(Path(tmp) / "out"),
|
|
"--dry-run",
|
|
"--json",
|
|
]
|
|
)
|
|
self.assertEqual(code, 0, stderr)
|
|
doc = json.loads(stdout)
|
|
self.assertEqual(len(doc["plan"]), 2)
|
|
self.assertFalse(doc["plan"][0]["would_fail"])
|
|
self.assertTrue(doc["plan"][1]["would_fail"])
|
|
self.assertIn("not found", doc["plan"][1]["error"])
|
|
|
|
def test_batch_dry_run_defaults_out_dir_to_cwd(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
code, stdout, stderr = run_in_process(
|
|
[
|
|
"batch",
|
|
str(DOCX),
|
|
"--dry-run",
|
|
"--json",
|
|
],
|
|
)
|
|
self.assertEqual(code, 0, stderr)
|
|
doc = json.loads(stdout)
|
|
self.assertEqual(doc["out_dir"], str(Path.cwd()))
|
|
|
|
def test_batch_requires_inputs(self):
|
|
code, stdout, stderr = run_in_process(["batch"])
|
|
self.assertEqual(code, 2)
|
|
self.assertEqual(stdout, "")
|
|
|
|
def test_convert_json_error_stays_parseable(self):
|
|
code, stdout, stderr = run_in_process(
|
|
["convert", "/nonexistent/anydoc-input.docx", "--json"]
|
|
)
|
|
self.assertEqual(code, 1)
|
|
doc = json.loads(stdout)
|
|
self.assertFalse(doc["ok"])
|
|
self.assertEqual(doc["exit_code"], 1)
|
|
self.assertIn("not found", doc["error"])
|
|
self.assertIn("not found", stderr)
|
|
|
|
def test_node_missing_error_via_minimal_path(self):
|
|
tmp, env = minimal_path_env()
|
|
try:
|
|
result = run_script(["convert", str(DOCX)], env=env)
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertEqual(result.stdout, "")
|
|
self.assertIn("node", result.stderr.lower())
|
|
self.assertIn("20", result.stderr)
|
|
self.assertNotIn("Traceback", result.stderr)
|
|
finally:
|
|
shutil.rmtree(tmp)
|
|
|
|
def test_node_too_old_error_via_shim(self):
|
|
tmp, env = node_shim_env('echo "v18.20.0"')
|
|
try:
|
|
result = run_script(["convert", str(DOCX)], env=env)
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertEqual(result.stdout, "")
|
|
self.assertIn("v18.20.0", result.stderr)
|
|
self.assertIn("20", result.stderr)
|
|
self.assertNotIn("Traceback", result.stderr)
|
|
finally:
|
|
shutil.rmtree(tmp)
|
|
|
|
@unittest.skipUnless(shutil.which("node"), "node not on PATH")
|
|
def test_npx_missing_error_via_path_with_node(self):
|
|
tmp = Path(tempfile.mkdtemp())
|
|
try:
|
|
bindir = tmp / "bin"
|
|
bindir.mkdir()
|
|
os.symlink(sys.executable, bindir / "python3")
|
|
node_bin = tmp / "bin2"
|
|
node_bin.mkdir()
|
|
os.symlink(Path(shutil.which("node")), node_bin / "node")
|
|
env = os.environ.copy()
|
|
env["PATH"] = str(node_bin) + os.pathsep + str(bindir)
|
|
result = run_script(["convert", str(DOCX)], env=env)
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertEqual(result.stdout, "")
|
|
self.assertIn("npx", result.stderr)
|
|
self.assertIn(PINNED, result.stderr)
|
|
self.assertNotIn("Traceback", result.stderr)
|
|
finally:
|
|
shutil.rmtree(tmp)
|
|
|
|
def test_hint_mapping_for_known_error_classes(self):
|
|
cases = (
|
|
(
|
|
"anydoc: unsupported input: PDF has no extractable text "
|
|
"(Scanned, 1 pages): OCR is required",
|
|
"no-ocr",
|
|
("OCR", "Firecrawl Parse", "not retry"),
|
|
),
|
|
("anydoc: document is encrypted", "encrypted", ("encrypted", "unencrypted")),
|
|
(
|
|
"anydoc: malformed document: not a readable zip archive: "
|
|
"invalid Zip archive: Could not find EOCD",
|
|
"malformed",
|
|
("malformed", "corrupt", "zip"),
|
|
),
|
|
(
|
|
"anydoc: unsupported input: unrecognized file content and "
|
|
"extension: unsupported.xyz",
|
|
"unsupported",
|
|
("unsupported", "-f"),
|
|
),
|
|
(
|
|
"anydoc: resource limit exceeded (max_entry_bytes): "
|
|
"word/document.xml declares 201326759 decompressed bytes",
|
|
"resource-limit",
|
|
(),
|
|
),
|
|
# Wrapper pre-validation messages map to the "io" class with no hint.
|
|
("input file not found: /x/missing.docx", "io", ()),
|
|
("input path is a directory, not a file: /x/dir", "io", ()),
|
|
)
|
|
for message, expected_class, keywords in cases:
|
|
with self.subTest(message=message):
|
|
error_class, hint = cli.error_class_hint(message)
|
|
self.assertEqual(error_class, expected_class)
|
|
if keywords:
|
|
self.assertIsNotNone(hint)
|
|
for keyword in keywords:
|
|
self.assertIn(keyword, hint)
|
|
|
|
def test_build_cli_command_shape(self):
|
|
self.assertEqual(
|
|
cli.build_cli_command("report.docx", "out.md", "csv"),
|
|
["npx", "-y", PINNED, "report.docx", "-o", "out.md", "-f", "csv"],
|
|
)
|
|
self.assertEqual(
|
|
cli.build_cli_command("report.docx", None, None),
|
|
["npx", "-y", PINNED, "report.docx"],
|
|
)
|
|
# stdin passes through as `-`
|
|
self.assertEqual(
|
|
cli.build_cli_command("-", None, "csv"),
|
|
["npx", "-y", PINNED, "-", "-f", "csv"],
|
|
)
|
|
# a dash-leading filename places -o/-f BEFORE the `--` separator
|
|
# (npx forwards `--` to the CLI, so options after it read as inputs)
|
|
self.assertEqual(
|
|
cli.build_cli_command("-weird", "o.md", "csv"),
|
|
["npx", "-y", PINNED, "-o", "o.md", "-f", "csv", "--", "-weird"],
|
|
)
|
|
self.assertEqual(
|
|
cli.build_cli_command("-weird", None, None),
|
|
["npx", "-y", PINNED, "--", "-weird"],
|
|
)
|
|
|
|
@unittest.skipUnless(shutil.which("node") and shutil.which("npx"), "toolchain missing")
|
|
def test_runtime_errors_empty_when_toolchain_present(self):
|
|
self.assertEqual(cli.runtime_errors(), [])
|
|
|
|
def test_format_aliases_accepted(self):
|
|
code, _stdout, stderr = run_in_process(
|
|
["convert", str(DOCX), "-f", "docm", "--dry-run"]
|
|
)
|
|
self.assertEqual(code, 0, stderr)
|
|
|
|
# --- CLI timeout: --json must still yield one parseable JSON document ---
|
|
|
|
def _timeout_side_effect(self):
|
|
"""A subprocess.run replacement that raises a TimeoutExpired."""
|
|
|
|
def _boom(*args, **kwargs):
|
|
exc = subprocess.TimeoutExpired(
|
|
cmd=args[0], timeout=cli.RUN_TIMEOUT
|
|
)
|
|
exc.pid = 4242 # set post-construction, as subprocess.run does
|
|
raise exc
|
|
|
|
return _boom
|
|
|
|
def test_run_cli_timeout_kills_group_and_raises(self):
|
|
with mock.patch.object(
|
|
cli.subprocess, "run", side_effect=self._timeout_side_effect()
|
|
), mock.patch.object(cli.os, "killpg") as mock_kill:
|
|
with self.assertRaises(cli.CliTimeoutError):
|
|
cli.run_cli(["npx", "-y", cli.PINNED, "x.docx"])
|
|
mock_kill.assert_called_once_with(4242, signal.SIGKILL)
|
|
|
|
def test_convert_timeout_with_json_emits_error_envelope(self):
|
|
with mock.patch.object(cli, "runtime_errors", return_value=[]), mock.patch.object(
|
|
cli.subprocess, "run", side_effect=self._timeout_side_effect()
|
|
), mock.patch.object(cli.os, "killpg") as mock_kill:
|
|
code, stdout, stderr = run_in_process(
|
|
["convert", str(DOCX), "--json"]
|
|
)
|
|
self.assertEqual(code, 1)
|
|
mock_kill.assert_called_once_with(4242, signal.SIGKILL)
|
|
doc = json.loads(stdout) # exactly one parseable JSON document
|
|
self.assertFalse(doc["ok"])
|
|
self.assertEqual(doc["exit_code"], 1)
|
|
self.assertEqual(doc["error_class"], "timeout")
|
|
self.assertIn("did not complete within 120 seconds", doc["error"])
|
|
self.assertIn("did not complete within 120 seconds", stderr)
|
|
self.assertNotIn("Traceback", stderr)
|
|
|
|
def test_convert_timeout_without_json_uses_stderr(self):
|
|
with mock.patch.object(cli, "runtime_errors", return_value=[]), mock.patch.object(
|
|
cli.subprocess, "run", side_effect=self._timeout_side_effect()
|
|
), mock.patch.object(cli.os, "killpg"):
|
|
code, stdout, stderr = run_in_process(["convert", str(DOCX)])
|
|
self.assertEqual(code, 1)
|
|
self.assertEqual(stdout, "")
|
|
self.assertIn("did not complete within 120 seconds", stderr)
|
|
self.assertNotIn("Traceback", stderr)
|
|
|
|
def test_batch_timeout_with_json_emits_error_envelope(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out_dir = Path(tmp) / "out"
|
|
with mock.patch.object(
|
|
cli, "runtime_errors", return_value=[]
|
|
), mock.patch.object(
|
|
cli.subprocess, "run", side_effect=self._timeout_side_effect()
|
|
), mock.patch.object(cli.os, "killpg"):
|
|
code, stdout, stderr = run_in_process(
|
|
["batch", str(DOCX), "--out-dir", str(out_dir), "--json"]
|
|
)
|
|
self.assertEqual(code, 1)
|
|
doc = json.loads(stdout)
|
|
self.assertFalse(doc["ok"])
|
|
self.assertEqual(doc["command"], "batch")
|
|
self.assertEqual(doc["error_class"], "timeout")
|
|
self.assertIn("did not complete within 120 seconds", stderr)
|
|
self.assertNotIn("Traceback", stderr)
|
|
|
|
|
|
class RealCliTests(unittest.TestCase):
|
|
"""End-to-end conversions through the pinned CLI; skip when unavailable."""
|
|
|
|
skip_reason = None
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
if not shutil.which("npx") or not shutil.which("node"):
|
|
cls.skip_reason = "npx/node not available"
|
|
return
|
|
try:
|
|
proc = subprocess.run(
|
|
["npx", "-y", PINNED, "--version"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=120,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
cls.skip_reason = "pinned CLI unavailable"
|
|
return
|
|
if proc.returncode != 0 or "0.1.6" not in proc.stdout:
|
|
cls.skip_reason = "pinned CLI unavailable"
|
|
return
|
|
cls.skip_reason = None
|
|
|
|
def setUp(self):
|
|
if self.__class__.skip_reason:
|
|
self.skipTest(self.__class__.skip_reason)
|
|
|
|
def _parse_ok_json(self, result):
|
|
"""Assert a successful --json run and return its parsed document."""
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stderr, "")
|
|
doc = json.loads(result.stdout)
|
|
self.assertTrue(doc["ok"])
|
|
self.assertEqual(doc["exit_code"], 0)
|
|
return doc
|
|
|
|
def test_convert_to_stdout(self):
|
|
result = run_script(["convert", str(DOCX)])
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stderr, "")
|
|
self.assertIn("# ", result.stdout)
|
|
self.assertIn("## ", result.stdout)
|
|
|
|
def test_convert_to_file_silent(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "out.md"
|
|
result = run_script(["convert", str(DOCX), "-o", str(out)])
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stdout, "")
|
|
self.assertEqual(result.stderr, "")
|
|
self.assertTrue(out.exists())
|
|
content = out.read_text(encoding="utf-8")
|
|
self.assertIn("## ", content)
|
|
|
|
def test_convert_silently_overwrites_seeded_file(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "out.md"
|
|
out.write_text("SENTINEL\n", encoding="utf-8")
|
|
result = run_script(["convert", str(DOCX), "-o", str(out)])
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stdout, "")
|
|
self.assertEqual(result.stderr, "")
|
|
content = out.read_text(encoding="utf-8")
|
|
self.assertNotIn("SENTINEL", content)
|
|
self.assertIn("## ", content)
|
|
|
|
def test_convert_fresh_cwd_creates_no_stray_files(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
result = run_script(["convert", str(DOCX)], cwd=tmp)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stderr, "")
|
|
self.assertFalse((Path(tmp) / "out.md").exists())
|
|
self.assertEqual(list(Path(tmp).iterdir()), [])
|
|
|
|
def test_convert_stdin_csv(self):
|
|
result = run_script(
|
|
["convert", "-", "-f", "csv"], input_bytes="a,b\n1,2\n"
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("| a | b |", result.stdout)
|
|
self.assertIn("| 1 | 2 |", result.stdout)
|
|
|
|
def test_convert_empty_stdin_errors_without_hanging(self):
|
|
result = run_script(["convert", "-"], input_bytes="")
|
|
self.assertNotEqual(result.returncode, 0)
|
|
self.assertIn("anydoc", result.stderr)
|
|
self.assertNotIn("Traceback", result.stderr)
|
|
|
|
def test_convert_extensionless_file_with_fmt_csv(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
data = Path(tmp) / "data"
|
|
data.write_bytes(CSV.read_bytes())
|
|
result = run_script(["convert", str(data), "-f", "csv"])
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("| Kind | Value | Note |", result.stdout)
|
|
|
|
def test_convert_scanned_pdf_hint(self):
|
|
result = run_script(["convert", str(SCANNED)])
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertEqual(result.stdout, "")
|
|
self.assertIn("OCR", result.stderr)
|
|
self.assertIn("Firecrawl Parse", result.stderr)
|
|
self.assertIn("not retry", result.stderr)
|
|
self.assertNotIn("Traceback", result.stderr)
|
|
|
|
def test_convert_encrypted_hint(self):
|
|
result = run_script(["convert", str(ENCRYPTED)])
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertEqual(result.stdout, "")
|
|
self.assertIn("encrypted", result.stderr)
|
|
self.assertIn("unencrypted", result.stderr)
|
|
self.assertNotIn("Traceback", result.stderr)
|
|
|
|
def test_convert_malformed_hint(self):
|
|
result = run_script(["convert", str(MALFORMED)])
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertEqual(result.stdout, "")
|
|
self.assertIn("malformed", result.stderr)
|
|
self.assertNotIn("Traceback", result.stderr)
|
|
|
|
def test_convert_unsupported_hint(self):
|
|
result = run_script(["convert", str(UNSUPPORTED)])
|
|
self.assertEqual(result.returncode, 1)
|
|
self.assertEqual(result.stdout, "")
|
|
self.assertIn("unsupported", result.stderr)
|
|
self.assertIn("-f", result.stderr)
|
|
self.assertNotIn("Traceback", result.stderr)
|
|
|
|
def test_convert_json_success_to_file(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out = Path(tmp) / "out.md"
|
|
result = run_script(
|
|
["convert", str(DOCX), "-o", str(out), "--json"]
|
|
)
|
|
doc = self._parse_ok_json(result)
|
|
self.assertEqual(doc["output"], str(out))
|
|
|
|
def test_convert_json_success_embeds_markdown(self):
|
|
result = run_script(["convert", str(DOCX), "--json"])
|
|
doc = self._parse_ok_json(result)
|
|
self.assertIn("## ", doc["markdown"])
|
|
|
|
def test_convert_json_failure(self):
|
|
result = run_script(["convert", str(SCANNED), "--json"])
|
|
self.assertEqual(result.returncode, 1)
|
|
doc = json.loads(result.stdout)
|
|
self.assertFalse(doc["ok"])
|
|
self.assertEqual(doc["exit_code"], 1)
|
|
self.assertEqual(doc["error_class"], "no-ocr")
|
|
self.assertIn("OCR", result.stderr)
|
|
|
|
def test_batch_mixed_continues_past_failures(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out_dir = Path(tmp) / "out"
|
|
result = run_script(
|
|
[
|
|
"batch",
|
|
str(DOCX),
|
|
str(ENCRYPTED),
|
|
str(TABLES),
|
|
"--out-dir",
|
|
str(out_dir),
|
|
]
|
|
)
|
|
self.assertEqual(result.returncode, 1)
|
|
stdout = result.stdout
|
|
self.assertIn("ok %s" % DOCX, stdout)
|
|
self.assertIn("FAIL %s" % ENCRYPTED, stdout)
|
|
self.assertIn("ok %s" % TABLES, stdout)
|
|
self.assertIn("summary: 3 total, 2 succeeded, 1 failed", stdout)
|
|
self.assertTrue((out_dir / "fixture-handmade-outline.md").exists())
|
|
self.assertTrue((out_dir / "fixture-handmade-tables.md").exists())
|
|
self.assertFalse((out_dir / "encrypted--errors.md").exists())
|
|
self.assertIn("encrypted", result.stderr)
|
|
self.assertIn("hint", result.stderr)
|
|
|
|
def test_batch_all_valid_exits_zero(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out_dir = Path(tmp) / "out"
|
|
result = run_script(
|
|
[
|
|
"batch",
|
|
str(DOCX),
|
|
str(CSV),
|
|
"--out-dir",
|
|
str(out_dir),
|
|
]
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(result.stderr, "")
|
|
self.assertIn("summary: 2 total, 2 succeeded, 0 failed", result.stdout)
|
|
self.assertTrue((out_dir / "fixture-handmade-outline.md").exists())
|
|
self.assertTrue((out_dir / "fixture-sheet.md").exists())
|
|
|
|
def test_batch_json_all_valid(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
result = run_script(
|
|
[
|
|
"batch",
|
|
str(DOCX),
|
|
str(CSV),
|
|
"--out-dir",
|
|
str(Path(tmp) / "out"),
|
|
"--json",
|
|
]
|
|
)
|
|
doc = self._parse_ok_json(result)
|
|
self.assertEqual(doc["summary"], {"total": 2, "succeeded": 2, "failed": 0})
|
|
self.assertEqual([f["status"] for f in doc["files"]], ["ok", "ok"])
|
|
|
|
def test_batch_json_mixed_keeps_stdout_parseable(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
result = run_script(
|
|
[
|
|
"batch",
|
|
str(DOCX),
|
|
str(ENCRYPTED),
|
|
"--out-dir",
|
|
str(Path(tmp) / "out"),
|
|
"--json",
|
|
]
|
|
)
|
|
self.assertEqual(result.returncode, 1)
|
|
doc = json.loads(result.stdout)
|
|
self.assertFalse(doc["ok"])
|
|
self.assertEqual(doc["exit_code"], 1)
|
|
self.assertEqual(doc["summary"], {"total": 2, "succeeded": 1, "failed": 1})
|
|
self.assertIn("encrypted", result.stderr)
|
|
|
|
def test_batch_json_failure_entries_share_error_class_shape(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
missing = Path(tmp) / "missing.docx"
|
|
result = run_script(
|
|
[
|
|
"batch",
|
|
str(ENCRYPTED),
|
|
str(missing),
|
|
"--out-dir",
|
|
str(Path(tmp) / "out"),
|
|
"--json",
|
|
]
|
|
)
|
|
self.assertEqual(result.returncode, 1)
|
|
doc = json.loads(result.stdout)
|
|
by_input = {entry["input"]: entry for entry in doc["files"]}
|
|
cli_fail = by_input[str(ENCRYPTED)]
|
|
pre_fail = by_input[str(missing)]
|
|
self.assertEqual(cli_fail["status"], "failed")
|
|
self.assertEqual(cli_fail["error_class"], "encrypted")
|
|
self.assertEqual(pre_fail["status"], "failed")
|
|
self.assertEqual(pre_fail["error_class"], "io")
|
|
self.assertEqual(
|
|
set(cli_fail.keys()),
|
|
set(pre_fail.keys()),
|
|
"all batch failure entries must share the same shape",
|
|
)
|
|
|
|
def test_convert_dash_leading_filename(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
(Path(tmp) / "-weird").write_bytes(CSV.read_bytes())
|
|
result = run_script(
|
|
["convert", "-f", "csv", "--", "-weird"],
|
|
cwd=tmp,
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("| Kind | Value | Note |", result.stdout)
|
|
|
|
def test_batch_duplicates_convert_per_occurrence(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
out_dir = Path(tmp) / "out"
|
|
result = run_script(
|
|
["batch", str(DOCX), str(DOCX), "--out-dir", str(out_dir)]
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertIn("summary: 2 total, 2 succeeded, 0 failed", result.stdout)
|
|
self.assertEqual(
|
|
sorted(p.name for p in out_dir.iterdir()), ["fixture-handmade-outline.md"]
|
|
)
|
|
|
|
def test_batch_same_basename_collision_last_wins(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
a_dir = Path(tmp) / "a"
|
|
b_dir = Path(tmp) / "b"
|
|
a_dir.mkdir()
|
|
b_dir.mkdir()
|
|
(a_dir / "same.docx").write_bytes(DOCX.read_bytes())
|
|
(b_dir / "same.docx").write_bytes(TABLES.read_bytes())
|
|
out_dir = Path(tmp) / "out"
|
|
result = run_script(
|
|
[
|
|
"batch",
|
|
str(a_dir / "same.docx"),
|
|
str(b_dir / "same.docx"),
|
|
"--out-dir",
|
|
str(out_dir),
|
|
]
|
|
)
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
self.assertEqual(
|
|
sorted(p.name for p in out_dir.iterdir()), ["same.md"]
|
|
)
|
|
content = (out_dir / "same.md").read_text(encoding="utf-8")
|
|
self.assertIn("| Head A | Head B | Head C |", content)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|