ArchiveBox/archivebox/tests/conftest.py
2026-07-24 00:50:40 -07:00

1795 lines
61 KiB
Python
Raw 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.

"""archivebox/tests/conftest.py - Pytest fixtures for CLI tests."""
import os
import json
import re
import secrets
import signal
import select
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
import shutil
import ctypes
from datetime import timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from threading import Event, Thread
from types import SimpleNamespace
from typing import Any
from collections.abc import Callable
import psutil
import pytest
import requests
REPO_ROOT = Path(__file__).resolve().parents[2]
WORKSPACE_ROOT = REPO_ROOT.parent
PYTEST_BASETEMP_ROOT = (REPO_ROOT / "tests" / "out").resolve()
SESSION_DATA_DIR = Path(
os.environ.get("ARCHIVEBOX_PYTEST_SESSION_DATA_DIR") or tempfile.mkdtemp(prefix="archivebox-pytest-session-"),
).resolve()
os.environ["ARCHIVEBOX_PYTEST_SESSION_DATA_DIR"] = str(SESSION_DATA_DIR)
os.environ["DATA_DIR"] = str(SESSION_DATA_DIR)
(SESSION_DATA_DIR / "tests").mkdir(parents=True, exist_ok=True)
os.chdir(SESSION_DATA_DIR)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "archivebox.core.settings")
os.environ.pop("ARCHIVE_DIR", None)
os.environ.pop("USERS_DIR", None)
os.environ.pop("CRAWL_DIR", None)
os.environ.pop("SNAP_DIR", None)
_RUNTIME_GUARD_ACTIVE = False
def _is_repo_path(path: Path) -> bool:
resolved = path.expanduser().resolve(strict=False)
if resolved == PYTEST_BASETEMP_ROOT or PYTEST_BASETEMP_ROOT in resolved.parents:
return False
return resolved == REPO_ROOT or REPO_ROOT in resolved.parents
def _assert_not_repo_path(path: Path, *, label: str) -> None:
if _is_repo_path(path):
raise AssertionError(f"{label} must not point inside the repo root during tests: {path}")
def _assert_safe_runtime_paths(*, cwd: Path | None = None, env: dict[str, str] | None = None) -> None:
if cwd is not None:
_assert_not_repo_path(cwd, label="cwd")
for key in ("CRAWL_DIR", "SNAP_DIR"):
value = (env or {}).get(key)
if value:
_assert_not_repo_path(Path(value), label=key)
def _test_source_pythonpath() -> str:
entries: list[str] = [str(REPO_ROOT.resolve(strict=False))]
for repo_name in ("abxpkg", "abx-plugins", "abx-dl"):
for repo_path in (WORKSPACE_ROOT / repo_name, REPO_ROOT / repo_name):
if repo_path.exists():
entries.append(str(repo_path.resolve(strict=False)))
break
return os.pathsep.join(entries)
def _set_test_source_pythonpath(env: dict[str, str]) -> None:
source_pythonpath = _test_source_pythonpath()
existing_entries = [
str(Path(entry).expanduser().resolve(strict=False))
for entry in (env.get("PYTHONPATH") or "").split(os.pathsep)
if entry and Path(entry).expanduser().is_absolute()
]
entries = [entry for entry in [*source_pythonpath.split(os.pathsep), *existing_entries] if entry]
if entries:
env["PYTHONPATH"] = os.pathsep.join(dict.fromkeys(entries))
else:
env.pop("PYTHONPATH", None)
def _sync_archivebox_test_data_dir(data_dir: Path) -> None:
from archivebox.config import constants as constants_mod
from archivebox.config import paths as paths_mod
data_dir = data_dir.resolve()
archive_dir = data_dir / constants_mod.CONSTANTS.ARCHIVE_DIR_NAME
users_dir = archive_dir / constants_mod.CONSTANTS.USERS_DIR_NAME
paths_mod.DATA_DIR = data_dir
paths_mod.ARCHIVE_DIR = archive_dir
paths_mod.USERS_DIR = users_dir
paths_mod.DATABASE_FILE = data_dir / constants_mod.CONSTANTS.SQL_INDEX_FILENAME
constants_mod.CONSTANTS.DATA_DIR = data_dir
constants_mod.CONSTANTS.ARCHIVE_DIR = archive_dir
constants_mod.CONSTANTS.USERS_DIR = users_dir
constants_mod.CONSTANTS.COLLECTION_ID = paths_mod.get_collection_id(data_dir)
constants_mod.CONSTANTS.SOURCES_DIR = data_dir / constants_mod.CONSTANTS.SOURCES_DIR_NAME
constants_mod.CONSTANTS.PERSONAS_DIR = data_dir / constants_mod.CONSTANTS.PERSONAS_DIR_NAME
constants_mod.CONSTANTS.LOGS_DIR = data_dir / constants_mod.CONSTANTS.LOGS_DIR_NAME
constants_mod.CONSTANTS.CACHE_DIR = data_dir / constants_mod.CONSTANTS.CACHE_DIR_NAME
constants_mod.CONSTANTS.CUSTOM_TEMPLATES_DIR = data_dir / constants_mod.CONSTANTS.CUSTOM_TEMPLATES_DIR_NAME
constants_mod.CONSTANTS.USER_PLUGINS_DIR = data_dir / constants_mod.CONSTANTS.CUSTOM_PLUGINS_DIR_NAME
constants_mod.CONSTANTS.CONFIG_FILE = data_dir / constants_mod.CONSTANTS.CONFIG_FILENAME
constants_mod.CONSTANTS.DATABASE_FILE = data_dir / constants_mod.CONSTANTS.SQL_INDEX_FILENAME
constants_mod.CONSTANTS.DEFAULT_TMP_DIR = data_dir / constants_mod.CONSTANTS.TMP_DIR_NAME / constants_mod.CONSTANTS.MACHINE_ID
constants_mod.CONSTANTS_CONFIG.update(
{key: value for key, value in constants_mod.CONSTANTS.__dict__.items() if key.isupper() and not key.startswith("_")},
)
def _archivebox_test_audit_hook(event: str, args: tuple[Any, ...]) -> None:
"""Enforce runtime path isolation through Python's native audit surface."""
if not _RUNTIME_GUARD_ACTIVE:
return
if event == "os.chdir":
path = Path(args[0])
_assert_not_repo_path(path, label="cwd")
_sync_archivebox_test_data_dir(path)
elif event == "subprocess.Popen":
cwd = args[2]
env = args[3]
if cwd is not None:
_assert_not_repo_path(Path(cwd), label="cwd")
_assert_safe_runtime_paths(cwd=Path(cwd) if cwd is not None else None, env=env)
sys.addaudithook(_archivebox_test_audit_hook)
# =============================================================================
# CLI Helpers (defined before fixtures that use them)
# =============================================================================
class ArchiveBoxCmdResult:
"""Process-like result for completed and live ArchiveBox CLI commands."""
def __init__(self, args: list[str], process: subprocess.Popen) -> None:
self.args = args
self._process = process
self._stdout = None
self._stderr = None
@property
def stdout(self):
if self._stdout is None:
return self._process.stdout
return self._stdout
@property
def stderr(self):
if self._stderr is None:
return self._process.stderr
return self._stderr
@property
def stdin(self):
return self._process.stdin
@property
def returncode(self) -> int | None:
return self._process.returncode
@property
def pid(self) -> int | None:
return self._process.pid
def poll(self) -> int | None:
return self._process.poll()
def wait(self, timeout: float | None = None) -> int | None:
return self._process.wait(timeout=timeout)
def communicate(self, input=None, timeout: float | None = None):
self._stdout, self._stderr = self._process.communicate(input=input, timeout=timeout)
return self._stdout, self._stderr
def terminate(self) -> None:
self._process.terminate()
def send_signal(self, sig: int) -> None:
self._process.send_signal(sig)
def run_archivebox_cmd(
args: list[str],
*,
cwd: Path | None = None,
input: str | bytes | None = None,
timeout: int = 60,
env: dict[str, str] | None = None,
check: bool = False,
text: bool = True,
capture_output: bool = True,
stdout: Any = None,
stderr: Any = None,
stdin: Any = None,
wait: bool = True,
start_new_session: bool = False,
default_cli_env: bool = False,
disable_extractors: bool = False,
replace_env: bool = False,
) -> ArchiveBoxCmdResult:
"""Run an ArchiveBox CLI command under test isolation."""
cwd = cwd or Path.cwd()
cmd = ["archivebox", *args]
_assert_not_repo_path(cwd, label="cwd")
run_env = {} if replace_env else os.environ.copy()
if default_cli_env or disable_extractors or env is not None:
if default_cli_env:
run_env["USE_COLOR"] = "False"
run_env["SHOW_PROGRESS"] = "False"
if disable_extractors:
run_env.update(
{
"SAVE_ARCHIVEDOTORG": "False",
"SAVE_TITLE": "False",
"SAVE_FAVICON": "False",
"SAVE_WGET": "False",
"SAVE_WARC": "False",
"SAVE_PDF": "False",
"SAVE_SCREENSHOT": "False",
"SAVE_DOM": "False",
"SAVE_SINGLEFILE": "False",
"SAVE_READABILITY": "False",
"SAVE_MERCURY": "False",
"SAVE_GIT": "False",
"SAVE_YTDLP": "False",
"SAVE_HEADERS": "False",
"SAVE_HTMLTOTEXT": "False",
},
)
if args and args[0] == "version":
run_env["PLUGINS"] = "__archivebox_test_no_plugins__"
if env:
run_env.update(env)
_set_test_source_pythonpath(run_env)
_assert_safe_runtime_paths(cwd=cwd, env=run_env)
if stdin is not None:
assert input is None, "pass either input or stdin, not both"
if wait:
input = stdin
if isinstance(input, str):
text = True
if capture_output:
stdout = subprocess.PIPE if stdout is None else stdout
stderr = subprocess.PIPE if stderr is None else stderr
process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE if wait and input is not None else stdin,
stdout=stdout,
stderr=stderr,
text=text,
cwd=cwd,
env=run_env,
start_new_session=start_new_session,
)
result = ArchiveBoxCmdResult(cmd, process)
if wait:
try:
result.communicate(input=input, timeout=timeout)
except subprocess.TimeoutExpired:
process.terminate()
process.wait(timeout=5)
raise
if check and result.returncode:
raise subprocess.CalledProcessError(
result.returncode,
cmd,
output=result.stdout,
stderr=result.stderr,
)
return result
def find_snapshot_dir(data_dir: Path, snapshot_id: str) -> Path | None:
candidates = {snapshot_id}
if len(snapshot_id) == 32:
candidates.add(f"{snapshot_id[:8]}-{snapshot_id[8:12]}-{snapshot_id[12:16]}-{snapshot_id[16:20]}-{snapshot_id[20:]}")
elif len(snapshot_id) == 36 and "-" in snapshot_id:
candidates.add(snapshot_id.replace("-", ""))
for needle in candidates:
for path in data_dir.rglob(needle):
if path.is_dir():
return path
return None
# =============================================================================
# Fixtures
# =============================================================================
def pytest_configure():
import django
from django.apps import apps
if not apps.ready:
django.setup()
@pytest.fixture(autouse=True)
def isolate_test_runtime(tmp_path):
"""
Run each pytest test from an isolated temp cwd and restore env mutations.
The maintained pytest suite lives under ``archivebox/tests``. Many of those
CLI tests shell out without passing ``cwd=`` explicitly, so the safest
contract is that every test starts in its own temp directory and any
in-process ``os.environ`` edits are rolled back afterwards.
ArchiveBox derives DATA_DIR from cwd, so subprocess helpers pass the target
collection as cwd instead of using DATA_DIR as an override.
"""
global _RUNTIME_GUARD_ACTIVE
_assert_not_repo_path(tmp_path, label="tmp_path")
original_cwd = Path.cwd()
original_env = os.environ.copy()
os.chdir(tmp_path)
_sync_archivebox_test_data_dir(tmp_path)
os.environ.pop("DATA_DIR", None)
def reset_machine_model_caches() -> None:
import archivebox.machine.models as machine_models
machine_models._CURRENT_MACHINE = None
machine_models._CURRENT_INTERFACE = None
machine_models._CURRENT_PROCESS = None
machine_models._CURRENT_BINARIES.clear()
reset_machine_model_caches()
_RUNTIME_GUARD_ACTIVE = True
try:
_assert_safe_runtime_paths(cwd=Path.cwd(), env=os.environ)
yield
finally:
_RUNTIME_GUARD_ACTIVE = False
reset_machine_model_caches()
os.chdir(original_cwd)
_sync_archivebox_test_data_dir(original_cwd)
os.environ.clear()
os.environ.update(original_env)
def pytest_sessionfinish(session, exitstatus):
shutil.rmtree(SESSION_DATA_DIR, ignore_errors=True)
@pytest.fixture
def isolated_data_dir(tmp_path):
"""
Create isolated DATA_DIR for each test.
Uses tmp_path for complete isolation.
"""
data_dir = tmp_path / "archivebox_data"
data_dir.mkdir()
return data_dir
@pytest.fixture
def hermetic_lib_dir(tmp_path):
"""
Point ABXPKG_LIB_DIR at a temporary directory for isolated abxpkg resolution.
Opt-in only: most tests should reuse the cached real ABXPKG_LIB_DIR for speed —
rebuilding from scratch per-test adds ~10× overhead. Use this only when
validating ABXPKG_LIB_DIR-relative behavior.
"""
import archivebox.machine.models as machine_models
lib_dir = tmp_path / "lib"
lib_dir.mkdir(parents=True, exist_ok=True)
original_lib_dir = os.environ.get("ABXPKG_LIB_DIR")
os.environ["ABXPKG_LIB_DIR"] = str(lib_dir)
machine_models._CURRENT_MACHINE = None
machine_models._CURRENT_PROCESS = None
try:
yield lib_dir
finally:
if original_lib_dir is None:
os.environ.pop("ABXPKG_LIB_DIR", None)
else:
os.environ["ABXPKG_LIB_DIR"] = original_lib_dir
machine_models._CURRENT_MACHINE = None
machine_models._CURRENT_PROCESS = None
@pytest.fixture
def cached_abxpkg_lib_dir():
"""Reuse the configured abxpkg cache when a test is not validating LIB_DIR isolation."""
from archivebox.config.common import get_config
return get_config().ABXPKG_LIB_DIR
@pytest.fixture
def initialized_archive(tmp_path):
"""
Initialize ArchiveBox archive in isolated directory.
Runs `archivebox init` via subprocess to set up database and directories.
"""
_cmd_result = run_archivebox_cmd(
["init", "--quick"],
cwd=tmp_path,
timeout=60,
default_cli_env=True,
disable_extractors=True,
)
stderr, returncode = _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, f"archivebox init failed: {stderr}"
return tmp_path
@pytest.fixture
def recursive_test_site():
pages = {
"/": """
<html>
<head>
<title>Root</title>
<link rel="icon" href="/favicon.ico">
</head>
<body>
<a href="/about">About</a>
<a href="/blog">Blog</a>
<a href="/contact">Contact</a>
</body>
</html>
""".strip().encode("utf-8"),
"/about": """
<html>
<body>
<main>
<article>
<h1>About</h1>
<p>This local article describes the ArchiveBox recursive crawl fixture with enough semantic content for real article extractors.</p>
<a href="/deep/about">Deep About</a>
</article>
</main>
</body>
</html>
""".strip().encode("utf-8"),
"/blog": """
<html>
<body>
<main>
<article>
<h1>Blog</h1>
<p>This local article exercises real browser, text extraction, and recursive discovery behavior without depending on a public website.</p>
<a href="/deep/blog">Deep Blog</a>
</article>
</main>
</body>
</html>
""".strip().encode("utf-8"),
"/contact": """
<html>
<body>
<main>
<article>
<h1>Contact</h1>
<p>This local article keeps the deterministic crawl graph content-rich while preserving the original contact and deep-contact URLs.</p>
<a href="/deep/contact">Deep Contact</a>
</article>
</main>
</body>
</html>
""".strip().encode("utf-8"),
"/deep/about": b"<html><body><main><article><h1>Deep About</h1><p>This depth-two article verifies that recursive snapshots retain extractable semantic content and their exact parent relationship.</p></article></main></body></html>",
"/deep/blog": b"<html><body><main><article><h1>Deep Blog</h1><p>This depth-two article provides deterministic local content for every real extraction plugin in the recursive crawl.</p></article></main></body></html>",
"/deep/contact": b"<html><body><main><article><h1>Deep Contact</h1><p>This depth-two article completes the fixed local graph without relying on mutable third-party pages or network policy.</p></article></main></body></html>",
"/favicon.ico": b"test-icon",
}
class RecursiveHandler(BaseHTTPRequestHandler):
def do_GET(self):
body = pages.get(self.path)
if body is None:
self.send_response(404)
self.end_headers()
return
self.send_response(200)
if self.path.endswith(".ico"):
self.send_header("Content-Type", "image/x-icon")
else:
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
return
server = ThreadingHTTPServer(("127.0.0.1", 0), RecursiveHandler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
base_url = f"http://127.0.0.1:{server.server_address[1]}"
yield {
"base_url": base_url,
"root_url": f"{base_url}/",
"child_urls": [f"{base_url}/about", f"{base_url}/blog", f"{base_url}/contact"],
"deep_urls": [f"{base_url}/deep/about", f"{base_url}/deep/blog", f"{base_url}/deep/contact"],
}
finally:
server.shutdown()
server.server_close()
thread.join()
@pytest.fixture
def blocking_http_server():
"""Serve one real request behind explicit start/release synchronization."""
request_started = Event()
release_response = Event()
class BlockingHandler(BaseHTTPRequestHandler):
def do_GET(self):
request_started.set()
release_response.wait()
body = b"<html><head><title>Barrier</title></head><body>released</body></html>"
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass
server = ThreadingHTTPServer(("127.0.0.1", 0), BlockingHandler)
thread = Thread(target=server.serve_forever, name="archivebox-test-http-barrier")
thread.start()
yield SimpleNamespace(
url=f"http://127.0.0.1:{server.server_port}/",
request_started=request_started,
release_response=release_response,
)
release_response.set()
server.shutdown()
server.server_close()
thread.join()
@pytest.fixture
def archivebox_daemon_server(initialized_archive, free_tcp_port_factory):
"""
Start a real daemonized ArchiveBox server in this test's DATA_DIR and
always stop its supervisord before the test exits.
"""
started: list[tuple[Path, dict[str, str]]] = []
def start(**env_overrides: str):
env_config = {
"SEARCH_BACKEND_SONIC_HOST_NAME": "127.0.0.1",
"SEARCH_BACKEND_SONIC_PORT": str(free_tcp_port_factory()),
**{key: str(value) for key, value in env_overrides.items()},
}
env = cli_env(
live=True,
**env_config,
)
port = free_tcp_port_factory()
result = run_archivebox_cmd(
["server", "--daemonize", f"127.0.0.1:{port}"],
cwd=initialized_archive,
env=env,
timeout=90,
)
assert result.returncode == 0, result.stderr or result.stdout
started.append((initialized_archive, env))
return SimpleNamespace(
data_dir=initialized_archive,
env=env,
port=port,
worker_state=lambda: _archivebox_worker_state(initialized_archive, env),
wait_for_workers=lambda names, timeout=45: _wait_for_archivebox_workers(initialized_archive, env, names, timeout=timeout),
)
try:
yield start
finally:
for cwd, env in reversed(started):
_stop_archivebox_supervisord(cwd, env)
def find_process(predicate: Callable[[psutil.Process, str], bool]) -> psutil.Process:
"""Locate a process after its native readiness signal has fired."""
last_seen: list[str] = []
for proc in psutil.process_iter(["pid", "ppid", "cmdline"]):
try:
cmdline = proc.info.get("cmdline") or []
command = " ".join(cmdline)
last_seen.append(f"{proc.info.get('pid')} {proc.info.get('ppid')} {command}")
if predicate(proc, command):
return proc
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
raise AssertionError("No matching live process found. Last seen:\n" + "\n".join(last_seen[-50:]))
def pid_is_alive(pid: int) -> bool:
try:
return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
except (psutil.NoSuchProcess, psutil.ZombieProcess):
return False
except psutil.AccessDenied:
return True
def wait_for_pid_to_disappear(pid: int, *, timeout: float = 20.0) -> None:
try:
psutil.Process(pid).wait(timeout=timeout)
except psutil.NoSuchProcess:
return
except psutil.TimeoutExpired as exc:
raise AssertionError(f"PID {pid} is still running") from exc
def cleanup_process_group(group_pid: int | None, *child_pids: int | None) -> None:
processes: list[psutil.Process] = []
if group_pid and pid_is_alive(group_pid):
try:
os.killpg(group_pid, signal.SIGTERM)
except ProcessLookupError:
pass
except OSError:
try:
os.kill(group_pid, signal.SIGTERM)
except ProcessLookupError:
pass
processes.append(psutil.Process(group_pid))
for pid in child_pids:
if pid and pid_is_alive(pid):
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
else:
processes.append(psutil.Process(pid))
_gone, alive = psutil.wait_procs(processes, timeout=10)
assert not alive, f"processes did not stop after SIGTERM: {[proc.pid for proc in alive]}"
def cli_env(
*,
port: int | None = None,
plugins_root: Path | None = None,
replace: bool = False,
disable_extractors: bool = False,
live: bool = False,
server: bool = False,
wget: bool = False,
**extra: str,
) -> dict[str, str]:
env = {} if replace else os.environ.copy()
_set_test_source_pythonpath(env)
env.update({"USE_COLOR": "False", "SHOW_PROGRESS": "False"})
if disable_extractors or live or server:
env.update(
{
"PLUGINS": "__archivebox_test_no_plugins__",
"SAVE_ARCHIVEDOTORG": "False",
"SAVE_TITLE": "False",
"SAVE_FAVICON": "False",
"SAVE_WGET": "False",
"SAVE_WARC": "False",
"SAVE_PDF": "False",
"SAVE_SCREENSHOT": "False",
"SAVE_DOM": "False",
"SAVE_SINGLEFILE": "False",
"SAVE_READABILITY": "False",
"SAVE_MERCURY": "False",
"SAVE_GIT": "False",
"SAVE_YTDLP": "False",
"SAVE_HEADERS": "False",
"SAVE_HTMLTOTEXT": "False",
},
)
if live:
env.update(
{
"TIMEOUT": "60",
"WGET_TIMEOUT": "45",
"CRAWL_MAX_CONCURRENT_SNAPSHOTS": "1",
"PARSE_HTML_URLS_ENABLED": "True",
"PARSE_DOM_OUTLINKS_ENABLED": "False",
"SEARCH_BACKEND_ENGINE": "sqlite",
},
)
if server:
assert port is not None, "port is required when server=True"
env.update(
{
"PLUGINS": "wget",
"BIND_ADDR": f"127.0.0.1:{port}",
"BASE_URL": f"http://archivebox.localhost:{port}",
"ALLOWED_HOSTS": "*",
"PUBLIC_ADD_VIEW": "True",
"TIMEOUT": "30",
"URL_ALLOWLIST": r"127\.0\.0\.1[:/].*|example\.com",
"SAVE_WGET": "True",
"USE_CHROME": "False",
},
)
if wget:
env.update({"PLUGINS": "wget", "SAVE_WGET": "True"})
if plugins_root is not None:
env["ABX_PLUGINS_DIR"] = str(plugins_root)
env.update(extra)
return env
def assert_port_open(host: str, port: int, *, timeout: float = 30.0) -> None:
"""Verify a listening socket after the server emitted its readiness event."""
with socket.create_connection((host, port), timeout=timeout) as connection:
assert connection.getpeername() == (host, port)
def _wait_for_log_match(log_path: Path, pattern: str, *, fixed: bool, count: int, timeout: float) -> str:
"""Block on native filesystem notifications until the requested log event exists."""
log_path.parent.mkdir(parents=True, exist_ok=True)
deadline = time.monotonic() + timeout
if sys.platform == "darwin":
parent_fd = os.open(log_path.parent, os.O_RDONLY)
watched_fd: int | None = None
event_queue = select.kqueue()
event_queue.control(
[
select.kevent(
parent_fd,
filter=select.KQ_FILTER_VNODE,
flags=select.KQ_EV_ADD | select.KQ_EV_CLEAR,
fflags=select.KQ_NOTE_WRITE | select.KQ_NOTE_RENAME | select.KQ_NOTE_DELETE,
),
],
0,
0,
)
def refresh_file_watcher() -> None:
nonlocal watched_fd
if watched_fd is not None:
try:
same_file = os.fstat(watched_fd).st_ino == log_path.stat().st_ino
except (FileNotFoundError, OSError):
same_file = False
if same_file:
return
try:
event_queue.control(
[
select.kevent(
watched_fd,
filter=select.KQ_FILTER_VNODE,
flags=select.KQ_EV_DELETE,
),
],
0,
0,
)
except OSError:
pass
os.close(watched_fd)
watched_fd = None
if log_path.exists():
try:
watched_fd = os.open(log_path, os.O_RDONLY)
except FileNotFoundError:
return
event_queue.control(
[
select.kevent(
watched_fd,
filter=select.KQ_FILTER_VNODE,
flags=select.KQ_EV_ADD | select.KQ_EV_CLEAR,
fflags=(
select.KQ_NOTE_WRITE
| select.KQ_NOTE_EXTEND
| select.KQ_NOTE_ATTRIB
| select.KQ_NOTE_RENAME
| select.KQ_NOTE_DELETE
),
),
],
0,
0,
)
def wait_for_change(remaining: float) -> None:
events = event_queue.control(None, 1, remaining)
assert events, f"timed out waiting for filesystem event on {log_path}"
refresh_file_watcher()
def close_watcher() -> None:
event_queue.close()
if watched_fd is not None:
os.close(watched_fd)
os.close(parent_fd)
refresh_file_watcher()
else:
libc = ctypes.CDLL(None, use_errno=True)
inotify_fd = libc.inotify_init1(os.O_CLOEXEC)
assert inotify_fd >= 0, os.strerror(ctypes.get_errno())
watch_mask = 0x00000002 | 0x00000008 | 0x00000080 | 0x00000100
watch_descriptor = libc.inotify_add_watch(inotify_fd, os.fsencode(log_path.parent), watch_mask)
assert watch_descriptor >= 0, os.strerror(ctypes.get_errno())
def wait_for_change(remaining: float) -> None:
readable, _writable, _errors = select.select([inotify_fd], [], [], remaining)
assert readable, f"timed out waiting for filesystem event on {log_path}"
os.read(inotify_fd, 65536)
def close_watcher() -> None:
os.close(inotify_fd)
try:
while True:
content = log_path.read_text(encoding="utf-8", errors="replace") if log_path.exists() else ""
matches = content.count(pattern) if fixed else len(re.findall(pattern, content))
if matches >= count:
return content
remaining = deadline - time.monotonic()
assert remaining > 0, f"timed out waiting for {pattern!r} in {log_path}:\n{content}"
wait_for_change(remaining)
finally:
close_watcher()
def wait_for_log(log_path: Path, text: str, *, timeout: float = 30.0) -> str:
content = _wait_for_log_match(log_path, text, fixed=True, count=1, timeout=timeout)
assert text in content, content
return content
def wait_for_log_count(log_path: Path, text: str, count: int, *, timeout: float = 30.0) -> str:
content = _wait_for_log_match(log_path, text, fixed=True, count=count, timeout=timeout)
assert content.count(text) >= count, content
return content
def wait_for_log_pattern(log_path: Path, pattern: str, *, timeout: float = 30.0) -> re.Match[str]:
content = _wait_for_log_match(log_path, pattern, fixed=False, count=1, timeout=timeout)
match = re.search(pattern, content)
assert match is not None, content
return match
def supervisor_pid_from_log(log_path: Path) -> int:
content = log_path.read_text(encoding="utf-8", errors="replace")
matches = re.findall(r"Supervisord connected \(pid=(\d+)\)", content)
assert matches, content
return int(matches[-1])
def worker_pid_from_log(log_path: Path, worker_name: str) -> int:
content = log_path.read_text(encoding="utf-8", errors="replace")
matches = re.findall(rf"Worker {re.escape(worker_name)}: started RUNNING \(pid (\d+),", content)
assert matches, content
return int(matches[-1])
def wait_for_worker_pid_from_log(log_path: Path, worker_name: str, *, timeout: float = 45.0) -> int:
wait_for_log_pattern(
log_path,
rf"Worker {re.escape(worker_name)}: started RUNNING \(pid [0-9]+,",
timeout=timeout,
)
return worker_pid_from_log(log_path, worker_name)
def pgrep_data_dir(data_dir: Path) -> list[str]:
lines: list[str] = []
seen_pids: set[int] = set()
resolved_data_dir = data_dir.resolve()
for process in psutil.process_iter(["pid", "ppid", "cmdline", "cwd"]):
try:
if process.pid == os.getpid():
continue
command = " ".join(process.info.get("cmdline") or [])
process_cwd = process.info.get("cwd")
cwd_matches = bool(process_cwd and Path(process_cwd).resolve() == resolved_data_dir)
env_data_dir = None if cwd_matches else process.environ().get("DATA_DIR")
env_matches = bool(env_data_dir and Path(env_data_dir).resolve() == resolved_data_dir)
if str(data_dir) not in command and not cwd_matches and not env_matches:
continue
pid = int(process.info["pid"])
seen_pids.add(pid)
lines.append(f"{pid} {process.info.get('ppid') or 0} {command}")
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
for runtime_root in (Path("/tmp/archivebox"), data_dir / "tmp"):
for config_path in runtime_root.glob("*/supervisord.conf"):
try:
config_text = config_path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
if str(data_dir) not in config_text:
continue
pid_path = config_path.with_name("supervisord.pid")
try:
pid = int(pid_path.read_text(encoding="utf-8").strip())
except (OSError, ValueError):
continue
if not pid_is_alive(pid):
continue
if pid in seen_pids:
continue
try:
process = psutil.Process(pid)
lines.append(f"{pid} {process.ppid()} {' '.join(process.cmdline())}")
seen_pids.add(pid)
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
return sorted(set(lines))
def assert_no_processes_for_data_dir(data_dir: Path, *, timeout: float = 10.0) -> None:
remaining = pgrep_data_dir(data_dir)
processes = []
for line in remaining:
pid = int(line.split(None, 1)[0])
try:
processes.append(psutil.Process(pid))
except psutil.NoSuchProcess:
continue
_gone, alive = psutil.wait_procs(processes, timeout=timeout)
final_remaining = pgrep_data_dir(data_dir)
assert not alive and not final_remaining, "processes still reference test DATA_DIR:\n" + "\n".join(final_remaining)
def kill_processes_for_data_dir(data_dir: Path) -> None:
processes: list[psutil.Process] = []
for line in pgrep_data_dir(data_dir):
try:
pid = int(line.split(None, 1)[0])
except (IndexError, ValueError):
continue
if pid != os.getpid():
try:
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
pass
else:
processes.append(psutil.Process(pid))
_gone, alive = psutil.wait_procs(processes, timeout=10)
assert not alive, f"processes did not stop after SIGTERM: {[proc.pid for proc in alive]}"
def start_archivebox_server(
cwd: Path,
*,
port: int,
env: dict[str, str] | None = None,
daemonize: bool | None = None,
log_name: str | None = None,
wait_for_log_text: str | None = "Listening on TCP",
):
if daemonize is None:
daemonize = log_name is None
args = ["server", f"127.0.0.1:{port}"]
if daemonize:
args.insert(1, "--daemonize")
log_path = cwd / log_name if log_name else None
log = log_path.open("w", encoding="utf-8") if log_path else None
proc = run_archivebox_cmd(
args,
cwd=cwd,
env=env or cli_env(live=True),
stdout=log if log else None,
stderr=subprocess.STDOUT if log else None,
text=daemonize,
start_new_session=not daemonize,
wait=daemonize,
)
if log is not None:
log.close()
proc.log_path = log_path
if daemonize:
assert proc.returncode == 0, proc.stderr or proc.stdout
return proc
if log_path is not None and wait_for_log_text is not None:
wait_for_log(log_path, wait_for_log_text, timeout=30.0)
assert_port_open("127.0.0.1", port)
return proc
def stop_archivebox_process(proc: subprocess.Popen[str], sig=signal.SIGTERM, *, timeout: float = 15.0) -> str:
try:
os.killpg(proc.pid, sig)
except (ProcessLookupError, OSError):
try:
os.kill(proc.pid, sig)
except ProcessLookupError:
pass
stdout, _stderr = proc.communicate(timeout=timeout)
return stdout or ""
def run_queued_crawls(cwd: Path, env: dict[str, str] | None = None, timeout: int = 180) -> None:
script = """
import json
from archivebox.crawls.models import Crawl
print(json.dumps([str(crawl_id) for crawl_id in Crawl.objects.order_by("created_at").values_list("id", flat=True)]))
"""
_cmd_result = run_archivebox_cmd(["manage", "shell", "-c", script], cwd=cwd, timeout=60, env=env)
stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, stderr or stdout
crawl_ids = json.loads(stdout.strip().splitlines()[-1])
for crawl_id in crawl_ids:
_cmd_result = run_archivebox_cmd(["run", f"--crawl-id={crawl_id}"], cwd=cwd, timeout=timeout, env=env)
stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, f"archivebox run --crawl-id={crawl_id} failed:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"
def _run_archivebox_manage_shell(cwd: Path, env: dict[str, str], script: str, timeout: int = 60) -> str:
result = run_archivebox_cmd(
["manage", "shell", "-c", script],
cwd=cwd,
env=env,
timeout=timeout,
)
assert result.returncode == 0, result.stderr or result.stdout
return result.stdout
def _archivebox_worker_state(cwd: Path, env: dict[str, str]) -> dict[str, Any]:
stdout = _run_archivebox_manage_shell(
cwd,
env,
"""
import json
from archivebox.workers.supervisord_util import get_existing_supervisord_process, get_worker
supervisor = get_existing_supervisord_process(quiet=True)
workers = {}
if supervisor:
for name in ("worker_daphne", "worker_sonic", "worker_runner"):
workers[name] = get_worker(supervisor, name)
print(json.dumps(workers, default=str))
""",
)
return json.loads(stdout.strip().splitlines()[-1])
def _stop_archivebox_supervisord(cwd: Path, env: dict[str, str]) -> None:
_run_archivebox_manage_shell(
cwd,
env,
"from archivebox.workers.supervisord_util import stop_existing_supervisord_process; stop_existing_supervisord_process()",
timeout=30,
)
def _wait_for_archivebox_workers(cwd: Path, env: dict[str, str], names: tuple[str, ...] | list[str], timeout: int = 45) -> dict[str, Any]:
supervisord_log = cwd / "logs" / "supervisord.log"
deadline = time.monotonic() + timeout
for name in names:
remaining = deadline - time.monotonic()
assert remaining > 0, f"timed out waiting for workers {names} in {supervisord_log}"
wait_for_log_pattern(
supervisord_log,
rf"success: {re.escape(name)} entered RUNNING state,",
timeout=remaining,
)
state = _archivebox_worker_state(cwd, env)
assert all(isinstance(state.get(name), dict) and state[name].get("statename") == "RUNNING" for name in names), state
return state
def stop_process(proc: subprocess.Popen[str]) -> tuple[str, str]:
proc.terminate()
return proc.communicate(timeout=5)
def run_python_cwd(
script: str,
cwd: Path,
timeout: int = 60,
) -> tuple[str, str, int]:
_assert_not_repo_path(cwd, label="cwd")
base_env = os.environ.copy()
_assert_safe_runtime_paths(cwd=cwd, env=base_env)
result = subprocess.run(
[sys.executable, "-"],
input=script,
capture_output=True,
text=True,
cwd=cwd,
env=base_env,
timeout=timeout,
)
return result.stdout, result.stderr, result.returncode
# =============================================================================
# Server/API Integration Helpers
# =============================================================================
API_TEST_HOST = "api.archivebox.localhost:8000"
ADMIN_TEST_HOST = "admin.archivebox.localhost:8000"
WEB_TEST_HOST = "web.archivebox.localhost:8000"
@pytest.fixture
def admin_user(request):
from django.contrib.auth import get_user_model
username = f"admin_{abs(hash(request.node.nodeid))}"
return get_user_model().objects.create_superuser(
username=username,
email=f"{username}@example.com",
password="testpassword",
)
@pytest.fixture
def admin_client(client, admin_user):
client.force_login(admin_user)
return client
@pytest.fixture
def crawl(admin_user, db):
from archivebox.crawls.models import Crawl
return Crawl.objects.create(
urls="https://example.com\nhttps://example.org",
tags_str="alpha,beta",
created_by=admin_user,
)
@pytest.fixture
def snapshot(crawl, db):
from archivebox.core.models import Snapshot
return Snapshot.objects.create(
url="https://example.com",
crawl=crawl,
status=Snapshot.StatusChoices.STARTED,
)
@pytest.fixture
def tagged_data(crawl, admin_user):
from archivebox.core.models import Snapshot, Tag
tag = Tag.objects.create(name="Alpha Research", created_by=admin_user)
first = Snapshot.objects.create(
url="https://example.com/one",
title="Example One",
crawl=crawl,
)
second = Snapshot.objects.create(
url="https://example.com/two",
title="Example Two",
crawl=crawl,
)
first.tags.add(tag)
second.tags.add(tag)
return tag, [first, second]
@pytest.fixture
def api_admin_user(request):
from django.contrib.auth import get_user_model
username = f"apiadmin_{abs(hash(request.node.nodeid))}"
return get_user_model().objects.create_superuser(
username=username,
email=f"{username}@example.com",
password="testpass123",
)
@pytest.fixture
def api_token(api_admin_user):
from archivebox.api.auth import get_or_create_api_token
token = get_or_create_api_token(api_admin_user)
assert token is not None
return token
@pytest.fixture
def api_headers(api_token) -> dict[str, str]:
return api_auth_headers(api_token.token, django_client=True)
def api_auth_headers(api_token: str, *, django_client: bool = False, port: int | None = None) -> dict[str, str]:
host = f"api.archivebox.localhost:{port}" if port is not None else API_TEST_HOST
if django_client:
return {
"HTTP_HOST": host,
"HTTP_X_ARCHIVEBOX_API_KEY": api_token,
}
return {
"Host": host,
"X-ArchiveBox-API-Key": api_token,
}
def wait_for_live_api(port: int, *, path: str = "/api/v1/docs"):
return get_http_response(port, host=f"api.archivebox.localhost:{port}", path=path)
def live_api_request(port: int, method: str, path: str, *, api_token: str, timeout: int = 30, **kwargs):
return requests.request(
method,
f"http://127.0.0.1:{port}{path}",
headers=api_auth_headers(api_token, port=port),
timeout=timeout,
**kwargs,
)
def api_client_request(
client,
method: str,
path: str,
*,
payload: dict[str, Any] | None = None,
api_token: str | None = None,
headers: dict[str, str] | None = None,
**kwargs,
):
request_kwargs = dict(kwargs)
if payload is not None:
request_kwargs["data"] = json.dumps(payload)
request_kwargs["content_type"] = "application/json"
if headers is None:
assert api_token is not None
headers = api_auth_headers(api_token, django_client=True)
request_kwargs.update(headers)
return getattr(client, method.lower())(path, **request_kwargs)
def init_archive(cwd: Path) -> None:
result = run_archivebox_cmd(
["init", "--quick"],
cwd=cwd,
timeout=60,
)
assert result.returncode == 0, result.stderr
def get_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
def stop_server(cwd: Path) -> None:
script = textwrap.dedent(
"""
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'archivebox.settings')
import django
django.setup()
from archivebox.workers.supervisord_util import stop_existing_supervisord_process
stop_existing_supervisord_process()
print('stopped')
""",
)
run_python_cwd(script, cwd=cwd, timeout=30)
def get_http_response(
port: int,
host: str,
path: str = "/",
timeout: float = 30.0,
process: subprocess.Popen[str] | None = None,
) -> requests.Response:
"""Perform one blocking HTTP exchange after native server readiness."""
if process is not None:
returncode = process.poll()
assert returncode is None, f"Server exited before becoming ready with code {returncode}"
response = requests.get(
f"http://127.0.0.1:{port}{path}",
headers={"Host": host},
timeout=timeout,
allow_redirects=False,
)
assert response.status_code < 500, f"HTTP {response.status_code} from {host}{path}"
return response
def make_latest_schedule_due(cwd: Path) -> None:
from archivebox.crawls.models import Crawl, CrawlSchedule
from archivebox.tests.test_orm_helpers import use_archivebox_db
from django.utils import timezone
with use_archivebox_db(cwd):
schedule = CrawlSchedule.objects.order_by("-created_at").select_related("template").first()
assert schedule is not None
Crawl.objects.filter(pk=schedule.template_id).update(
created_at=timezone.now() - timedelta(days=2),
modified_at=timezone.now() - timedelta(days=2),
)
def get_snapshot_file_text(cwd: Path, url: str) -> str:
script = textwrap.dedent(
f"""
import os
from pathlib import Path
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'archivebox.settings')
import django
django.setup()
from archivebox.core.models import Snapshot
snapshot = Snapshot.objects.filter(url={url!r}).order_by('-created_at').first()
assert snapshot is not None, 'missing snapshot'
assert snapshot.status == 'sealed', snapshot.status
snapshot_dir = Path(snapshot.output_dir)
candidates = []
preferred_patterns = (
'wget/**/index.html',
'wget/**/*.html',
'trafilatura/content.html',
'trafilatura/content.txt',
'defuddle/content.html',
'defuddle/content.txt',
)
for pattern in preferred_patterns:
for candidate in snapshot_dir.glob(pattern):
if candidate.is_file():
candidates.append(candidate)
if not candidates:
for candidate in snapshot_dir.rglob('*'):
if not candidate.is_file():
continue
rel = candidate.relative_to(snapshot_dir)
if rel.parts and rel.parts[0] == 'responses':
continue
if len(rel.parts) == 1 and rel.name == 'index.html':
continue
if candidate.suffix not in ('.html', '.htm', '.txt'):
continue
if candidate.name in ('stdout.log', 'stderr.log'):
continue
candidates.append(candidate)
assert candidates, f'no captured html/txt files found in {{snapshot_dir}}'
print(candidates[0].read_text(errors='ignore'))
""",
)
stdout, stderr, code = run_python_cwd(script, cwd=cwd, timeout=60)
assert code == 0, stderr
return stdout
def wait_for_snapshot_capture(cwd: Path, url: str, timeout: int = 180) -> str:
script = textwrap.dedent(
f"""
from archivebox.core.models import Snapshot
snapshot = Snapshot.objects.filter(url={url!r}).order_by('-created_at').first()
assert snapshot is not None
print(snapshot.output_dir / 'index.jsonl')
""",
)
result = run_archivebox_cmd(["manage", "shell", "-c", script], cwd=cwd, timeout=30)
assert result.returncode == 0, result.stderr or result.stdout
index_path = Path(result.stdout.strip().splitlines()[-1])
_wait_for_log_match(index_path, ".", fixed=False, count=1, timeout=timeout)
return get_snapshot_file_text(cwd, url)
def get_counts(cwd: Path, scheduled_url: str, one_shot_url: str) -> tuple[int, int, int]:
from archivebox.core.models import Snapshot
from archivebox.crawls.models import Crawl
from archivebox.tests.test_orm_helpers import use_archivebox_db
with use_archivebox_db(cwd):
scheduled_snapshots = Snapshot.objects.filter(url=scheduled_url).count()
one_shot_snapshots = Snapshot.objects.filter(url=one_shot_url).count()
scheduled_crawls = Crawl.objects.filter(schedule__isnull=False, urls=scheduled_url).count()
return scheduled_snapshots, one_shot_snapshots, scheduled_crawls
def get_depth_counts(cwd: Path) -> dict[int, int]:
from archivebox.core.models import Snapshot
from archivebox.tests.test_orm_helpers import use_archivebox_db
with use_archivebox_db(cwd):
return {depth: Snapshot.objects.filter(depth=depth).count() for depth in set(Snapshot.objects.values_list("depth", flat=True))}
def get_crawl_runtime_state(cwd: Path, crawl_id: str) -> dict[str, object]:
from archivebox.core.models import ArchiveResult
from archivebox.crawls.models import Crawl
from archivebox.tests.test_orm_helpers import use_archivebox_db
from archivebox.workers.models import RETRY_AT_MAX
with use_archivebox_db(cwd):
crawl = Crawl.objects.get(id=crawl_id)
snapshots = list(
crawl.snapshot_set.order_by("created_at").values(
"id",
"url",
"status",
"retry_at",
),
)
results = list(
ArchiveResult.objects.filter(snapshot__crawl=crawl)
.order_by("snapshot_id", "plugin", "hook_name")
.values(
"snapshot_id",
"plugin",
"hook_name",
"status",
"retry_at",
"output_files",
"output_size",
),
)
return {
"retry_at_max": RETRY_AT_MAX,
"crawl_status": crawl.status,
"crawl_retry_at": crawl.retry_at,
"snapshots": snapshots,
"results": results,
}
def create_admin_and_token(cwd: Path) -> str:
script = textwrap.dedent(
"""
import os
from datetime import timedelta
from django.utils import timezone
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'archivebox.settings')
import django
django.setup()
from django.contrib.auth import get_user_model
from archivebox.api.models import APIToken
User = get_user_model()
user, _ = User.objects.get_or_create(
username='apitestadmin',
defaults={
'email': 'apitestadmin@example.com',
'is_staff': True,
'is_superuser': True,
},
)
user.is_staff = True
user.is_superuser = True
user.set_password('testpass123')
user.save()
token = APIToken.objects.create(
created_by=user,
expires=timezone.now() + timedelta(days=1),
)
print(token.token)
""",
)
stdout, stderr, code = run_python_cwd(script, cwd=cwd, timeout=60)
assert code == 0, stderr
return stdout.strip().splitlines()[-1]
def wait_for_archive_outputs(
cwd: Path,
url: str,
timeout: int = 120,
) -> bool:
wait_for_snapshot_capture(cwd, url, timeout=timeout)
script = textwrap.dedent(
f"""\
from pathlib import Path
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'archivebox.core.settings')
import django
django.setup()
from archivebox.core.models import Snapshot
snapshot = Snapshot.objects.filter(url={url!r}).order_by('-created_at').first()
if snapshot is None or snapshot.status != 'sealed':
raise SystemExit(1)
output_rel = None
for output in snapshot.discover_outputs():
candidate = output.get('path')
if not candidate or candidate.startswith('responses/'):
continue
if Path(snapshot.output_dir, candidate).is_file():
output_rel = candidate
break
if output_rel is None:
fallback = Path(snapshot.output_dir, 'index.jsonl')
if fallback.exists():
output_rel = 'index.jsonl'
if output_rel is None:
snapshot_dir = Path(snapshot.output_dir)
for candidate in snapshot_dir.rglob('*'):
if not candidate.is_file():
continue
rel_path = candidate.relative_to(snapshot_dir)
if rel_path.parts and rel_path.parts[0] == 'responses':
continue
if rel_path.name in {"stdout.log", "stderr.log"}:
continue
output_rel = str(rel_path)
break
if output_rel is None:
raise SystemExit(1)
responses_root = Path(snapshot.output_dir) / 'responses'
if not responses_root.exists():
raise SystemExit(1)
if not any(candidate.is_file() and snapshot.domain in candidate.relative_to(responses_root).parts for candidate in responses_root.rglob('*')):
raise SystemExit(1)
print('READY')
""",
)
stdout, _stderr, returncode = run_python_cwd(script, cwd=cwd, timeout=30)
return returncode == 0 and "READY" in stdout
def _get_machine_type() -> str:
import platform
os_name = platform.system().lower()
arch = platform.machine().lower()
in_docker = os.environ.get("IN_DOCKER", "").lower() in ("1", "true", "yes")
suffix = "-docker" if in_docker else ""
return f"{arch}-{os_name}{suffix}"
def resolve_abxpkg_binary_env(
lib_dir: Path,
*binary_names: str,
env: dict[str, str] | None = None,
deps_from: Path | list[Path] | tuple[Path, ...] | None = None,
) -> dict[str, str]:
"""Resolve already-available test dependencies through abxpkg."""
command_env = dict(env) if env is not None else os.environ.copy()
command_env["ABXPKG_LIB_DIR"] = str(lib_dir)
command = [
str(Path(sys.executable).with_name("abxpkg")),
"env",
"--json",
f"--lib={lib_dir}",
]
deps_configs = [deps_from] if isinstance(deps_from, Path) else list(deps_from or ())
command.extend(f"--deps-from={config}:required_binaries" for config in deps_configs)
command.extend(binary_names)
result = subprocess.run(
command,
capture_output=True,
text=True,
env=command_env,
)
assert result.returncode == 0, result.stderr or result.stdout
payload = json.loads(result.stdout)
return {str(key): str(value) for key, value in payload.items()}
def resolve_abxpkg_chrome_env(lib_dir: Path, env: dict[str, str] | None = None) -> dict[str, str]:
from abx_plugins import get_plugins_dir
chrome_config = Path(get_plugins_dir()) / "chrome" / "config.json"
payload = resolve_abxpkg_binary_env(
lib_dir,
env=env,
deps_from=chrome_config,
)
chrome_binary = Path(payload["CHROME_BINARY"])
node_binary = Path(payload["NODE_BINARY"])
assert chrome_binary.is_file()
assert node_binary.is_file()
return payload
def install_real_binary(
name: str,
*,
machine=None,
binproviders: str = "env",
overrides: dict[str, dict[str, Any]] | None = None,
):
"""Install and persist a real binary through the normal Binary state machine."""
from archivebox.machine.models import Binary, Machine
binary = Binary.objects.create(
machine=machine or Machine.current(refresh=True),
name=name,
binproviders=binproviders,
overrides=overrides or {},
status=Binary.StatusChoices.QUEUED,
)
assert binary.tick_claimed(lock_seconds=600)
binary.refresh_from_db()
assert binary.status == Binary.StatusChoices.INSTALLED
assert binary.retry_at is None
assert binary.binprovider in binary.binproviders.split(",")
assert binary.version
assert binary.abspath
assert Path(binary.abspath).exists()
return binary
@pytest.fixture(scope="class")
def real_archive_with_example(tmp_path_factory, request):
"""
Initialize archive and add https://example.com using responses only.
Uses cwd for DATA_DIR.
"""
tmp_path = tmp_path_factory.mktemp("archivebox_data")
if request.cls is not None:
request.cls.data_dir = tmp_path
_cmd_result = run_archivebox_cmd(
["init", "--quick"],
cwd=tmp_path,
timeout=120,
)
stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, f"archivebox init failed: {stderr}"
_cmd_result = run_archivebox_cmd(
[
"config",
"--set",
"BIND_ADDR=127.0.0.1:8000",
"BASE_URL=http://archivebox.localhost:8000",
"PUBLIC_INDEX=True",
"PUBLIC_ADD_VIEW=True",
],
cwd=tmp_path,
)
stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, f"archivebox config failed: {stderr}"
add_env = {
"RESPONSES_ENABLED": "True",
"SHOW_PROGRESS": "False",
"USE_COLOR": "False",
"RESPONSES_TIMEOUT": "30",
}
_cmd_result = run_archivebox_cmd(
["add", "--depth=0", "--plugins=responses", "https://example.com"],
cwd=tmp_path,
timeout=600,
env=add_env,
)
stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, f"archivebox add failed:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"
ready = wait_for_archive_outputs(tmp_path, "https://example.com", timeout=60)
assert ready, f"archivebox add did not produce required outputs within timeout:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"
return tmp_path
# =============================================================================
# Output Assertions
# =============================================================================
def parse_jsonl_output(stdout: str) -> list[dict[str, Any]]:
"""Parse JSONL output into list of dicts via Process parser."""
from archivebox.machine.models import Process
return Process.parse_records_from_text(stdout or "")
def stdout_lines(stdout: str) -> list[str]:
return [line for line in stdout.splitlines() if line.strip()]
def assert_jsonl_only(stdout: str) -> None:
lines = stdout_lines(stdout)
assert lines, "Expected stdout to contain JSONL records"
assert all(line.lstrip().startswith("{") for line in lines), stdout
def assert_jsonl_contains_type(stdout: str, record_type: str, min_count: int = 1):
"""Assert output contains at least min_count records of type."""
records = parse_jsonl_output(stdout)
matching = [r for r in records if r.get("type") == record_type]
assert len(matching) >= min_count, f"Expected >= {min_count} {record_type}, got {len(matching)}"
return matching
def assert_jsonl_pass_through(stdout: str, input_records: list[dict[str, Any]]):
"""Assert that input records appear in output (pass-through behavior)."""
output_records = parse_jsonl_output(stdout)
output_ids = {r.get("id") for r in output_records if r.get("id")}
for input_rec in input_records:
input_id = input_rec.get("id")
if input_id:
assert input_id in output_ids, f"Input record {input_id} not found in output (pass-through failed)"
def assert_record_has_fields(record: dict[str, Any], required_fields: list[str]):
"""Assert record has all required fields with non-None values."""
for field in required_fields:
assert field in record, f"Record missing field: {field}"
assert record[field] is not None, f"Record field is None: {field}"
# =============================================================================
# Test Data Factories
# =============================================================================
def create_test_url(domain: str = "example.com", path: str | None = None) -> str:
"""Generate unique test URL."""
path = path or secrets.token_hex(4)
return f"https://{domain}/{path}"
def create_test_crawl_json(urls: list[str] | None = None, **kwargs) -> dict[str, Any]:
"""Create Crawl JSONL record for testing."""
urls = urls or [create_test_url()]
return {
"type": "Crawl",
"urls": "\n".join(urls),
"max_depth": kwargs.get("max_depth", 0),
"tags_str": kwargs.get("tags_str", ""),
"status": kwargs.get("status", "queued"),
**{k: v for k, v in kwargs.items() if k not in ("max_depth", "tags_str", "status")},
}
def create_test_snapshot_json(url: str | None = None, **kwargs) -> dict[str, Any]:
"""Create Snapshot JSONL record for testing."""
return {
"type": "Snapshot",
"url": url or create_test_url(),
"tags_str": kwargs.get("tags_str", ""),
"status": kwargs.get("status", "queued"),
**{k: v for k, v in kwargs.items() if k not in ("tags_str", "status")},
}