diff --git a/archivebox/__main__.py b/archivebox/__main__.py
index 125ae205..fe4b74f4 100755
--- a/archivebox/__main__.py
+++ b/archivebox/__main__.py
@@ -17,4 +17,4 @@ ASCII_LOGO_MINI = r"""
"""
if __name__ == "__main__":
- main(args=sys.argv[1:], stdin=sys.stdin)
+ main(args=sys.argv[1:])
diff --git a/archivebox/api/auth.py b/archivebox/api/auth.py
index 68992ca7..70d38766 100644
--- a/archivebox/api/auth.py
+++ b/archivebox/api/auth.py
@@ -51,6 +51,27 @@ def auth_using_token(token: str | None, request: HttpRequest | None = None) -> U
return user
+def token_from_request(request: HttpRequest) -> str:
+ token = request.GET.get("api_key") or request.headers.get("X-ArchiveBox-API-Key") or ""
+ auth_header = request.headers.get("Authorization", "")
+ if not token and auth_header.lower().startswith("bearer "):
+ token = auth_header.split(None, 1)[1].strip()
+ return token
+
+
+def authenticated_user_from_request(request: HttpRequest) -> User | None:
+ user = request.user
+ if user.is_authenticated and user.is_active:
+ return user
+
+ token = token_from_request(request)
+ token_user = auth_using_token(token=token, request=request) if token else None
+ if token_user and token_user.is_active:
+ request.user = token_user
+ return token_user
+ return None
+
+
def auth_using_password(username: str | None, password: str | None, request: HttpRequest | None = None) -> User | None:
"""Given a username and password, check if they are valid and return the corresponding user"""
user: User | None = None
diff --git a/archivebox/api/v1_cli.py b/archivebox/api/v1_cli.py
index 02ce46e0..09af4319 100644
--- a/archivebox/api/v1_cli.py
+++ b/archivebox/api/v1_cli.py
@@ -8,8 +8,11 @@ from enum import Enum
from django.http import HttpRequest
from ninja import Router, Schema
+from ninja.errors import HttpError
+from pydantic import Field
from archivebox.misc.util import ansi_to_html
+from archivebox.core.models import SnapshotQuerySet
# from .auth import API_AUTH_METHODS
@@ -21,6 +24,7 @@ router = Router(tags=["ArchiveBox CLI Sub-Commands"])
# Schemas
JSONType = list[Any] | dict[str, Any] | bool | int | str | None
+FILTER_PATTERNS_EXAMPLES = [["https://example.com"]]
class CLICommandResponseSchema(Schema):
@@ -32,26 +36,11 @@ class CLICommandResponseSchema(Schema):
stderr: str
-class FilterTypeChoices(str, Enum):
- exact = "exact"
- substring = "substring"
- regex = "regex"
- domain = "domain"
- tag = "tag"
- timestamp = "timestamp"
-
-
-class StatusChoices(str, Enum):
- indexed = "indexed"
- archived = "archived"
- unarchived = "unarchived"
- present = "present"
- valid = "valid"
- invalid = "invalid"
- duplicate = "duplicate"
- orphaned = "orphaned"
- corrupted = "corrupted"
- unrecognized = "unrecognized"
+FilterTypeChoices = Enum(
+ "FilterTypeChoices",
+ {filter_type: filter_type for filter_type in SnapshotQuerySet.FILTER_TYPE_CHOICES},
+ type=str,
+)
class AddCommandSchema(Schema):
@@ -71,14 +60,27 @@ class AddCommandSchema(Schema):
index_only: bool = False
-class UpdateCommandSchema(Schema):
- resume: str | None = None
+class SnapshotFilterCommandSchema(Schema):
after: float | None = 0
- before: float | None = 999999999999999
+ before: float | None = None
filter_type: str | None = FilterTypeChoices.substring
- filter_patterns: list[str] | None = ["https://example.com"]
+ filter_patterns: list[str] | None = Field(default=None, examples=FILTER_PATTERNS_EXAMPLES)
+ status: str | None = None
+ url__icontains: str | None = None
+ url__istartswith: str | None = None
+ tag: str | None = None
+ crawl_id: str | None = None
+ limit: int | None = None
+ sort: str | None = None
+ search: str | None = None
+
+
+class UpdateCommandSchema(SnapshotFilterCommandSchema):
+ resume: str | None = None
batch_size: int = 100
continuous: bool = False
+ index_only: bool = False
+ migrate_only: bool = False
class ScheduleCommandSchema(Schema):
@@ -97,24 +99,23 @@ class ScheduleCommandSchema(Schema):
clear: bool = False
-class ListCommandSchema(Schema):
- filter_patterns: list[str] | None = ["https://example.com"]
- filter_type: str = FilterTypeChoices.substring
- status: StatusChoices = StatusChoices.indexed
- after: float | None = 0
- before: float | None = 999999999999999
- sort: str = "bookmarked_at"
+class ListCommandSchema(SnapshotFilterCommandSchema):
as_json: bool = True
as_html: bool = False
as_csv: str | None = "timestamp,url"
with_headers: bool = False
-class RemoveCommandSchema(Schema):
- after: float | None = 0
- before: float | None = 999999999999999
+class RemoveCommandSchema(SnapshotFilterCommandSchema):
filter_type: str = FilterTypeChoices.exact
- filter_patterns: list[str] | None = ["https://example.com"]
+ timeout: float = 60.0
+
+
+def snapshot_filter_kwargs(args: SnapshotFilterCommandSchema, *, default_filter_type: str) -> dict[str, Any]:
+ kwargs = args.dict()
+ kwargs["filter_patterns"] = kwargs.get("filter_patterns") or []
+ kwargs["filter_type"] = kwargs.get("filter_type") or default_filter_type
+ return kwargs
@router.post("/add", response=CLICommandResponseSchema, summary="archivebox add [args] [urls]")
@@ -165,24 +166,39 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
@router.post("/update", response=CLICommandResponseSchema, summary="archivebox update [args] [filter_patterns]")
def cli_update(request: HttpRequest, args: UpdateCommandSchema):
- from archivebox.cli.archivebox_update import update
+ from archivebox.cli.archivebox_update import _build_filtered_snapshots_queryset, update
+ from archivebox.core.snapshot_status import normalize_snapshot_status
- result = update(
- filter_patterns=args.filter_patterns or [],
- filter_type=args.filter_type or FilterTypeChoices.substring,
- after=args.after,
- before=args.before,
- resume=args.resume,
- batch_size=args.batch_size,
- continuous=args.continuous,
- stop_daemon_stack=False,
+ try:
+ status = normalize_snapshot_status(args.status)
+ except ValueError as err:
+ raise HttpError(400, str(err)) from err
+
+ update_kwargs = snapshot_filter_kwargs(args, default_filter_type=FilterTypeChoices.substring)
+ update_kwargs["status"] = status
+ update_kwargs["stop_daemon_stack"] = False
+
+ is_filtered_update = any(
+ (update_kwargs.get(key) for key in (*SnapshotQuerySet.FILTER_ARG_KEYS, "resume") if key != "filter_type"),
)
+ matched_snapshot_ids = []
+ if is_filtered_update:
+ matched_snapshot_ids = [
+ str(snapshot_id) for snapshot_id in _build_filtered_snapshots_queryset(**update_kwargs).values_list("id", flat=True)
+ ]
+
+ update(**update_kwargs)
stdout = request.__dict__.get("stdout")
stderr = request.__dict__.get("stderr")
return {
"success": True,
"errors": [],
- "result": result,
+ "result": {
+ "matched_count": len(matched_snapshot_ids),
+ "snapshot_ids": matched_snapshot_ids,
+ }
+ if is_filtered_update
+ else None,
"stdout": ansi_to_html(stdout.getvalue().strip()) if isinstance(stdout, StringIO) else "",
"stderr": ansi_to_html(stderr.getvalue().strip()) if isinstance(stderr, StringIO) else "",
}
@@ -225,29 +241,35 @@ def cli_schedule(request: HttpRequest, args: ScheduleCommandSchema):
@router.post("/search", response=CLICommandResponseSchema, summary="archivebox search [args] [filter_patterns]")
def cli_search(request: HttpRequest, args: ListCommandSchema):
- from archivebox.cli.archivebox_search import search
+ from archivebox.cli.archivebox_snapshot import build_snapshot_queryset
- result = search(
- filter_patterns=args.filter_patterns,
- filter_type=args.filter_type,
- status=args.status,
- after=args.after,
- before=args.before,
- sort=args.sort,
- csv=args.as_csv,
- json=args.as_json,
- html=args.as_html,
- with_headers=args.with_headers,
- )
+ search_kwargs = snapshot_filter_kwargs(args, default_filter_type=FilterTypeChoices.substring)
+ as_json = search_kwargs.pop("as_json")
+ as_html = search_kwargs.pop("as_html")
+ as_csv = search_kwargs.pop("as_csv")
+ with_headers = search_kwargs.pop("with_headers")
+ try:
+ snapshots = build_snapshot_queryset(**search_kwargs).select_related("crawl", "crawl__created_by")
+ except ValueError as err:
+ raise HttpError(400, str(err)) from err
result_format = "txt"
- if args.as_json:
+ if as_json:
result_format = "json"
- result = json.loads(result)
- elif args.as_html:
+ result = [
+ json.loads(json.dumps(snapshot.to_dict(extended=True), default=str))
+ for snapshot in snapshots.prefetch_related("tags").iterator(chunk_size=500)
+ ]
+ elif as_html:
result_format = "html"
- elif args.as_csv:
+ result = "\n".join(snapshot.url for snapshot in snapshots.iterator(chunk_size=500))
+ elif as_csv:
result_format = "csv"
+ cols = [col.strip() for col in as_csv.split(",") if col.strip()]
+ rows = [snapshot.to_csv(cols=cols, separator=",") for snapshot in snapshots.prefetch_related("tags").iterator(chunk_size=500)]
+ result = "\n".join((",".join(cols), *rows) if with_headers else rows)
+ else:
+ result = "\n".join(snapshot.url for snapshot in snapshots.iterator(chunk_size=500))
stdout = request.__dict__.get("stdout")
stderr = request.__dict__.get("stderr")
@@ -264,37 +286,23 @@ def cli_search(request: HttpRequest, args: ListCommandSchema):
@router.post("/remove", response=CLICommandResponseSchema, summary="archivebox remove [args] [filter_patterns]")
def cli_remove(request: HttpRequest, args: RemoveCommandSchema):
from archivebox.cli.archivebox_remove import remove
- from archivebox.cli.archivebox_search import get_snapshots
from archivebox.core.models import Snapshot
- filter_patterns = args.filter_patterns or []
- snapshots_to_remove = get_snapshots(
- filter_patterns=filter_patterns,
- filter_type=args.filter_type,
- after=args.after,
- before=args.before,
- )
- removed_snapshot_ids = [str(snapshot_id) for snapshot_id in snapshots_to_remove.values_list("id", flat=True)]
+ remove_kwargs = snapshot_filter_kwargs(args, default_filter_type=FilterTypeChoices.exact)
+ timeout_arg = remove_kwargs.pop("timeout")
+ timeout = min(float(timeout_arg if timeout_arg is not None else 60.0), 60.0)
+ snapshots_to_remove = Snapshot.objects.order_by("-created_at").search(**remove_kwargs)
- remove(
+ result = remove(
yes=True, # no way to interactively ask for confirmation via API, so we force yes
snapshots=snapshots_to_remove,
- before=args.before,
- after=args.after,
- filter_type=args.filter_type,
- filter_patterns=filter_patterns,
+ timeout=timeout,
)
-
- result = {
- "removed_count": len(removed_snapshot_ids),
- "removed_snapshot_ids": removed_snapshot_ids,
- "remaining_snapshots": Snapshot.objects.count(),
- }
stdout = request.__dict__.get("stdout")
stderr = request.__dict__.get("stderr")
return {
- "success": True,
- "errors": [],
+ "success": bool(result["success"]),
+ "errors": [str(result["error"])] if result["error"] else [],
"result": result,
"result_format": "json",
"stdout": ansi_to_html(stdout.getvalue().strip()) if isinstance(stdout, StringIO) else "",
diff --git a/archivebox/api/v1_core.py b/archivebox/api/v1_core.py
index b7f806a0..a90ed6ec 100644
--- a/archivebox/api/v1_core.py
+++ b/archivebox/api/v1_core.py
@@ -29,7 +29,7 @@ from ninja.errors import HttpError
from archivebox.core.models import Snapshot, ArchiveResult, Tag
from archivebox.core.permissions import public_snapshots_queryset
-from archivebox.api.auth import auth_using_token
+from archivebox.api.auth import authenticated_user_from_request
from archivebox.config.common import get_config
from archivebox.core.routes_util import build_web_url
from archivebox.misc.util import filter_queryset_by_uuid_substring, validate_url_length
@@ -49,9 +49,10 @@ from archivebox.core.tag_util import (
rename_tag as rename_tag_record,
)
from archivebox.crawls.models import Crawl
-from archivebox.api.v1_crawls import CrawlSchema
+from archivebox.api.v1_crawls import CrawlSchema, get_crawl_by_ref
from archivebox.search.config import get_search_mode, get_search_mode_backend
from archivebox.search.query import apply_snapshot_search
+from archivebox.core.snapshot_status import filter_snapshots_by_status, normalize_snapshot_status
router = Router(tags=["Core Models"])
@@ -79,7 +80,7 @@ class CustomPagination(PaginationBase):
def paginate_queryset(self, queryset, pagination: Input, request: HttpRequest, **params):
limit = min(pagination.limit, 500)
offset = pagination.offset or (pagination.page * limit)
- total = queryset.count()
+ total = queryset.values("pk").distinct().count() if queryset.query.distinct else queryset.count()
total_pages = math.ceil(total / limit)
current_page = math.ceil(offset / (limit + 1))
items = queryset[offset : offset + limit]
@@ -212,7 +213,10 @@ class ArchiveResultFilterSchema(FilterSchema):
@paginate(CustomPagination)
def get_archiveresults(request: HttpRequest, filters: Query[ArchiveResultFilterSchema]):
"""List all ArchiveResult entries matching these filters."""
- return filters.filter(ArchiveResult.objects.all()).distinct()
+ queryset = filters.filter(ArchiveResult.objects.all())
+ if filters.search or filters.snapshot_tag:
+ return queryset.distinct()
+ return queryset
def _uuid_ref_query(field_name: str, ref: str) -> Q:
@@ -854,6 +858,7 @@ class SnapshotFilterSchema(FilterSchema):
modified_at__lt: Annotated[datetime | None, FilterLookup("modified_at__lt")] = None
search: str | None = None
search_mode: str | None = None
+ status: str | None = None
url: Annotated[str | None, FilterLookup("url")] = None
tag: Annotated[str | None, FilterLookup("tags__name")] = None
title: Annotated[str | None, FilterLookup("title__icontains")] = None
@@ -867,13 +872,20 @@ class SnapshotFilterSchema(FilterSchema):
def filter_search_mode(self, value: str | None) -> Q:
return Q()
+ def filter_status(self, value: str | None) -> Q:
+ return Q()
+
@router.get("/snapshots", response=list[SnapshotSchema], url_name="get_snapshots")
@paginate(CustomPagination)
def get_snapshots(request: HttpRequest, filters: Query[SnapshotFilterSchema], with_archiveresults: bool = False):
"""List all Snapshot entries matching these filters."""
setattr(request, "with_archiveresults", with_archiveresults)
- queryset = filters.filter(Snapshot.objects.all()).distinct()
+ try:
+ queryset = filter_snapshots_by_status(Snapshot.objects.all(), filters.status)
+ except ValueError as err:
+ raise HttpError(400, str(err)) from err
+ queryset = filters.filter(queryset).distinct()
query = (filters.search or "").strip()
if not query:
return queryset
@@ -916,18 +928,16 @@ def get_snapshots_rss(
def get_snapshot(request: HttpRequest, snapshot_id: str, with_archiveresults: bool = True):
"""Get a specific Snapshot by id."""
setattr(request, "with_archiveresults", with_archiveresults)
- queryset = Snapshot.objects.all()
- try:
- return queryset.get(_uuid_ref_query("id", snapshot_id) | Q(timestamp__startswith=snapshot_id))
- except Snapshot.DoesNotExist:
- return queryset.get(_uuid_ref_query("id", snapshot_id))
+ return _get_snapshot_by_ref(snapshot_id)
@router.post("/snapshots", response=SnapshotSchema, url_name="create_snapshot")
def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
tags = normalize_tag_list(data.tags)
- if data.status is not None and data.status not in Snapshot.StatusChoices.values:
- raise HttpError(400, f"Invalid status: {data.status}")
+ try:
+ status = normalize_snapshot_status(data.status)
+ except ValueError as err:
+ raise HttpError(400, str(err)) from err
if not data.url.strip():
raise HttpError(400, "URL is required")
try:
@@ -938,7 +948,7 @@ def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
raise HttpError(400, "depth must be between 0 and 4")
if data.crawl_id:
- crawl = filter_queryset_by_uuid_substring(Crawl.objects.all(), data.crawl_id).get()
+ crawl = get_crawl_by_ref(data.crawl_id)
crawl_tags = normalize_tag_list(crawl.tags_str.split(","))
tags = tags or crawl_tags
else:
@@ -955,7 +965,7 @@ def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
"depth": data.depth,
"title": data.title,
"timestamp": str(timezone.now().timestamp()),
- "status": data.status or Snapshot.StatusChoices.QUEUED,
+ "status": status or Snapshot.StatusChoices.QUEUED,
"retry_at": timezone.now(),
}
snapshot, _ = Snapshot.objects.get_or_create(
@@ -968,10 +978,8 @@ def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
if data.title is not None and snapshot.title != data.title:
snapshot.title = data.title
update_fields.append("title")
- if data.status is not None and snapshot.status != data.status:
- if data.status not in Snapshot.StatusChoices.values:
- raise HttpError(400, f"Invalid status: {data.status}")
- snapshot.status = data.status
+ if status is not None and snapshot.status != status:
+ snapshot.status = status
update_fields.append("status")
if update_fields:
update_fields.append("modified_at")
@@ -992,10 +1000,7 @@ def create_snapshot(request: HttpRequest, data: SnapshotCreateSchema):
@router.patch("/snapshot/{snapshot_id}", response=SnapshotSchema, url_name="patch_snapshot")
def patch_snapshot(request: HttpRequest, snapshot_id: str, data: SnapshotUpdateSchema):
"""Update a snapshot (e.g., set status=sealed to cancel queued work)."""
- try:
- snapshot = Snapshot.objects.get(Q(id__startswith=snapshot_id) | Q(timestamp__startswith=snapshot_id))
- except Snapshot.DoesNotExist:
- snapshot = filter_queryset_by_uuid_substring(Snapshot.objects.all(), snapshot_id).get()
+ snapshot = _get_snapshot_by_ref(snapshot_id)
payload = data.dict(exclude_unset=True)
update_fields = ["modified_at"]
@@ -1018,9 +1023,10 @@ def patch_snapshot(request: HttpRequest, snapshot_id: str, data: SnapshotUpdateS
raise HttpError(400, f"Invalid action: {action}")
if "status" in payload:
- if payload["status"] not in Snapshot.StatusChoices.values:
- raise HttpError(400, f"Invalid status: {payload['status']}")
- snapshot.status = payload["status"]
+ try:
+ snapshot.status = normalize_snapshot_status(payload["status"])
+ except ValueError as err:
+ raise HttpError(400, str(err)) from err
if snapshot.status == Snapshot.StatusChoices.SEALED and "retry_at" not in payload:
snapshot.retry_at = None
update_fields.append("status")
@@ -1289,16 +1295,7 @@ def _public_tag_listing_enabled() -> bool:
def _request_has_tag_autocomplete_access(request: HttpRequest) -> bool:
- user = request.user
- if user.is_authenticated:
- return True
-
- token = request.GET.get("api_key") or request.headers.get("X-ArchiveBox-API-Key")
- auth_header = request.headers.get("Authorization", "")
- if not token and auth_header.lower().startswith("bearer "):
- token = auth_header.split(None, 1)[1].strip()
-
- if token and auth_using_token(token=token, request=request):
+ if authenticated_user_from_request(request):
return True
return _public_tag_listing_enabled()
diff --git a/archivebox/api/v1_crawls.py b/archivebox/api/v1_crawls.py
index ef043ede..711d7d65 100644
--- a/archivebox/api/v1_crawls.py
+++ b/archivebox/api/v1_crawls.py
@@ -24,7 +24,7 @@ from archivebox.config.common import get_config
from archivebox.crawls.models import Crawl
from archivebox.misc.util import filter_queryset_by_uuid_substring
-from .auth import API_AUTH_METHODS, auth_using_token
+from .auth import API_AUTH_METHODS, authenticated_user_from_request
router = Router(tags=["Crawl Models"], auth=API_AUTH_METHODS)
@@ -137,12 +137,16 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
return crawl
+def get_crawl_by_ref(crawl_id: str):
+ return filter_queryset_by_uuid_substring(Crawl.objects.all(), crawl_id).get()
+
+
@router.get("/crawl/{crawl_id}", response=CrawlSchema, url_name="get_crawl")
def get_crawl(request: HttpRequest, crawl_id: str, as_rss: bool = False, with_snapshots: bool = False, with_archiveresults: bool = False):
"""Get a specific Crawl by id."""
setattr(request, "with_snapshots", with_snapshots)
setattr(request, "with_archiveresults", with_archiveresults)
- crawl = filter_queryset_by_uuid_substring(Crawl.objects.all(), crawl_id).get()
+ crawl = get_crawl_by_ref(crawl_id)
if crawl and as_rss:
query = request.GET.copy()
@@ -156,33 +160,18 @@ def get_crawl(request: HttpRequest, crawl_id: str, as_rss: bool = False, with_sn
def crawl_file(request: HttpRequest, crawl_id: str, path: str):
# Try to resolve the crawl first; if it doesn't exist, return 404.
try:
- crawl = filter_queryset_by_uuid_substring(Crawl.objects.all(), crawl_id).get()
+ crawl = get_crawl_by_ref(crawl_id)
except Crawl.DoesNotExist:
raise HttpError(404, "Crawl not found")
- # Determine the effective viewer: session user takes precedence, otherwise
- # fall back to an API token passed via ?api_key=, X-ArchiveBox-API-Key, or
- # Authorization: Bearer ... (so that programmatic clients still work).
- user = request.user
- is_authenticated = bool(user.is_authenticated and user.is_active)
- if not is_authenticated:
- token = request.GET.get("api_key") or request.headers.get("X-ArchiveBox-API-Key")
- auth_header = request.headers.get("Authorization", "")
- if not token and auth_header.lower().startswith("bearer "):
- token = auth_header.split(None, 1)[1].strip()
- token_user = auth_using_token(token=token, request=request) if token else None
- if token_user and token_user.is_active:
- user = token_user
- is_authenticated = True
- # Re-bind so is_admin_user() / ownership checks below see the token user.
- setattr(request, "user", token_user)
+ user = authenticated_user_from_request(request)
# Gate access using the same model as SnapshotView/can_view_snapshot:
# admins always pass; owners can see their own crawls; otherwise the crawl
# must be PUBLIC or UNLISTED. Don't disclose existence of private crawls.
if not is_admin_user(request):
permissions = normalize_permissions(crawl.permissions)
- is_owner = bool(is_authenticated and crawl.created_by_id == user.id)
+ is_owner = bool(user and crawl.created_by_id == user.id)
if not is_owner and permissions not in {PERMISSIONS_PUBLIC, PERMISSIONS_UNLISTED}:
raise HttpError(404, "Crawl not found")
@@ -217,7 +206,7 @@ def crawl_file_nested_2(request: HttpRequest, crawl_id: str, folder: str, subfol
@router.patch("/crawl/{crawl_id}", response=CrawlSchema, url_name="patch_crawl")
def patch_crawl(request: HttpRequest, crawl_id: str, data: CrawlUpdateSchema):
"""Update a crawl (e.g., set status=sealed to cancel queued work)."""
- crawl = filter_queryset_by_uuid_substring(Crawl.objects.all(), crawl_id).get()
+ crawl = get_crawl_by_ref(crawl_id)
payload = data.dict(exclude_unset=True)
update_fields = ["modified_at"]
@@ -259,7 +248,7 @@ def patch_crawl(request: HttpRequest, crawl_id: str, data: CrawlUpdateSchema):
@router.delete("/crawl/{crawl_id}", response=CrawlDeleteResponseSchema, url_name="delete_crawl")
def delete_crawl(request: HttpRequest, crawl_id: str):
- crawl = filter_queryset_by_uuid_substring(Crawl.objects.all(), crawl_id).get()
+ crawl = get_crawl_by_ref(crawl_id)
crawl_id_str = str(crawl.id)
snapshot_count = crawl.snapshot_set.count()
deleted_count, _ = crawl.delete()
diff --git a/archivebox/api/v1_personas.py b/archivebox/api/v1_personas.py
index 65945762..15036202 100644
--- a/archivebox/api/v1_personas.py
+++ b/archivebox/api/v1_personas.py
@@ -8,8 +8,10 @@ from uuid import UUID
from django.db.models import Q
from django.http import HttpRequest
from ninja import Router, Schema
+from ninja.pagination import paginate
from pydantic import Field
+from archivebox.api.v1_core import CustomPagination
from archivebox.personas.importers import validate_persona_name
from archivebox.personas.models import Persona
@@ -113,6 +115,7 @@ def find_persona(extension_persona_id: str, name: str) -> Persona | None:
@router.get("/personas", response=list[PersonaSchema], url_name="get_personas")
+@paginate(CustomPagination)
def get_personas(request: HttpRequest):
"""List personas available on this ArchiveBox server."""
return Persona.objects.all().order_by("name")
diff --git a/archivebox/cli/__init__.py b/archivebox/cli/__init__.py
index bbb29ca5..9ea9992a 100644
--- a/archivebox/cli/__init__.py
+++ b/archivebox/cli/__init__.py
@@ -66,10 +66,6 @@ class ArchiveBoxGroup(click.Group):
# Introspection commands
"pluginmap": "archivebox.cli.archivebox_pluginmap.main",
}
- legacy_model_commands = {
- "crawl": "archivebox.cli.archivebox_crawl_compat.main",
- "snapshot": "archivebox.cli.archivebox_snapshot_compat.main",
- }
all_subcommands = {
**meta_commands,
**setup_commands,
@@ -81,34 +77,29 @@ class ArchiveBoxGroup(click.Group):
"import": "add",
"archive": "add",
}
- legacy_model_subcommands = {
- "crawl": {"create", "list", "update", "delete"},
- "snapshot": {"create", "list", "update", "delete"},
- }
@classmethod
def get_canonical_name(cls, cmd_name):
return cls.renamed_commands.get(cmd_name, cmd_name)
@classmethod
- def _should_use_legacy_model_command(cls, cmd_name: str) -> bool:
- if cmd_name not in cls.legacy_model_commands:
- return False
+ def _needs_django_for_lazy_import(cls, cmd_name: str) -> bool:
+ wants_help = any(arg in ("-h", "--help", "--version") for arg in sys.argv[1:])
+ return not wants_help and (cmd_name in cls.archive_commands or cmd_name in cls.model_commands)
- try:
- arg_idx = sys.argv.index(cmd_name)
- except ValueError:
- return False
+ @classmethod
+ def _setup_django_for_lazy_import(cls, cmd_name: str) -> None:
+ if not cls._needs_django_for_lazy_import(cmd_name):
+ return
- remaining_args = sys.argv[arg_idx + 1 :]
- if not remaining_args:
- return False
+ from django.apps import apps
- first_arg = remaining_args[0]
- if first_arg in ("-h", "--help"):
- return False
+ if apps.ready:
+ return
- return first_arg not in cls.legacy_model_subcommands[cmd_name]
+ from archivebox.config.django import setup_django
+
+ setup_django()
def get_command(self, ctx, cmd_name):
# handle renamed commands
@@ -120,11 +111,9 @@ class ArchiveBoxGroup(click.Group):
cmd_name = new_name
ctx.invoked_subcommand = cmd_name
- if self._should_use_legacy_model_command(cmd_name):
- return self._lazy_load(self.legacy_model_commands[cmd_name])
-
# handle lazy loading of commands
if cmd_name in self.all_subcommands:
+ self._setup_django_for_lazy_import(cmd_name)
return self._lazy_load(cmd_name)
# fall-back to using click's default command lookup
@@ -199,15 +188,12 @@ def cli(ctx, help=False):
raise
-def main(args=None, prog_name=None, stdin=None):
+def main(args=None, prog_name=None):
# show `docker run archivebox xyz` in help messages if running in docker
IN_DOCKER = os.environ.get("IN_DOCKER", False) in ("1", "true", "True", "TRUE", "yes")
IS_TTY = sys.stdin.isatty()
prog_name = prog_name or (f"docker compose run{'' if IS_TTY else ' -T'} archivebox" if IN_DOCKER else "archivebox")
- # stdin param allows passing input data from caller (used by __main__.py)
- # currently not used by click-based CLI, but kept for backwards compatibility
-
previous_unraisablehook = sys.unraisablehook
def ignore_shutdown_unraisable(unraisable):
diff --git a/archivebox/cli/archivebox_add.py b/archivebox/cli/archivebox_add.py
index 3108fb86..6d2ea4c7 100644
--- a/archivebox/cli/archivebox_add.py
+++ b/archivebox/cli/archivebox_add.py
@@ -108,6 +108,7 @@ def add(
# import models once django is set up
from archivebox.crawls.models import Crawl
+ from archivebox.core.models import Snapshot
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.personas.models import Persona
from archivebox.misc.logging_util import printable_filesize
@@ -124,16 +125,28 @@ def add(
url_list = [str(url).strip() for url in urls if str(url).strip()]
if snapshot_ids and len(snapshot_ids) != len(url_list):
raise ValueError("snapshot_ids length must match urls length")
+ admitted_urls: list[str] = []
+ admitted_snapshot_ids: list[str] | None = [] if snapshot_ids else None
+ for index, url in enumerate(url_list):
+ if Snapshot.is_archivebox_internal_url(url):
+ print(f"[yellow][!] Skipping internal ArchiveBox URL: {url}[/yellow]")
+ continue
+ admitted_urls.append(url)
+ if admitted_snapshot_ids is not None and snapshot_ids:
+ admitted_snapshot_ids.append(snapshot_ids[index])
# 1. Save the provided URLs to sources/2024-11-05__23-59-59__cli_add.txt
sources_file = CONSTANTS.SOURCES_DIR / f"{timezone.now().strftime('%Y-%m-%d__%H-%M-%S')}__cli_add.txt"
sources_file.parent.mkdir(parents=True, exist_ok=True)
- if snapshot_ids:
+ if admitted_snapshot_ids is not None:
sources_file.write_text(
- "\n".join(json.dumps({"url": url, "id": snapshot_ids[index], "tags": tag, "depth": 0}) for index, url in enumerate(url_list)),
+ "\n".join(
+ json.dumps({"url": url, "id": admitted_snapshot_ids[index], "tags": tag, "depth": 0})
+ for index, url in enumerate(admitted_urls)
+ ),
)
else:
- sources_file.write_text("\n".join(url_list))
+ sources_file.write_text("\n".join(admitted_urls))
# 2. Create a new Crawl with inline URLs
cli_args = [*sys.argv]
@@ -143,7 +156,6 @@ def add(
timestamp = timezone.now().strftime("%Y-%m-%d__%H-%M-%S")
- # Read URLs directly into crawl
urls_content = sources_file.read_text()
persona_name = (persona or "Default").strip() or "Default"
plugins = plugins or ""
@@ -214,8 +226,8 @@ def add(
# Foreground mode: run full crawl runner until all work is done
print("[green]\\[*] Starting crawl runner to process crawl...[/green]")
from archivebox.machine.models import Process
- from archivebox.core.takeover_util import command_owns_runtime_stack, current_command, standby_until_runtime_stack_needed
- from archivebox.workers.supervisord_util import run_runner_worker, stop_own_supervisord_process
+ from archivebox.core.takeover_util import command_owns_foreground_runner, current_command, standby_until_foreground_runner_needed
+ from archivebox.workers.supervisord_util import get_existing_supervisord_process, run_runner_worker, stop_own_supervisord_process
command = current_command(Process.TypeChoices.ADD, data_dir=CONSTANTS.DATA_DIR, url=first_url)
exit_code = 0
@@ -223,15 +235,27 @@ def add(
try:
with foreground_shutdown_signals(first_signal_message=None), foreground_parent_watchdog():
while True:
- standby_until_runtime_stack_needed(command, data_dir=CONSTANTS.DATA_DIR)
+ standby_until_foreground_runner_needed(command, data_dir=CONSTANTS.DATA_DIR)
exit_code = run_runner_worker(
["--crawl-id", str(crawl.id)],
name=f"worker_runner_add_{os.getpid()}",
interactive_interrupts=True,
)
+ crawl.refresh_from_db(fields=["status", "retry_at"])
+ if exit_code == 0 and crawl.status == crawl.StatusChoices.SEALED:
+ break
+ # A shared supervisord can be stopped by another
+ # foreground owner (for example `archivebox server`
+ # shutting down) and stop the one-shot add runner while
+ # this add's crawl is still runnable. Keep the add
+ # process alive so it can re-enter the normal
+ # foreground-runner ownership loop and finish its crawl.
+ supervisor_gone = exit_code == 1 and get_existing_supervisord_process(quiet=True) is None
+ if crawl.status in crawl.RUNNABLE_STATES and (exit_code in (0, 130, 143) or supervisor_gone):
+ continue
if exit_code == 0:
break
- if not command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR):
+ if not command_owns_foreground_runner(command, data_dir=CONSTANTS.DATA_DIR):
continue
raise SystemExit(exit_code)
except KeyboardInterrupt:
diff --git a/archivebox/cli/archivebox_config.py b/archivebox/cli/archivebox_config.py
index 86b8632f..5dc50528 100644
--- a/archivebox/cli/archivebox_config.py
+++ b/archivebox/cli/archivebox_config.py
@@ -6,6 +6,7 @@ import sys
import toml
import rich_click as click
from rich import print
+from pathlib import Path
from archivebox.misc.util import docstring, enforce_types
from archivebox.misc.toml_util import CustomTOMLEncoder
@@ -30,12 +31,17 @@ def config(
from archivebox.misc.logging_util import printable_config
from abx_plugins.plugins.base.utils import resolve_alias
from archivebox.config.collection import write_config_file
+ from archivebox.config import CONSTANTS_CONFIG
from archivebox.config.common import ArchiveBoxConfig, get_config, get_all_configs
from archivebox.plugins.discovery import discover_plugin_configs
check_data_folder()
FLAT_CONFIG = get_config().as_dict()
+ runtime_derived_keys = ArchiveBoxConfig.runtime_derived_config_keys()
+ readonly_config = {key: val for key, val in CONSTANTS_CONFIG.items() if key.isupper() and isinstance(val, Path)}
+ writable_config = {key: val for key, val in FLAT_CONFIG.items() if key not in runtime_derived_keys and key not in readonly_config}
+ readable_config = {**writable_config, **readonly_config}
CONFIGS = get_all_configs()
plugin_schemas = {
plugin_name: schema.get("properties", {}) for plugin_name, schema in discover_plugin_configs().items() if isinstance(schema, dict)
@@ -56,18 +62,23 @@ def config(
config_options = [
core_config_aliases.get(key.upper().strip()) or resolve_alias(key.upper().strip(), plugin_schemas) for key in config_options
]
- matching_config = {key: FLAT_CONFIG[key] for key in config_options if key in FLAT_CONFIG}
+ matching_config = {key: readable_config[key] for key in config_options if key in readable_config}
for config_section in CONFIGS.values():
aliases = {str(field.alias): field_name for field_name, field in type(config_section).model_fields.items() if field.alias}
for search_key in config_options:
# search all aliases in the section
for alias_key, key in aliases.items():
- if search_key.lower() in alias_key.lower():
+ if key in readable_config and search_key.lower() in alias_key.lower():
matching_config[key] = dict(config_section)[key]
# search all keys and values in the section
for existing_key, value in dict(config_section).items():
+ if existing_key in readable_config and (
+ search_key.lower() in existing_key.lower() or search_key.lower() in str(value).lower()
+ ):
+ matching_config[existing_key] = value
+ for existing_key, value in readonly_config.items():
if search_key.lower() in existing_key.lower() or search_key.lower() in str(value).lower():
matching_config[existing_key] = value
@@ -79,14 +90,14 @@ def config(
config_options = [
core_config_aliases.get(key.upper().strip()) or resolve_alias(key.upper().strip(), plugin_schemas) for key in config_options
]
- matching_config = {key: FLAT_CONFIG[key] for key in config_options if key in FLAT_CONFIG}
- failed_config = [key for key in config_options if key not in FLAT_CONFIG]
+ matching_config = {key: readable_config[key] for key in config_options if key in readable_config}
+ failed_config = [key for key in config_options if key not in readable_config]
if failed_config:
print("\n[red][X] These options failed to get[/red]")
print(" {}".format("\n ".join(config_options)))
raise SystemExit(1)
else:
- matching_config = FLAT_CONFIG
+ matching_config = readable_config
# Display core config sections
for config_section in CONFIGS.values():
@@ -100,12 +111,18 @@ def config(
print(_format_toml(kv_in_section))
print("[grey53]################################################################[/grey53]")
+ readonly_keys = {key: val for key, val in readonly_config.items() if key in matching_config}
+ if readonly_keys:
+ print("[grey53]\\[CONSTANTS] # (read-only)[/grey53]")
+ print(_format_toml(readonly_keys))
+ print("[grey53]################################################################[/grey53]")
+
plugin_keys = {}
# Collect all plugin config keys
for schema in plugin_schemas.values():
for key in schema.keys():
- if key in matching_config:
+ if key in matching_config and key in writable_config:
plugin_keys[key] = matching_config[key]
# Display all plugin config in single [PLUGINS] section
@@ -135,7 +152,7 @@ def config(
f"[yellow][i] Note: The config option {raw_key} has been renamed to {key}, please use the new name going forwards.[/yellow]",
)
- if key in FLAT_CONFIG:
+ if key in writable_config:
new_config[key] = val.strip()
else:
failed_options.append(line)
diff --git a/archivebox/cli/archivebox_crawl_compat.py b/archivebox/cli/archivebox_crawl_compat.py
deleted file mode 100644
index a9210ff8..00000000
--- a/archivebox/cli/archivebox_crawl_compat.py
+++ /dev/null
@@ -1,27 +0,0 @@
-#!/usr/bin/env python3
-
-__package__ = "archivebox.cli"
-__command__ = "archivebox crawl"
-
-import sys
-
-import rich_click as click
-
-from archivebox.cli.archivebox_add import add, _collect_input_urls
-
-
-@click.command(context_settings={"ignore_unknown_options": True})
-@click.option("--depth", "-d", type=int, default=0, help="Max crawl depth (default: 0)")
-@click.option("--tag", "-t", default="", help="Comma-separated tags to add")
-@click.option("--status", "-s", default="queued", help="Initial status (default: queued)")
-@click.option("--wait/--no-wait", "wait", default=True, help="Accepted for backwards compatibility")
-@click.argument("urls", nargs=-1)
-def main(depth: int, tag: str, status: str, wait: bool, urls: tuple[str, ...]):
- """Backwards-compatible `archivebox crawl URL...` entrypoint."""
- del status, wait
- add(_collect_input_urls(urls), depth=depth, tag=tag, bg=True)
- sys.exit(0)
-
-
-if __name__ == "__main__":
- main()
diff --git a/archivebox/cli/archivebox_help.py b/archivebox/cli/archivebox_help.py
index 812b78d4..390618c2 100755
--- a/archivebox/cli/archivebox_help.py
+++ b/archivebox/cli/archivebox_help.py
@@ -13,6 +13,10 @@ from rich.panel import Panel
def _command_doc(cmd: str, import_path: str) -> str:
+ def first_doc_line(docstring: str | None) -> str:
+ lines = (docstring or "").splitlines()
+ return lines[0] if lines else ""
+
modname, _ = import_path.rsplit(".", 1)
spec = importlib.util.find_spec(modname)
if spec is None or spec.origin is None:
@@ -26,9 +30,9 @@ def _command_doc(cmd: str, import_path: str) -> str:
for name in (cmd, "main"):
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == name:
- return (ast.get_docstring(node) or "").splitlines()[0]
+ return first_doc_line(ast.get_docstring(node))
- return (ast.get_docstring(tree) or "").splitlines()[0]
+ return first_doc_line(ast.get_docstring(tree))
def help() -> None:
diff --git a/archivebox/cli/archivebox_list.py b/archivebox/cli/archivebox_list.py
index 8010bade..1a1fe264 100644
--- a/archivebox/cli/archivebox_list.py
+++ b/archivebox/cli/archivebox_list.py
@@ -7,50 +7,15 @@ import sys
import rich_click as click
-from archivebox.cli.archivebox_snapshot import list_snapshots
+from archivebox.cli.archivebox_snapshot import list_snapshots, snapshot_filter_options, snapshot_output_options
@click.command()
-@click.option("--status", "-s", help="Filter by status (queued, started, sealed)")
-@click.option("--url__icontains", help="Filter by URL contains")
-@click.option("--url__istartswith", help="Filter by URL starts with")
-@click.option("--tag", "-t", help="Filter by tag name")
-@click.option("--crawl-id", help="Filter by crawl ID")
-@click.option("--limit", "-n", type=int, help="Limit number of results")
-@click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at")
-@click.option("--csv", "-C", type=str, help="Print output as CSV with the provided fields, e.g.: timestamp,url,title")
-@click.option("--with-headers", is_flag=True, help="Include column headers in structured output")
-@click.option("--search", help="Search mode to use for the query")
-@click.argument("query", nargs=-1)
-def main(
- status: str | None,
- url__icontains: str | None,
- url__istartswith: str | None,
- tag: str | None,
- crawl_id: str | None,
- limit: int | None,
- sort: str | None,
- csv: str | None,
- with_headers: bool,
- search: str | None,
- query: tuple[str, ...],
-) -> None:
+@snapshot_output_options
+@snapshot_filter_options(default_filter_type="substring")
+def main(**kwargs) -> None:
"""List Snapshots."""
- sys.exit(
- list_snapshots(
- status=status,
- url__icontains=url__icontains,
- url__istartswith=url__istartswith,
- tag=tag,
- crawl_id=crawl_id,
- limit=limit,
- sort=sort,
- csv=csv,
- with_headers=with_headers,
- search=search,
- query=" ".join(query),
- ),
- )
+ sys.exit(list_snapshots(**kwargs))
if __name__ == "__main__":
diff --git a/archivebox/cli/archivebox_remove.py b/archivebox/cli/archivebox_remove.py
index d677e3c6..791c69e6 100644
--- a/archivebox/cli/archivebox_remove.py
+++ b/archivebox/cli/archivebox_remove.py
@@ -23,6 +23,7 @@ from archivebox.misc.logging_util import (
log_removal_finished,
TimedProgress,
)
+from archivebox.cli.archivebox_snapshot import snapshot_filter_options
@enforce_types
@@ -34,48 +35,54 @@ def remove(
before: float | None = None,
yes: bool = False,
out_dir: Path = CONSTANTS.DATA_DIR,
-) -> QuerySet:
+ timeout: float | None = None,
+ **kwargs,
+) -> dict[str, object]:
"""Remove the specified URLs from the archive"""
setup_django()
check_data_folder()
+ timeout = float(timeout) if timeout is not None else None
- from archivebox.cli.archivebox_search import get_snapshots
+ from archivebox.core.models import Snapshot
+ filter_kwargs = {
+ **kwargs,
+ "filter_patterns": filter_patterns,
+ "filter_type": filter_type,
+ "after": after,
+ "before": before,
+ }
pattern_list = list(filter_patterns)
log_list_started(pattern_list or None, filter_type)
timer = TimedProgress(360, prefix=" ")
try:
- snapshots = get_snapshots(
- snapshots=snapshots,
- filter_patterns=pattern_list or None,
- filter_type=filter_type,
- after=after,
- before=before,
- )
+ if snapshots is None:
+ snapshots = Snapshot.objects.order_by("-created_at").search(**filter_kwargs)
+ # Freeze the target set up-front so a concurrent daemon writing new
+ # snapshots can't extend the deletion under us, and so the cursor isn't
+ # held open across the per-row deletes below.
+ snapshot_pks = list(snapshots.values_list("pk", flat=True))
finally:
timer.end()
- if not snapshots.exists():
+ if not snapshot_pks:
log_removal_finished(0, 0)
raise SystemExit(1)
- log_list_finished(snapshots)
- log_removal_started(snapshots, yes=yes)
+ if not yes:
+ log_list_finished(snapshots)
+ log_removal_started(snapshots, yes=False)
- from archivebox.core.models import Snapshot
from archivebox.search.query import flush_search_index
- # Freeze the target set up-front so a concurrent daemon writing new
- # snapshots can't extend the deletion under us, and so the cursor isn't
- # held open across the per-row deletes below.
- snapshot_pks = list(snapshots.values_list("pk", flat=True))
- to_remove = len(snapshot_pks)
+ started_at = time.monotonic()
+ deadline = started_at + timeout if timeout is not None else None
# Search-index flush touches a separate backend (FTS / sonic), not the
# main index.sqlite3 writer lock, so it's safe to do once up front.
- flush_search_index(snapshots=Snapshot.objects.filter(pk__in=snapshot_pks))
+ flush_search_index(snapshots=snapshots)
# Delete one snapshot at a time. Each ``.delete()`` is its own short
# Django-atomic block, so the writer lock is released between rows and
@@ -90,40 +97,59 @@ def remove(
# own retry loop at this outer (non-atomic) level. Each attempt is a
# fresh atomic; an exception cleanly rolls it back before we sleep.
retry_interval = 1.0
- retry_timeout = 60.0
- for pk in snapshot_pks:
- deadline = time.monotonic() + retry_timeout
+ deleted_snapshot_pks = []
+ timed_out = False
+ timeout_error = ""
+ for index, pk in enumerate(snapshot_pks):
+ if deadline is not None and time.monotonic() >= deadline:
+ timed_out = True
+ timeout_error = f"Remove timed out after {timeout:g}s with {len(snapshot_pks) - index} snapshots remaining."
+ break
while True:
try:
- Snapshot.objects.filter(pk=pk).delete()
+ deleted_count, _ = Snapshot.objects.filter(pk=pk).delete()
+ if deleted_count:
+ deleted_snapshot_pks.append(pk)
break
except OperationalError as err:
- if "database is locked" not in str(err) or time.monotonic() >= deadline:
+ if "database is locked" not in str(err):
raise
- time.sleep(retry_interval)
+ remaining_time = deadline - time.monotonic() if deadline is not None else None
+ if remaining_time is not None and remaining_time <= 0:
+ timed_out = True
+ timeout_error = f"Remove timed out after {timeout:g}s while waiting for the database lock."
+ break
+ time.sleep(min(retry_interval, remaining_time) if remaining_time is not None else retry_interval)
+ if timed_out:
+ break
all_snapshots = Snapshot.objects.all()
- log_removal_finished(all_snapshots.count(), to_remove)
+ remaining_count = all_snapshots.count()
+ deleted_snapshot_id_set = set(deleted_snapshot_pks)
+ remaining_snapshot_pks = [snapshot_id for snapshot_id in snapshot_pks if snapshot_id not in deleted_snapshot_id_set]
+ log_removal_finished(remaining_count, len(deleted_snapshot_pks))
- return all_snapshots
+ return {
+ "removed_count": len(deleted_snapshot_pks),
+ "removed_snapshot_ids": [str(snapshot_id) for snapshot_id in deleted_snapshot_pks],
+ "not_removed_count": len(remaining_snapshot_pks),
+ "not_removed_snapshot_ids": [str(snapshot_id) for snapshot_id in remaining_snapshot_pks],
+ "success": not timed_out,
+ "error": timeout_error,
+ "timeout": timeout,
+ }
@click.command()
@click.option("--yes", is_flag=True, help="Remove links instantly without prompting to confirm")
-@click.option("--before", type=float, help="Remove only URLs bookmarked before timestamp")
-@click.option("--after", type=float, help="Remove only URLs bookmarked after timestamp")
-@click.option(
- "--filter-type",
- "-f",
- type=click.Choice(("exact", "substring", "domain", "regex", "tag")),
- default="exact",
- help="Type of pattern matching to use when filtering URLs",
-)
-@click.argument("filter_patterns", nargs=-1)
+@click.option("--timeout", type=float, default=None, help="Maximum seconds to spend deleting snapshots")
+@snapshot_filter_options(default_filter_type="exact")
@docstring(remove.__doc__)
def main(**kwargs):
"""Remove the specified URLs from the archive"""
- remove(**kwargs)
+ result = remove(**kwargs)
+ if not result["success"]:
+ raise SystemExit(124)
if __name__ == "__main__":
diff --git a/archivebox/cli/archivebox_run.py b/archivebox/cli/archivebox_run.py
index 709d580f..e0908c1c 100644
--- a/archivebox/cli/archivebox_run.py
+++ b/archivebox/cli/archivebox_run.py
@@ -259,11 +259,24 @@ def run_runner(daemon: bool = False, crawl_id: str | None = None, maintenance_on
from archivebox.config import CONSTANTS
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
from archivebox.machine.models import Machine, Process
- from archivebox.core.takeover_util import enter_single_runner_gate
- from archivebox.services.runner import recover_orchestrator_state, run_pending_crawls
+ from archivebox.core.takeover_util import enter_single_runner_gate, standby_until_foreground_runner_needed
+ from archivebox.core.recovery_util import recover_orchestrator_state
+ from archivebox.services.runner import run_pending_crawls
Machine.current()
current = Process.current()
+ root_command = current.root
+ if daemon and root_command.process_type in (
+ Process.TypeChoices.SERVER,
+ Process.TypeChoices.ADD,
+ Process.TypeChoices.UPDATE,
+ ):
+ # Server-owned daemon runners are persistent supervisor workers, but
+ # foreground add/update commands are allowed to borrow runner/sonic
+ # leadership without taking down Daphne. Waiting here keeps the worker
+ # on the normal runner path while preventing a server restart loop from
+ # immediately stealing the single-runner gate back from the newer CLI.
+ standby_until_foreground_runner_needed(root_command, data_dir=CONSTANTS.DATA_DIR)
if not enter_single_runner_gate(current, data_dir=CONSTANTS.DATA_DIR):
current.mark_exited()
return 0
diff --git a/archivebox/cli/archivebox_search.py b/archivebox/cli/archivebox_search.py
index e84f3698..c192bed9 100644
--- a/archivebox/cli/archivebox_search.py
+++ b/archivebox/cli/archivebox_search.py
@@ -3,255 +3,7 @@
__package__ = "archivebox.cli"
__command__ = "archivebox search"
-import sys
-from pathlib import Path
-from typing import TYPE_CHECKING
-from collections.abc import Callable
-
-import rich_click as click
-
-from django.db.models import Q, QuerySet
-
-from archivebox.config import CONSTANTS
-from archivebox.config.common import get_config
-from archivebox.misc.logging import stderr
-from archivebox.misc.util import enforce_types, docstring
-
-if TYPE_CHECKING:
- from archivebox.core.models import Snapshot
-
-# Filter types for URL matching
-LINK_FILTERS: dict[str, Callable[[str], Q]] = {
- "exact": lambda pattern: Q(url=pattern),
- "substring": lambda pattern: Q(url__icontains=pattern),
- "regex": lambda pattern: Q(url__iregex=pattern),
- "domain": lambda pattern: (
- Q(url__istartswith=f"http://{pattern}") | Q(url__istartswith=f"https://{pattern}") | Q(url__istartswith=f"ftp://{pattern}")
- ),
- "tag": lambda pattern: Q(tags__name=pattern),
- "timestamp": lambda pattern: Q(timestamp=pattern),
-}
-
-STATUS_CHOICES = ["indexed", "archived", "unarchived"]
-
-
-def _apply_pattern_filters(
- snapshots: QuerySet["Snapshot", "Snapshot"],
- filter_patterns: list[str],
- filter_type: str,
-) -> QuerySet["Snapshot", "Snapshot"]:
- filter_builder = LINK_FILTERS.get(filter_type)
- if filter_builder is None:
- stderr()
- stderr(f"[X] Got invalid pattern for --filter-type={filter_type}", color="red")
- raise SystemExit(2)
-
- query = Q()
- for pattern in filter_patterns:
- query |= filter_builder(pattern)
- return snapshots.filter(query)
-
-
-def _snapshots_to_json(
- snapshots: QuerySet["Snapshot", "Snapshot"],
- *,
- with_headers: bool,
-) -> str:
- from datetime import datetime, timezone as tz
-
- from archivebox.config import VERSION
- from archivebox.misc.util import to_json
-
- config = get_config()
- main_index_header = (
- {
- "info": "This is an index of site data archived by ArchiveBox: The self-hosted web archive.",
- "schema": "archivebox.index.json",
- "copyright_info": config.FOOTER_INFO,
- "meta": {
- "project": "ArchiveBox",
- "version": VERSION,
- "git_sha": VERSION,
- "website": "https://ArchiveBox.io",
- "docs": "https://github.com/ArchiveBox/ArchiveBox/wiki",
- "source": "https://github.com/ArchiveBox/ArchiveBox",
- "issues": "https://github.com/ArchiveBox/ArchiveBox/issues",
- "dependencies": {},
- },
- }
- if with_headers
- else {}
- )
-
- snapshot_dicts = [snapshot.to_dict(extended=True) for snapshot in snapshots.iterator(chunk_size=500)]
- output: dict[str, object] | list[dict[str, object]]
- if with_headers:
- output = {
- **main_index_header,
- "num_links": len(snapshot_dicts),
- "updated": datetime.now(tz.utc),
- "last_run_cmd": sys.argv,
- "links": snapshot_dicts,
- }
- else:
- output = snapshot_dicts
-
- return to_json(output, indent=4, sort_keys=True)
-
-
-def _snapshots_to_csv(
- snapshots: QuerySet["Snapshot", "Snapshot"],
- *,
- cols: list[str],
- with_headers: bool,
-) -> str:
- header = ",".join(cols) if with_headers else ""
- rows = [snapshot.to_csv(cols=cols, separator=",") for snapshot in snapshots.iterator(chunk_size=500)]
- return "\n".join((header, *rows))
-
-
-def _snapshots_to_html(
- snapshots: QuerySet["Snapshot", "Snapshot"],
- *,
- with_headers: bool,
-) -> str:
- from datetime import datetime, timezone as tz
-
- from django.template.loader import render_to_string
-
- from archivebox.config import VERSION
- from archivebox.config.version import get_COMMIT_HASH
-
- config = get_config()
- template = "static_index.html" if with_headers else "minimal_index.html"
- snapshot_list = list(snapshots.iterator(chunk_size=500))
-
- return render_to_string(
- template,
- {
- "version": VERSION,
- "git_sha": get_COMMIT_HASH() or VERSION,
- "num_links": str(len(snapshot_list)),
- "date_updated": datetime.now(tz.utc).strftime("%Y-%m-%d"),
- "time_updated": datetime.now(tz.utc).strftime("%Y-%m-%d %H:%M"),
- "links": snapshot_list,
- "FOOTER_INFO": config.FOOTER_INFO,
- },
- )
-
-
-def get_snapshots(
- snapshots: QuerySet["Snapshot", "Snapshot"] | None = None,
- filter_patterns: list[str] | None = None,
- filter_type: str = "substring",
- after: float | None = None,
- before: float | None = None,
- out_dir: Path = CONSTANTS.DATA_DIR,
-) -> QuerySet["Snapshot", "Snapshot"]:
- """Filter and return Snapshots matching the given criteria."""
- from archivebox.core.models import Snapshot
-
- if snapshots is not None:
- result = snapshots
- else:
- result = Snapshot.objects.all()
-
- if after is not None:
- result = result.filter(timestamp__gte=after)
- if before is not None:
- result = result.filter(timestamp__lt=before)
- if filter_patterns:
- result = _apply_pattern_filters(result, filter_patterns, filter_type)
-
- # Prefetch crawl relationship to avoid N+1 queries when accessing output_dir
- result = result.select_related("crawl", "crawl__created_by")
-
- if not result.exists():
- stderr("[!] No Snapshots matched your filters:", filter_patterns, f"({filter_type})", color="lightyellow")
-
- return result
-
-
-@enforce_types
-def search(
- filter_patterns: list[str] | None = None,
- filter_type: str = "substring",
- status: str = "indexed",
- before: float | None = None,
- after: float | None = None,
- sort: str | None = None,
- json: bool = False,
- html: bool = False,
- csv: str | None = None,
- with_headers: bool = False,
-):
- """List, filter, and export information about archive entries"""
-
- if with_headers and not (json or html or csv):
- stderr("[X] --with-headers requires --json, --html or --csv\n", color="red")
- raise SystemExit(2)
-
- # Query DB directly - no filesystem scanning
- snapshots = get_snapshots(
- filter_patterns=list(filter_patterns) if filter_patterns else None,
- filter_type=filter_type,
- before=before,
- after=after,
- )
-
- # Apply status filter
- if status == "archived":
- snapshots = snapshots.filter(downloaded_at__isnull=False)
- elif status == "unarchived":
- snapshots = snapshots.filter(downloaded_at__isnull=True)
- # 'indexed' = all snapshots (no filter)
-
- if sort:
- snapshots = snapshots.order_by(sort)
-
- # Export to requested format
- if json:
- output = _snapshots_to_json(snapshots, with_headers=with_headers)
- elif html:
- output = _snapshots_to_html(snapshots, with_headers=with_headers)
- elif csv:
- output = _snapshots_to_csv(snapshots, cols=csv.split(","), with_headers=with_headers)
- else:
- from archivebox.misc.logging_util import printable_folders
-
- # Convert to dict for printable_folders
- folders: dict[str, Snapshot | None] = {str(snapshot.output_dir): snapshot for snapshot in snapshots}
- output = printable_folders(folders, with_headers)
-
- # Structured exports must be written directly to stdout.
- # rich.print() reflows long lines to console width, which corrupts JSON/CSV/HTML output.
- sys.stdout.write(output)
- if not output.endswith("\n"):
- sys.stdout.write("\n")
- return output
-
-
-@click.command()
-@click.option(
- "--filter-type",
- "-f",
- type=click.Choice(["search", *LINK_FILTERS.keys()]),
- default="substring",
- help="Pattern matching type for filtering URLs",
-)
-@click.option("--status", "-s", type=click.Choice(STATUS_CHOICES), default="indexed", help="List snapshots with the given status")
-@click.option("--before", "-b", type=float, help="List snapshots bookmarked before the given UNIX timestamp")
-@click.option("--after", "-a", type=float, help="List snapshots bookmarked after the given UNIX timestamp")
-@click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at")
-@click.option("--json", "-J", is_flag=True, help="Print output in JSON format")
-@click.option("--html", "-M", is_flag=True, help="Print output in HTML format (suitable for viewing statically without a server)")
-@click.option("--csv", "-C", type=str, help="Print output as CSV with the provided fields, e.g.: created_at,url,title")
-@click.option("--with-headers", "-H", is_flag=True, help="Include extra CSV/HTML headers in the output")
-@click.help_option("--help", "-h")
-@click.argument("filter_patterns", nargs=-1)
-@docstring(search.__doc__)
-def main(**kwargs):
- return search(**kwargs)
+from archivebox.cli.archivebox_list import main
if __name__ == "__main__":
diff --git a/archivebox/cli/archivebox_server.py b/archivebox/cli/archivebox_server.py
index e6934558..65576ec1 100644
--- a/archivebox/cli/archivebox_server.py
+++ b/archivebox/cli/archivebox_server.py
@@ -313,6 +313,7 @@ def server(
from archivebox.core.takeover_util import (
command_owns_runtime_stack,
current_command,
+ foreground_runner_owner,
runtime_stack_owner,
standby_until_runtime_stack_needed,
)
@@ -354,7 +355,10 @@ def server(
):
while True:
standby_result = standby_until_runtime_stack_needed(command, data_dir=CONSTANTS.DATA_DIR)
- older_owner = runtime_stack_owner(data_dir=CONSTANTS.DATA_DIR, exclude_id=command.id)
+ older_owner = runtime_stack_owner(data_dir=CONSTANTS.DATA_DIR, exclude_id=command.id) or foreground_runner_owner(
+ data_dir=CONSTANTS.DATA_DIR,
+ exclude_id=command.id,
+ )
takeover_components = active_supervisord_runtime_components(config=config)
if older_owner and takeover_components:
print(
diff --git a/archivebox/cli/archivebox_snapshot.py b/archivebox/cli/archivebox_snapshot.py
index 4d3a6388..5aaefcb0 100644
--- a/archivebox/cli/archivebox_snapshot.py
+++ b/archivebox/cli/archivebox_snapshot.py
@@ -35,9 +35,9 @@ from collections.abc import Iterable
import rich_click as click
from rich import print as rprint
-from django.db.models import Case, IntegerField, QuerySet, When
+from django.db.models import QuerySet
-from archivebox.cli.cli_util import apply_filters
+SNAPSHOT_FILTER_TYPE_CHOICES = ("exact", "substring", "regex", "domain", "tag", "timestamp")
# =============================================================================
@@ -178,72 +178,63 @@ def create_snapshots(
# =============================================================================
+def snapshot_filter_options(*, default_filter_type: str):
+ def decorate(func):
+ for decorator in reversed(
+ (
+ click.option("--status", "-s", help="Filter by status (queued, started, sealed)"),
+ click.option("--url__icontains", help="Filter by URL contains"),
+ click.option("--url__istartswith", help="Filter by URL starts with"),
+ click.option("--tag", "-t", help="Filter by tag name"),
+ click.option("--crawl-id", help="Filter by crawl ID"),
+ click.option("--limit", "-n", type=int, help="Limit number of results"),
+ click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at"),
+ click.option("--search", help="Search mode to use for positional query"),
+ click.option("--before", type=float, help="Only snapshots bookmarked before timestamp"),
+ click.option("--after", type=float, help="Only snapshots bookmarked after timestamp"),
+ click.option(
+ "--filter-type",
+ "-f",
+ type=click.Choice(SNAPSHOT_FILTER_TYPE_CHOICES),
+ default=default_filter_type,
+ help="Type of pattern matching to use for positional filters",
+ ),
+ click.argument("filter_patterns", nargs=-1),
+ ),
+ ):
+ func = decorator(func)
+ return func
+
+ return decorate
+
+
+def snapshot_output_options(func):
+ for decorator in reversed(
+ (
+ click.option("--csv", "-C", type=str, help="Print output as CSV with the provided fields, e.g.: timestamp,url,title"),
+ click.option("--json", "as_json", is_flag=True, help="Print output as a JSON array"),
+ click.option("--html", "as_html", is_flag=True, help="Print output as HTML"),
+ click.option("--with-headers", is_flag=True, help="Include column headers in structured output"),
+ ),
+ ):
+ func = decorator(func)
+ return func
+
+
def build_snapshot_queryset(
- *,
- status: str | None = None,
- url__icontains: str | None = None,
- url__istartswith: str | None = None,
- tag: str | None = None,
- crawl_id: str | None = None,
- sort: str | None = None,
- search: str | None = None,
- query: str | None = None,
- limit: int | None = None,
+ **kwargs,
) -> QuerySet:
from archivebox.core.models import Snapshot
- from archivebox.search.query import apply_snapshot_search
- queryset = Snapshot.objects.order_by("-created_at")
- queryset = apply_filters(
- queryset,
- {
- "status": status,
- "url__icontains": url__icontains,
- "url__istartswith": url__istartswith,
- "crawl_id": crawl_id,
- },
- )
-
- if tag:
- queryset = queryset.filter(tags__name__iexact=tag)
-
- query = (query or "").strip()
- if query:
- try:
- queryset = apply_snapshot_search(
- queryset,
- query,
- search_mode=search,
- ordering=("-created_at",) if not sort else None,
- max_results=limit,
- skip_backend_when_metadata_satisfies_limit=True,
- include_metadata_for_forced_backend=True,
- )
- except Exception as err:
- rprint(
- f"[yellow]Search backend error, falling back to metadata search: {err}[/yellow]",
- file=sys.stderr,
- )
- queryset = apply_snapshot_search(queryset, query, search_mode="meta")
-
- if sort:
- queryset = queryset.order_by(sort)
-
- return queryset
+ return Snapshot.objects.order_by("-created_at").search(**kwargs)
def list_snapshots(
- status: str | None = None,
- url__icontains: str | None = None,
- url__istartswith: str | None = None,
- tag: str | None = None,
- crawl_id: str | None = None,
- limit: int | None = None,
- sort: str | None = None,
csv: str | None = None,
+ as_json: bool = False,
+ as_html: bool = False,
with_headers: bool = False,
- search: str | None = None,
- query: str | None = None,
+ **kwargs,
) -> int:
"""
List Snapshots as JSONL with optional filters.
@@ -252,90 +243,71 @@ def list_snapshots(
0: Success (even if no results)
"""
from archivebox.misc.jsonl import write_record
- from archivebox.core.models import Snapshot
- if with_headers and not csv:
- rprint("[red]--with-headers requires --csv[/red]", file=sys.stderr)
+ output_formats = sum(bool(output_format) for output_format in (csv, as_json, as_html))
+ if output_formats > 1:
+ rprint("[red]Choose only one output format: --csv, --json, or --html[/red]", file=sys.stderr)
+ return 2
+ if with_headers and not output_formats:
+ rprint("[red]--with-headers requires --csv, --json, or --html[/red]", file=sys.stderr)
return 2
- is_tty = sys.stdout.isatty() and not csv
+ is_tty = sys.stdout.isatty() and not output_formats
- queryset = build_snapshot_queryset(
- status=status,
- url__icontains=url__icontains,
- url__istartswith=url__istartswith,
- tag=tag,
- crawl_id=crawl_id,
- sort=sort,
- search=search,
- query=query,
- limit=limit,
- )
-
- if not is_tty:
- if limit:
- limited_ids = list(queryset.values_list("id", flat=True)[:limit])
- preserved_order = Case(
- *(When(id=snapshot_id, then=position) for position, snapshot_id in enumerate(limited_ids)),
- output_field=IntegerField(),
- )
- queryset = Snapshot.objects.filter(id__in=limited_ids).order_by(preserved_order)
- queryset = queryset.prefetch_related("tags")
- elif limit:
- queryset = queryset[:limit]
+ try:
+ queryset = build_snapshot_queryset(**kwargs)
+ except ValueError as err:
+ rprint(f"[red]{err}[/red]", file=sys.stderr)
+ return 2
count = 0
+ if as_json:
+ queryset = queryset.prefetch_related("tags")
+ output = queryset.to_json(with_headers=with_headers)
+ sys.stdout.write(output)
+ if output and not output.endswith("\n"):
+ sys.stdout.write("\n")
+ rprint(f"[dim]Listed {queryset.count()} snapshots[/dim]", file=sys.stderr)
+ return 0
+
+ if as_html:
+ queryset = queryset.prefetch_related("tags")
+ output = queryset.to_html(with_headers=with_headers)
+ sys.stdout.write(output)
+ if output and not output.endswith("\n"):
+ sys.stdout.write("\n")
+ rprint(f"[dim]Listed {queryset.count()} snapshots[/dim]", file=sys.stderr)
+ return 0
+
if csv:
cols = [col.strip() for col in csv.split(",") if col.strip()]
if not cols:
rprint("[red]No CSV columns provided[/red]", file=sys.stderr)
return 2
- rows: list[str] = []
if with_headers:
- rows.append(",".join(cols))
- simple_cols = {
- "id",
- "crawl_id",
- "url",
- "title",
- "timestamp",
- "depth",
- "status",
- "fs_version",
- "bookmarked_at",
- "created_at",
- "modified_at",
- "retry_at",
- "downloaded_at",
- }
- from archivebox.misc.util import to_json
-
- for snapshot in queryset.iterator(chunk_size=500):
- if set(cols).issubset(simple_cols):
- rows.append(
- ",".join(to_json(snapshot.serializable_value(col), indent=None) for col in cols),
- )
- else:
- rows.append(snapshot.to_csv(cols=cols, separator=","))
+ sys.stdout.write(",".join(cols))
+ sys.stdout.write("\n")
+ for snapshot in queryset.prefetch_related("tags").iterator(chunk_size=500):
+ sys.stdout.write(snapshot.to_csv(cols=cols, separator=","))
+ sys.stdout.write("\n")
count += 1
- output = "\n".join(rows)
- if output:
- sys.stdout.write(output)
- if not output.endswith("\n"):
- sys.stdout.write("\n")
rprint(f"[dim]Listed {count} snapshots[/dim]", file=sys.stderr)
return 0
- for snapshot in queryset:
- if is_tty:
- status_color = {
- "queued": "yellow",
- "started": "blue",
- "sealed": "green",
- }.get(snapshot.status, "dim")
- rprint(f"[{status_color}]{snapshot.status:8}[/{status_color}] [dim]{snapshot.id}[/dim] {snapshot.url[:60]}")
- else:
+ if not is_tty:
+ for snapshot in queryset.prefetch_related("tags").iterator(chunk_size=500):
write_record(snapshot.to_json())
+ count += 1
+ rprint(f"[dim]Listed {count} snapshots[/dim]", file=sys.stderr)
+ return 0
+
+ for snapshot in queryset.iterator(chunk_size=500):
+ status_color = {
+ "queued": "yellow",
+ "started": "blue",
+ "sealed": "green",
+ }.get(snapshot.status, "dim")
+ rprint(f"[{status_color}]{snapshot.status:8}[/{status_color}] [dim]{snapshot.id}[/dim] {snapshot.url[:60]}")
count += 1
rprint(f"[dim]Listed {count} snapshots[/dim]", file=sys.stderr)
@@ -494,46 +466,11 @@ def create_cmd(urls: tuple, tag: str, status: str, depth: int):
@main.command("list")
-@click.option("--status", "-s", help="Filter by status (queued, started, sealed)")
-@click.option("--url__icontains", help="Filter by URL contains")
-@click.option("--url__istartswith", help="Filter by URL starts with")
-@click.option("--tag", "-t", help="Filter by tag name")
-@click.option("--crawl-id", help="Filter by crawl ID")
-@click.option("--limit", "-n", type=int, help="Limit number of results")
-@click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at")
-@click.option("--csv", "-C", type=str, help="Print output as CSV with the provided fields, e.g.: timestamp,url,title")
-@click.option("--with-headers", is_flag=True, help="Include column headers in structured output")
-@click.option("--search", help="Search mode to use for the query")
-@click.argument("query", nargs=-1)
-def list_cmd(
- status: str | None,
- url__icontains: str | None,
- url__istartswith: str | None,
- tag: str | None,
- crawl_id: str | None,
- limit: int | None,
- sort: str | None,
- csv: str | None,
- with_headers: bool,
- search: str | None,
- query: tuple[str, ...],
-):
+@snapshot_output_options
+@snapshot_filter_options(default_filter_type="substring")
+def list_cmd(**kwargs):
"""List Snapshots as JSONL."""
- sys.exit(
- list_snapshots(
- status=status,
- url__icontains=url__icontains,
- url__istartswith=url__istartswith,
- tag=tag,
- crawl_id=crawl_id,
- limit=limit,
- sort=sort,
- csv=csv,
- with_headers=with_headers,
- search=search,
- query=" ".join(query),
- ),
- )
+ sys.exit(list_snapshots(**kwargs))
@main.command("update")
diff --git a/archivebox/cli/archivebox_snapshot_compat.py b/archivebox/cli/archivebox_snapshot_compat.py
deleted file mode 100644
index 62f684e0..00000000
--- a/archivebox/cli/archivebox_snapshot_compat.py
+++ /dev/null
@@ -1,24 +0,0 @@
-#!/usr/bin/env python3
-
-__package__ = "archivebox.cli"
-__command__ = "archivebox snapshot"
-
-import sys
-
-import rich_click as click
-
-from archivebox.cli.archivebox_snapshot import create_snapshots
-
-
-@click.command(context_settings={"ignore_unknown_options": True})
-@click.option("--tag", "-t", default="", help="Comma-separated tags to add")
-@click.option("--status", "-s", default="queued", help="Initial status (default: queued)")
-@click.option("--depth", "-d", type=int, default=0, help="Crawl depth (default: 0)")
-@click.argument("urls", nargs=-1)
-def main(tag: str, status: str, depth: int, urls: tuple[str, ...]):
- """Backwards-compatible `archivebox snapshot URL...` entrypoint."""
- sys.exit(create_snapshots(urls, tag=tag, status=status, depth=depth))
-
-
-if __name__ == "__main__":
- main()
diff --git a/archivebox/cli/archivebox_update.py b/archivebox/cli/archivebox_update.py
index 3adb935c..75766074 100644
--- a/archivebox/cli/archivebox_update.py
+++ b/archivebox/cli/archivebox_update.py
@@ -15,6 +15,7 @@ from pathlib import Path
import rich_click as click
from archivebox.misc.util import enforce_types, docstring
+from archivebox.cli.archivebox_snapshot import snapshot_filter_options
if TYPE_CHECKING:
from django.db.models import QuerySet
@@ -47,51 +48,19 @@ def _get_search_indexing_plugins() -> list[str]:
def _build_filtered_snapshots_queryset(
- *,
- filter_patterns: Iterable[str],
- filter_type: str,
- status: str | None = None,
- url__icontains: str | None = None,
- url__istartswith: str | None = None,
- tag: str | None = None,
- crawl_id: str | None = None,
- limit: int | None = None,
- sort: str | None = None,
- search: str | None = None,
- before: float | None = None,
- after: float | None = None,
- resume: str | None = None,
+ **kwargs,
):
- from datetime import datetime
+ from archivebox.core.models import Snapshot
from archivebox.cli.archivebox_snapshot import build_snapshot_queryset
- filter_patterns = tuple(filter_patterns)
- snapshots = build_snapshot_queryset(
- status=status,
- url__icontains=url__icontains,
- url__istartswith=url__istartswith,
- tag=tag,
- crawl_id=crawl_id,
- sort=sort,
- search=search,
- query=" ".join(filter_patterns) if search else None,
- )
-
- if filter_patterns and not search:
- snapshots = snapshots.filter_by_patterns(list(filter_patterns), filter_type)
-
- if before:
- snapshots = snapshots.filter(bookmarked_at__lt=datetime.fromtimestamp(before))
- if after:
- snapshots = snapshots.filter(bookmarked_at__gt=datetime.fromtimestamp(after))
- if resume:
- snapshots = snapshots.filter(timestamp__lte=resume)
- if not sort:
- snapshots = snapshots.order_by("-timestamp")
+ limit = kwargs.pop("limit", None)
+ snapshots = build_snapshot_queryset(**kwargs)
+ if kwargs.get("resume"):
+ snapshots = snapshots.filter(timestamp__lte=kwargs["resume"])
snapshots = snapshots.select_related("crawl")
- if limit:
- limited_ids = list(snapshots.values_list("id", flat=True)[:limit])
- snapshots = snapshots.model.objects.filter(id__in=limited_ids).select_related("crawl")
+ if limit is not None and limit > 0:
+ snapshot_ids = list(snapshots.values_list("id", flat=True)[:limit])
+ snapshots = Snapshot.objects.filter(id__in=snapshot_ids).select_related("crawl")
return snapshots
@@ -254,27 +223,29 @@ def update(
from archivebox.machine.models import Process
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals, raise_if_shutdown_requested
from archivebox.core.takeover_util import (
- command_owns_runtime_stack,
+ command_owns_foreground_runner,
current_command,
ensure_daemon_stack,
- standby_until_runtime_stack_needed,
+ standby_until_foreground_runner_needed,
)
- from archivebox.workers.supervisord_util import run_runner_worker, stop_existing_supervisord_process, stop_own_supervisord_process
+ from archivebox.workers.supervisord_util import run_runner_worker, stop_own_supervisord_process
command = current_command(Process.TypeChoices.UPDATE, data_dir=CONSTANTS.DATA_DIR)
def wait_for_turn() -> None:
raise_if_shutdown_requested()
- standby_until_runtime_stack_needed(command, data_dir=CONSTANTS.DATA_DIR)
+ standby_until_foreground_runner_needed(command, data_dir=CONSTANTS.DATA_DIR)
raise_if_shutdown_requested()
- def run_scoped_runner(*args: str) -> None:
+ def run_scoped_runner(*args: str, ensure_daemon_reason: str | None = None) -> None:
while True:
wait_for_turn()
+ if ensure_daemon_reason:
+ ensure_daemon_stack(reason=ensure_daemon_reason)
exit_code = run_runner_worker(list(args), name=f"worker_runner_update_{os.getpid()}")
if exit_code == 0:
return
- if not command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR):
+ if not command_owns_foreground_runner(command, data_dir=CONSTANTS.DATA_DIR):
continue
raise SystemExit(exit_code)
@@ -298,8 +269,6 @@ def update(
try:
wait_for_turn()
- if stop_daemon_stack:
- stop_existing_supervisord_process()
with foreground_shutdown_signals(), foreground_parent_watchdog():
while True:
@@ -466,7 +435,10 @@ def update(
for snapshot_id in sorted(touched_snapshot_ids):
run_scoped_runner("--snapshot-id", snapshot_id)
else:
- run_scoped_runner(*(["--maintenance-only"] if index_only or migrate_only else []))
+ run_scoped_runner(
+ *(["--maintenance-only"] if index_only or migrate_only else []),
+ ensure_daemon_reason="search indexing" if do_index else None,
+ )
if not continuous:
break
@@ -987,22 +959,11 @@ def print_index_stats(stats: dict[str, Any]) -> None:
@click.command()
@click.option("--resume", type=str, help="Resume from timestamp")
-@click.option("--status", "-s", help="Filter by status (queued, started, sealed)")
-@click.option("--url__icontains", help="Filter by URL contains")
-@click.option("--url__istartswith", help="Filter by URL starts with")
-@click.option("--tag", "-t", help="Filter by tag name")
-@click.option("--crawl-id", help="Filter by crawl ID")
-@click.option("--limit", "-n", type=int, help="Limit number of snapshots to update")
-@click.option("--sort", "-o", type=str, help="Field to sort by, e.g. url, created_at, bookmarked_at, downloaded_at")
-@click.option("--search", help="Search mode to use for positional query")
-@click.option("--before", type=float, help="Only snapshots before timestamp")
-@click.option("--after", type=float, help="Only snapshots after timestamp")
-@click.option("--filter-type", type=click.Choice(["exact", "substring", "regex", "domain", "tag", "timestamp"]), default="exact")
@click.option("--batch-size", type=int, default=500, help="Commit every N records")
@click.option("--continuous", is_flag=True, help="Run continuously as background worker")
@click.option("--index-only", is_flag=True, help="Backfill available search indexes from existing archived content")
@click.option("--migrate-only", is_flag=True, help="Only migrate filesystem and update database/index state")
-@click.argument("filter_patterns", nargs=-1)
+@snapshot_filter_options(default_filter_type="exact")
@docstring(update.__doc__)
def main(**kwargs):
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
@@ -1010,6 +971,8 @@ def main(**kwargs):
try:
with foreground_shutdown_signals(), foreground_parent_watchdog():
update(**kwargs)
+ except ValueError as err:
+ raise click.BadParameter(str(err), param_hint="--status") from err
except KeyboardInterrupt:
raise SystemExit(130) from None
diff --git a/archivebox/core/admin_snapshots.py b/archivebox/core/admin_snapshots.py
index de9dbbbb..25b098f9 100644
--- a/archivebox/core/admin_snapshots.py
+++ b/archivebox/core/admin_snapshots.py
@@ -280,7 +280,7 @@ class SnapshotChangeList(SearchResultsChangeList):
else:
self.full_result_count = self.model_admin.get_paginator(
request,
- self.model._default_manager.all().order_by(),
+ self.model._default_manager.all().order_by("-id"),
self.list_per_page,
).count
self.show_full_result_count = True
@@ -465,7 +465,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
),
)
- ordering = ["-timestamp"]
+ ordering = ["-id"]
actions = [
"add_tags",
"remove_tags",
diff --git a/archivebox/core/models.py b/archivebox/core/models.py
index 86f72761..31afba9a 100755
--- a/archivebox/core/models.py
+++ b/archivebox/core/models.py
@@ -267,6 +267,22 @@ class SnapshotQuerySet(models.QuerySet):
"tag": lambda pattern: models.Q(tags__name=pattern),
"timestamp": lambda pattern: models.Q(timestamp=pattern),
}
+ FILTER_TYPE_CHOICES = tuple(FILTER_TYPES)
+ FILTER_ARG_KEYS = (
+ "after",
+ "before",
+ "filter_type",
+ "filter_patterns",
+ "status",
+ "url__icontains",
+ "url__istartswith",
+ "tag",
+ "crawl_id",
+ "limit",
+ "sort",
+ "search",
+ )
+ SPECIAL_FILTER_ARG_KEYS = frozenset({"filter_patterns", "filter_type", "query", "search", "tag", "before", "after", "limit", "sort"})
def filter_by_patterns(self, patterns: list[str], filter_type: str = "exact") -> "SnapshotQuerySet":
"""Filter snapshots by URL patterns using specified filter type"""
@@ -283,6 +299,62 @@ class SnapshotQuerySet(models.QuerySet):
raise SystemExit(2)
return self.filter(q_filter)
+ def search(self, **kwargs) -> "SnapshotQuerySet":
+ from datetime import timezone as dt_timezone
+
+ from archivebox.core.snapshot_status import filter_snapshots_by_status
+ from archivebox.search.query import apply_snapshot_search
+
+ queryset = self
+ filter_patterns = tuple(str(pattern) for pattern in kwargs.get("filter_patterns") or ())
+ filter_type = kwargs.get("filter_type") or "substring"
+ query = kwargs.get("query")
+ if isinstance(query, (list, tuple)):
+ query = " ".join(str(part) for part in query)
+ query = (query or (" ".join(filter_patterns) if kwargs.get("search") else "")).strip()
+
+ field_names = {field.name for field in self.model._meta.get_fields()}
+ field_names.update(field.attname for field in self.model._meta.fields)
+ field_filters = {
+ key: value
+ for key, value in kwargs.items()
+ if value is not None and key not in self.SPECIAL_FILTER_ARG_KEYS and key.split("__", 1)[0] in field_names
+ }
+ status = field_filters.pop("status", None)
+ queryset = filter_snapshots_by_status(queryset, status)
+ if field_filters:
+ queryset = queryset.filter(**field_filters)
+ if kwargs.get("tag"):
+ queryset = queryset.filter(tags__name__iexact=kwargs["tag"])
+ if kwargs.get("before") is not None:
+ queryset = queryset.filter(bookmarked_at__lt=datetime.fromtimestamp(float(kwargs["before"]), tz=dt_timezone.utc))
+ if kwargs.get("after") is not None:
+ queryset = queryset.filter(bookmarked_at__gt=datetime.fromtimestamp(float(kwargs["after"]), tz=dt_timezone.utc))
+
+ if query:
+ queryset = apply_snapshot_search(
+ queryset,
+ query,
+ search_mode=kwargs.get("search"),
+ ordering=("-created_at",) if not kwargs.get("sort") else None,
+ max_results=kwargs.get("limit"),
+ skip_backend_when_metadata_satisfies_limit=True,
+ include_metadata_for_forced_backend=True,
+ )
+ elif filter_patterns:
+ queryset = queryset.filter_by_patterns(list(filter_patterns), filter_type)
+
+ if kwargs.get("sort"):
+ queryset = queryset.order_by(kwargs["sort"])
+ elif not queryset.query.order_by:
+ queryset = queryset.order_by("-created_at")
+
+ limit = kwargs.get("limit")
+ if limit is not None and limit > 0:
+ queryset = queryset[:limit]
+
+ return queryset
+
# =========================================================================
# Export Methods
# =========================================================================
@@ -690,6 +762,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
from archivebox.core.routes_util import (
get_admin_host,
get_api_host,
+ get_base_host,
get_listen_host,
get_public_host,
get_web_host,
@@ -704,6 +777,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
protected_roots: set[tuple[str, str | None]] = set()
for host_value in (
get_listen_host(config=config),
+ get_base_host(config=config),
get_admin_host(config=config),
get_web_host(config=config),
get_api_host(config=config),
@@ -3173,7 +3247,11 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
"""Convert to CSV string"""
data = self.to_dict()
cols = cols or ["timestamp", "is_archived", "url"]
- return separator.join(to_json(data.get(col, ""), indent=None).ljust(ljust) for col in cols)
+ invalid_cols = [col for col in dict.fromkeys(cols) if col not in data]
+ if invalid_cols:
+ supported_cols = ", ".join(sorted(data))
+ raise ValueError(f"Invalid CSV field(s): {', '.join(invalid_cols)}\nSupported CSV fields: {supported_cols}")
+ return separator.join(to_json(data[col], indent=None).ljust(ljust) for col in cols)
def write_json_details(self, out_dir: Path | str | None = None) -> None:
"""Write JSON index file for this snapshot to its output directory"""
diff --git a/archivebox/core/recovery_util.py b/archivebox/core/recovery_util.py
index 725f5d7f..aeb3b6ce 100644
--- a/archivebox/core/recovery_util.py
+++ b/archivebox/core/recovery_util.py
@@ -20,6 +20,7 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
"crawls_queued_without_retry_at": 0,
"snapshots_queued_without_retry_at": 0,
"archiveresults_backoff": 0,
+ "snapshots_queued_plugin_rows_waiting_on_stale_lease": 0,
"archiveresults_started_without_running_process": 0,
"snapshots_started_without_running_results": 0,
"crawls_started_with_due_snapshots": 0,
@@ -73,6 +74,24 @@ def recover_orchestrator_state(*, include_chrome: bool = False) -> dict[str, int
retry_at__isnull=True,
).update(retry_at=now, modified_at=now)
cleaned["archiveresults_backoff"] = backoff_results.update(status=ArchiveResult.StatusChoices.QUEUED, modified_at=now)
+ # Targeted plugin rows on final/paused Snapshots are scheduled through the
+ # parent Snapshot.retry_at. retry_at=NULL is the normal idle marker for a
+ # sealed Snapshot and must not be interpreted as queued work just because
+ # old/synthetic ArchiveResult rows exist. If takeover kills the runner
+ # after it leases the Snapshot but before queued ArchiveResult rows finish,
+ # the rows remain QUEUED while retry_at sits in the future. Recovery runs
+ # only after this runner has won the single-runner gate, so it can safely
+ # unlock those stale plugin leases for immediate processing instead of
+ # waiting out the previous owner's full lock timeout.
+ queued_plugin_results = ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.QUEUED)
+ cleaned["snapshots_queued_plugin_rows_waiting_on_stale_lease"] = (
+ Snapshot.objects.filter(
+ id__in=queued_plugin_results.values("snapshot_id"),
+ status__in=[Snapshot.StatusChoices.SEALED, Snapshot.StatusChoices.PAUSED],
+ )
+ .filter(retry_at__gt=now)
+ .update(retry_at=now, modified_at=now)
+ )
# Impossible state repair: STARTED ArchiveResults without a live Process
# have no owner left to emit completion. Requeue only the result row; the
# snapshot/crawl schedulers will pick up normal retry processing.
diff --git a/archivebox/core/settings.py b/archivebox/core/settings.py
index 4d3f559f..403525d5 100644
--- a/archivebox/core/settings.py
+++ b/archivebox/core/settings.py
@@ -77,6 +77,8 @@ INSTALLED_APPS = [
"django_extensions", # provides Django Debug Toolbar (and other non-debug helpers)
]
+DJANGO_OBJECT_ACTIONS_DEFAULT_HTTP_METHOD = "POST"
+
MIDDLEWARE = [
"archivebox.core.middleware.TimezoneMiddleware",
diff --git a/archivebox/core/snapshot_status.py b/archivebox/core/snapshot_status.py
new file mode 100644
index 00000000..f9df3808
--- /dev/null
+++ b/archivebox/core/snapshot_status.py
@@ -0,0 +1,25 @@
+__package__ = "archivebox.core"
+
+from django.db.models import QuerySet
+
+
+def snapshot_status_values() -> tuple[str, ...]:
+ from archivebox.core.models import Snapshot
+
+ return tuple(Snapshot.StatusChoices.values)
+
+
+def normalize_snapshot_status(status: str | None) -> str | None:
+ value = str(status or "").strip().lower()
+ if not value:
+ return None
+
+ valid_statuses = snapshot_status_values()
+ if value not in valid_statuses:
+ raise ValueError(f"Invalid snapshot status: {status}. Expected one of: {', '.join(valid_statuses)}")
+ return value
+
+
+def filter_snapshots_by_status(queryset: QuerySet, status: str | None) -> QuerySet:
+ value = normalize_snapshot_status(status)
+ return queryset.filter(status=value) if value else queryset
diff --git a/archivebox/core/takeover_util.py b/archivebox/core/takeover_util.py
index cecd1b94..d5b39eb1 100644
--- a/archivebox/core/takeover_util.py
+++ b/archivebox/core/takeover_util.py
@@ -18,10 +18,18 @@ def runtime_stack_owner_types():
from archivebox.machine.models import Process
return (
- Process.TypeChoices.UPDATE,
Process.TypeChoices.SERVER,
Process.TypeChoices.ORCHESTRATOR,
+ )
+
+
+def foreground_runner_owner_types():
+ from archivebox.machine.models import Process
+
+ return (
+ Process.TypeChoices.SERVER,
Process.TypeChoices.ADD,
+ Process.TypeChoices.UPDATE,
)
@@ -70,15 +78,13 @@ def runtime_stack_owner(*, data_dir: str | Path, exclude_id=None):
if exclude_id is not None:
base_qs = base_qs.exclude(id=exclude_id)
- top_level_types = (
- Process.TypeChoices.UPDATE,
- Process.TypeChoices.SERVER,
- Process.TypeChoices.ADD,
- )
for qs in (
- base_qs.filter(process_type__in=top_level_types),
+ # Only server parents own HTTP runtime leadership. Foreground add/update
+ # commands can own runner/sonic components, but server startup must never
+ # wait behind them before binding Daphne.
+ base_qs.filter(process_type=Process.TypeChoices.SERVER),
# A foreground `archivebox run` process is allowed to own the runtime
- # stack when no server/update/add parent is alive. A runner launched by
+ # stack when no server/add parent is alive. A runner launched by
# supervisord is only a child worker; after its parent is killed it must
# not keep stealing leadership from the next foreground command.
base_qs.filter(process_type=Process.TypeChoices.ORCHESTRATOR).exclude(parent__process_type=Process.TypeChoices.SUPERVISORD),
@@ -95,6 +101,30 @@ def command_owns_runtime_stack(command, *, data_dir: str | Path) -> bool:
return bool(owner and owner.id == command.id)
+def foreground_runner_owner(*, data_dir: str | Path, exclude_id=None):
+ from archivebox.machine.models import Machine, Process
+
+ machine = Machine.current()
+ qs = Process.objects.filter(
+ machine=machine,
+ status=Process.StatusChoices.RUNNING,
+ pwd=str(data_dir),
+ process_type__in=foreground_runner_owner_types(),
+ )
+ if exclude_id is not None:
+ qs = qs.exclude(id=exclude_id)
+ for proc in qs.order_by("-created_at", "-modified_at").iterator(chunk_size=50):
+ if proc.is_running:
+ return proc
+ proc.mark_exited(exit_code=proc.exit_code if proc.exit_code is not None else 0)
+ return None
+
+
+def command_owns_foreground_runner(command, *, data_dir: str | Path) -> bool:
+ owner = foreground_runner_owner(data_dir=data_dir)
+ return bool(owner and owner.id == command.id)
+
+
def runtime_stack_component_label(*, owner=None, data_dir: str | Path) -> str:
try:
from archivebox.config.common import get_config
@@ -108,9 +138,9 @@ def runtime_stack_component_label(*, owner=None, data_dir: str | Path) -> str:
if not names and owner is not None:
from archivebox.machine.models import Process
- if owner.process_type in {Process.TypeChoices.SERVER, Process.TypeChoices.ADD}:
+ if owner.process_type == Process.TypeChoices.SERVER:
names = ["orchestrator", "server"]
- elif owner.process_type in {Process.TypeChoices.UPDATE, Process.TypeChoices.ORCHESTRATOR}:
+ elif owner.process_type == Process.TypeChoices.ORCHESTRATOR:
names = ["orchestrator"]
return ", ".join(dict.fromkeys(names)) or "runtime stack"
@@ -292,3 +322,26 @@ def standby_until_runtime_stack_needed(command, *, data_dir: str | Path, interva
command.modified_at = timezone.now()
command.save(update_fields=["modified_at"])
return {"resumed": announced, "previous_owner_pid": previous_owner_pid}
+
+
+def standby_until_foreground_runner_needed(command, *, data_dir: str | Path, interval: float = 2.0) -> dict[str, object]:
+ from archivebox.workers.supervisord_util import reap_foreground_supervisord_process
+
+ announced = False
+ previous_owner_pid = None
+ while not command_owns_foreground_runner(command, data_dir=data_dir):
+ reap_foreground_supervisord_process()
+ if not announced:
+ owner = foreground_runner_owner(data_dir=data_dir)
+ owner_pid = owner.pid if owner else "unknown"
+ previous_owner_pid = owner_pid
+ rprint(
+ f"[yellow][*] A newer archivebox process took over the orchestrator, sonic "
+ f"(pid={owner_pid}). Work will continue there, and will resume here if that process exits and work still remains.[/yellow]",
+ file=sys.stderr,
+ )
+ announced = True
+ time.sleep(interval)
+ command.modified_at = timezone.now()
+ command.save(update_fields=["modified_at"])
+ return {"resumed": announced, "previous_owner_pid": previous_owner_pid}
diff --git a/archivebox/crawls/admin.py b/archivebox/crawls/admin.py
index c05859ee..150bde3d 100644
--- a/archivebox/crawls/admin.py
+++ b/archivebox/crawls/admin.py
@@ -943,7 +943,7 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
updated += len(batch)
return updated
- @action(label="Recrawl", description="Create a new crawl with the same settings")
+ @action(label="Recrawl", description="Create a new crawl with the same settings", methods=("POST",))
def recrawl(self, request, obj):
"""Duplicate this crawl as a new crawl with the same URLs and settings."""
diff --git a/archivebox/machine/admin.py b/archivebox/machine/admin.py
index 9fc49093..4119b4c9 100644
--- a/archivebox/machine/admin.py
+++ b/archivebox/machine/admin.py
@@ -548,6 +548,7 @@ class ProcessAdmin(BaseModelAdmin):
label="Kill",
description="Kill this process if it is still running",
attrs={"class": "deletelink"},
+ methods=("POST",),
)
def kill_process(self, request, obj):
self._terminate_processes(request, [obj])
diff --git a/archivebox/services/runner.py b/archivebox/services/runner.py
index 0f8cd81a..5913e8dd 100644
--- a/archivebox/services/runner.py
+++ b/archivebox/services/runner.py
@@ -56,7 +56,6 @@ from abxbus.event_bus import EventBus, get_current_event, in_handler_context
from abxbus.event_handler import EventHandlerAbortedError, EventHandlerCancelledError
from archivebox.config.common import ArchiveBoxBaseConfig, normalize_runtime_config
-from archivebox.core.recovery_util import recover_orchestrator_state
from archivebox.misc.db import run_db_analyze_batch
from archivebox.core.shutdown_util import foreground_shutdown_signals, raise_if_shutdown_requested
from archivebox.search.sonic_daemon import register_sonic_daemon_event_handler
@@ -930,15 +929,14 @@ class CrawlRunner:
event_handler_slow_timeout=slow_warning_timeout(crawl_setup_phase_timeout),
),
)
- # Normal crawl shutdown drives cleanup synchronously so
- # ProcessKillEvent handlers get their grace period. During
- # OS-signal shutdown, asyncio is already cancelling tasks;
- # keep the child event attached for any remaining bus tick,
- # but do not call now()/wait() because the bus context may
- # disappear before delivery and produce noisy shutdown
- # exceptions instead of useful cleanup.
- if not self._signal_abort_requested:
- await _run_event_now(cleanup_event, crawl_setup_phase_timeout)
+ # Cleanup owns ProcessKillEvent emission for crawl-scoped
+ # setup hooks. Even during OS-signal shutdown we must drive
+ # it synchronously before bus teardown; otherwise daemon/bg
+ # setup hooks can outlive the foreground runner that
+ # launched them. _run_event_now() is already bounded by the
+ # crawl setup timeout and cleanup handlers provide their own
+ # hook-level grace periods.
+ await _run_event_now(cleanup_event, crawl_setup_phase_timeout)
finally:
cancel_watcher.cancel()
await asyncio.gather(cancel_watcher, return_exceptions=True)
@@ -2134,6 +2132,8 @@ def run_pending_crawls(
if daemon:
now_monotonic = time.monotonic()
if now_monotonic - last_recovery_at >= 30.0:
+ from archivebox.core.recovery_util import recover_orchestrator_state
+
recover_orchestrator_state()
last_recovery_at = now_monotonic
# SQLite query plans degrade as the snapshot/archiveresult tables grow
diff --git a/archivebox/templates/admin/crawls/crawl/change_form.html b/archivebox/templates/admin/crawls/crawl/change_form.html
index d5a7452e..fb345268 100644
--- a/archivebox/templates/admin/crawls/crawl/change_form.html
+++ b/archivebox/templates/admin/crawls/crawl/change_form.html
@@ -1,4 +1,5 @@
{% extends "admin/change_form.html" %}
+{% load add_preserved_filters from admin_urls %}
{% block object-tools-items %}
{% if original %}
@@ -31,6 +32,12 @@
{% endif %}
{% endif %}
+{% for tool in objectactions %}
+
+ {% url tools_view_name pk=object_id tool=tool.name as action_url %}
+ {% include 'django_object_actions/action_trigger.html' %}
+
+{% endfor %}
{{ block.super }}
{% endblock %}
diff --git a/archivebox/templates/static/admin.css b/archivebox/templates/static/admin.css
index 193f4c1e..b01b6f8e 100755
--- a/archivebox/templates/static/admin.css
+++ b/archivebox/templates/static/admin.css
@@ -134,13 +134,23 @@ body.change-list #content .object-tools {
clear: both;
}
-#content .object-tools a:link, #content .object-tools a:visited {
+#content .object-tools a:link,
+#content .object-tools a:visited,
+#content .object-tools form button {
border-radius: 0px;
background-color: #f5dd5d;
color: #333;
font-size: 12px;
font-weight: 800;
}
+#content .object-tools form {
+ display: inline;
+ margin: 0;
+}
+#content .object-tools form button {
+ border: 0;
+ cursor: pointer;
+}
#content .object-tools a.addlink {
background-blend-mode: difference;
diff --git a/archivebox/tests/conftest.py b/archivebox/tests/conftest.py
index 77fb0e6d..13e4fcde 100644
--- a/archivebox/tests/conftest.py
+++ b/archivebox/tests/conftest.py
@@ -2,7 +2,9 @@
import os
import json
+import re
import secrets
+import signal
import socket
import subprocess
import sys
@@ -11,7 +13,9 @@ import textwrap
import time
import shutil
from datetime import timedelta
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
+from threading import Thread
from types import SimpleNamespace
from typing import Any
from collections.abc import Callable
@@ -21,8 +25,6 @@ import pytest
import requests
from django.utils import timezone
-pytest_plugins = ["archivebox.tests.fixtures"]
-
REPO_ROOT = Path(__file__).resolve().parents[2]
PYTEST_BASETEMP_ROOT = (REPO_ROOT / "tests" / "out").resolve()
SESSION_DATA_DIR = Path(tempfile.mkdtemp(prefix="archivebox-pytest-session-")).resolve()
@@ -90,64 +92,168 @@ def _sync_archivebox_test_data_dir(data_dir: Path) -> None:
# =============================================================================
+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 kill(self) -> None:
+ self._process.kill()
+
+ def send_signal(self, sig: int) -> None:
+ self._process.send_signal(sig)
+
+
def run_archivebox_cmd(
args: list[str],
- data_dir: Path,
- stdin: str | None = None,
+ *,
+ cwd: Path | None = None,
+ input: str | bytes | None = None,
timeout: int = 60,
env: dict[str, str] | None = None,
-) -> tuple[str, str, int]:
- """
- Run archivebox command via subprocess, return (stdout, stderr, returncode).
+ 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]
- Args:
- args: Command arguments (e.g., ['crawl', 'create', 'https://example.com'])
- data_dir: The DATA_DIR to use
- stdin: Optional string to pipe to stdin
- timeout: Command timeout in seconds
- env: Additional environment variables
+ _assert_not_repo_path(cwd, label="cwd")
- Returns:
- Tuple of (stdout, stderr, returncode)
- """
- cmd = [sys.executable, "-m", "archivebox"] + args
+ run_env: dict[str, str] | None = None
+ if default_cli_env or disable_extractors or env is not None:
+ run_env = {} if replace_env else os.environ.copy()
+ 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 env:
+ run_env.update(env)
- _assert_not_repo_path(data_dir, label="cwd")
- base_env = os.environ.copy()
- base_env["USE_COLOR"] = "False"
- base_env["SHOW_PROGRESS"] = "False"
- # Disable slow extractors for faster tests
- base_env["SAVE_ARCHIVEDOTORG"] = "False"
- base_env["SAVE_TITLE"] = "False"
- base_env["SAVE_FAVICON"] = "False"
- base_env["SAVE_WGET"] = "False"
- base_env["SAVE_WARC"] = "False"
- base_env["SAVE_PDF"] = "False"
- base_env["SAVE_SCREENSHOT"] = "False"
- base_env["SAVE_DOM"] = "False"
- base_env["SAVE_SINGLEFILE"] = "False"
- base_env["SAVE_READABILITY"] = "False"
- base_env["SAVE_MERCURY"] = "False"
- base_env["SAVE_GIT"] = "False"
- base_env["SAVE_YTDLP"] = "False"
- base_env["SAVE_HEADERS"] = "False"
- base_env["SAVE_HTMLTOTEXT"] = "False"
+ _assert_safe_runtime_paths(cwd=cwd, env=run_env or os.environ)
- if env:
- base_env.update(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
- _assert_safe_runtime_paths(cwd=data_dir, env=base_env)
- result = subprocess.run(
+ 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,
- input=stdin,
- capture_output=True,
- text=True,
- cwd=data_dir,
- env=base_env,
- timeout=timeout,
+ 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)
- return result.stdout, result.stderr, result.returncode
+ if wait:
+ try:
+ result.communicate(input=input, timeout=timeout)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ result.communicate()
+ 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
# =============================================================================
@@ -251,58 +357,134 @@ def hermetic_lib_dir(tmp_path, monkeypatch):
@pytest.fixture
-def initialized_archive(isolated_data_dir):
+def initialized_archive(tmp_path):
"""
Initialize ArchiveBox archive in isolated directory.
Runs `archivebox init` via subprocess to set up database and directories.
"""
- stdout, stderr, returncode = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["init", "--quick"],
- data_dir=isolated_data_dir,
+ 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 isolated_data_dir
+ return tmp_path
@pytest.fixture
-def archivebox_daemon_server(tmp_path, process, free_tcp_port_factory):
+def recursive_test_site():
+ pages = {
+ "/": """
+
+
+ Root
+
+
+
+ About
+ Blog
+ Contact
+
+
+ """.strip().encode("utf-8"),
+ "/about": """
+
+
+ Deep About
+
+
+ """.strip().encode("utf-8"),
+ "/blog": """
+
+
+ Deep Blog
+
+
+ """.strip().encode("utf-8"),
+ "/contact": """
+
+
+ Deep Contact
+
+
+ """.strip().encode("utf-8"),
+ "/deep/about": b"Deep About
",
+ "/deep/blog": b"Deep Blog
",
+ "/deep/contact": b"Deep Contact
",
+ "/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(timeout=5)
+
+
+@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.
"""
- assert process.returncode == 0, process.stderr
started: list[tuple[Path, dict[str, str]]] = []
def start(**env_overrides: str):
- env = os.environ.copy()
- env.update(
- {
- "USE_COLOR": "False",
- "SHOW_PROGRESS": "False",
- "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,
+ 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()},
)
port = free_tcp_port_factory()
- result = subprocess.run(
- [sys.executable, "-m", "archivebox", "server", "--daemonize", f"127.0.0.1:{port}"],
- cwd=tmp_path,
+ result = run_archivebox_cmd(
+ ["server", "--daemonize", f"127.0.0.1:{port}"],
+ cwd=initialized_archive,
env=env,
- capture_output=True,
- text=True,
timeout=90,
)
assert result.returncode == 0, result.stderr or result.stdout
- started.append((tmp_path, env))
+ started.append((initialized_archive, env))
return SimpleNamespace(
- data_dir=tmp_path,
+ data_dir=initialized_archive,
env=env,
port=port,
- worker_state=lambda: _archivebox_worker_state(tmp_path, env),
- wait_for_workers=lambda names, timeout=45: _wait_for_archivebox_workers(tmp_path, env, names, timeout=timeout),
+ 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:
@@ -330,44 +512,307 @@ def wait_for_process(predicate: Callable[[psutil.Process, str], bool], *, timeou
raise AssertionError("No matching live process found. Last seen:\n" + "\n".join(last_seen[-50:]))
-# =============================================================================
-# CWD-based CLI Helpers (no DATA_DIR env)
-# =============================================================================
+def pid_is_alive(pid: int) -> bool:
+ try:
+ os.kill(pid, 0)
+ except ProcessLookupError:
+ return False
+ except PermissionError:
+ return True
+ return True
-def run_archivebox_cmd_cwd(
- args: list[str],
+def wait_for_pid_to_disappear(pid: int, *, timeout: float = 20.0) -> None:
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ if not pid_is_alive(pid):
+ return
+ time.sleep(0.1)
+ raise AssertionError(f"PID {pid} is still running")
+
+
+def cleanup_process_group(group_pid: int | None, *child_pids: int | None) -> None:
+ if group_pid and pid_is_alive(group_pid):
+ try:
+ os.killpg(group_pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ except OSError:
+ try:
+ os.kill(group_pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ for pid in child_pids:
+ if pid and pid_is_alive(pid):
+ try:
+ os.kill(pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+
+
+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()
+ env.update({"USE_COLOR": "False", "SHOW_PROGRESS": "False"})
+
+ if disable_extractors or live or server:
+ env.update(
+ {
+ "SAVE_ARCHIVEDOTORG": "False",
+ "SAVE_TITLE": "False",
+ "SAVE_FAVICON": "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 wait_for_port_open(host: str, port: int, *, timeout: float = 30.0) -> None:
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ try:
+ with socket.create_connection((host, port), timeout=0.25):
+ return
+ except OSError:
+ time.sleep(0.1)
+ raise AssertionError(f"server did not listen on {host}:{port}")
+
+
+def wait_for_log(log_path: Path, text: str, *, timeout: float = 30.0) -> str:
+ deadline = time.time() + timeout
+ content = ""
+ while time.time() < deadline:
+ if log_path.exists():
+ content = log_path.read_text(encoding="utf-8", errors="replace")
+ if text in content:
+ return content
+ time.sleep(0.1)
+ raise AssertionError(f"timed out waiting for {text!r} in {log_path}:\n{content}")
+
+
+def wait_for_log_count(log_path: Path, text: str, count: int, *, timeout: float = 30.0) -> str:
+ deadline = time.time() + timeout
+ content = ""
+ while time.time() < deadline:
+ if log_path.exists():
+ content = log_path.read_text(encoding="utf-8", errors="replace")
+ if content.count(text) >= count:
+ return content
+ time.sleep(0.1)
+ raise AssertionError(f"timed out waiting for {count} occurrences of {text!r} in {log_path}:\n{content}")
+
+
+def wait_for_log_pattern(log_path: Path, pattern: str, *, timeout: float = 30.0) -> re.Match[str]:
+ deadline = time.time() + timeout
+ content = ""
+ while time.time() < deadline:
+ if log_path.exists():
+ content = log_path.read_text(encoding="utf-8", errors="replace")
+ match = re.search(pattern, content)
+ if match:
+ return match
+ time.sleep(0.1)
+ raise AssertionError(f"timed out waiting for pattern {pattern!r} in {log_path}:\n{content}")
+
+
+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:
+ deadline = time.time() + timeout
+ last_error = ""
+ while time.time() < deadline:
+ try:
+ return worker_pid_from_log(log_path, worker_name)
+ except AssertionError as err:
+ last_error = str(err)
+ time.sleep(0.1)
+ raise AssertionError(last_error or f"timed out waiting for worker {worker_name!r} in {log_path}")
+
+
+def pgrep_data_dir(data_dir: Path) -> list[str]:
+ result = subprocess.run(["pgrep", "-af", str(data_dir)], capture_output=True, text=True, timeout=5)
+ lines = [line for line in result.stdout.splitlines() if "pgrep -af" not in line]
+
+ 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
+ ps_line = subprocess.run(
+ ["ps", "-p", str(pid), "-o", "pid=,ppid=,command="],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ ).stdout.strip()
+ if ps_line:
+ lines.append(ps_line)
+
+ return sorted(set(lines))
+
+
+def assert_no_processes_for_data_dir(data_dir: Path, *, timeout: float = 10.0) -> None:
+ deadline = time.time() + timeout
+ remaining: list[str] = []
+ while time.time() < deadline:
+ remaining = pgrep_data_dir(data_dir)
+ if not remaining:
+ return
+ time.sleep(0.25)
+ raise AssertionError("processes still reference test DATA_DIR:\n" + "\n".join(remaining))
+
+
+def kill_processes_for_data_dir(data_dir: Path) -> None:
+ 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.SIGKILL)
+ except ProcessLookupError:
+ pass
+
+
+def start_archivebox_server(
cwd: Path,
- stdin: str | None = None,
- timeout: int = 60,
+ *,
+ port: int,
env: dict[str, str] | None = None,
-) -> tuple[str, str, int]:
- """
- Run archivebox command via subprocess using cwd as DATA_DIR (no DATA_DIR env).
- Returns (stdout, stderr, returncode).
- """
- cmd = [sys.executable, "-m", "archivebox"] + args
+ daemonize: bool | None = None,
+ log_name: str | None = None,
+ wait_for_log_text: str | None = "Tailing worker logs",
+):
+ if daemonize is None:
+ daemonize = log_name is None
- _assert_not_repo_path(cwd, label="cwd")
- base_env = os.environ.copy()
- base_env["USE_COLOR"] = "False"
- base_env["SHOW_PROGRESS"] = "False"
+ args = ["server", f"127.0.0.1:{port}"]
+ if daemonize:
+ args.insert(1, "--daemonize")
- if env:
- base_env.update(env)
-
- _assert_safe_runtime_paths(cwd=cwd, env=base_env)
- result = subprocess.run(
- cmd,
- input=stdin,
- capture_output=True,
- text=True,
+ 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=base_env,
- timeout=timeout,
+ 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
+ wait_for_port_open("127.0.0.1", port)
+ 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)
+ return proc
- return result.stdout, result.stderr, result.returncode
+
+def stop_archivebox_process(proc: subprocess.Popen[str], sig=signal.SIGTERM, *, timeout: float = 15.0) -> str:
+ if proc.poll() is None:
+ try:
+ os.killpg(proc.pid, sig)
+ except (ProcessLookupError, OSError):
+ try:
+ os.kill(proc.pid, sig)
+ except ProcessLookupError:
+ pass
+ try:
+ stdout, _stderr = proc.communicate(timeout=timeout)
+ return stdout or ""
+ except subprocess.TimeoutExpired:
+ try:
+ os.killpg(proc.pid, signal.SIGKILL)
+ except (ProcessLookupError, OSError):
+ try:
+ os.kill(proc.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ stdout, _stderr = proc.communicate(timeout=5)
+ return stdout or ""
def run_queued_crawls(cwd: Path, env: dict[str, str] | None = None, timeout: int = 180) -> None:
@@ -376,21 +821,21 @@ 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)]))
"""
- stdout, stderr, returncode = run_archivebox_cmd_cwd(["manage", "shell", "-c", script], cwd=cwd, timeout=60, env=env)
+ _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:
- stdout, stderr, returncode = run_archivebox_cmd_cwd(["run", f"--crawl-id={crawl_id}"], cwd=cwd, timeout=timeout, env=env)
+ _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 = subprocess.run(
- [sys.executable, "-m", "archivebox", "manage", "shell", "-c", script],
+ result = run_archivebox_cmd(
+ ["manage", "shell", "-c", script],
cwd=cwd,
env=env,
- capture_output=True,
- text=True,
timeout=timeout,
)
assert result.returncode == 0, result.stderr or result.stdout
@@ -469,71 +914,161 @@ def run_python_cwd(
# Server/API Integration Helpers
# =============================================================================
+API_TEST_HOST = "api.archivebox.localhost:8000"
+ADMIN_TEST_HOST = "admin.archivebox.localhost:8000"
+PUBLIC_TEST_HOST = "public.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 wait_for_http(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 = subprocess.run(
- [sys.executable, "-m", "archivebox", "init", "--quick"],
+ result = run_archivebox_cmd(
+ ["init", "--quick"],
cwd=cwd,
- capture_output=True,
- text=True,
timeout=60,
)
assert result.returncode == 0, result.stderr
-def build_test_env(port: int, **extra: str) -> dict[str, str]:
- env = os.environ.copy()
- 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",
- "USE_COLOR": "False",
- "SHOW_PROGRESS": "False",
- "TIMEOUT": "30",
- "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*|example\.com",
- "SAVE_ARCHIVEDOTORG": "False",
- "SAVE_TITLE": "False",
- "SAVE_FAVICON": "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",
- "SAVE_WGET": "True",
- "USE_CHROME": "False",
- },
- )
- env.update(extra)
- return env
-
-
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 start_server(cwd: Path, env: dict[str, str], port: int) -> None:
- result = subprocess.run(
- [sys.executable, "-m", "archivebox", "server", "--daemonize", f"127.0.0.1:{port}"],
- cwd=cwd,
- capture_output=True,
- text=True,
- env=env,
- timeout=60,
- )
- assert result.returncode == 0, result.stderr
-
-
def stop_server(cwd: Path) -> None:
script = textwrap.dedent(
"""
@@ -549,10 +1084,18 @@ def stop_server(cwd: Path) -> None:
run_python_cwd(script, cwd=cwd, timeout=30)
-def wait_for_http(port: int, host: str, path: str = "/", timeout: int = 30) -> requests.Response:
+def wait_for_http(
+ port: int,
+ host: str,
+ path: str = "/",
+ timeout: float = 30.0,
+ process: subprocess.Popen[str] | None = None,
+) -> requests.Response:
deadline = time.time() + timeout
last_exc = None
while time.time() < deadline:
+ if process is not None and process.poll() is not None:
+ raise AssertionError(f"Server exited before becoming ready with code {process.returncode}")
try:
response = requests.get(
f"http://127.0.0.1:{port}{path}",
@@ -562,6 +1105,7 @@ def wait_for_http(port: int, host: str, path: str = "/", timeout: int = 30) -> r
)
if response.status_code < 500:
return response
+ last_exc = f"HTTP {response.status_code}"
except requests.RequestException as exc:
last_exc = exc
time.sleep(0.5)
@@ -886,14 +1430,15 @@ def real_archive_with_example(tmp_path_factory, request):
if request.cls is not None:
request.cls.data_dir = tmp_path
- stdout, stderr, returncode = run_archivebox_cmd_cwd(
+ _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}"
- stdout, stderr, returncode = run_archivebox_cmd_cwd(
+ _cmd_result = run_archivebox_cmd(
[
"config",
"--set",
@@ -904,6 +1449,7 @@ def real_archive_with_example(tmp_path_factory, request):
],
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 = {
@@ -915,12 +1461,13 @@ def real_archive_with_example(tmp_path_factory, request):
system_browser = _find_system_browser()
if system_browser:
add_env["CHROME_BINARY"] = str(system_browser)
- stdout, stderr, returncode = run_archivebox_cmd_cwd(
+ _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)
@@ -941,6 +1488,16 @@ def parse_jsonl_output(stdout: str) -> list[dict[str, Any]]:
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)
diff --git a/archivebox/tests/fixtures.py b/archivebox/tests/fixtures.py
deleted file mode 100644
index a674795f..00000000
--- a/archivebox/tests/fixtures.py
+++ /dev/null
@@ -1,121 +0,0 @@
-import os
-import subprocess
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-from threading import Thread
-
-import pytest
-
-
-@pytest.fixture
-def process(tmp_path):
- process = subprocess.run(
- ["archivebox", "init"],
- capture_output=True,
- cwd=tmp_path,
- )
- return process
-
-
-@pytest.fixture
-def disable_extractors_dict():
- env = os.environ.copy()
- env.update(
- {
- "SAVE_WGET": "false",
- "SAVE_SINGLEFILE": "false",
- "SAVE_READABILITY": "false",
- "SAVE_MERCURY": "false",
- "SAVE_HTMLTOTEXT": "false",
- "SAVE_PDF": "false",
- "SAVE_SCREENSHOT": "false",
- "SAVE_DOM": "false",
- "SAVE_HEADERS": "false",
- "SAVE_GIT": "false",
- "SAVE_YTDLP": "false",
- "SAVE_ARCHIVEDOTORG": "false",
- "SAVE_TITLE": "false",
- "SAVE_FAVICON": "false",
- "PLUGINS": "__archivebox_test_no_plugins__",
- },
- )
- return env
-
-
-@pytest.fixture
-def recursive_test_site():
- pages = {
- "/": """
-
-
- Root
-
-
-
- About
- Blog
- Contact
-
-
- """.strip().encode("utf-8"),
- "/about": """
-
-
- Deep About
-
-
- """.strip().encode("utf-8"),
- "/blog": """
-
-
- Deep Blog
-
-
- """.strip().encode("utf-8"),
- "/contact": """
-
-
- Deep Contact
-
-
- """.strip().encode("utf-8"),
- "/deep/about": b"Deep About
",
- "/deep/blog": b"Deep Blog
",
- "/deep/contact": b"Deep Contact
",
- "/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(timeout=5)
diff --git a/archivebox/tests/migrations_helpers.py b/archivebox/tests/migrations_helpers.py
index 113b6073..dc4a4d63 100644
--- a/archivebox/tests/migrations_helpers.py
+++ b/archivebox/tests/migrations_helpers.py
@@ -8,14 +8,12 @@ This module provides:
- Helper functions to run archivebox commands and verify results
"""
-import os
-import sys
import json
import sqlite3
-import subprocess
from pathlib import Path
from datetime import datetime, timezone
+from archivebox.tests.conftest import cli_env, run_archivebox_cmd
from archivebox.uuid_compat import uuid7
@@ -1020,41 +1018,21 @@ def seed_0_8_data(db_path: Path) -> dict[str, list[dict]]:
# =============================================================================
-def run_archivebox(data_dir: Path, args: list, timeout: int = 60, env: dict | None = None) -> subprocess.CompletedProcess:
+def run_archivebox_migration_cmd(data_dir: Path, args: list, timeout: int = 60, env: dict | None = None):
"""Run archivebox command in subprocess with given data directory."""
- base_env = os.environ.copy()
- base_env["USE_COLOR"] = "False"
- base_env["SHOW_PROGRESS"] = "False"
- # Disable ALL extractors for faster tests (can be overridden by env parameter)
- base_env["PLUGINS"] = "__archivebox_test_no_plugins__"
- base_env["SAVE_ARCHIVEDOTORG"] = "False"
- base_env["SAVE_TITLE"] = "False"
- base_env["SAVE_FAVICON"] = "False"
- base_env["SAVE_WGET"] = "False"
- base_env["SAVE_SINGLEFILE"] = "False"
- base_env["SAVE_SCREENSHOT"] = "False"
- base_env["SAVE_PDF"] = "False"
- base_env["SAVE_DOM"] = "False"
- base_env["SAVE_READABILITY"] = "False"
- base_env["SAVE_MERCURY"] = "False"
- base_env["SAVE_GIT"] = "False"
- base_env["SAVE_YTDLP"] = "False"
- base_env["SAVE_HEADERS"] = "False"
- base_env["SAVE_HTMLTOTEXT"] = "False"
-
- # Override with any custom env vars
+ base_env = cli_env(
+ disable_extractors=True,
+ PLUGINS="__archivebox_test_no_plugins__",
+ )
if env:
base_env.update(env)
- cmd = [sys.executable, "-m", "archivebox"] + args
-
- return subprocess.run(
- cmd,
- capture_output=True,
- text=True,
+ return run_archivebox_cmd(
+ args,
env=base_env,
- cwd=str(data_dir),
+ cwd=data_dir,
timeout=timeout,
+ replace_env=True,
)
diff --git a/archivebox/tests/test_api_archiveresult.py b/archivebox/tests/test_api_archiveresult.py
new file mode 100644
index 00000000..06782923
--- /dev/null
+++ b/archivebox/tests/test_api_archiveresult.py
@@ -0,0 +1 @@
+# Tests moved to test_api_v1_core_archiveresults.py and test_api_v1_core_archiveresult_archiveresult_id.py.
diff --git a/archivebox/tests/test_api_cli.py b/archivebox/tests/test_api_cli.py
index 0ff51b3d..b4267b1b 100644
--- a/archivebox/tests/test_api_cli.py
+++ b/archivebox/tests/test_api_cli.py
@@ -1,155 +1 @@
-import os
-import time
-
-import pytest
-import requests
-
-from archivebox.core.models import Snapshot
-from archivebox.crawls.models import Crawl
-from archivebox.tests.test_orm_helpers import use_archivebox_db
-from .conftest import (
- build_test_env,
- create_admin_and_token,
- get_free_port,
- init_archive,
- start_server,
- stop_server,
- wait_for_http,
-)
-
-pytestmark = pytest.mark.django_db(transaction=True)
-
-
-@pytest.mark.timeout(180)
-def test_cli_api_add_search_update_remove_over_server(tmp_path):
- os.chdir(tmp_path)
- init_archive(tmp_path)
-
- port = get_free_port()
- env = build_test_env(port, PUBLIC_INDEX="True")
- api_token = create_admin_and_token(tmp_path)
- api_headers = {
- "Host": f"api.archivebox.localhost:{port}",
- "X-ArchiveBox-API-Key": api_token,
- }
- target_url = "https://example.com/"
-
- try:
- start_server(tmp_path, env=env, port=port)
- wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
-
- add_response = requests.post(
- f"http://127.0.0.1:{port}/api/v1/cli/add",
- headers=api_headers,
- json={
- "urls": [target_url],
- "tag": "api-cli",
- "depth": 0,
- "parser": "url_list",
- "plugins": "wget",
- "update": True,
- "overwrite": False,
- "index_only": True,
- },
- timeout=10,
- )
- assert add_response.status_code == 200, add_response.text
- add_payload = add_response.json()
- assert add_payload["success"] is True
- assert add_payload["result_format"] == "json"
- assert add_payload["result"]["num_snapshots"] == 0
- crawl_id = add_payload["result"]["crawl_id"]
- assert add_payload["result"]["snapshot_ids"] == []
- stop_server(tmp_path)
- from archivebox.services.runner import run_crawl
-
- with use_archivebox_db(tmp_path):
- run_crawl(crawl_id, show_progress=False)
- start_server(tmp_path, env=env, port=port)
- wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
-
- deadline = time.time() + 180
- snapshot_id = None
- while time.time() < deadline:
- with use_archivebox_db(tmp_path):
- snapshot = Snapshot.objects.filter(crawl_id=crawl_id, url=target_url).first()
- if snapshot is not None:
- snapshot_id = str(snapshot.id)
- break
- time.sleep(1)
- assert snapshot_id is not None
-
- search_response = requests.post(
- f"http://127.0.0.1:{port}/api/v1/cli/search",
- headers=api_headers,
- json={
- "filter_patterns": [target_url],
- "filter_type": "exact",
- "status": "indexed",
- "sort": "bookmarked_at",
- "as_json": True,
- "as_html": False,
- "as_csv": "",
- "with_headers": False,
- },
- timeout=10,
- )
- assert search_response.status_code == 200, search_response.text
- search_payload = search_response.json()
- assert search_payload["success"] is True
- assert search_payload["result_format"] == "json"
- assert any(item["url"] == target_url for item in search_payload["result"])
-
- update_response = requests.post(
- f"http://127.0.0.1:{port}/api/v1/cli/update",
- headers=api_headers,
- json={
- "resume": None,
- "after": 0,
- "before": 4102444800,
- "filter_type": "exact",
- "filter_patterns": [target_url],
- "batch_size": 1,
- "continuous": False,
- },
- timeout=20,
- )
- assert update_response.status_code == 200, update_response.text
- assert update_response.json()["success"] is True
- stop_server(tmp_path)
- start_server(tmp_path, env=env, port=port)
- wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
-
- with use_archivebox_db(tmp_path):
- crawl_obj = Crawl.objects.filter(pk=crawl_id).first()
- crawl = (crawl_obj.max_depth, crawl_obj.tags_str, crawl_obj.config) if crawl_obj else None
-
- assert crawl is not None
- assert crawl[0] == 0
- assert crawl[1] == "api-cli"
- assert crawl[2]["INDEX_ONLY"] is True
-
- remove_response = requests.post(
- f"http://127.0.0.1:{port}/api/v1/cli/remove",
- headers=api_headers,
- json={
- "delete": True,
- "after": 0,
- "before": 4102444800,
- "filter_type": "exact",
- "filter_patterns": [target_url],
- },
- timeout=20,
- )
- assert remove_response.status_code == 200, remove_response.text
- remove_payload = remove_response.json()
- assert remove_payload["success"] is True
- assert remove_payload["result"]["removed_count"] == 1
- assert snapshot_id in remove_payload["result"]["removed_snapshot_ids"]
-
- with use_archivebox_db(tmp_path):
- snapshot_count = Snapshot.objects.filter(pk=snapshot_id).count()
-
- assert snapshot_count == 0
- finally:
- stop_server(tmp_path)
+# CLI endpoint tests moved to test_api_v1_cli_add.py and test_api_v1_cli_update.py.
diff --git a/archivebox/tests/test_api_cli_schedule.py b/archivebox/tests/test_api_cli_schedule.py
index 7aa6eee7..def03802 100644
--- a/archivebox/tests/test_api_cli_schedule.py
+++ b/archivebox/tests/test_api_cli_schedule.py
@@ -1,83 +1 @@
-import os
-from io import StringIO
-
-import pytest
-import requests
-from django.contrib.auth import get_user_model
-from django.test import RequestFactory
-
-from archivebox.api.v1_cli import ScheduleCommandSchema, cli_schedule
-from archivebox.crawls.models import CrawlSchedule
-from .conftest import (
- build_test_env,
- create_admin_and_token,
- get_free_port,
- init_archive,
- start_server,
- stop_server,
- wait_for_http,
-)
-
-User = get_user_model()
-
-
-@pytest.mark.django_db
-def test_schedule_api_creates_schedule_via_view_request():
- user = User.objects.create_user(
- username="api-user",
- password="testpass123",
- email="api@example.com",
- )
- request = RequestFactory().post("/api/v1/cli/schedule")
- request.user = user
- setattr(request, "stdout", StringIO())
- setattr(request, "stderr", StringIO())
- args = ScheduleCommandSchema(
- every="daily",
- import_path="https://example.com/feed.xml",
- quiet=True,
- )
-
- response = cli_schedule(request, args)
-
- assert response["success"] is True
- assert response["result_format"] == "json"
- assert CrawlSchedule.objects.count() == 1
- assert len(response["result"]["created_schedule_ids"]) == 1
-
-
-@pytest.mark.django_db(transaction=True)
-@pytest.mark.timeout(180)
-def test_api_v1_cli_schedule_creates_schedule_over_server(tmp_path, recursive_test_site):
- os.chdir(tmp_path)
- init_archive(tmp_path)
-
- port = get_free_port()
- env = build_test_env(port)
- api_token = create_admin_and_token(tmp_path)
-
- try:
- start_server(tmp_path, env=env, port=port)
- wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
-
- response = requests.post(
- f"http://127.0.0.1:{port}/api/v1/cli/schedule",
- headers={
- "Host": f"api.archivebox.localhost:{port}",
- "X-ArchiveBox-API-Key": api_token,
- },
- json={
- "every": "daily",
- "import_path": recursive_test_site["root_url"],
- "quiet": True,
- },
- timeout=10,
- )
-
- assert response.status_code == 200, response.text
- payload = response.json()
- assert payload["success"] is True
- assert payload["result_format"] == "json"
- assert len(payload["result"]["created_schedule_ids"]) == 1
- finally:
- stop_server(tmp_path)
+# CLI schedule endpoint tests moved to test_api_v1_cli_schedule.py.
diff --git a/archivebox/tests/test_api_core.py b/archivebox/tests/test_api_core.py
deleted file mode 100644
index 8843519c..00000000
--- a/archivebox/tests/test_api_core.py
+++ /dev/null
@@ -1,374 +0,0 @@
-import os
-from datetime import timedelta
-
-import pytest
-import requests
-from django.core.files.uploadedfile import SimpleUploadedFile
-from django.contrib.auth import get_user_model
-from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
-from django.utils import timezone
-
-from archivebox.core.models import ArchiveResult, Snapshot, Tag
-from archivebox.crawls.models import Crawl
-from archivebox.tests.test_orm_helpers import use_archivebox_db
-from .conftest import (
- build_test_env,
- create_admin_and_token,
- get_free_port,
- init_archive,
- start_server,
- stop_server,
- wait_for_http,
-)
-
-pytestmark = pytest.mark.django_db(transaction=True)
-
-
-def test_archiveresult_upload_api_queues_snapshot_maintenance_without_finalizing(client):
- from archivebox.api.auth import get_or_create_api_token
-
- user = get_user_model().objects.create_superuser(
- username="uploadapiadmin",
- email="uploadapiadmin@example.com",
- password="testpass123",
- )
- api_token = get_or_create_api_token(user)
- assert api_token is not None
-
- crawl = Crawl.objects.create(
- urls="https://example.com",
- created_by=user,
- status=Crawl.StatusChoices.STARTED,
- retry_at=timezone.now(),
- )
- active_retry_at = timezone.now() + timedelta(minutes=5)
- active_snapshot = Snapshot.objects.create(
- url="https://example.com/active",
- crawl=crawl,
- status=Snapshot.StatusChoices.STARTED,
- retry_at=active_retry_at,
- )
- sealed_snapshot = Snapshot.objects.create(
- url="https://example.com/sealed",
- crawl=crawl,
- status=Snapshot.StatusChoices.SEALED,
- retry_at=None,
- )
-
- active_response = client.post(
- "/api/v1/core/archiveresults",
- {
- "snapshot_id": str(active_snapshot.id),
- "plugin": "chrome_extension_dom",
- "hook_name": "on_Snapshot__archivebox_browser_extension_upload",
- "status": ArchiveResult.StatusChoices.SUCCEEDED,
- "output_str": "uploaded active snapshot output",
- },
- HTTP_HOST="api.archivebox.localhost:8000",
- HTTP_X_ARCHIVEBOX_API_KEY=api_token.token,
- )
- assert active_response.status_code == 200, active_response.content
- active_snapshot.refresh_from_db()
- assert active_snapshot.status == Snapshot.StatusChoices.STARTED
- assert active_snapshot.retry_at == active_retry_at
- assert active_snapshot.downloaded_at is not None
-
- sealed_response = client.post(
- "/api/v1/core/archiveresults",
- {
- "snapshot_id": str(sealed_snapshot.id),
- "plugin": "chrome_extension_mhtml",
- "hook_name": "on_Snapshot__archivebox_browser_extension_upload",
- "status": ArchiveResult.StatusChoices.SUCCEEDED,
- "output_str": "uploaded sealed snapshot output",
- },
- HTTP_HOST="api.archivebox.localhost:8000",
- HTTP_X_ARCHIVEBOX_API_KEY=api_token.token,
- )
- assert sealed_response.status_code == 200, sealed_response.content
- sealed_snapshot.refresh_from_db()
- assert sealed_snapshot.status == Snapshot.StatusChoices.SEALED
- assert sealed_snapshot.retry_at is not None
- assert sealed_snapshot.downloaded_at is not None
-
-
-def test_archiveresult_patch_upload_finalizes_queued_result(client):
- from archivebox.api.auth import get_or_create_api_token
-
- user = get_user_model().objects.create_superuser(
- username="patchuploadapiadmin",
- email="patchuploadapiadmin@example.com",
- password="testpass123",
- )
- api_token = get_or_create_api_token(user)
- assert api_token is not None
-
- crawl = Crawl.objects.create(
- urls="https://example.com",
- created_by=user,
- status=Crawl.StatusChoices.SEALED,
- retry_at=None,
- )
- snapshot = Snapshot.objects.create(
- url="https://example.com/upload-patch",
- crawl=crawl,
- status=Snapshot.StatusChoices.SEALED,
- retry_at=None,
- )
- result = ArchiveResult.objects.create(
- snapshot=snapshot,
- plugin="dom",
- hook_name="on_Snapshot__archivebox_browser_extension_upload",
- status=ArchiveResult.StatusChoices.QUEUED,
- )
-
- response = client.generic(
- "PATCH",
- f"/api/v1/core/archiveresult/{result.id}",
- encode_multipart(
- BOUNDARY,
- {
- "files": SimpleUploadedFile("output.html", b"uploaded", content_type="text/html"),
- "output_paths": "output.html",
- "output_str": "output.html",
- },
- ),
- content_type=MULTIPART_CONTENT,
- HTTP_HOST="api.archivebox.localhost:8000",
- HTTP_X_ARCHIVEBOX_API_KEY=api_token.token,
- )
- assert response.status_code == 200, response.content
-
- result.refresh_from_db()
- snapshot.refresh_from_db()
- assert result.status == ArchiveResult.StatusChoices.SUCCEEDED
- assert result.output_str == "output.html"
- assert snapshot.status == Snapshot.StatusChoices.SEALED
- assert snapshot.retry_at is not None
-
-
-def test_crawl_cancel_api_defers_cleanup_to_runner(client):
- from archivebox.api.auth import get_or_create_api_token
- from archivebox.services.runner import run_due_crawl
-
- user = get_user_model().objects.create_superuser(
- username="cancelapiadmin",
- email="cancelapiadmin@example.com",
- password="testpass123",
- )
- api_token = get_or_create_api_token(user)
- assert api_token is not None
-
- crawl = Crawl.objects.create(
- urls="https://example.com",
- created_by=user,
- status=Crawl.StatusChoices.STARTED,
- retry_at=timezone.now() + timedelta(minutes=5),
- )
- child = Snapshot.objects.create(
- url="https://example.com/cancel-child",
- crawl=crawl,
- status=Snapshot.StatusChoices.STARTED,
- retry_at=timezone.now() + timedelta(minutes=5),
- )
- crawl.output_dir.mkdir(parents=True, exist_ok=True)
- pid_file = crawl.output_dir / "cleanup-test.pid"
- pid_file.write_text("12345")
-
- response = client.patch(
- f"/api/v1/crawls/crawl/{crawl.id}",
- {"action": "cancel"},
- content_type="application/json",
- HTTP_HOST="api.archivebox.localhost:8000",
- HTTP_X_ARCHIVEBOX_API_KEY=api_token.token,
- )
- assert response.status_code == 200, response.content
-
- crawl.refresh_from_db()
- child.refresh_from_db()
- assert crawl.status == Crawl.StatusChoices.SEALED
- assert crawl.retry_at is not None
- assert crawl.retry_at <= timezone.now()
- assert child.status == Snapshot.StatusChoices.STARTED
- assert child.retry_at is not None
- assert child.retry_at <= timezone.now()
- assert pid_file.exists()
-
- assert run_due_crawl(crawl, lock_seconds=60) is True
- crawl.refresh_from_db()
- assert crawl.retry_at is None
- assert not pid_file.exists()
-
-
-@pytest.mark.timeout(180)
-def test_core_api_crud_uses_token_auth_and_persists_side_effects_over_server(tmp_path, recursive_test_site):
- os.chdir(tmp_path)
- init_archive(tmp_path)
-
- port = get_free_port()
- env = build_test_env(port, PUBLIC_INDEX="True")
- api_token = create_admin_and_token(tmp_path)
- api_headers = {
- "Host": f"api.archivebox.localhost:{port}",
- "X-ArchiveBox-API-Key": api_token,
- }
-
- try:
- start_server(tmp_path, env=env, port=port)
- docs = wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
- assert docs.status_code == 200
- openapi = wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/openapi.json")
- assert openapi.status_code == 200
- paths = openapi.json()["paths"]
- assert "/api/v1/core/snapshots" in paths
- assert "/api/v1/crawls/crawls" in paths
-
- unauth = requests.get(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawls",
- headers={"Host": f"api.archivebox.localhost:{port}"},
- timeout=10,
- )
- assert unauth.status_code in (401, 403)
- bad_auth = requests.get(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawls",
- headers={"Host": f"api.archivebox.localhost:{port}", "X-ArchiveBox-API-Key": "bad-token"},
- timeout=10,
- )
- assert bad_auth.status_code in (401, 403)
-
- crawl_response = requests.post(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawls",
- headers=api_headers,
- json={
- "urls": [recursive_test_site["root_url"]],
- "max_depth": 2,
- "tags": ["api-depth-two"],
- "label": "api crawl",
- "notes": "created through REST API",
- "config": {
- "PLUGINS": "wget,parse_html_urls",
- "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*",
- "CRAWL_MAX_URLS": 7,
- "CRAWL_MAX_SIZE": "0",
- "SNAPSHOT_MAX_SIZE": "0",
- },
- },
- timeout=10,
- )
- assert crawl_response.status_code == 200, crawl_response.text
- crawl_payload = crawl_response.json()
- crawl_id = crawl_payload["id"]
- assert crawl_payload["max_depth"] == 2
- assert crawl_payload["tags_str"] == "api-depth-two"
- assert crawl_payload["config"]["PLUGINS"] == "wget,parse_html_urls"
- assert crawl_payload["config"]["CRAWL_MAX_URLS"] == 7
-
- snapshot_response = requests.post(
- f"http://127.0.0.1:{port}/api/v1/core/snapshots",
- headers=api_headers,
- json={
- "url": recursive_test_site["child_urls"][0],
- "crawl_id": crawl_id,
- "depth": 1,
- "title": "API child snapshot",
- "tags": ["api-child"],
- "status": "queued",
- },
- timeout=10,
- )
- assert snapshot_response.status_code == 200, snapshot_response.text
- snapshot_payload = snapshot_response.json()
- snapshot_id = snapshot_payload["id"]
- assert snapshot_payload["url"] == recursive_test_site["child_urls"][0]
- assert snapshot_payload["tags"] == ["api-child"]
-
- patch_snapshot = requests.patch(
- f"http://127.0.0.1:{port}/api/v1/core/snapshot/{snapshot_id}",
- headers=api_headers,
- json={"status": "sealed", "tags": ["api-child", "api-patched"]},
- timeout=10,
- )
- assert patch_snapshot.status_code == 200, patch_snapshot.text
- assert patch_snapshot.json()["status"] == "sealed"
- assert set(patch_snapshot.json()["tags"]) == {"api-child", "api-patched"}
-
- tag_create = requests.post(
- f"http://127.0.0.1:{port}/api/v1/core/tags/create/",
- headers=api_headers,
- json={"name": "api-extra"},
- timeout=10,
- )
- assert tag_create.status_code == 200, tag_create.text
- tag_id = tag_create.json()["tag_id"]
-
- add_tag = requests.post(
- f"http://127.0.0.1:{port}/api/v1/core/tags/add-to-snapshot/",
- headers=api_headers,
- json={"snapshot_id": snapshot_id, "tag_id": tag_id},
- timeout=10,
- )
- assert add_tag.status_code == 200, add_tag.text
- remove_tag = requests.post(
- f"http://127.0.0.1:{port}/api/v1/core/tags/remove-from-snapshot/",
- headers=api_headers,
- json={"snapshot_id": snapshot_id, "tag_name": "api-extra"},
- timeout=10,
- )
- assert remove_tag.status_code == 200, remove_tag.text
-
- crawl_patch = requests.patch(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawl/{crawl_id}",
- headers=api_headers,
- json={"status": "sealed", "tags": ["api-sealed"]},
- timeout=10,
- )
- assert crawl_patch.status_code == 200, crawl_patch.text
- assert crawl_patch.json()["status"] == "sealed"
- assert crawl_patch.json()["tags_str"] == "api-sealed"
-
- snapshots_list = requests.get(
- f"http://127.0.0.1:{port}/api/v1/core/snapshots?tag=api-patched&with_archiveresults=true",
- headers=api_headers,
- timeout=10,
- )
- assert snapshots_list.status_code == 200, snapshots_list.text
- snapshot_items = snapshots_list.json()["items"]
- assert len(snapshot_items) == 1
- assert snapshot_items[0]["id"] == snapshot_id
- assert snapshot_items[0]["archiveresults"] == []
-
- bearer_response = requests.get(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawl/{crawl_id}",
- headers={"Host": f"api.archivebox.localhost:{port}", "Authorization": f"Bearer {api_token}"},
- timeout=10,
- )
- assert bearer_response.status_code == 200, bearer_response.text
- query_response = requests.get(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawl/{crawl_id}?api_key={api_token}",
- headers={"Host": f"api.archivebox.localhost:{port}"},
- timeout=10,
- )
- assert query_response.status_code == 200, query_response.text
-
- delete_snapshot = requests.delete(
- f"http://127.0.0.1:{port}/api/v1/core/snapshot/{snapshot_id}",
- headers=api_headers,
- timeout=10,
- )
- assert delete_snapshot.status_code == 200, delete_snapshot.text
- assert delete_snapshot.json()["success"] is True
-
- delete_crawl = requests.delete(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawl/{crawl_id}",
- headers=api_headers,
- timeout=10,
- )
- assert delete_crawl.status_code == 200, delete_crawl.text
- assert delete_crawl.json()["success"] is True
-
- with use_archivebox_db(tmp_path):
- assert Crawl.objects.filter(pk=crawl_id).count() == 0
- assert Snapshot.objects.filter(pk=snapshot_id).count() == 0
- assert Tag.objects.filter(name="api-extra").count() == 1
- finally:
- stop_server(tmp_path)
diff --git a/archivebox/tests/test_api_crawl.py b/archivebox/tests/test_api_crawl.py
new file mode 100644
index 00000000..49248675
--- /dev/null
+++ b/archivebox/tests/test_api_crawl.py
@@ -0,0 +1 @@
+# Tests moved to test_api_v1_crawls_crawl_crawl_id.py.
diff --git a/archivebox/tests/test_api_crud.py b/archivebox/tests/test_api_crud.py
new file mode 100644
index 00000000..98b61d35
--- /dev/null
+++ b/archivebox/tests/test_api_crud.py
@@ -0,0 +1 @@
+# Tests moved to test_api_v1_workflow_core_token_auth_side_effects.py and exact endpoint files under test_api_v1_core_*.
diff --git a/archivebox/tests/test_api_delete_paths.py b/archivebox/tests/test_api_delete_paths.py
index cfb5f9e7..46113b20 100644
--- a/archivebox/tests/test_api_delete_paths.py
+++ b/archivebox/tests/test_api_delete_paths.py
@@ -1,88 +1 @@
-import json
-from pathlib import Path
-
-import pytest
-from django.contrib.auth import get_user_model
-
-from archivebox.api.auth import get_or_create_api_token
-from archivebox.cli.archivebox_add import add
-from archivebox.cli.archivebox_run import run_runner
-from archivebox.core.models import Snapshot
-from archivebox.crawls.models import Crawl
-
-
-pytestmark = pytest.mark.django_db(transaction=True)
-
-API_HOST = "api.archivebox.localhost:8000"
-
-
-def _api_headers(token):
- return {
- "HTTP_HOST": API_HOST,
- "HTTP_X_ARCHIVEBOX_API_KEY": token.token,
- }
-
-
-def _admin_token():
- user = get_user_model().objects.create_superuser(
- username="deletepathadmin",
- email="deletepathadmin@test.com",
- password="testpassword",
- )
- token = get_or_create_api_token(user)
- assert token is not None
- return token
-
-
-def test_rest_snapshot_delete_removes_output_dir(client):
- token = _admin_token()
- url = "https://example.com/delete-path-snapshot"
-
- response = client.post(
- "/api/v1/core/snapshots",
- data=json.dumps({"url": url, "depth": 0, "status": Snapshot.StatusChoices.QUEUED}),
- content_type="application/json",
- **_api_headers(token),
- )
- assert response.status_code == 200, response.content.decode()
-
- snapshot = Snapshot.objects.get(url=url)
- snapshot_dir = Path(snapshot.output_dir)
- snapshot_dir.mkdir(parents=True, exist_ok=True)
- (snapshot_dir / "delete-path-test.txt").write_text("snapshot output")
- assert snapshot_dir.exists()
-
- response = client.delete(f"/api/v1/core/snapshot/{snapshot.id}", **_api_headers(token))
- assert response.status_code == 200, response.content.decode()
- assert not Snapshot.objects.filter(pk=snapshot.pk).exists()
- assert not snapshot_dir.exists()
-
-
-def test_rest_crawl_delete_removes_crawl_and_snapshot_output_dirs(client):
- token = _admin_token()
- url = "https://example.com/delete-path-crawl"
-
- crawl, _snapshots = add(
- urls=[url],
- depth=0,
- max_urls=1,
- plugins="__archivebox_test_no_plugins__",
- bg=True,
- )
- assert run_runner(daemon=False, crawl_id=str(crawl.id)) == 0
- snapshot = Snapshot.objects.get(crawl=crawl, url=url)
- crawl_dir = Path(crawl.output_dir)
- snapshot_dir = Path(snapshot.output_dir)
- crawl_dir.mkdir(parents=True, exist_ok=True)
- snapshot_dir.mkdir(parents=True, exist_ok=True)
- (crawl_dir / "delete-path-crawl.txt").write_text("crawl output")
- (snapshot_dir / "delete-path-snapshot.txt").write_text("snapshot output")
- assert crawl_dir.exists()
- assert snapshot_dir.exists()
-
- response = client.delete(f"/api/v1/crawls/crawl/{crawl.id}", **_api_headers(token))
- assert response.status_code == 200, response.content.decode()
- assert not Crawl.objects.filter(pk=crawl.pk).exists()
- assert not Snapshot.objects.filter(pk=snapshot.pk).exists()
- assert not crawl_dir.exists()
- assert not snapshot_dir.exists()
+# Tests moved to test_api_v1_core_snapshot_snapshot_id.py and test_api_v1_crawls_crawl_crawl_id.py.
diff --git a/archivebox/tests/test_api_personas.py b/archivebox/tests/test_api_personas.py
new file mode 100644
index 00000000..227efc93
--- /dev/null
+++ b/archivebox/tests/test_api_personas.py
@@ -0,0 +1 @@
+# Tests moved to test_api_v1_personas_sync.py and test_api_v1_personas_personas.py.
diff --git a/archivebox/tests/test_api_remove.py b/archivebox/tests/test_api_remove.py
new file mode 100644
index 00000000..80a2395f
--- /dev/null
+++ b/archivebox/tests/test_api_remove.py
@@ -0,0 +1 @@
+# CLI remove endpoint tests moved to test_api_v1_cli_remove.py.
diff --git a/archivebox/tests/test_api_rss.py b/archivebox/tests/test_api_rss.py
index e5d892c8..dd3acbbc 100644
--- a/archivebox/tests/test_api_rss.py
+++ b/archivebox/tests/test_api_rss.py
@@ -1,166 +1 @@
-from datetime import datetime
-from typing import cast
-
-import pytest
-from django.contrib.auth import get_user_model
-from django.contrib.auth.models import UserManager
-from django.utils import timezone
-
-
-pytestmark = pytest.mark.django_db
-
-
-User = get_user_model()
-ADMIN_HOST = "admin.archivebox.localhost:8000"
-
-
-@pytest.fixture
-def admin_user(db):
- return cast(UserManager, User.objects).create_superuser(
- username="rssadmin",
- email="rssadmin@test.com",
- password="testpassword",
- )
-
-
-@pytest.fixture
-def other_user(db):
- return cast(UserManager, User.objects).create_user(
- username="rssother",
- email="rssother@test.com",
- password="testpassword",
- )
-
-
-@pytest.fixture
-def api_token(admin_user):
- from archivebox.api.auth import get_or_create_api_token
-
- token = get_or_create_api_token(admin_user)
- assert token is not None
- return token.token
-
-
-def make_snapshot(*, user, url: str, title: str, bookmarked_at: datetime):
- from archivebox.core.models import Snapshot
- from archivebox.crawls.models import Crawl
-
- crawl = Crawl.objects.create(urls=url, created_by=user)
- snapshot = Snapshot.objects.create(
- url=url,
- title=title,
- crawl=crawl,
- bookmarked_at=bookmarked_at,
- )
- return crawl, snapshot
-
-
-def test_snapshots_rss_filters_by_user_and_orders_newest_first(client, api_token, admin_user, other_user):
- from archivebox.core.models import Tag
-
- older_at = timezone.make_aware(datetime(2026, 5, 22, 8, 0, 0))
- newer_at = timezone.make_aware(datetime(2026, 5, 23, 8, 0, 0))
- _crawl, older_snapshot = make_snapshot(
- user=admin_user,
- url="https://example.com/rss-older",
- title="Older & Escaped",
- bookmarked_at=older_at,
- )
- make_snapshot(
- user=admin_user,
- url="https://example.com/rss-newer",
- title="Newer Snapshot",
- bookmarked_at=newer_at,
- )
- make_snapshot(
- user=other_user,
- url="https://example.com/rss-other-user",
- title="Other User",
- bookmarked_at=timezone.make_aware(datetime(2026, 5, 23, 9, 0, 0)),
- )
- older_snapshot.tags.add(Tag.objects.create(name="rss-tag", created_by=admin_user))
-
- response = client.get(
- "/api/v1/core/snapshots.rss",
- {"created_by": admin_user.username, "limit": 50, "api_key": api_token},
- HTTP_HOST=ADMIN_HOST,
- )
-
- assert response.status_code == 200
- assert response["Content-Type"].startswith("application/rss+xml")
- body = response.content.decode()
- assert 'rss-tag" in body
- assert "rss-other-user" not in body
- assert body.index("rss-newer") < body.index("rss-older")
-
-
-def test_snapshots_rss_supports_before_yyyymmdd_and_limit(client, api_token, admin_user):
- make_snapshot(
- user=admin_user,
- url="https://example.com/rss-before-too-new",
- title="Too New",
- bookmarked_at=timezone.make_aware(datetime(2026, 5, 24, 8, 0, 0)),
- )
- make_snapshot(
- user=admin_user,
- url="https://example.com/rss-before-keep-one",
- title="Keep One",
- bookmarked_at=timezone.make_aware(datetime(2026, 5, 23, 12, 0, 0)),
- )
- make_snapshot(
- user=admin_user,
- url="https://example.com/rss-before-keep-two",
- title="Keep Two",
- bookmarked_at=timezone.make_aware(datetime(2026, 5, 22, 12, 0, 0)),
- )
-
- response = client.get(
- "/api/v1/core/snapshots.rss",
- {"created_by": str(admin_user.pk), "before": "20260523", "limit": 1, "api_key": api_token},
- HTTP_HOST=ADMIN_HOST,
- )
-
- assert response.status_code == 200
- body = response.content.decode()
- assert "rss-before-too-new" not in body
- assert "rss-before-keep-one" in body
- assert "rss-before-keep-two" not in body
-
-
-def test_crawl_as_rss_redirects_to_canonical_snapshots_feed(client, api_token, admin_user, other_user):
- crawl, _snapshot = make_snapshot(
- user=admin_user,
- url="https://example.com/rss-crawl-feed",
- title="Crawl Feed Snapshot",
- bookmarked_at=timezone.make_aware(datetime(2026, 5, 23, 8, 0, 0)),
- )
- make_snapshot(
- user=other_user,
- url="https://example.com/rss-crawl-other",
- title="Other Crawl Snapshot",
- bookmarked_at=timezone.make_aware(datetime(2026, 5, 23, 9, 0, 0)),
- )
-
- response = client.get(
- f"/api/v1/crawls/crawl/{crawl.id}",
- {"as_rss": "true", "limit": 50, "api_key": api_token},
- HTTP_HOST=ADMIN_HOST,
- follow=True,
- )
-
- assert response.status_code == 200
- assert response.redirect_chain
- redirect_url = response.redirect_chain[0][0]
- assert redirect_url.startswith("/api/v1/core/snapshots.rss?")
- assert f"crawl_id={crawl.id}" in redirect_url
- assert "as_rss" not in redirect_url
- assert response["Content-Type"].startswith("application/rss+xml")
- body = response.content.decode()
- assert "rss-crawl-feed" in body
- assert "rss-crawl-other" not in body
+# Tests moved to test_api_v1_core_snapshots_rss.py and test_api_v1_crawls_crawl_crawl_id.py.
diff --git a/archivebox/tests/test_api_search.py b/archivebox/tests/test_api_search.py
new file mode 100644
index 00000000..df3443be
--- /dev/null
+++ b/archivebox/tests/test_api_search.py
@@ -0,0 +1 @@
+# Tests moved to test_api_v1_core_snapshots.py.
diff --git a/archivebox/tests/test_api_v1_auth_check_api_token.py b/archivebox/tests/test_api_v1_auth_check_api_token.py
new file mode 100644
index 00000000..d03e73d4
--- /dev/null
+++ b/archivebox/tests/test_api_v1_auth_check_api_token.py
@@ -0,0 +1,19 @@
+import pytest
+
+from archivebox.tests.conftest import API_TEST_HOST, api_client_request
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_token):
+ response = api_client_request(
+ client,
+ "post",
+ "/api/v1/auth/check_api_token",
+ payload={"token": api_token.token},
+ headers={"HTTP_HOST": API_TEST_HOST},
+ )
+
+ assert response.status_code == 200, response.content
+ assert response.json()["success"] is True
diff --git a/archivebox/tests/test_api_v1_auth_get_api_token.py b/archivebox/tests/test_api_v1_auth_get_api_token.py
new file mode 100644
index 00000000..6a6f4027
--- /dev/null
+++ b/archivebox/tests/test_api_v1_auth_get_api_token.py
@@ -0,0 +1,22 @@
+import pytest
+
+from archivebox.tests.conftest import API_TEST_HOST, api_client_request
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user):
+ response = api_client_request(
+ client,
+ "post",
+ "/api/v1/auth/get_api_token",
+ payload={
+ "username": api_admin_user.username,
+ "password": "testpass123",
+ },
+ headers={"HTTP_HOST": API_TEST_HOST},
+ )
+
+ assert response.status_code == 200, response.content
+ assert response.json()["success"] is True
diff --git a/archivebox/tests/test_api_v1_cli_add.py b/archivebox/tests/test_api_v1_cli_add.py
new file mode 100644
index 00000000..17a01957
--- /dev/null
+++ b/archivebox/tests/test_api_v1_cli_add.py
@@ -0,0 +1,29 @@
+import pytest
+
+from .conftest import (
+ api_client_request,
+ init_archive,
+)
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_headers):
+ init_archive(tmp_path)
+
+ response = api_client_request(
+ client,
+ "post",
+ "/api/v1/cli/add",
+ payload={
+ "urls": ["https://example.com/api-cli-add-basic"],
+ "depth": 0,
+ "parser": "url_list",
+ "plugins": "__archivebox_test_no_plugins__",
+ "index_only": True,
+ },
+ headers=api_headers,
+ )
+
+ assert response.status_code == 200, response.content
+ assert response.json()["success"] is True
diff --git a/archivebox/tests/test_api_v1_cli_remove.py b/archivebox/tests/test_api_v1_cli_remove.py
new file mode 100644
index 00000000..f1eabc31
--- /dev/null
+++ b/archivebox/tests/test_api_v1_cli_remove.py
@@ -0,0 +1,209 @@
+from datetime import datetime, timedelta
+from pathlib import Path
+
+import pytest
+from django.utils import timezone
+
+from archivebox.core.models import Snapshot, Tag
+from archivebox.crawls.models import Crawl
+from archivebox.tests.conftest import api_client_request, init_archive
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def _crawl(user, urls: str, label: str) -> Crawl:
+ return Crawl.objects.create(
+ urls=urls,
+ created_by=user,
+ status=Crawl.StatusChoices.SEALED,
+ retry_at=None,
+ tags_str=label,
+ )
+
+
+def _snapshot(crawl: Crawl, url: str, *, status: str = Snapshot.StatusChoices.SEALED, tag: str = "", bookmarked_at=None) -> Snapshot:
+ snapshot = Snapshot.objects.create(
+ url=url,
+ crawl=crawl,
+ status=status,
+ retry_at=None,
+ bookmarked_at=bookmarked_at or timezone.now(),
+ )
+ if tag:
+ tag_obj, _ = Tag.objects.get_or_create(name=tag)
+ snapshot.tags.add(tag_obj)
+ return snapshot
+
+
+def _touch_output(snapshot: Snapshot) -> Path:
+ output_dir = Path(snapshot.output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+ (output_dir / "api-remove-test.txt").write_text(str(snapshot.id))
+ return output_dir
+
+
+def _bulk_timeout_snapshots(crawl: Crawl, *, count: int = 30000) -> tuple[list[Snapshot], dict[str, Path]]:
+ base = timezone.make_aware(datetime(2026, 2, 1, 12, 0, 0))
+ snapshots = [
+ Snapshot(
+ url=f"https://example.com/remove-timeout-{idx}",
+ crawl=crawl,
+ status=Snapshot.StatusChoices.SEALED,
+ retry_at=None,
+ timestamp=f"88{idx:030d}",
+ bookmarked_at=base + timedelta(seconds=idx),
+ created_at=base + timedelta(seconds=idx),
+ )
+ for idx in range(count)
+ ]
+ Snapshot.objects.bulk_create(snapshots, batch_size=1000)
+
+ sample = [*snapshots[-200:], *snapshots[:200]]
+ return snapshots, {str(snapshot.id): _touch_output(snapshot) for snapshot in sample}
+
+
+def _post_remove(client, api_headers, body: dict):
+ return api_client_request(
+ client,
+ "post",
+ "/api/v1/cli/remove",
+ payload=body,
+ headers=api_headers,
+ )
+
+
+def test_cli_remove_api_removes_rows_and_respects_snapshot_filters(client, tmp_path, api_admin_user, api_headers):
+ init_archive(tmp_path)
+ base = timezone.make_aware(datetime(2026, 1, 1, 12, 0, 0))
+
+ crawl_a = _crawl(api_admin_user, "https://alpha.example.com/articles/needle", "crawl-a")
+ crawl_b = _crawl(api_admin_user, "https://beta.example.org/posts/needle", "crawl-b")
+ exact = _snapshot(crawl_a, "https://alpha.example.com/articles/exact", tag="api-keep", bookmarked_at=base)
+ keep = _snapshot(crawl_a, "https://alpha.example.com/articles/needle", tag="api-keep", bookmarked_at=base + timedelta(hours=1))
+ wrong_status = _snapshot(
+ crawl_a,
+ "https://alpha.example.com/articles/needle-queued",
+ status=Snapshot.StatusChoices.QUEUED,
+ tag="api-keep",
+ bookmarked_at=base + timedelta(hours=2),
+ )
+ other_crawl = _snapshot(crawl_b, "https://beta.example.org/posts/needle", tag="api-other", bookmarked_at=base + timedelta(hours=3))
+ exact_dir = _touch_output(exact)
+ keep_dir = _touch_output(keep)
+ wrong_status_dir = _touch_output(wrong_status)
+ other_crawl_dir = _touch_output(other_crawl)
+
+ exact_response = _post_remove(
+ client,
+ api_headers,
+ {
+ "filter_type": "exact",
+ "filter_patterns": [exact.url],
+ "timeout": 60,
+ },
+ )
+ assert exact_response.status_code == 200, exact_response.content
+ exact_payload = exact_response.json()
+ assert exact_payload["success"] is True
+ assert exact_payload["result"]["removed_count"] == 1
+ assert exact_payload["result"]["removed_snapshot_ids"] == [str(exact.id)]
+ assert exact_payload["result"]["not_removed_count"] == 0
+ assert not Snapshot.objects.filter(pk=exact.pk).exists()
+ assert not exact_dir.exists()
+
+ filtered_response = _post_remove(
+ client,
+ api_headers,
+ {
+ "filter_type": "substring",
+ "filter_patterns": ["needle"],
+ "status": Snapshot.StatusChoices.SEALED,
+ "tag": "api-keep",
+ "url__istartswith": "https://alpha.example.com",
+ "crawl_id": str(crawl_a.id),
+ "after": (base + timedelta(minutes=30)).timestamp(),
+ "before": (base + timedelta(hours=2)).timestamp(),
+ "timeout": 60,
+ },
+ )
+ assert filtered_response.status_code == 200, filtered_response.content
+ filtered_payload = filtered_response.json()
+ assert filtered_payload["success"] is True
+ assert filtered_payload["result"]["removed_count"] == 1
+ assert filtered_payload["result"]["removed_snapshot_ids"] == [str(keep.id)]
+ assert filtered_payload["result"]["not_removed_count"] == 0
+ assert not Snapshot.objects.filter(pk=keep.pk).exists()
+ assert not keep_dir.exists()
+ assert Snapshot.objects.filter(pk=wrong_status.pk).exists()
+ assert Snapshot.objects.filter(pk=other_crawl.pk).exists()
+ assert wrong_status_dir.exists()
+ assert other_crawl_dir.exists()
+
+
+def test_cli_remove_api_reports_timeout_and_clamps_timeout_to_sixty_seconds(client, tmp_path, api_admin_user, api_headers):
+ init_archive(tmp_path)
+ crawl = _crawl(api_admin_user, "https://example.com/remove-timeout-0", "timeout")
+ snapshots, output_dirs_by_id = _bulk_timeout_snapshots(crawl)
+
+ timeout_response = _post_remove(
+ client,
+ api_headers,
+ {
+ "filter_type": "substring",
+ "filter_patterns": ["remove-timeout-"],
+ "timeout": 3,
+ },
+ )
+ assert timeout_response.status_code == 200, timeout_response.content
+ timeout_payload = timeout_response.json()
+ assert timeout_payload["success"] is False
+ assert timeout_payload["errors"]
+ assert set(timeout_payload["result"]) == {
+ "removed_count",
+ "removed_snapshot_ids",
+ "not_removed_count",
+ "not_removed_snapshot_ids",
+ "success",
+ "error",
+ "timeout",
+ }
+ assert timeout_payload["result"]["success"] is False
+ assert timeout_payload["result"]["timeout"] == 3.0
+ assert timeout_payload["result"]["error"]
+ assert timeout_payload["result"]["removed_count"] == len(timeout_payload["result"]["removed_snapshot_ids"])
+ assert timeout_payload["result"]["not_removed_count"] == len(timeout_payload["result"]["not_removed_snapshot_ids"])
+ assert timeout_payload["result"]["removed_count"] > 0
+ assert timeout_payload["result"]["not_removed_count"] > 0
+ assert timeout_payload["result"]["removed_count"] + timeout_payload["result"]["not_removed_count"] == len(snapshots)
+
+ removed_ids = set(timeout_payload["result"]["removed_snapshot_ids"])
+ not_removed_ids = set(timeout_payload["result"]["not_removed_snapshot_ids"])
+ assert Snapshot.objects.filter(url__icontains="remove-timeout-").count() == len(not_removed_ids)
+ assert removed_ids & set(output_dirs_by_id)
+ assert not_removed_ids & set(output_dirs_by_id)
+ for snapshot_id in removed_ids & set(output_dirs_by_id):
+ assert not Snapshot.objects.filter(pk=snapshot_id).exists()
+ assert not output_dirs_by_id[snapshot_id].exists()
+ for snapshot_id in not_removed_ids & set(output_dirs_by_id):
+ assert Snapshot.objects.filter(pk=snapshot_id).exists()
+ assert output_dirs_by_id[snapshot_id].exists()
+
+ clamp_snapshot = _snapshot(crawl, "https://example.com/remove-timeout-clamp")
+ clamp_dir = _touch_output(clamp_snapshot)
+ clamp_response = _post_remove(
+ client,
+ api_headers,
+ {
+ "filter_type": "exact",
+ "filter_patterns": [clamp_snapshot.url],
+ "timeout": 999,
+ },
+ )
+ assert clamp_response.status_code == 200, clamp_response.content
+ clamp_payload = clamp_response.json()
+ assert clamp_payload["success"] is True
+ assert clamp_payload["result"]["timeout"] == 60.0
+ assert clamp_payload["result"]["removed_snapshot_ids"] == [str(clamp_snapshot.id)]
+ assert not Snapshot.objects.filter(pk=clamp_snapshot.pk).exists()
+ assert not clamp_dir.exists()
diff --git a/archivebox/tests/test_api_v1_cli_schedule.py b/archivebox/tests/test_api_v1_cli_schedule.py
new file mode 100644
index 00000000..97e91961
--- /dev/null
+++ b/archivebox/tests/test_api_v1_cli_schedule.py
@@ -0,0 +1,71 @@
+from io import StringIO
+
+import pytest
+import requests
+from django.test import RequestFactory
+
+from archivebox.api.v1_cli import ScheduleCommandSchema, cli_schedule
+from archivebox.crawls.models import CrawlSchedule
+from .conftest import (
+ api_auth_headers,
+ cli_env,
+ create_admin_and_token,
+ get_free_port,
+ init_archive,
+ start_archivebox_server,
+ stop_server,
+ wait_for_http,
+)
+
+
+@pytest.mark.django_db
+def test_schedule_api_creates_schedule_via_view_request(api_admin_user):
+ request = RequestFactory().post("/api/v1/cli/schedule")
+ request.user = api_admin_user
+ setattr(request, "stdout", StringIO())
+ setattr(request, "stderr", StringIO())
+ args = ScheduleCommandSchema(
+ every="daily",
+ import_path="https://example.com/feed.xml",
+ quiet=True,
+ )
+
+ response = cli_schedule(request, args)
+
+ assert response["success"] is True
+ assert response["result_format"] == "json"
+ assert CrawlSchedule.objects.count() == 1
+ assert len(response["result"]["created_schedule_ids"]) == 1
+
+
+@pytest.mark.django_db(transaction=True)
+@pytest.mark.timeout(180)
+def test_api_v1_cli_schedule_creates_schedule_over_server(tmp_path, recursive_test_site):
+ init_archive(tmp_path)
+
+ port = get_free_port()
+ env = cli_env(port=port, server=True)
+ api_token = create_admin_and_token(tmp_path)
+
+ try:
+ start_archivebox_server(tmp_path, env=env, port=port)
+ wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
+
+ response = requests.post(
+ f"http://127.0.0.1:{port}/api/v1/cli/schedule",
+ headers=api_auth_headers(api_token, port=port),
+ json={
+ "every": "daily",
+ "import_path": recursive_test_site["root_url"],
+ "quiet": True,
+ },
+ timeout=10,
+ )
+
+ assert response.status_code == 200, response.text
+ payload = response.json()
+ assert payload["success"] is True
+ assert payload["result_format"] == "json"
+ assert len(payload["result"]["created_schedule_ids"]) == 1
+ finally:
+ stop_server(tmp_path)
diff --git a/archivebox/tests/test_api_v1_cli_search.py b/archivebox/tests/test_api_v1_cli_search.py
new file mode 100644
index 00000000..7fa60b74
--- /dev/null
+++ b/archivebox/tests/test_api_v1_cli_search.py
@@ -0,0 +1,31 @@
+import pytest
+
+from archivebox.core.models import Snapshot
+from archivebox.crawls.models import Crawl
+from .conftest import api_client_request
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(urls="https://example.com/api-cli-search-basic", created_by=api_admin_user)
+ Snapshot.objects.create(url="https://example.com/api-cli-search-basic", crawl=crawl)
+
+ response = api_client_request(
+ client,
+ "post",
+ "/api/v1/cli/search",
+ payload={
+ "filter_patterns": ["https://example.com/api-cli-search-basic"],
+ "filter_type": "exact",
+ "as_json": True,
+ "as_html": False,
+ "as_csv": "",
+ "with_headers": False,
+ },
+ headers=api_headers,
+ )
+
+ assert response.status_code == 200, response.content
+ assert response.json()["success"] is True
diff --git a/archivebox/tests/test_api_v1_cli_update.py b/archivebox/tests/test_api_v1_cli_update.py
new file mode 100644
index 00000000..0892e51a
--- /dev/null
+++ b/archivebox/tests/test_api_v1_cli_update.py
@@ -0,0 +1,137 @@
+import json
+
+import pytest
+
+from .conftest import (
+ api_client_request,
+ cli_env,
+ create_admin_and_token,
+ get_free_port,
+ init_archive,
+ live_api_request,
+ parse_jsonl_output,
+ run_archivebox_cmd,
+ start_archivebox_server,
+ stop_server,
+ wait_for_live_api,
+)
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_cli_update_api_accepts_empty_json_without_traceback(client, tmp_path, api_headers):
+ init_archive(tmp_path)
+
+ try:
+ response = api_client_request(
+ client,
+ "post",
+ "/api/v1/cli/update",
+ payload={},
+ headers=api_headers,
+ )
+ finally:
+ stop_server(tmp_path)
+
+ assert response.status_code == 200, response.content
+ payload = response.json()
+ assert payload["success"] is True
+ assert "Traceback" not in response.content.decode()
+
+
+@pytest.mark.timeout(180)
+def test_cli_update_api_supports_all_snapshot_list_filters_with_real_rows(tmp_path):
+ env = cli_env(disable_extractors=True)
+ init_archive(tmp_path)
+
+ records = [
+ {
+ "type": "Snapshot",
+ "url": "https://alpha.example.com/articles/needle",
+ "title": "Needle Alpha",
+ "tags": "api-keep",
+ "timestamp": "1700000000",
+ "bookmarked_at": "2023-11-14T22:13:20+00:00",
+ },
+ {
+ "type": "Snapshot",
+ "url": "https://beta.example.org/posts/haystack",
+ "title": "Haystack Beta",
+ "tags": "api-other",
+ "timestamp": "1710000000",
+ "bookmarked_at": "2024-03-09T16:00:00+00:00",
+ },
+ {
+ "type": "Snapshot",
+ "url": "https://docs.archivebox.io/manual",
+ "title": "Manual Gamma",
+ "tags": "api-docs",
+ "timestamp": "1720000000",
+ "bookmarked_at": "2024-07-03T09:46:40+00:00",
+ },
+ ]
+ stdin = "\n".join(json.dumps(record) for record in records) + "\n"
+ run_archivebox_cmd(["snapshot", "create"], cwd=tmp_path, stdin=stdin, env=env, check=True)
+
+ port = get_free_port()
+ env = {
+ **cli_env(port=port, server=True, PUBLIC_INDEX="True"),
+ **env,
+ }
+ api_token = create_admin_and_token(tmp_path)
+
+ def assert_update_filter(label, body, expected_records):
+ response = live_api_request(
+ port,
+ "post",
+ "/api/v1/cli/update",
+ api_token=api_token,
+ json={**body, "batch_size": 100},
+ timeout=30,
+ )
+ assert response.status_code == 200, f"{label}: {response.text}"
+ assert "Traceback" not in response.text
+ payload = response.json()
+ assert payload["success"] is True, label
+ expected_ids = {record["id"] for record in expected_records}
+ assert set(payload["result"]["snapshot_ids"]) == expected_ids, label
+ assert payload["result"]["matched_count"] == len(expected_ids), label
+
+ try:
+ start_archivebox_server(tmp_path, env=env, port=port)
+ wait_for_live_api(port)
+ list_result = run_archivebox_cmd(["snapshot", "list", "--sort", "timestamp"], cwd=tmp_path, env=env, check=True)
+ snapshots = {record["url"]: record for record in parse_jsonl_output(list_result.stdout) if record.get("type") == "Snapshot"}
+ alpha = snapshots["https://alpha.example.com/articles/needle"]
+ beta = snapshots["https://beta.example.org/posts/haystack"]
+ gamma = snapshots["https://docs.archivebox.io/manual"]
+ status_result = run_archivebox_cmd(
+ ["snapshot", "list", "--status", alpha["status"]],
+ cwd=tmp_path,
+ env=env,
+ check=True,
+ )
+ status_records = [record for record in parse_jsonl_output(status_result.stdout) if record.get("type") == "Snapshot"]
+
+ cases = [
+ ("status", {"status": alpha["status"]}, status_records),
+ ("filter_type exact", {"filter_type": "exact", "filter_patterns": [alpha["url"]]}, [alpha]),
+ ("filter_type substring", {"filter_type": "substring", "filter_patterns": ["needle"]}, [alpha]),
+ ("filter_type regex", {"filter_type": "regex", "filter_patterns": [r"alpha\.example\.com/.+needle"]}, [alpha]),
+ ("filter_type domain", {"filter_type": "domain", "filter_patterns": ["alpha.example.com"]}, [alpha]),
+ ("filter_type tag", {"filter_type": "tag", "filter_patterns": ["api-keep"]}, [alpha]),
+ ("filter_type timestamp", {"filter_type": "timestamp", "filter_patterns": [alpha["timestamp"]]}, [alpha]),
+ ("url__icontains", {"url__icontains": "needle"}, [alpha]),
+ ("url__istartswith", {"url__istartswith": "https://alpha.example.com"}, [alpha]),
+ ("tag", {"tag": "api-keep"}, [alpha]),
+ ("crawl_id", {"crawl_id": alpha["crawl_id"]}, [alpha]),
+ ("limit and sort", {"limit": 1, "sort": "timestamp"}, [alpha]),
+ ("search", {"search": "meta", "filter_patterns": ["Needle Alpha"]}, [alpha]),
+ ("before", {"before": 1715000000}, [alpha, beta]),
+ ("after", {"after": 1715000000}, [gamma]),
+ ("resume", {"resume": beta["timestamp"]}, [alpha, beta]),
+ ]
+ for label, body, expected_records in cases:
+ assert_update_filter(label, body, expected_records)
+ finally:
+ stop_server(tmp_path)
diff --git a/archivebox/tests/test_api_v1_cli_workflow_add_search_update_remove.py b/archivebox/tests/test_api_v1_cli_workflow_add_search_update_remove.py
new file mode 100644
index 00000000..1fd7eec3
--- /dev/null
+++ b/archivebox/tests/test_api_v1_cli_workflow_add_search_update_remove.py
@@ -0,0 +1,160 @@
+import time
+
+import pytest
+
+from archivebox.core.models import Snapshot
+from archivebox.crawls.models import Crawl
+from archivebox.tests.test_orm_helpers import use_archivebox_db
+from .conftest import (
+ cli_env,
+ create_admin_and_token,
+ get_free_port,
+ init_archive,
+ live_api_request,
+ start_archivebox_server,
+ stop_server,
+ wait_for_live_api,
+)
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+@pytest.mark.timeout(180)
+def test_cli_api_add_search_update_remove_over_server(tmp_path):
+ init_archive(tmp_path)
+
+ port = get_free_port()
+ env = cli_env(port=port, server=True, PUBLIC_INDEX="True")
+ api_token = create_admin_and_token(tmp_path)
+ target_url = "https://example.com/"
+
+ try:
+ start_archivebox_server(tmp_path, env=env, port=port)
+ wait_for_live_api(port)
+
+ add_response = live_api_request(
+ port,
+ "post",
+ "/api/v1/cli/add",
+ api_token=api_token,
+ json={
+ "urls": [target_url],
+ "tag": "api-cli",
+ "depth": 0,
+ "parser": "url_list",
+ "plugins": "wget",
+ "update": True,
+ "overwrite": False,
+ "index_only": True,
+ },
+ timeout=10,
+ )
+ assert add_response.status_code == 200, add_response.text
+ add_payload = add_response.json()
+ assert add_payload["success"] is True
+ assert add_payload["result_format"] == "json"
+ assert add_payload["result"]["num_snapshots"] == 0
+ crawl_id = add_payload["result"]["crawl_id"]
+ assert add_payload["result"]["snapshot_ids"] == []
+ stop_server(tmp_path)
+ from archivebox.services.runner import run_crawl
+
+ with use_archivebox_db(tmp_path):
+ run_crawl(crawl_id, show_progress=False)
+ start_archivebox_server(tmp_path, env=env, port=port)
+ wait_for_live_api(port)
+
+ deadline = time.time() + 180
+ snapshot_id = None
+ snapshot_status = None
+ while time.time() < deadline:
+ with use_archivebox_db(tmp_path):
+ snapshot = Snapshot.objects.filter(crawl_id=crawl_id, url=target_url).first()
+ if snapshot is not None:
+ snapshot_id = str(snapshot.id)
+ snapshot_status = snapshot.status
+ break
+ time.sleep(1)
+ assert snapshot_id is not None
+ assert snapshot_status is not None
+
+ search_response = live_api_request(
+ port,
+ "post",
+ "/api/v1/cli/search",
+ api_token=api_token,
+ json={
+ "filter_patterns": [target_url],
+ "filter_type": "exact",
+ "status": snapshot_status,
+ "sort": "bookmarked_at",
+ "as_json": True,
+ "as_html": False,
+ "as_csv": "",
+ "with_headers": False,
+ },
+ timeout=10,
+ )
+ assert search_response.status_code == 200, search_response.text
+ search_payload = search_response.json()
+ assert search_payload["success"] is True
+ assert search_payload["result_format"] == "json"
+ assert any(item["url"] == target_url for item in search_payload["result"])
+
+ update_response = live_api_request(
+ port,
+ "post",
+ "/api/v1/cli/update",
+ api_token=api_token,
+ json={
+ "resume": None,
+ "after": 0,
+ "before": 4102444800,
+ "filter_type": "exact",
+ "filter_patterns": [target_url],
+ "batch_size": 1,
+ "continuous": False,
+ },
+ timeout=20,
+ )
+ assert update_response.status_code == 200, update_response.text
+ assert update_response.json()["success"] is True
+ stop_server(tmp_path)
+ start_archivebox_server(tmp_path, env=env, port=port)
+ wait_for_live_api(port)
+
+ with use_archivebox_db(tmp_path):
+ crawl_obj = Crawl.objects.filter(pk=crawl_id).first()
+ crawl = (crawl_obj.max_depth, crawl_obj.tags_str, crawl_obj.config) if crawl_obj else None
+
+ assert crawl is not None
+ assert crawl[0] == 0
+ assert crawl[1] == "api-cli"
+ assert crawl[2]["INDEX_ONLY"] is True
+
+ remove_response = live_api_request(
+ port,
+ "post",
+ "/api/v1/cli/remove",
+ api_token=api_token,
+ json={
+ "delete": True,
+ "after": 0,
+ "before": 4102444800,
+ "filter_type": "exact",
+ "filter_patterns": [target_url],
+ },
+ timeout=20,
+ )
+ assert remove_response.status_code == 200, remove_response.text
+ remove_payload = remove_response.json()
+ assert remove_payload["success"] is True
+ assert remove_payload["result"]["removed_count"] == 1
+ assert snapshot_id in remove_payload["result"]["removed_snapshot_ids"]
+
+ with use_archivebox_db(tmp_path):
+ snapshot_count = Snapshot.objects.filter(pk=snapshot_id).count()
+
+ assert snapshot_count == 0
+ finally:
+ stop_server(tmp_path)
diff --git a/archivebox/tests/test_api_v1_core_any_id.py b/archivebox/tests/test_api_v1_core_any_id.py
new file mode 100644
index 00000000..da4efe39
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_any_id.py
@@ -0,0 +1,16 @@
+import pytest
+
+from archivebox.core.models import Snapshot
+from archivebox.crawls.models import Crawl
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(urls="https://example.com/any", created_by=api_admin_user)
+ snapshot = Snapshot.objects.create(url="https://example.com/any", crawl=crawl)
+
+ response = client.get(f"/api/v1/core/any/{snapshot.id}", follow=True, **api_headers)
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_core_archiveresult_archiveresult_id.py b/archivebox/tests/test_api_v1_core_archiveresult_archiveresult_id.py
new file mode 100644
index 00000000..43285a2c
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_archiveresult_archiveresult_id.py
@@ -0,0 +1,69 @@
+import pytest
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
+
+from archivebox.core.models import ArchiveResult, Snapshot
+from archivebox.crawls.models import Crawl
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(urls="https://example.com/archiveresult-detail", created_by=api_admin_user)
+ snapshot = Snapshot.objects.create(url="https://example.com/archiveresult-detail", crawl=crawl)
+ result = ArchiveResult.objects.create(
+ snapshot=snapshot,
+ plugin="api-basic",
+ hook_name="on_Snapshot__api_basic",
+ status=ArchiveResult.StatusChoices.SUCCEEDED,
+ output_str="ok",
+ )
+
+ response = client.get(f"/api/v1/core/archiveresult/{result.id}", **api_headers)
+
+ assert response.status_code == 200, response.content
+
+
+def test_archiveresult_patch_upload_finalizes_queued_result(client, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(
+ urls="https://example.com",
+ created_by=api_admin_user,
+ status=Crawl.StatusChoices.SEALED,
+ retry_at=None,
+ )
+ snapshot = Snapshot.objects.create(
+ url="https://example.com/upload-patch",
+ crawl=crawl,
+ status=Snapshot.StatusChoices.SEALED,
+ retry_at=None,
+ )
+ result = ArchiveResult.objects.create(
+ snapshot=snapshot,
+ plugin="dom",
+ hook_name="on_Snapshot__archivebox_browser_extension_upload",
+ status=ArchiveResult.StatusChoices.QUEUED,
+ )
+
+ response = client.generic(
+ "PATCH",
+ f"/api/v1/core/archiveresult/{result.id}",
+ encode_multipart(
+ BOUNDARY,
+ {
+ "files": SimpleUploadedFile("output.html", b"uploaded", content_type="text/html"),
+ "output_paths": "output.html",
+ "output_str": "output.html",
+ },
+ ),
+ content_type=MULTIPART_CONTENT,
+ **api_headers,
+ )
+ assert response.status_code == 200, response.content
+
+ result.refresh_from_db()
+ snapshot.refresh_from_db()
+ assert result.status == ArchiveResult.StatusChoices.SUCCEEDED
+ assert result.output_str == "output.html"
+ assert snapshot.status == Snapshot.StatusChoices.SEALED
+ assert snapshot.retry_at is not None
diff --git a/archivebox/tests/test_api_v1_core_archiveresults.py b/archivebox/tests/test_api_v1_core_archiveresults.py
new file mode 100644
index 00000000..09c3af9e
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_archiveresults.py
@@ -0,0 +1,170 @@
+from datetime import timedelta
+
+import pytest
+from django.db import connection
+from django.test.utils import CaptureQueriesContext
+from django.utils import timezone
+
+from archivebox.core.models import ArchiveResult, Snapshot
+from archivebox.crawls.models import Crawl
+from archivebox.tests.conftest import api_client_request
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_archiveresult_upload_api_queues_snapshot_maintenance_without_finalizing(client, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(
+ urls="https://example.com",
+ created_by=api_admin_user,
+ status=Crawl.StatusChoices.STARTED,
+ retry_at=timezone.now(),
+ )
+ active_retry_at = timezone.now() + timedelta(minutes=5)
+ active_snapshot = Snapshot.objects.create(
+ url="https://example.com/active",
+ crawl=crawl,
+ status=Snapshot.StatusChoices.STARTED,
+ retry_at=active_retry_at,
+ )
+ sealed_snapshot = Snapshot.objects.create(
+ url="https://example.com/sealed",
+ crawl=crawl,
+ status=Snapshot.StatusChoices.SEALED,
+ retry_at=None,
+ )
+
+ active_response = client.post(
+ "/api/v1/core/archiveresults",
+ {
+ "snapshot_id": str(active_snapshot.id),
+ "plugin": "chrome_extension_dom",
+ "hook_name": "on_Snapshot__archivebox_browser_extension_upload",
+ "status": ArchiveResult.StatusChoices.SUCCEEDED,
+ "output_str": "uploaded active snapshot output",
+ },
+ **api_headers,
+ )
+ assert active_response.status_code == 200, active_response.content
+ active_snapshot.refresh_from_db()
+ assert active_snapshot.status == Snapshot.StatusChoices.STARTED
+ assert active_snapshot.retry_at == active_retry_at
+ assert active_snapshot.downloaded_at is not None
+
+ sealed_response = client.post(
+ "/api/v1/core/archiveresults",
+ {
+ "snapshot_id": str(sealed_snapshot.id),
+ "plugin": "chrome_extension_mhtml",
+ "hook_name": "on_Snapshot__archivebox_browser_extension_upload",
+ "status": ArchiveResult.StatusChoices.SUCCEEDED,
+ "output_str": "uploaded sealed snapshot output",
+ },
+ **api_headers,
+ )
+ assert sealed_response.status_code == 200, sealed_response.content
+ sealed_snapshot.refresh_from_db()
+ assert sealed_snapshot.status == Snapshot.StatusChoices.SEALED
+ assert sealed_snapshot.retry_at is not None
+ assert sealed_snapshot.downloaded_at is not None
+
+
+def test_archiveresults_api_limit_uses_exact_count_without_full_row_distinct(client, api_headers):
+ snapshot_response = api_client_request(
+ client,
+ "post",
+ "/api/v1/core/snapshots",
+ payload={
+ "url": "https://example.com/archive-result-pagination",
+ "title": "ArchiveResult pagination",
+ "status": Snapshot.StatusChoices.QUEUED,
+ },
+ headers=api_headers,
+ )
+ assert snapshot_response.status_code == 200, snapshot_response.content
+ snapshot_id = snapshot_response.json()["id"]
+
+ for plugin_name in ("dom", "screenshot"):
+ result_response = client.post(
+ "/api/v1/core/archiveresults",
+ {
+ "snapshot_id": snapshot_id,
+ "plugin": plugin_name,
+ "hook_name": f"on_Snapshot__test_{plugin_name}",
+ "status": ArchiveResult.StatusChoices.SUCCEEDED,
+ "output_str": f"{plugin_name} output",
+ },
+ **api_headers,
+ )
+ assert result_response.status_code == 200, result_response.content
+
+ total_archiveresults = ArchiveResult.objects.count()
+ with CaptureQueriesContext(connection) as captured_queries:
+ response = client.get(
+ "/api/v1/core/archiveresults?limit=1",
+ **api_headers,
+ )
+
+ assert response.status_code == 200, response.content
+ payload = response.json()
+ assert payload["count"] == total_archiveresults
+ assert payload["total_items"] == total_archiveresults
+ assert payload["limit"] == 1
+ assert payload["num_items"] == 1
+
+ count_queries = [
+ query["sql"] for query in captured_queries if "COUNT" in query["sql"].upper() and '"core_archiveresult"' in query["sql"]
+ ]
+ assert count_queries
+ assert not any("SELECT DISTINCT" in query.upper() for query in count_queries), count_queries
+
+
+def test_archiveresults_api_join_filters_count_distinct_primary_keys(client, api_headers):
+ snapshot_response = api_client_request(
+ client,
+ "post",
+ "/api/v1/core/snapshots",
+ payload={
+ "url": "https://example.com/archive-result-tag-pagination",
+ "title": "ArchiveResult tag pagination",
+ "tags": ["api-tag-pagination-one", "api-tag-pagination-two"],
+ "status": Snapshot.StatusChoices.QUEUED,
+ },
+ headers=api_headers,
+ )
+ assert snapshot_response.status_code == 200, snapshot_response.content
+ snapshot_id = snapshot_response.json()["id"]
+
+ result_response = client.post(
+ "/api/v1/core/archiveresults",
+ {
+ "snapshot_id": snapshot_id,
+ "plugin": "dom",
+ "hook_name": "on_Snapshot__test_tag_pagination",
+ "status": ArchiveResult.StatusChoices.SUCCEEDED,
+ "output_str": "tag pagination output",
+ },
+ **api_headers,
+ )
+ assert result_response.status_code == 200, result_response.content
+
+ with CaptureQueriesContext(connection) as captured_queries:
+ response = client.get(
+ "/api/v1/core/archiveresults?search=api-tag-pagination&limit=1",
+ **api_headers,
+ )
+
+ assert response.status_code == 200, response.content
+ payload = response.json()
+ assert payload["count"] == 1
+ assert payload["total_items"] == 1
+ assert payload["num_items"] == 1
+ assert [item["id"] for item in payload["items"]] == [result_response.json()["id"]]
+
+ count_queries = [
+ query["sql"] for query in captured_queries if "COUNT" in query["sql"].upper() and '"core_archiveresult"' in query["sql"]
+ ]
+ assert count_queries
+ assert any("SELECT DISTINCT" in query.upper() for query in count_queries), count_queries
+ assert not any('"core_archiveresult"."output_files" AS' in query for query in count_queries), count_queries
+ assert not any('"core_archiveresult"."notes" AS' in query for query in count_queries), count_queries
diff --git a/archivebox/tests/test_api_v1_core_snapshot_snapshot_id.py b/archivebox/tests/test_api_v1_core_snapshot_snapshot_id.py
new file mode 100644
index 00000000..4d3e75a2
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_snapshot_snapshot_id.py
@@ -0,0 +1,497 @@
+import json
+import time
+from pathlib import Path
+
+import pytest
+from django.utils import timezone
+
+from archivebox.core.models import ArchiveResult, Snapshot
+from archivebox.crawls.models import Crawl
+from archivebox.tests.conftest import run_archivebox_cmd
+from archivebox.tests.test_orm_helpers import use_archivebox_db
+from archivebox.workers.models import RETRY_AT_MAX
+
+from .conftest import (
+ api_client_request,
+ cli_env,
+ create_admin_and_token,
+ get_crawl_runtime_state,
+ get_free_port,
+ init_archive,
+ live_api_request,
+ start_archivebox_server,
+ stop_server,
+ wait_for_live_api,
+ wait_for_snapshot_capture,
+)
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def _seed_archiveresult(
+ snapshot: Snapshot,
+ *,
+ plugin: str,
+ hook_name: str,
+ status: str,
+ output_text: str = "",
+ output_path: str | None = None,
+) -> ArchiveResult:
+ output_files = {}
+ output_size = 0
+ output_mimetypes = ""
+ if output_path is not None:
+ output_bytes = output_text.encode()
+ absolute_path = Path(snapshot.output_dir) / output_path
+ absolute_path.parent.mkdir(parents=True, exist_ok=True)
+ absolute_path.write_bytes(output_bytes)
+ output_size = len(output_bytes)
+ output_mimetypes = "text/plain"
+ output_files[output_path] = {
+ "extension": Path(output_path).suffix.lstrip("."),
+ "mimetype": "text/plain",
+ "size": output_size,
+ }
+
+ now = timezone.now()
+ return ArchiveResult.objects.create(
+ snapshot=snapshot,
+ plugin=plugin,
+ hook_name=hook_name,
+ status=status,
+ output_str=output_path or output_text,
+ output_files=output_files,
+ output_size=output_size,
+ output_mimetypes=output_mimetypes,
+ start_ts=now if status != ArchiveResult.StatusChoices.QUEUED else None,
+ end_ts=now if status in ArchiveResult.FINAL_STATES else None,
+ )
+
+
+def _snapshot_hook_name(plugin_name: str) -> str:
+ from abx_dl.models import discover_plugins
+
+ plugin = discover_plugins().get(plugin_name)
+ assert plugin is not None, f"missing test plugin {plugin_name}"
+ hooks = plugin.filter_hooks("Snapshot")
+ assert hooks, f"missing Snapshot hooks for {plugin_name}"
+ return hooks[0].name
+
+
+def _snapshot_state(cwd: Path, url: str) -> dict[str, object]:
+ with use_archivebox_db(cwd):
+ snapshot = Snapshot.objects.select_related("crawl", "crawl__created_by").get(url=url)
+ snapshot_dir = Path(snapshot.output_dir)
+ crawl_dir = Path(snapshot.crawl.output_dir)
+ crawl_link = crawl_dir / "snapshots" / Snapshot.extract_domain_from_url(snapshot.url) / str(snapshot.id)
+ results = list(
+ ArchiveResult.objects.filter(snapshot=snapshot)
+ .order_by("plugin", "hook_name")
+ .values("plugin", "hook_name", "status", "output_files", "output_size"),
+ )
+ return {
+ "id": str(snapshot.id),
+ "crawl_id": str(snapshot.crawl_id),
+ "status": snapshot.status,
+ "retry_at": snapshot.retry_at,
+ "downloaded_at": snapshot.downloaded_at,
+ "output_size": snapshot.output_size,
+ "snapshot_dir": snapshot_dir,
+ "crawl_dir": crawl_dir,
+ "crawl_link": crawl_link,
+ "results": results,
+ }
+
+
+def _paused_snapshot_state(cwd: Path, snapshot_id: str) -> dict[str, object]:
+ with use_archivebox_db(cwd):
+ snapshot = Snapshot.objects.select_related("crawl").get(id=snapshot_id)
+ succeeded_results = ArchiveResult.objects.filter(snapshot=snapshot, status=ArchiveResult.StatusChoices.SUCCEEDED).count()
+ return {
+ "status": snapshot.status,
+ "retry_at": snapshot.retry_at,
+ "crawl_status": snapshot.crawl.status,
+ "succeeded_results": succeeded_results,
+ "snapshot_dir": Path(snapshot.output_dir),
+ }
+
+
+def _wait_for_paused_scheduler_marker(cwd: Path, snapshot_id: str, timeout: int = 60) -> dict[str, object]:
+ deadline = time.time() + timeout
+ last_state: dict[str, object] = {}
+ while time.time() < deadline:
+ last_state = _paused_snapshot_state(cwd, snapshot_id)
+ if last_state["status"] == Snapshot.StatusChoices.PAUSED and last_state["retry_at"] == RETRY_AT_MAX:
+ return last_state
+ if last_state["status"] == Snapshot.StatusChoices.SEALED:
+ return last_state
+ time.sleep(1)
+ raise AssertionError(f"paused snapshot did not settle back to retry_at=MAX: {last_state}")
+
+
+def _wait_for_crawl_snapshot_rows(cwd: Path, crawl_id: str, timeout: int = 45) -> dict[str, object]:
+ deadline = time.time() + timeout
+ latest_state: dict[str, object] | None = None
+ while time.time() < deadline:
+ latest_state = get_crawl_runtime_state(cwd, crawl_id)
+ if latest_state["snapshots"]:
+ return latest_state
+ time.sleep(0.2)
+ raise AssertionError(f"timed out waiting for snapshot rows for crawl {crawl_id}: {latest_state}")
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(urls="https://example.com/snapshot-detail", created_by=api_admin_user)
+ snapshot = Snapshot.objects.create(url="https://example.com/snapshot-detail", crawl=crawl)
+
+ response = client.get(f"/api/v1/core/snapshot/{snapshot.id}", **api_headers)
+
+ assert response.status_code == 200, response.content
+
+
+def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_finished_rows(
+ tmp_path,
+ client,
+ recursive_test_site,
+):
+ init_archive(tmp_path)
+ api_token = create_admin_and_token(tmp_path)
+
+ with use_archivebox_db(tmp_path):
+ create_response = api_client_request(
+ client,
+ "post",
+ "/api/v1/core/snapshots",
+ api_token=api_token,
+ payload={
+ "url": recursive_test_site["root_url"],
+ "depth": 0,
+ "title": "Snapshot pause target",
+ "tags": ["snapshot-pause-e2e"],
+ "status": "queued",
+ },
+ )
+ assert create_response.status_code == 200, create_response.content.decode()
+ snapshot_id = json.loads(create_response.content.decode())["id"]
+ snapshot = Snapshot.objects.get(id=snapshot_id)
+
+ queued_result = _seed_archiveresult(
+ snapshot,
+ plugin="manualqueue",
+ hook_name="on_Snapshot__manual_queue",
+ status=ArchiveResult.StatusChoices.QUEUED,
+ )
+ started_result = _seed_archiveresult(
+ snapshot,
+ plugin="manualstart",
+ hook_name="on_Snapshot__manual_start",
+ status=ArchiveResult.StatusChoices.STARTED,
+ )
+ succeeded_result = _seed_archiveresult(
+ snapshot,
+ plugin="manualdone",
+ hook_name="on_Snapshot__manual_done",
+ status=ArchiveResult.StatusChoices.SUCCEEDED,
+ output_text="finished result should stay finished",
+ output_path="manualdone/final.txt",
+ )
+ failed_result = _seed_archiveresult(
+ snapshot,
+ plugin="manualfail",
+ hook_name="on_Snapshot__manual_fail",
+ status=ArchiveResult.StatusChoices.FAILED,
+ output_text="failed result should stay failed",
+ )
+
+ invalid_response = api_client_request(
+ client,
+ "patch",
+ f"/api/v1/core/snapshot/{snapshot_id}",
+ api_token=api_token,
+ payload={"action": "hold"},
+ )
+ assert invalid_response.status_code == 400
+ snapshot = Snapshot.objects.get(id=snapshot_id)
+ assert snapshot.status == Snapshot.StatusChoices.QUEUED
+
+ pause_response = api_client_request(
+ client,
+ "patch",
+ f"/api/v1/core/snapshot/{snapshot_id}",
+ api_token=api_token,
+ payload={"action": "pause"},
+ )
+ assert pause_response.status_code == 200, pause_response.content.decode()
+ assert json.loads(pause_response.content.decode())["status"] == Snapshot.StatusChoices.PAUSED
+
+ snapshot.refresh_from_db()
+ crawl = Crawl.objects.get(id=snapshot.crawl_id)
+ assert snapshot.status == Snapshot.StatusChoices.PAUSED
+ assert snapshot.retry_at == RETRY_AT_MAX
+ assert crawl.status == Crawl.StatusChoices.QUEUED
+
+ active_rows = {
+ row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[queued_result.id, started_result.id])
+ }
+ assert active_rows == {
+ "manualqueue": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX),
+ "manualstart": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX),
+ }
+
+ finished_rows = {
+ row.plugin: (row.status, row.retry_at, row.output_size)
+ for row in ArchiveResult.objects.filter(id__in=[succeeded_result.id, failed_result.id])
+ }
+ assert finished_rows["manualdone"][0] == ArchiveResult.StatusChoices.SUCCEEDED
+ assert finished_rows["manualdone"][1] is None
+ assert finished_rows["manualdone"][2] == len("finished result should stay finished")
+ assert finished_rows["manualfail"] == (ArchiveResult.StatusChoices.FAILED, None, 0)
+
+ succeeded_row = ArchiveResult.objects.get(id=succeeded_result.id)
+ output_path = Path(snapshot.output_dir) / next(iter(succeeded_row.output_files))
+ assert output_path.read_text() == "finished result should stay finished"
+
+ resume_response = api_client_request(
+ client,
+ "patch",
+ f"/api/v1/core/snapshot/{snapshot_id}",
+ api_token=api_token,
+ payload={"action": "resume"},
+ )
+ assert resume_response.status_code == 200, resume_response.content.decode()
+ assert json.loads(resume_response.content.decode())["status"] == Snapshot.StatusChoices.QUEUED
+
+ snapshot.refresh_from_db()
+ crawl.refresh_from_db()
+ assert snapshot.status == Snapshot.StatusChoices.QUEUED
+ assert snapshot.retry_at is not None
+ assert snapshot.retry_at != RETRY_AT_MAX
+ assert crawl.status == Crawl.StatusChoices.QUEUED
+ assert crawl.retry_at is not None
+ assert crawl.retry_at != RETRY_AT_MAX
+
+ resumed_rows = {
+ row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[queued_result.id, started_result.id])
+ }
+ assert resumed_rows["manualqueue"][0] == ArchiveResult.StatusChoices.QUEUED
+ assert resumed_rows["manualqueue"][1] is not None
+ assert resumed_rows["manualqueue"][1] != RETRY_AT_MAX
+ assert resumed_rows["manualstart"][0] == ArchiveResult.StatusChoices.QUEUED
+ assert resumed_rows["manualstart"][1] is not None
+ assert resumed_rows["manualstart"][1] != RETRY_AT_MAX
+
+ assert ArchiveResult.objects.get(id=succeeded_result.id).status == ArchiveResult.StatusChoices.SUCCEEDED
+ assert ArchiveResult.objects.get(id=failed_result.id).status == ArchiveResult.StatusChoices.FAILED
+ assert output_path.read_text() == "finished result should stay finished"
+
+
+def test_targeted_extract_retries_one_failed_archiveresult_while_snapshot_stays_paused(
+ tmp_path,
+ client,
+ recursive_test_site,
+):
+ init_archive(tmp_path)
+ api_token = create_admin_and_token(tmp_path)
+
+ with use_archivebox_db(tmp_path):
+ snapshot_response = api_client_request(
+ client,
+ "post",
+ "/api/v1/core/snapshots",
+ api_token=api_token,
+ payload={
+ "url": recursive_test_site["root_url"],
+ "depth": 0,
+ "title": "Paused targeted retry",
+ "tags": ["targeted-extract-pause"],
+ "status": "queued",
+ },
+ )
+ assert snapshot_response.status_code == 200, snapshot_response.content.decode()
+ snapshot_id = json.loads(snapshot_response.content.decode())["id"]
+ snapshot = Snapshot.objects.get(id=snapshot_id)
+
+ wget_result = _seed_archiveresult(
+ snapshot,
+ plugin="wget",
+ hook_name=_snapshot_hook_name("wget"),
+ status=ArchiveResult.StatusChoices.FAILED,
+ output_text="initial failure before targeted retry",
+ )
+ unrelated_result = _seed_archiveresult(
+ snapshot,
+ plugin="manualqueue",
+ hook_name="on_Snapshot__manual_queue",
+ status=ArchiveResult.StatusChoices.QUEUED,
+ )
+ finished_result = _seed_archiveresult(
+ snapshot,
+ plugin="manualdone",
+ hook_name="on_Snapshot__manual_done",
+ status=ArchiveResult.StatusChoices.SUCCEEDED,
+ output_text="finished row must survive targeted retry",
+ output_path="manualdone/targeted.txt",
+ )
+
+ pause_response = api_client_request(
+ client,
+ "patch",
+ f"/api/v1/core/snapshot/{snapshot_id}",
+ api_token=api_token,
+ payload={"action": "pause"},
+ )
+ assert pause_response.status_code == 200, pause_response.content.decode()
+ assert json.loads(pause_response.content.decode())["status"] == Snapshot.StatusChoices.PAUSED
+
+ snapshot = Snapshot.objects.get(id=snapshot_id)
+ assert snapshot.status == Snapshot.StatusChoices.PAUSED
+ assert snapshot.retry_at == RETRY_AT_MAX
+ assert ArchiveResult.objects.get(id=wget_result.id).status == ArchiveResult.StatusChoices.FAILED
+ assert ArchiveResult.objects.get(id=unrelated_result.id).status == ArchiveResult.StatusChoices.PAUSED
+ finished_row = ArchiveResult.objects.get(id=finished_result.id)
+ finished_output_path = Path(snapshot.output_dir) / next(iter(finished_row.output_files))
+ assert finished_output_path.read_text() == "finished row must survive targeted retry"
+
+ env = cli_env(
+ port=get_free_port(),
+ PLUGINS="wget",
+ SAVE_WGET="True",
+ WGET_WARC_ENABLED="False",
+ URL_ALLOWLIST=r"127\.0\.0\.1[:/].*",
+ )
+ extract = run_archivebox_cmd(
+ ["extract", str(wget_result.id)],
+ cwd=tmp_path,
+ env=env,
+ timeout=150,
+ )
+ assert extract.returncode == 0, f"STDOUT:\n{extract.stdout}\nSTDERR:\n{extract.stderr}"
+
+ with use_archivebox_db(tmp_path):
+ snapshot = Snapshot.objects.get(id=snapshot_id)
+ assert snapshot.status == Snapshot.StatusChoices.PAUSED
+ assert snapshot.retry_at == RETRY_AT_MAX
+
+ retried_wget = ArchiveResult.objects.get(id=wget_result.id)
+ assert retried_wget.status == ArchiveResult.StatusChoices.SUCCEEDED
+ assert retried_wget.output_size > 0
+ assert retried_wget.output_files
+
+ unrelated = ArchiveResult.objects.get(id=unrelated_result.id)
+ assert unrelated.status == ArchiveResult.StatusChoices.PAUSED
+ assert unrelated.retry_at == RETRY_AT_MAX
+
+ finished = ArchiveResult.objects.get(id=finished_result.id)
+ assert finished.status == ArchiveResult.StatusChoices.SUCCEEDED
+ assert finished.retry_at is None
+ assert finished_output_path.read_text() == "finished row must survive targeted retry"
+
+
+@pytest.mark.timeout(240)
+def test_paused_snapshot_survives_server_restart_and_resumes_via_api(tmp_path, recursive_test_site):
+ init_archive(tmp_path)
+
+ port = get_free_port()
+ env = cli_env(port=port, server=True, PLUGINS="wget", SAVE_WGET="True")
+ api_token = create_admin_and_token(tmp_path)
+
+ try:
+ start_archivebox_server(tmp_path, env=env, port=port)
+ wait_for_live_api(port)
+
+ crawl_response = live_api_request(
+ port,
+ "post",
+ "/api/v1/crawls/crawls",
+ api_token=api_token,
+ json={
+ "urls": [recursive_test_site["root_url"]],
+ "max_depth": 0,
+ "tags": ["snapshot-pause-restart-e2e"],
+ "config": {"PLUGINS": "wget", "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*"},
+ },
+ timeout=10,
+ )
+ assert crawl_response.status_code == 200, crawl_response.text
+ crawl_id = crawl_response.json()["id"]
+ crawl_state = _wait_for_crawl_snapshot_rows(tmp_path, crawl_id)
+ snapshot_id = crawl_state["snapshots"][0]["id"]
+
+ pause_response = live_api_request(
+ port,
+ "patch",
+ f"/api/v1/crawls/crawl/{crawl_id}",
+ api_token=api_token,
+ json={"action": "pause"},
+ timeout=10,
+ )
+ assert pause_response.status_code == 200, pause_response.text
+
+ current_state = _paused_snapshot_state(tmp_path, snapshot_id)
+ if current_state["status"] == Snapshot.StatusChoices.SEALED:
+ assert current_state["succeeded_results"] > 0
+ return
+
+ paused_state = _wait_for_paused_scheduler_marker(tmp_path, snapshot_id)
+ if paused_state["status"] == Snapshot.StatusChoices.SEALED:
+ assert paused_state["succeeded_results"] > 0
+ return
+ assert paused_state["succeeded_results"] == 0
+ assert not list((paused_state["snapshot_dir"] / "wget").rglob("*.html"))
+
+ stop_server(tmp_path)
+ start_archivebox_server(tmp_path, env=env, port=port)
+ wait_for_live_api(port)
+
+ restarted_state = _wait_for_paused_scheduler_marker(tmp_path, snapshot_id)
+ assert restarted_state["status"] == Snapshot.StatusChoices.PAUSED
+ assert restarted_state["succeeded_results"] == 0
+
+ resume_response = live_api_request(
+ port,
+ "patch",
+ f"/api/v1/core/snapshot/{snapshot_id}",
+ api_token=api_token,
+ json={"action": "resume"},
+ timeout=10,
+ )
+ assert resume_response.status_code == 200, resume_response.text
+ assert resume_response.json()["status"] == Snapshot.StatusChoices.QUEUED
+
+ captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180)
+ assert "Root" in captured_text
+ assert "About" in captured_text
+
+ final_state = _snapshot_state(tmp_path, recursive_test_site["root_url"])
+ assert final_state["status"] == Snapshot.StatusChoices.SEALED
+ assert final_state["downloaded_at"] is not None
+ assert any(
+ result["plugin"] == "wget" and result["status"] == ArchiveResult.StatusChoices.SUCCEEDED for result in final_state["results"]
+ )
+ finally:
+ stop_server(tmp_path)
+
+
+def test_rest_snapshot_delete_removes_output_dir(client, api_headers):
+ url = "https://example.com/delete-path-snapshot"
+
+ response = api_client_request(
+ client,
+ "post",
+ "/api/v1/core/snapshots",
+ payload={"url": url, "depth": 0, "status": Snapshot.StatusChoices.QUEUED},
+ headers=api_headers,
+ )
+ assert response.status_code == 200, response.content.decode()
+
+ snapshot = Snapshot.objects.get(url=url)
+ snapshot_dir = Path(snapshot.output_dir)
+ snapshot_dir.mkdir(parents=True, exist_ok=True)
+ (snapshot_dir / "delete-path-test.txt").write_text("snapshot output")
+ assert snapshot_dir.exists()
+
+ response = client.delete(f"/api/v1/core/snapshot/{snapshot.id}", **api_headers)
+ assert response.status_code == 200, response.content.decode()
+ assert not Snapshot.objects.filter(pk=snapshot.pk).exists()
+ assert not snapshot_dir.exists()
diff --git a/archivebox/tests/test_api_v1_core_snapshots.py b/archivebox/tests/test_api_v1_core_snapshots.py
new file mode 100644
index 00000000..54edfa92
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_snapshots.py
@@ -0,0 +1,46 @@
+import pytest
+
+from archivebox.core.models import Snapshot
+from archivebox.crawls.models import Crawl
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_snapshots_api_filters_status_column_and_rejects_legacy_status(client, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(
+ urls="https://example.com",
+ created_by=api_admin_user,
+ status=Crawl.StatusChoices.SEALED,
+ retry_at=None,
+ )
+ Snapshot.objects.create(
+ url="https://example.com/api-status-queued",
+ crawl=crawl,
+ status=Snapshot.StatusChoices.QUEUED,
+ )
+ sealed_snapshot = Snapshot.objects.create(
+ url="https://example.com/api-status-sealed",
+ crawl=crawl,
+ status=Snapshot.StatusChoices.SEALED,
+ retry_at=None,
+ )
+
+ response = client.get(
+ "/api/v1/core/snapshots",
+ {"status": "sealed"},
+ **api_headers,
+ )
+ assert response.status_code == 200, response.content
+ payload = response.json()
+ items = payload["items"] if isinstance(payload, dict) and "items" in payload else payload
+ assert [item["id"] for item in items] == [str(sealed_snapshot.id)]
+ assert [item["status"] for item in items] == ["sealed"]
+
+ legacy_response = client.get(
+ "/api/v1/core/snapshots",
+ {"status": "unarchived"},
+ **api_headers,
+ )
+ assert legacy_response.status_code == 400
+ assert "Invalid snapshot status" in legacy_response.content.decode()
diff --git a/archivebox/tests/test_api_v1_core_snapshots_rss.py b/archivebox/tests/test_api_v1_core_snapshots_rss.py
new file mode 100644
index 00000000..c5f2d1a5
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_snapshots_rss.py
@@ -0,0 +1,115 @@
+from datetime import datetime
+from typing import cast
+
+import pytest
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import UserManager
+from django.utils import timezone
+
+
+pytestmark = pytest.mark.django_db
+
+
+User = get_user_model()
+ADMIN_HOST = "admin.archivebox.localhost:8000"
+
+
+@pytest.fixture
+def other_user(db):
+ return cast(UserManager, User.objects).create_user(
+ username="rssother",
+ email="rssother@test.com",
+ password="testpassword",
+ )
+
+
+def make_snapshot(*, user, url: str, title: str, bookmarked_at: datetime):
+ from archivebox.core.models import Snapshot
+ from archivebox.crawls.models import Crawl
+
+ crawl = Crawl.objects.create(urls=url, created_by=user)
+ snapshot = Snapshot.objects.create(
+ url=url,
+ title=title,
+ crawl=crawl,
+ bookmarked_at=bookmarked_at,
+ )
+ return crawl, snapshot
+
+
+def test_snapshots_rss_filters_by_user_and_orders_newest_first(client, api_token, api_admin_user, other_user):
+ from archivebox.core.models import Tag
+
+ older_at = timezone.make_aware(datetime(2026, 5, 22, 8, 0, 0))
+ newer_at = timezone.make_aware(datetime(2026, 5, 23, 8, 0, 0))
+ _crawl, older_snapshot = make_snapshot(
+ user=api_admin_user,
+ url="https://example.com/rss-older",
+ title="Older & Escaped",
+ bookmarked_at=older_at,
+ )
+ make_snapshot(
+ user=api_admin_user,
+ url="https://example.com/rss-newer",
+ title="Newer Snapshot",
+ bookmarked_at=newer_at,
+ )
+ make_snapshot(
+ user=other_user,
+ url="https://example.com/rss-other-user",
+ title="Other User",
+ bookmarked_at=timezone.make_aware(datetime(2026, 5, 23, 9, 0, 0)),
+ )
+ older_snapshot.tags.add(Tag.objects.create(name="rss-tag", created_by=api_admin_user))
+
+ response = client.get(
+ "/api/v1/core/snapshots.rss",
+ {"created_by": api_admin_user.username, "limit": 50, "api_key": api_token.token},
+ HTTP_HOST=ADMIN_HOST,
+ )
+
+ assert response.status_code == 200
+ assert response["Content-Type"].startswith("application/rss+xml")
+ body = response.content.decode()
+ assert 'rss-tag" in body
+ assert "rss-other-user" not in body
+ assert body.index("rss-newer") < body.index("rss-older")
+
+
+def test_snapshots_rss_supports_before_yyyymmdd_and_limit(client, api_token, api_admin_user):
+ make_snapshot(
+ user=api_admin_user,
+ url="https://example.com/rss-before-too-new",
+ title="Too New",
+ bookmarked_at=timezone.make_aware(datetime(2026, 5, 24, 8, 0, 0)),
+ )
+ make_snapshot(
+ user=api_admin_user,
+ url="https://example.com/rss-before-keep-one",
+ title="Keep One",
+ bookmarked_at=timezone.make_aware(datetime(2026, 5, 23, 12, 0, 0)),
+ )
+ make_snapshot(
+ user=api_admin_user,
+ url="https://example.com/rss-before-keep-two",
+ title="Keep Two",
+ bookmarked_at=timezone.make_aware(datetime(2026, 5, 22, 12, 0, 0)),
+ )
+
+ response = client.get(
+ "/api/v1/core/snapshots.rss",
+ {"created_by": str(api_admin_user.pk), "before": "20260523", "limit": 1, "api_key": api_token.token},
+ HTTP_HOST=ADMIN_HOST,
+ )
+
+ assert response.status_code == 200
+ body = response.content.decode()
+ assert "rss-before-too-new" not in body
+ assert "rss-before-keep-one" in body
+ assert "rss-before-keep-two" not in body
diff --git a/archivebox/tests/test_api_v1_core_tag_tag_id.py b/archivebox/tests/test_api_v1_core_tag_tag_id.py
new file mode 100644
index 00000000..2da14fdb
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_tag_tag_id.py
@@ -0,0 +1,15 @@
+import pytest
+
+from archivebox.core.models import Tag
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+@pytest.mark.parametrize("request_method", ("get", "delete"))
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers, request_method):
+ tag = Tag.objects.create(name="api-basic-tag", created_by=api_admin_user)
+
+ response = getattr(client, request_method)(f"/api/v1/core/tag/{tag.id}", **api_headers)
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_core_tag_tag_id_rename.py b/archivebox/tests/test_api_v1_core_tag_tag_id_rename.py
new file mode 100644
index 00000000..c5c65426
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_tag_tag_id_rename.py
@@ -0,0 +1,38 @@
+import pytest
+
+from archivebox.core.models import Tag
+from archivebox.tests.conftest import ADMIN_TEST_HOST, api_client_request
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ tag = Tag.objects.create(name="api-basic-tag", created_by=api_admin_user)
+
+ response = api_client_request(
+ client,
+ "post",
+ f"/api/v1/core/tag/{tag.id}/rename",
+ payload={"name": "api-basic-renamed"},
+ headers=api_headers,
+ )
+
+ assert response.status_code == 200, response.content
+
+
+def test_tag_rename_api_updates_name(client, api_token, tagged_data):
+ tag, _ = tagged_data
+
+ response = api_client_request(
+ client,
+ "post",
+ f"/api/v1/core/tag/{tag.id}/rename?api_key={api_token.token}",
+ payload={"name": "Alpha Archive"},
+ headers={"HTTP_HOST": ADMIN_TEST_HOST},
+ )
+
+ assert response.status_code == 200
+
+ tag.refresh_from_db()
+ assert tag.name == "Alpha Archive"
diff --git a/archivebox/tests/test_api_v1_core_tag_tag_id_snapshots_jsonl.py b/archivebox/tests/test_api_v1_core_tag_tag_id_snapshots_jsonl.py
new file mode 100644
index 00000000..31a9d07e
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_tag_tag_id_snapshots_jsonl.py
@@ -0,0 +1,36 @@
+import pytest
+
+from archivebox.core.models import Snapshot, Tag
+from archivebox.crawls.models import Crawl
+from archivebox.tests.conftest import ADMIN_TEST_HOST
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ tag = Tag.objects.create(name="api-basic-tag", created_by=api_admin_user)
+ crawl = Crawl.objects.create(urls="https://example.com/tag-jsonl-export", created_by=api_admin_user)
+ snapshot = Snapshot.objects.create(url="https://example.com/tag-jsonl-export", crawl=crawl)
+ snapshot.tags.add(tag)
+
+ response = client.get(f"/api/v1/core/tag/{tag.id}/snapshots.jsonl", **api_headers)
+
+ assert response.status_code == 200, response.content
+
+
+def test_tag_snapshots_export_returns_jsonl(client, api_token, tagged_data):
+ tag, _ = tagged_data
+
+ response = client.get(
+ f"/api/v1/core/tag/{tag.id}/snapshots.jsonl",
+ {"api_key": api_token.token},
+ HTTP_HOST=ADMIN_TEST_HOST,
+ )
+
+ assert response.status_code == 200
+ assert response["Content-Type"].startswith("application/x-ndjson")
+ assert f"tag-{tag.slug}-snapshots.jsonl" in response["Content-Disposition"]
+ body = response.content.decode()
+ assert '"type": "Snapshot"' in body
+ assert '"tags": "Alpha Research"' in body
diff --git a/archivebox/tests/test_api_v1_core_tag_tag_id_urls_txt.py b/archivebox/tests/test_api_v1_core_tag_tag_id_urls_txt.py
new file mode 100644
index 00000000..88aaf861
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_tag_tag_id_urls_txt.py
@@ -0,0 +1,35 @@
+import pytest
+
+from archivebox.core.models import Snapshot, Tag
+from archivebox.crawls.models import Crawl
+from archivebox.tests.conftest import ADMIN_TEST_HOST
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ tag = Tag.objects.create(name="api-basic-tag", created_by=api_admin_user)
+ crawl = Crawl.objects.create(urls="https://example.com/tag-url-export", created_by=api_admin_user)
+ snapshot = Snapshot.objects.create(url="https://example.com/tag-url-export", crawl=crawl)
+ snapshot.tags.add(tag)
+
+ response = client.get(f"/api/v1/core/tag/{tag.id}/urls.txt", **api_headers)
+
+ assert response.status_code == 200, response.content
+
+
+def test_tag_urls_export_returns_plain_text_urls(client, api_token, tagged_data):
+ tag, snapshots = tagged_data
+
+ response = client.get(
+ f"/api/v1/core/tag/{tag.id}/urls.txt",
+ {"api_key": api_token.token},
+ HTTP_HOST=ADMIN_TEST_HOST,
+ )
+
+ assert response.status_code == 200
+ assert response["Content-Type"].startswith("text/plain")
+ assert f"tag-{tag.slug}-urls.txt" in response["Content-Disposition"]
+ exported_urls = set(filter(None, response.content.decode().splitlines()))
+ assert exported_urls == {snapshot.url for snapshot in snapshots}
diff --git a/archivebox/tests/test_api_v1_core_tags.py b/archivebox/tests/test_api_v1_core_tags.py
new file mode 100644
index 00000000..fcf7464a
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_tags.py
@@ -0,0 +1,14 @@
+import pytest
+
+from archivebox.core.models import Tag
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ Tag.objects.create(name="api-basic-tag", created_by=api_admin_user)
+
+ response = client.get("/api/v1/core/tags", **api_headers)
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_core_tags_add_to_snapshot.py b/archivebox/tests/test_api_v1_core_tags_add_to_snapshot.py
new file mode 100644
index 00000000..c732ca39
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_tags_add_to_snapshot.py
@@ -0,0 +1,25 @@
+import pytest
+
+from archivebox.core.models import Snapshot, Tag
+from archivebox.crawls.models import Crawl
+from archivebox.tests.conftest import api_client_request
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(urls="https://example.com/tag-add", created_by=api_admin_user)
+ snapshot = Snapshot.objects.create(url="https://example.com/tag-add", crawl=crawl)
+ tag = Tag.objects.create(name="api-basic-add-tag", created_by=api_admin_user)
+
+ response = api_client_request(
+ client,
+ "post",
+ "/api/v1/core/tags/add-to-snapshot/",
+ payload={"snapshot_id": str(snapshot.id), "tag_id": tag.id},
+ headers=api_headers,
+ )
+
+ assert response.status_code == 200, response.content
+ assert response.json()["success"] is True
diff --git a/archivebox/tests/test_api_v1_core_tags_autocomplete.py b/archivebox/tests/test_api_v1_core_tags_autocomplete.py
new file mode 100644
index 00000000..71f242eb
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_tags_autocomplete.py
@@ -0,0 +1,18 @@
+import pytest
+
+from archivebox.core.models import Tag
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_token):
+ Tag.objects.create(name="api-basic-tag", created_by=api_admin_user)
+
+ response = client.get(
+ "/api/v1/core/tags/autocomplete/",
+ {"q": "api-basic", "api_key": api_token.token},
+ HTTP_HOST="api.archivebox.localhost:8000",
+ )
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_core_tags_create.py b/archivebox/tests/test_api_v1_core_tags_create.py
new file mode 100644
index 00000000..c069c515
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_tags_create.py
@@ -0,0 +1,19 @@
+import pytest
+
+from archivebox.tests.conftest import api_client_request
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, api_headers):
+ response = api_client_request(
+ client,
+ "post",
+ "/api/v1/core/tags/create/",
+ payload={"name": "api-basic-created-tag"},
+ headers=api_headers,
+ )
+
+ assert response.status_code == 200, response.content
+ assert response.json()["success"] is True
diff --git a/archivebox/tests/test_api_v1_core_tags_remove_from_snapshot.py b/archivebox/tests/test_api_v1_core_tags_remove_from_snapshot.py
new file mode 100644
index 00000000..2b56770c
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_tags_remove_from_snapshot.py
@@ -0,0 +1,26 @@
+import pytest
+
+from archivebox.core.models import Snapshot, Tag
+from archivebox.crawls.models import Crawl
+from archivebox.tests.conftest import api_client_request
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(urls="https://example.com/tag-remove", created_by=api_admin_user)
+ snapshot = Snapshot.objects.create(url="https://example.com/tag-remove", crawl=crawl)
+ tag = Tag.objects.create(name="api-basic-remove-tag", created_by=api_admin_user)
+ snapshot.tags.add(tag)
+
+ response = api_client_request(
+ client,
+ "post",
+ "/api/v1/core/tags/remove-from-snapshot/",
+ payload={"snapshot_id": str(snapshot.id), "tag_id": tag.id},
+ headers=api_headers,
+ )
+
+ assert response.status_code == 200, response.content
+ assert response.json()["success"] is True
diff --git a/archivebox/tests/test_api_v1_core_tags_search.py b/archivebox/tests/test_api_v1_core_tags_search.py
new file mode 100644
index 00000000..b489e90b
--- /dev/null
+++ b/archivebox/tests/test_api_v1_core_tags_search.py
@@ -0,0 +1,83 @@
+import pytest
+from django.contrib.auth import get_user_model
+from django.utils import timezone
+
+from archivebox.core.models import Snapshot, Tag
+from archivebox.tests.conftest import ADMIN_TEST_HOST
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ Tag.objects.create(name="api-basic-tag", created_by=api_admin_user)
+
+ response = client.get("/api/v1/core/tags/search/", {"q": "api-basic"}, **api_headers)
+
+ assert response.status_code == 200, response.content
+
+
+def test_tag_search_api_returns_card_payload(client, api_token, tagged_data):
+ tag, snapshots = tagged_data
+
+ response = client.get(
+ "/api/v1/core/tags/search/",
+ {"q": "Alpha", "api_key": api_token.token},
+ HTTP_HOST=ADMIN_TEST_HOST,
+ )
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["sort"] == "created_desc"
+ assert payload["created_by"] == ""
+ assert payload["year"] == ""
+ assert payload["has_snapshots"] == "all"
+ assert payload["tags"][0]["id"] == tag.id
+ assert payload["tags"][0]["name"] == "Alpha Research"
+ assert payload["tags"][0]["num_snapshots"] == 2
+ assert payload["tags"][0]["snapshots"] == []
+ assert payload["tags"][0]["export_jsonl_url"].endswith(f"/api/v1/core/tag/{tag.id}/snapshots.jsonl")
+ assert payload["tags"][0]["filter_url"].endswith(f"/admin/core/snapshot/?tags__id__exact={tag.id}")
+ assert {snap.url for snap in snapshots} == {"https://example.com/one", "https://example.com/two"}
+
+
+def test_tag_search_api_respects_sort_and_filters(client, api_token, admin_user, crawl, tagged_data):
+ from datetime import datetime
+
+ other_user = get_user_model().objects.create_user(
+ username="tagother",
+ email="tagother@test.com",
+ password="unused",
+ )
+ tag_with_snapshots = tagged_data[0]
+ empty_tag = Tag.objects.create(name="Zulu Empty", created_by=other_user)
+ alpha_tag = Tag.objects.create(name="Alpha Empty", created_by=other_user)
+ Snapshot.objects.create(
+ url="https://example.com/three",
+ title="Example Three",
+ crawl=crawl,
+ ).tags.add(alpha_tag)
+
+ Tag.objects.filter(pk=empty_tag.pk).update(created_at=timezone.make_aware(datetime(2024, 1, 1, 12, 0, 0)))
+ Tag.objects.filter(pk=alpha_tag.pk).update(created_at=timezone.make_aware(datetime(2025, 1, 1, 12, 0, 0)))
+ Tag.objects.filter(pk=tag_with_snapshots.pk).update(created_at=timezone.make_aware(datetime(2026, 1, 1, 12, 0, 0)))
+
+ response = client.get(
+ "/api/v1/core/tags/search/",
+ {
+ "sort": "name_desc",
+ "created_by": str(other_user.pk),
+ "year": "2024",
+ "has_snapshots": "no",
+ "api_key": api_token.token,
+ },
+ HTTP_HOST=ADMIN_TEST_HOST,
+ )
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["sort"] == "name_desc"
+ assert payload["created_by"] == str(other_user.pk)
+ assert payload["year"] == "2024"
+ assert payload["has_snapshots"] == "no"
+ assert [tag["name"] for tag in payload["tags"]] == ["Zulu Empty"]
diff --git a/archivebox/tests/test_api_v1_crawls_crawl_crawl_id.py b/archivebox/tests/test_api_v1_crawls_crawl_crawl_id.py
new file mode 100644
index 00000000..2e391dce
--- /dev/null
+++ b/archivebox/tests/test_api_v1_crawls_crawl_crawl_id.py
@@ -0,0 +1,614 @@
+import json
+import time
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import cast
+
+import pytest
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import UserManager
+from django.utils import timezone
+
+from archivebox.core.models import ArchiveResult, Snapshot
+from archivebox.crawls.models import Crawl
+from archivebox.tests.test_orm_helpers import use_archivebox_db
+from archivebox.workers.models import RETRY_AT_MAX
+
+from .conftest import (
+ api_client_request,
+ cli_env,
+ create_admin_and_token,
+ get_crawl_runtime_state,
+ get_free_port,
+ init_archive,
+ live_api_request,
+ run_archivebox_cmd,
+ start_archivebox_server,
+ stop_server,
+ wait_for_live_api,
+ wait_for_snapshot_capture,
+)
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+User = get_user_model()
+ADMIN_HOST = "admin.archivebox.localhost:8000"
+
+
+@pytest.fixture
+def other_user(db):
+ return cast(UserManager, User.objects).create_user(
+ username="rssother",
+ email="rssother@test.com",
+ password="testpassword",
+ )
+
+
+def _seed_archiveresult(
+ snapshot: Snapshot,
+ *,
+ plugin: str,
+ hook_name: str,
+ status: str,
+ output_text: str = "",
+ output_path: str | None = None,
+) -> ArchiveResult:
+ output_files = {}
+ output_size = 0
+ output_mimetypes = ""
+ if output_path is not None:
+ output_bytes = output_text.encode()
+ absolute_path = Path(snapshot.output_dir) / output_path
+ absolute_path.parent.mkdir(parents=True, exist_ok=True)
+ absolute_path.write_bytes(output_bytes)
+ output_size = len(output_bytes)
+ output_mimetypes = "text/plain"
+ output_files[output_path] = {
+ "extension": Path(output_path).suffix.lstrip("."),
+ "mimetype": "text/plain",
+ "size": output_size,
+ }
+
+ now = timezone.now()
+ return ArchiveResult.objects.create(
+ snapshot=snapshot,
+ plugin=plugin,
+ hook_name=hook_name,
+ status=status,
+ output_str=output_path or output_text,
+ output_files=output_files,
+ output_size=output_size,
+ output_mimetypes=output_mimetypes,
+ start_ts=now if status != ArchiveResult.StatusChoices.QUEUED else None,
+ end_ts=now if status in ArchiveResult.FINAL_STATES else None,
+ )
+
+
+def wait_for_crawl_snapshot_rows(cwd, crawl_id, timeout=45):
+ deadline = time.time() + timeout
+ latest_state = None
+ while time.time() < deadline:
+ latest_state = get_crawl_runtime_state(cwd, crawl_id)
+ if latest_state["snapshots"]:
+ return latest_state
+ time.sleep(0.2)
+ raise AssertionError(f"timed out waiting for runner to create snapshots for crawl {crawl_id}: {latest_state}")
+
+
+def wait_for_crawl_child_snapshots_paused_or_sealed(cwd, crawl_id, timeout=45):
+ deadline = time.time() + timeout
+ latest_state = None
+ while time.time() < deadline:
+ latest_state = get_crawl_runtime_state(cwd, crawl_id)
+ snapshots = latest_state["snapshots"]
+ if snapshots and all(snapshot["status"] in {"paused", "sealed"} for snapshot in snapshots):
+ return latest_state
+ time.sleep(0.2)
+ raise AssertionError(f"timed out waiting for runner to pause or seal snapshots for crawl {crawl_id}: {latest_state}")
+
+
+def make_snapshot(*, user, url: str, title: str, bookmarked_at: datetime):
+ crawl = Crawl.objects.create(urls=url, created_by=user)
+ snapshot = Snapshot.objects.create(
+ url=url,
+ title=title,
+ crawl=crawl,
+ bookmarked_at=bookmarked_at,
+ )
+ return crawl, snapshot
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(urls="https://example.com/crawl-detail", created_by=api_admin_user)
+
+ response = client.get(f"/api/v1/crawls/crawl/{crawl.id}", **api_headers)
+
+ assert response.status_code == 200, response.content
+
+
+def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snapshot_results_alone(
+ tmp_path,
+ client,
+ recursive_test_site,
+):
+ init_archive(tmp_path)
+ api_token = create_admin_and_token(tmp_path)
+
+ with use_archivebox_db(tmp_path):
+ crawl_response = api_client_request(
+ client,
+ "post",
+ "/api/v1/crawls/crawls",
+ api_token=api_token,
+ payload={
+ "urls": [recursive_test_site["root_url"]],
+ "max_depth": 0,
+ "tags": ["crawl-archiveresult-pause"],
+ "config": {"PLUGINS": "wget", "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*"},
+ },
+ )
+ assert crawl_response.status_code == 200, crawl_response.content.decode()
+ crawl_id = json.loads(crawl_response.content.decode())["id"]
+ from archivebox.services.runner import run_due_snapshot
+
+ active_response = api_client_request(
+ client,
+ "post",
+ "/api/v1/core/snapshots",
+ api_token=api_token,
+ payload={
+ "url": recursive_test_site["root_url"],
+ "crawl_id": crawl_id,
+ "depth": 0,
+ "title": "Active child",
+ "status": "queued",
+ },
+ )
+ assert active_response.status_code == 200, active_response.content.decode()
+ active_snapshot = Snapshot.objects.get(id=json.loads(active_response.content.decode())["id"])
+
+ sealed_response = api_client_request(
+ client,
+ "post",
+ "/api/v1/core/snapshots",
+ api_token=api_token,
+ payload={
+ "url": recursive_test_site["child_urls"][0],
+ "crawl_id": crawl_id,
+ "depth": 0,
+ "title": "Already sealed child",
+ "status": "queued",
+ },
+ )
+ assert sealed_response.status_code == 200, sealed_response.content.decode()
+ sealed_snapshot_id = json.loads(sealed_response.content.decode())["id"]
+ sealed_snapshot = Snapshot.objects.get(id=sealed_snapshot_id)
+ sealed_done = _seed_archiveresult(
+ sealed_snapshot,
+ plugin="sealedone",
+ hook_name="on_Snapshot__sealed_done",
+ status=ArchiveResult.StatusChoices.SUCCEEDED,
+ output_text="sealed snapshot result remains finished",
+ output_path="sealedone/final.txt",
+ )
+ sealed_snapshot.sm.seal()
+ sealed_snapshot.refresh_from_db()
+ assert sealed_snapshot.status == Snapshot.StatusChoices.SEALED
+ assert sealed_snapshot.retry_at is None
+
+ active_queued = _seed_archiveresult(
+ active_snapshot,
+ plugin="manualqueue",
+ hook_name="on_Snapshot__manual_queue",
+ status=ArchiveResult.StatusChoices.QUEUED,
+ )
+ active_started = _seed_archiveresult(
+ active_snapshot,
+ plugin="manualstart",
+ hook_name="on_Snapshot__manual_start",
+ status=ArchiveResult.StatusChoices.STARTED,
+ )
+ active_done = _seed_archiveresult(
+ active_snapshot,
+ plugin="manualdone",
+ hook_name="on_Snapshot__manual_done",
+ status=ArchiveResult.StatusChoices.SUCCEEDED,
+ output_text="parent cascade should not rewrite finished rows",
+ output_path="manualdone/cascade.txt",
+ )
+ pause_response = api_client_request(
+ client,
+ "patch",
+ f"/api/v1/crawls/crawl/{crawl_id}",
+ api_token=api_token,
+ payload={"action": "pause"},
+ )
+ assert pause_response.status_code == 200, pause_response.content.decode()
+ assert json.loads(pause_response.content.decode())["status"] == Crawl.StatusChoices.PAUSED
+
+ active_snapshot.refresh_from_db()
+ sealed_snapshot.refresh_from_db()
+ crawl = Crawl.objects.get(id=crawl_id)
+ assert crawl.status == Crawl.StatusChoices.PAUSED
+ assert crawl.retry_at == RETRY_AT_MAX
+ assert active_snapshot.status == Snapshot.StatusChoices.QUEUED
+ assert active_snapshot.retry_at is not None
+ assert active_snapshot.retry_at <= timezone.now()
+ assert ArchiveResult.objects.get(id=active_queued.id).status == ArchiveResult.StatusChoices.QUEUED
+ assert ArchiveResult.objects.get(id=active_started.id).status == ArchiveResult.StatusChoices.STARTED
+
+ assert run_due_snapshot(active_snapshot, lock_seconds=60) is True
+ active_snapshot.refresh_from_db()
+ sealed_snapshot.refresh_from_db()
+ assert active_snapshot.status == Snapshot.StatusChoices.PAUSED
+ assert active_snapshot.retry_at == RETRY_AT_MAX
+ assert sealed_snapshot.status == Snapshot.StatusChoices.SEALED
+ assert sealed_snapshot.retry_at is None
+
+ paused_rows = {
+ row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[active_queued.id, active_started.id])
+ }
+ assert paused_rows == {
+ "manualqueue": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX),
+ "manualstart": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX),
+ }
+
+ active_done_row = ArchiveResult.objects.get(id=active_done.id)
+ sealed_done_row = ArchiveResult.objects.get(id=sealed_done.id)
+ active_done_path = Path(active_snapshot.output_dir) / next(iter(active_done_row.output_files))
+ sealed_done_path = Path(sealed_snapshot.output_dir) / next(iter(sealed_done_row.output_files))
+ assert active_done_row.status == ArchiveResult.StatusChoices.SUCCEEDED
+ assert active_done_row.retry_at is None
+ assert active_done_path.read_text() == "parent cascade should not rewrite finished rows"
+ assert sealed_done_row.status == ArchiveResult.StatusChoices.SUCCEEDED
+ assert sealed_done_row.retry_at is None
+ assert sealed_done_path.read_text() == "sealed snapshot result remains finished"
+
+ resume_response = api_client_request(
+ client,
+ "patch",
+ f"/api/v1/crawls/crawl/{crawl_id}",
+ api_token=api_token,
+ payload={"action": "resume"},
+ )
+ assert resume_response.status_code == 200, resume_response.content.decode()
+ assert json.loads(resume_response.content.decode())["status"] == Crawl.StatusChoices.QUEUED
+
+ active_snapshot.refresh_from_db()
+ sealed_snapshot.refresh_from_db()
+ crawl.refresh_from_db()
+ assert crawl.status == Crawl.StatusChoices.QUEUED
+ assert crawl.retry_at is not None
+ assert crawl.retry_at != RETRY_AT_MAX
+ assert active_snapshot.status == Snapshot.StatusChoices.QUEUED
+ assert active_snapshot.retry_at is not None
+ assert active_snapshot.retry_at != RETRY_AT_MAX
+ assert sealed_snapshot.status == Snapshot.StatusChoices.SEALED
+ assert sealed_snapshot.retry_at is None
+
+ resumed_rows = {
+ row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[active_queued.id, active_started.id])
+ }
+ assert resumed_rows["manualqueue"][0] == ArchiveResult.StatusChoices.QUEUED
+ assert resumed_rows["manualqueue"][1] is not None
+ assert resumed_rows["manualqueue"][1] != RETRY_AT_MAX
+ assert resumed_rows["manualstart"][0] == ArchiveResult.StatusChoices.QUEUED
+ assert resumed_rows["manualstart"][1] is not None
+ assert resumed_rows["manualstart"][1] != RETRY_AT_MAX
+ assert ArchiveResult.objects.get(id=active_done.id).status == ArchiveResult.StatusChoices.SUCCEEDED
+ assert ArchiveResult.objects.get(id=sealed_done.id).status == ArchiveResult.StatusChoices.SUCCEEDED
+ assert active_done_path.read_text() == "parent cascade should not rewrite finished rows"
+ assert sealed_done_path.read_text() == "sealed snapshot result remains finished"
+
+
+@pytest.mark.timeout(240)
+def test_crawl_pause_resume_api_survives_server_restart_and_processes_after_resume(tmp_path, recursive_test_site):
+ init_archive(tmp_path)
+
+ port = get_free_port()
+ env = cli_env(port=port, server=True, PLUGINS="wget", SAVE_WGET="True")
+ api_token = create_admin_and_token(tmp_path)
+
+ try:
+ start_archivebox_server(tmp_path, env=env, port=port)
+ wait_for_live_api(port)
+
+ crawl_response = live_api_request(
+ port,
+ "post",
+ "/api/v1/crawls/crawls",
+ api_token=api_token,
+ json={
+ "urls": [recursive_test_site["root_url"]],
+ "max_depth": 0,
+ "tags": ["pause-resume-e2e"],
+ "config": {"PLUGINS": "wget", "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*"},
+ },
+ timeout=10,
+ )
+ assert crawl_response.status_code == 200, crawl_response.text
+ crawl_id = crawl_response.json()["id"]
+ wait_for_crawl_snapshot_rows(tmp_path, crawl_id)
+
+ pause_response = live_api_request(
+ port,
+ "patch",
+ f"/api/v1/crawls/crawl/{crawl_id}",
+ api_token=api_token,
+ json={"action": "pause"},
+ timeout=10,
+ )
+ assert pause_response.status_code == 200, pause_response.text
+ assert pause_response.json()["status"] == "paused"
+
+ paused_state = wait_for_crawl_child_snapshots_paused_or_sealed(tmp_path, crawl_id)
+ assert paused_state["crawl_status"] == "paused"
+ assert paused_state["crawl_retry_at"] == paused_state["retry_at_max"]
+ assert len(paused_state["snapshots"]) == 1
+ snapshot_finished_before_pause = paused_state["snapshots"][0]["status"] == "sealed"
+ if snapshot_finished_before_pause:
+ assert any(result["status"] == "succeeded" for result in paused_state["results"])
+ else:
+ assert paused_state["snapshots"][0]["status"] == "paused"
+ assert paused_state["snapshots"][0]["retry_at"] == paused_state["retry_at_max"]
+
+ stop_server(tmp_path)
+ start_archivebox_server(tmp_path, env=env, port=port)
+ wait_for_live_api(port)
+
+ restarted_state = get_crawl_runtime_state(tmp_path, crawl_id)
+ assert restarted_state["crawl_status"] == "paused"
+ assert restarted_state["crawl_retry_at"] == restarted_state["retry_at_max"]
+ if snapshot_finished_before_pause:
+ assert restarted_state["snapshots"][0]["status"] == "sealed"
+ assert any(result["status"] == "succeeded" for result in restarted_state["results"])
+ return
+ assert restarted_state["snapshots"][0]["status"] == "paused"
+ assert restarted_state["snapshots"][0]["retry_at"] == restarted_state["retry_at_max"]
+ assert not any(result["status"] == "succeeded" for result in restarted_state["results"])
+
+ resume_response = live_api_request(
+ port,
+ "patch",
+ f"/api/v1/crawls/crawl/{crawl_id}",
+ api_token=api_token,
+ json={"action": "resume"},
+ timeout=10,
+ )
+ assert resume_response.status_code == 200, resume_response.text
+ assert resume_response.json()["status"] == "queued"
+
+ captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180)
+ assert "Root" in captured_text
+ assert "About" in captured_text
+
+ final_state = get_crawl_runtime_state(tmp_path, crawl_id)
+ assert final_state["snapshots"][0]["status"] == "sealed"
+ wget_results = [result for result in final_state["results"] if result["plugin"] == "wget"]
+ assert wget_results
+ assert any(result["status"] == "succeeded" and result["output_size"] > 0 for result in wget_results)
+ finally:
+ stop_server(tmp_path)
+
+
+@pytest.mark.timeout(180)
+def test_update_index_only_runs_paused_search_rows_and_resume_later_runs_crawl(tmp_path, recursive_test_site):
+ init_archive(tmp_path)
+
+ port = get_free_port()
+ env = cli_env(port=port, server=True, PLUGINS="wget", SAVE_WGET="True")
+ api_token = create_admin_and_token(tmp_path)
+
+ try:
+ start_archivebox_server(tmp_path, env=env, port=port)
+ wait_for_live_api(port)
+
+ crawl_response = live_api_request(
+ port,
+ "post",
+ "/api/v1/crawls/crawls",
+ api_token=api_token,
+ json={
+ "urls": [recursive_test_site["root_url"]],
+ "max_depth": 0,
+ "tags": ["paused-index-e2e"],
+ "config": {"PLUGINS": "wget", "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*"},
+ },
+ timeout=10,
+ )
+ assert crawl_response.status_code == 200, crawl_response.text
+ crawl_id = crawl_response.json()["id"]
+ wait_for_crawl_snapshot_rows(tmp_path, crawl_id)
+
+ pause_response = live_api_request(
+ port,
+ "patch",
+ f"/api/v1/crawls/crawl/{crawl_id}",
+ api_token=api_token,
+ json={"action": "pause"},
+ timeout=10,
+ )
+ assert pause_response.status_code == 200, pause_response.text
+ assert pause_response.json()["status"] == "paused"
+ paused_state = wait_for_crawl_child_snapshots_paused_or_sealed(tmp_path, crawl_id)
+ snapshot_finished_before_pause = paused_state["snapshots"][0]["status"] == "sealed"
+ finally:
+ stop_server(tmp_path)
+
+ if snapshot_finished_before_pause:
+ indexed_state = get_crawl_runtime_state(tmp_path, crawl_id)
+ assert indexed_state["crawl_status"] == "paused"
+ assert indexed_state["snapshots"][0]["status"] == "sealed"
+ return
+
+ update_env = cli_env(
+ port=port,
+ PLUGINS="search_backend_sqlite",
+ SEARCH_BACKEND_ENGINE="sqlite",
+ )
+ update_process = run_archivebox_cmd(
+ [
+ "update",
+ "--index-only",
+ "--crawl-id",
+ crawl_id,
+ "--limit",
+ "1",
+ "--batch-size",
+ "1",
+ ],
+ cwd=tmp_path,
+ env=update_env,
+ timeout=120,
+ )
+ assert update_process.returncode == 0, update_process.stderr
+
+ indexed_state = get_crawl_runtime_state(tmp_path, crawl_id)
+ assert indexed_state["crawl_status"] == "paused"
+ assert indexed_state["crawl_retry_at"] == indexed_state["retry_at_max"]
+ assert indexed_state["snapshots"][0]["status"] == "paused"
+ assert indexed_state["snapshots"][0]["retry_at"] == indexed_state["retry_at_max"]
+ search_results = [result for result in indexed_state["results"] if result["plugin"] == "search_backend_sqlite"]
+ assert search_results
+ assert all(result["status"] not in {"queued", "started", "paused"} for result in search_results)
+
+ try:
+ start_archivebox_server(tmp_path, env=env, port=port)
+ wait_for_live_api(port)
+
+ still_paused_state = get_crawl_runtime_state(tmp_path, crawl_id)
+ assert still_paused_state["crawl_status"] == "paused"
+ assert still_paused_state["snapshots"][0]["status"] == "paused"
+ assert not any(result["plugin"] == "wget" and result["status"] == "succeeded" for result in still_paused_state["results"])
+
+ resume_response = live_api_request(
+ port,
+ "patch",
+ f"/api/v1/crawls/crawl/{crawl_id}",
+ api_token=api_token,
+ json={"action": "resume"},
+ timeout=10,
+ )
+ assert resume_response.status_code == 200, resume_response.text
+ assert resume_response.json()["status"] == "queued"
+
+ captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180)
+ assert "Root" in captured_text
+ assert "About" in captured_text
+
+ resumed_state = get_crawl_runtime_state(tmp_path, crawl_id)
+ assert resumed_state["snapshots"][0]["status"] == "sealed"
+ wget_results = [result for result in resumed_state["results"] if result["plugin"] == "wget"]
+ assert wget_results
+ assert any(result["status"] == "succeeded" and result["output_size"] > 0 for result in wget_results)
+ finally:
+ stop_server(tmp_path)
+
+
+def test_crawl_cancel_api_defers_cleanup_to_runner(client, api_admin_user, api_headers):
+ from archivebox.services.runner import run_due_crawl
+
+ crawl = Crawl.objects.create(
+ urls="https://example.com",
+ created_by=api_admin_user,
+ status=Crawl.StatusChoices.STARTED,
+ retry_at=timezone.now() + timedelta(minutes=5),
+ )
+ child = Snapshot.objects.create(
+ url="https://example.com/cancel-child",
+ crawl=crawl,
+ status=Snapshot.StatusChoices.STARTED,
+ retry_at=timezone.now() + timedelta(minutes=5),
+ )
+ crawl.output_dir.mkdir(parents=True, exist_ok=True)
+ pid_file = crawl.output_dir / "cleanup-test.pid"
+ pid_file.write_text("12345")
+
+ response = api_client_request(
+ client,
+ "patch",
+ f"/api/v1/crawls/crawl/{crawl.id}",
+ payload={"action": "cancel"},
+ headers=api_headers,
+ )
+ assert response.status_code == 200, response.content
+
+ crawl.refresh_from_db()
+ child.refresh_from_db()
+ assert crawl.status == Crawl.StatusChoices.SEALED
+ assert crawl.retry_at is not None
+ assert crawl.retry_at <= timezone.now()
+ assert child.status == Snapshot.StatusChoices.STARTED
+ assert child.retry_at is not None
+ assert child.retry_at <= timezone.now()
+ assert pid_file.exists()
+
+ assert run_due_crawl(crawl, lock_seconds=60) is True
+ crawl.refresh_from_db()
+ assert crawl.retry_at is None
+ assert not pid_file.exists()
+
+
+def test_rest_crawl_delete_removes_crawl_and_snapshot_output_dirs(client, api_admin_user, api_headers):
+ url = "https://example.com/delete-path-crawl"
+
+ crawl = Crawl.objects.create(
+ urls=url,
+ max_depth=0,
+ created_by=api_admin_user,
+ status=Crawl.StatusChoices.SEALED,
+ )
+ snapshot = Snapshot.objects.create(
+ crawl=crawl,
+ url=url,
+ depth=0,
+ status=Snapshot.StatusChoices.SEALED,
+ )
+ crawl_dir = Path(crawl.output_dir)
+ snapshot_dir = Path(snapshot.output_dir)
+ crawl_dir.mkdir(parents=True, exist_ok=True)
+ snapshot_dir.mkdir(parents=True, exist_ok=True)
+ (crawl_dir / "delete-path-crawl.txt").write_text("crawl output")
+ (snapshot_dir / "delete-path-snapshot.txt").write_text("snapshot output")
+ assert crawl_dir.exists()
+ assert snapshot_dir.exists()
+
+ response = client.delete(f"/api/v1/crawls/crawl/{crawl.id}", **api_headers)
+ assert response.status_code == 200, response.content.decode()
+ assert not Crawl.objects.filter(pk=crawl.pk).exists()
+ assert not Snapshot.objects.filter(pk=snapshot.pk).exists()
+ assert not crawl_dir.exists()
+ assert not snapshot_dir.exists()
+
+
+def test_crawl_as_rss_redirects_to_canonical_snapshots_feed(client, api_token, api_admin_user, other_user):
+ crawl, _snapshot = make_snapshot(
+ user=api_admin_user,
+ url="https://example.com/rss-crawl-feed",
+ title="Crawl Feed Snapshot",
+ bookmarked_at=timezone.make_aware(datetime(2026, 5, 23, 8, 0, 0)),
+ )
+ make_snapshot(
+ user=other_user,
+ url="https://example.com/rss-crawl-other",
+ title="Other Crawl Snapshot",
+ bookmarked_at=timezone.make_aware(datetime(2026, 5, 23, 9, 0, 0)),
+ )
+
+ response = client.get(
+ f"/api/v1/crawls/crawl/{crawl.id}",
+ {"as_rss": "true", "limit": 50, "api_key": api_token.token},
+ HTTP_HOST=ADMIN_HOST,
+ follow=True,
+ )
+
+ assert response.status_code == 200
+ assert response.redirect_chain
+ redirect_url = response.redirect_chain[0][0]
+ assert redirect_url.startswith("/api/v1/core/snapshots.rss?")
+ assert f"crawl_id={crawl.id}" in redirect_url
+ assert "as_rss" not in redirect_url
+ assert response["Content-Type"].startswith("application/rss+xml")
+ body = response.content.decode()
+ assert "rss-crawl-feed" in body
+ assert "rss-crawl-other" not in body
diff --git a/archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_filename.py b/archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_filename.py
new file mode 100644
index 00000000..b8b14317
--- /dev/null
+++ b/archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_filename.py
@@ -0,0 +1,16 @@
+import pytest
+
+from archivebox.crawls.models import Crawl
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(urls="https://example.com/crawl-file-root", created_by=api_admin_user)
+ crawl.output_dir.mkdir(parents=True, exist_ok=True)
+ (crawl.output_dir / "basic.txt").write_text("ok")
+
+ response = client.get(f"/api/v1/crawls/crawl/{crawl.id}/files/basic.txt", **api_headers)
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_folder_filename.py b/archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_folder_filename.py
new file mode 100644
index 00000000..14885982
--- /dev/null
+++ b/archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_folder_filename.py
@@ -0,0 +1,17 @@
+import pytest
+
+from archivebox.crawls.models import Crawl
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(urls="https://example.com/crawl-file-nested", created_by=api_admin_user)
+ nested_dir = crawl.output_dir / "folder"
+ nested_dir.mkdir(parents=True, exist_ok=True)
+ (nested_dir / "basic.txt").write_text("ok")
+
+ response = client.get(f"/api/v1/crawls/crawl/{crawl.id}/files/folder/basic.txt", **api_headers)
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_folder_subfolder_filename.py b/archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_folder_subfolder_filename.py
new file mode 100644
index 00000000..3a3fbca3
--- /dev/null
+++ b/archivebox/tests/test_api_v1_crawls_crawl_crawl_id_files_folder_subfolder_filename.py
@@ -0,0 +1,17 @@
+import pytest
+
+from archivebox.crawls.models import Crawl
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_admin_user, api_headers):
+ crawl = Crawl.objects.create(urls="https://example.com/crawl-file-deep", created_by=api_admin_user)
+ nested_dir = crawl.output_dir / "folder" / "subfolder"
+ nested_dir.mkdir(parents=True, exist_ok=True)
+ (nested_dir / "basic.txt").write_text("ok")
+
+ response = client.get(f"/api/v1/crawls/crawl/{crawl.id}/files/folder/subfolder/basic.txt", **api_headers)
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_crawls_crawls.py b/archivebox/tests/test_api_v1_crawls_crawls.py
new file mode 100644
index 00000000..8a18d2cb
--- /dev/null
+++ b/archivebox/tests/test_api_v1_crawls_crawls.py
@@ -0,0 +1,38 @@
+from django.test import RequestFactory
+
+import pytest
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_create_crawl_api_queues_crawl_without_spawning_runner():
+ from django.contrib.auth import get_user_model
+
+ from archivebox.api.v1_crawls import CrawlCreateSchema, create_crawl
+
+ user = get_user_model().objects.create_superuser(
+ username="runner-api-admin",
+ email="runner-api-admin@example.com",
+ password="testpassword",
+ )
+ request = RequestFactory().post("/api/v1/crawls")
+ request.user = user
+
+ crawl = create_crawl(
+ request,
+ CrawlCreateSchema(
+ urls=["https://example.com"],
+ max_depth=0,
+ tags=[],
+ tags_str="",
+ label="",
+ notes="",
+ config={},
+ ),
+ )
+
+ assert str(crawl.id)
+ assert crawl.status == "queued"
+ assert crawl.retry_at is not None
+ assert crawl.snapshot_set.count() == 0
diff --git a/archivebox/tests/test_api_v1_machine_binaries.py b/archivebox/tests/test_api_v1_machine_binaries.py
new file mode 100644
index 00000000..9ae80e67
--- /dev/null
+++ b/archivebox/tests/test_api_v1_machine_binaries.py
@@ -0,0 +1,22 @@
+import pytest
+
+from archivebox.machine.models import Binary, Machine
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_headers):
+ machine = Machine.current(refresh=True)
+ Binary.objects.create(
+ machine=machine,
+ name="api-basic-bin",
+ binprovider="env",
+ abspath="/usr/bin/env",
+ version="1.0",
+ status=Binary.StatusChoices.INSTALLED,
+ )
+
+ response = client.get("/api/v1/machine/binaries", **api_headers)
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_machine_binary_binary_id.py b/archivebox/tests/test_api_v1_machine_binary_binary_id.py
new file mode 100644
index 00000000..a8c1d541
--- /dev/null
+++ b/archivebox/tests/test_api_v1_machine_binary_binary_id.py
@@ -0,0 +1,22 @@
+import pytest
+
+from archivebox.machine.models import Binary, Machine
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_headers):
+ machine = Machine.current(refresh=True)
+ binary = Binary.objects.create(
+ machine=machine,
+ name="api-basic-bin",
+ binprovider="env",
+ abspath="/usr/bin/env",
+ version="1.0",
+ status=Binary.StatusChoices.INSTALLED,
+ )
+
+ response = client.get(f"/api/v1/machine/binary/{binary.id}", **api_headers)
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_machine_binary_by_name_name.py b/archivebox/tests/test_api_v1_machine_binary_by_name_name.py
new file mode 100644
index 00000000..45744fa2
--- /dev/null
+++ b/archivebox/tests/test_api_v1_machine_binary_by_name_name.py
@@ -0,0 +1,22 @@
+import pytest
+
+from archivebox.machine.models import Binary, Machine
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_headers):
+ machine = Machine.current(refresh=True)
+ Binary.objects.create(
+ machine=machine,
+ name="api-basic-bin",
+ binprovider="env",
+ abspath="/usr/bin/env",
+ version="1.0",
+ status=Binary.StatusChoices.INSTALLED,
+ )
+
+ response = client.get("/api/v1/machine/binary/by-name/api-basic-bin", **api_headers)
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_machine_machine_current.py b/archivebox/tests/test_api_v1_machine_machine_current.py
new file mode 100644
index 00000000..ec303476
--- /dev/null
+++ b/archivebox/tests/test_api_v1_machine_machine_current.py
@@ -0,0 +1,10 @@
+import pytest
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_headers):
+ response = client.get("/api/v1/machine/machine/current", **api_headers)
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_machine_machine_machine_id.py b/archivebox/tests/test_api_v1_machine_machine_machine_id.py
new file mode 100644
index 00000000..db5a5ea7
--- /dev/null
+++ b/archivebox/tests/test_api_v1_machine_machine_machine_id.py
@@ -0,0 +1,14 @@
+import pytest
+
+from archivebox.machine.models import Machine
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_headers):
+ machine = Machine.current(refresh=True)
+
+ response = client.get(f"/api/v1/machine/machine/{machine.id}", **api_headers)
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_machine_machines.py b/archivebox/tests/test_api_v1_machine_machines.py
new file mode 100644
index 00000000..3101c2d1
--- /dev/null
+++ b/archivebox/tests/test_api_v1_machine_machines.py
@@ -0,0 +1,14 @@
+import pytest
+
+from archivebox.machine.models import Machine
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, tmp_path, api_headers):
+ Machine.current(refresh=True)
+
+ response = client.get("/api/v1/machine/machines", **api_headers)
+
+ assert response.status_code == 200, response.content
diff --git a/archivebox/tests/test_api_v1_personas_personas.py b/archivebox/tests/test_api_v1_personas_personas.py
new file mode 100644
index 00000000..04cb74a7
--- /dev/null
+++ b/archivebox/tests/test_api_v1_personas_personas.py
@@ -0,0 +1,57 @@
+import pytest
+
+from archivebox.personas.models import Persona
+from archivebox.tests.conftest import api_client_request
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_personas_api_returns_paginated_envelope(client, api_headers):
+ for name in ("api-persona-alpha", "api-persona-bravo", "api-persona-charlie"):
+ create_response = api_client_request(
+ client,
+ "post",
+ "/api/v1/personas/sync",
+ payload={
+ "extension_persona_id": f"extension-{name}",
+ "name": name,
+ "settings": {},
+ "cookies_txt": "",
+ "auth_json": {},
+ },
+ headers=api_headers,
+ )
+ assert create_response.status_code == 200, create_response.content
+ assert create_response.json()["created"] is True
+
+ total_personas = Persona.objects.count()
+ response = client.get(
+ "/api/v1/personas/personas?limit=1",
+ **api_headers,
+ )
+
+ assert response.status_code == 200, response.content
+ payload = response.json()
+ assert isinstance(payload, dict)
+ assert set(payload) >= {"items", "count", "total_items", "total_pages", "page", "limit", "offset", "num_items"}
+ assert payload["count"] == total_personas
+ assert payload["total_items"] == total_personas
+ assert payload["limit"] == 1
+ assert payload["offset"] == 0
+ assert payload["num_items"] == 1
+ assert len(payload["items"]) == 1
+
+ limit_two_response = client.get(
+ "/api/v1/personas/personas?limit=2",
+ **api_headers,
+ )
+ assert limit_two_response.status_code == 200, limit_two_response.content
+ limit_two_payload = limit_two_response.json()
+ assert isinstance(limit_two_payload, dict)
+ assert limit_two_payload["count"] == total_personas
+ assert limit_two_payload["total_items"] == total_personas
+ assert limit_two_payload["limit"] == 2
+ assert limit_two_payload["offset"] == 0
+ assert limit_two_payload["num_items"] == 2
+ assert len(limit_two_payload["items"]) == 2
diff --git a/archivebox/tests/test_api_v1_personas_sync.py b/archivebox/tests/test_api_v1_personas_sync.py
new file mode 100644
index 00000000..cea4d5d2
--- /dev/null
+++ b/archivebox/tests/test_api_v1_personas_sync.py
@@ -0,0 +1,25 @@
+import pytest
+
+from archivebox.tests.conftest import api_client_request
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_basic_success_case_request(client, api_headers):
+ response = api_client_request(
+ client,
+ "post",
+ "/api/v1/personas/sync",
+ payload={
+ "extension_persona_id": "extension-api-persona-basic",
+ "name": "api-persona-basic",
+ "settings": {},
+ "cookies_txt": "",
+ "auth_json": {},
+ },
+ headers=api_headers,
+ )
+
+ assert response.status_code == 200, response.content
+ assert response.json()["success"] is True
diff --git a/archivebox/tests/test_api_v1_workflow_core_token_auth_side_effects.py b/archivebox/tests/test_api_v1_workflow_core_token_auth_side_effects.py
new file mode 100644
index 00000000..d7917289
--- /dev/null
+++ b/archivebox/tests/test_api_v1_workflow_core_token_auth_side_effects.py
@@ -0,0 +1,207 @@
+import pytest
+import requests
+
+from archivebox.core.models import Snapshot
+from archivebox.crawls.models import Crawl
+from archivebox.tests.conftest import (
+ cli_env,
+ create_admin_and_token,
+ get_free_port,
+ init_archive,
+ live_api_request,
+ start_archivebox_server,
+ stop_server,
+ wait_for_live_api,
+)
+from archivebox.tests.test_orm_helpers import use_archivebox_db
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+@pytest.mark.timeout(180)
+def test_core_api_workflow_uses_token_auth_and_persists_side_effects_over_server(tmp_path, recursive_test_site):
+ init_archive(tmp_path)
+
+ port = get_free_port()
+ env = cli_env(port=port, server=True, PUBLIC_INDEX="True")
+ api_token = create_admin_and_token(tmp_path)
+
+ try:
+ start_archivebox_server(tmp_path, env=env, port=port)
+ docs = wait_for_live_api(port)
+ assert docs.status_code == 200
+ openapi = wait_for_live_api(port, path="/api/v1/openapi.json")
+ assert openapi.status_code == 200
+ paths = openapi.json()["paths"]
+ assert "/api/v1/core/snapshots" in paths
+ assert "/api/v1/crawls/crawls" in paths
+
+ unauth = requests.get(
+ f"http://127.0.0.1:{port}/api/v1/crawls/crawls",
+ headers={"Host": f"api.archivebox.localhost:{port}"},
+ timeout=10,
+ )
+ assert unauth.status_code in (401, 403)
+ bad_auth = requests.get(
+ f"http://127.0.0.1:{port}/api/v1/crawls/crawls",
+ headers={"Host": f"api.archivebox.localhost:{port}", "X-ArchiveBox-API-Key": "bad-token"},
+ timeout=10,
+ )
+ assert bad_auth.status_code in (401, 403)
+
+ crawl_response = live_api_request(
+ port,
+ "post",
+ "/api/v1/crawls/crawls",
+ api_token=api_token,
+ json={
+ "urls": [recursive_test_site["root_url"]],
+ "max_depth": 2,
+ "tags": ["api-depth-two"],
+ "label": "api crawl",
+ "notes": "created through REST API",
+ "config": {
+ "PLUGINS": "wget,parse_html_urls",
+ "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*",
+ "CRAWL_MAX_URLS": 7,
+ "CRAWL_MAX_SIZE": "0",
+ "SNAPSHOT_MAX_SIZE": "0",
+ },
+ },
+ timeout=10,
+ )
+ assert crawl_response.status_code == 200, crawl_response.text
+ crawl_payload = crawl_response.json()
+ crawl_id = crawl_payload["id"]
+ assert crawl_payload["max_depth"] == 2
+ assert crawl_payload["tags_str"] == "api-depth-two"
+ assert crawl_payload["config"]["PLUGINS"] == "wget,parse_html_urls"
+ assert crawl_payload["config"]["CRAWL_MAX_URLS"] == 7
+
+ snapshot_response = live_api_request(
+ port,
+ "post",
+ "/api/v1/core/snapshots",
+ api_token=api_token,
+ json={
+ "url": recursive_test_site["child_urls"][0],
+ "crawl_id": crawl_id,
+ "depth": 1,
+ "title": "API child snapshot",
+ "tags": ["api-child"],
+ "status": "queued",
+ },
+ timeout=10,
+ )
+ assert snapshot_response.status_code == 200, snapshot_response.text
+ snapshot_payload = snapshot_response.json()
+ snapshot_id = snapshot_payload["id"]
+ assert snapshot_payload["url"] == recursive_test_site["child_urls"][0]
+ assert snapshot_payload["tags"] == ["api-child"]
+
+ patch_snapshot = live_api_request(
+ port,
+ "patch",
+ f"/api/v1/core/snapshot/{snapshot_id}",
+ api_token=api_token,
+ json={"status": "sealed", "tags": ["api-child", "api-patched"]},
+ timeout=10,
+ )
+ assert patch_snapshot.status_code == 200, patch_snapshot.text
+ assert patch_snapshot.json()["status"] == "sealed"
+ assert set(patch_snapshot.json()["tags"]) == {"api-child", "api-patched"}
+
+ tag_create = live_api_request(
+ port,
+ "post",
+ "/api/v1/core/tags/create/",
+ api_token=api_token,
+ json={"name": "api-extra"},
+ timeout=10,
+ )
+ assert tag_create.status_code == 200, tag_create.text
+ tag_id = tag_create.json()["tag_id"]
+
+ add_tag = live_api_request(
+ port,
+ "post",
+ "/api/v1/core/tags/add-to-snapshot/",
+ api_token=api_token,
+ json={"snapshot_id": snapshot_id, "tag_id": tag_id},
+ timeout=10,
+ )
+ assert add_tag.status_code == 200, add_tag.text
+ remove_tag = live_api_request(
+ port,
+ "post",
+ "/api/v1/core/tags/remove-from-snapshot/",
+ api_token=api_token,
+ json={"snapshot_id": snapshot_id, "tag_name": "api-extra"},
+ timeout=10,
+ )
+ assert remove_tag.status_code == 200, remove_tag.text
+
+ crawl_patch = live_api_request(
+ port,
+ "patch",
+ f"/api/v1/crawls/crawl/{crawl_id}",
+ api_token=api_token,
+ json={"status": "sealed", "tags": ["api-sealed"]},
+ timeout=10,
+ )
+ assert crawl_patch.status_code == 200, crawl_patch.text
+ assert crawl_patch.json()["status"] == "sealed"
+ assert crawl_patch.json()["tags_str"] == "api-sealed"
+
+ snapshots_list = live_api_request(
+ port,
+ "get",
+ "/api/v1/core/snapshots?tag=api-patched&with_archiveresults=true",
+ api_token=api_token,
+ timeout=10,
+ )
+ assert snapshots_list.status_code == 200, snapshots_list.text
+ snapshot_items = snapshots_list.json()["items"]
+ assert len(snapshot_items) == 1
+ assert snapshot_items[0]["id"] == snapshot_id
+ assert snapshot_items[0]["archiveresults"] == []
+
+ bearer_response = requests.get(
+ f"http://127.0.0.1:{port}/api/v1/crawls/crawl/{crawl_id}",
+ headers={"Host": f"api.archivebox.localhost:{port}", "Authorization": f"Bearer {api_token}"},
+ timeout=10,
+ )
+ assert bearer_response.status_code == 200, bearer_response.text
+ query_response = requests.get(
+ f"http://127.0.0.1:{port}/api/v1/crawls/crawl/{crawl_id}?api_key={api_token}",
+ headers={"Host": f"api.archivebox.localhost:{port}"},
+ timeout=10,
+ )
+ assert query_response.status_code == 200, query_response.text
+
+ delete_snapshot = live_api_request(
+ port,
+ "delete",
+ f"/api/v1/core/snapshot/{snapshot_id}",
+ api_token=api_token,
+ timeout=10,
+ )
+ assert delete_snapshot.status_code == 200, delete_snapshot.text
+ assert delete_snapshot.json()["success"] is True
+
+ delete_crawl = live_api_request(
+ port,
+ "delete",
+ f"/api/v1/crawls/crawl/{crawl_id}",
+ api_token=api_token,
+ timeout=10,
+ )
+ assert delete_crawl.status_code == 200, delete_crawl.text
+ assert delete_crawl.json()["success"] is True
+
+ with use_archivebox_db(tmp_path):
+ assert Crawl.objects.filter(pk=crawl_id).count() == 0
+ assert Snapshot.objects.filter(pk=snapshot_id).count() == 0
+ finally:
+ stop_server(tmp_path)
diff --git a/archivebox/tests/test_api_v1_workflow_frozen_crawl_config_sources.py b/archivebox/tests/test_api_v1_workflow_frozen_crawl_config_sources.py
new file mode 100644
index 00000000..9f68f3af
--- /dev/null
+++ b/archivebox/tests/test_api_v1_workflow_frozen_crawl_config_sources.py
@@ -0,0 +1,66 @@
+from django.test import RequestFactory
+
+import pytest
+
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+SENSITIVE_SECRET = "raw-twocaptcha-secret-for-frozen-crawl-test"
+
+
+@pytest.fixture
+def archivebox_db(initialized_archive):
+ from archivebox.tests.test_orm_helpers import use_archivebox_db
+
+ with use_archivebox_db(initialized_archive):
+ yield initialized_archive
+
+
+def _user(username="frozen-config-admin"):
+ from django.contrib.auth import get_user_model
+
+ return get_user_model().objects.create_superuser(
+ username=username,
+ email=f"{username}@example.com",
+ password="testpassword",
+ )
+
+
+def test_api_create_and_cli_add_store_full_frozen_config(archivebox_db):
+ from archivebox.api.v1_crawls import CrawlCreateSchema, CrawlSchema, create_crawl
+ from archivebox.cli.archivebox_add import add
+ from archivebox.config.common import SENSITIVE_CONFIG_VALUE_REDACTED
+
+ user = _user("frozen-config-api-admin")
+ request = RequestFactory().post("/api/v1/crawls")
+ request.user = user
+
+ api_crawl = create_crawl(
+ request,
+ CrawlCreateSchema(
+ urls=["https://example.com/api"],
+ max_depth=0,
+ tags=[],
+ tags_str="",
+ label="API frozen config",
+ notes="",
+ config={"TWOCAPTCHA_API_KEY": SENSITIVE_SECRET, "TIMEOUT": 33, "SECRET_KEY": "must-not-freeze", "PUBLIC_ADD_VIEW": True},
+ ),
+ )
+ assert "CHECK_SSL_VALIDITY" in api_crawl.config
+ assert api_crawl.config["TIMEOUT"] == 33
+ assert api_crawl.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
+ assert "SECRET_KEY" not in api_crawl.config
+ assert "PUBLIC_ADD_VIEW" not in api_crawl.config
+ assert CrawlSchema.resolve_config(api_crawl)["TWOCAPTCHA_API_KEY"] == SENSITIVE_CONFIG_VALUE_REDACTED
+
+ cli_crawl, _snapshots = add(
+ "https://example.com/cli",
+ bg=True,
+ created_by_id=user.pk,
+ config={"TWOCAPTCHA_API_KEY": SENSITIVE_SECRET, "TIMEOUT": 44},
+ )
+ assert "CHECK_SSL_VALIDITY" in cli_crawl.config
+ assert cli_crawl.config["TIMEOUT"] == 44
+ assert cli_crawl.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
diff --git a/archivebox/tests/test_archiveresult_pause.py b/archivebox/tests/test_archiveresult_pause.py
index c19fd6ef..d1974060 100644
--- a/archivebox/tests/test_archiveresult_pause.py
+++ b/archivebox/tests/test_archiveresult_pause.py
@@ -1,372 +1,2 @@
-import json
-import os
-import subprocess
-import sys
-from pathlib import Path
-
-import pytest
-from django.utils import timezone
-
-from archivebox.core.models import ArchiveResult, Snapshot
-from archivebox.crawls.models import Crawl
-from archivebox.tests.test_orm_helpers import use_archivebox_db
-from archivebox.workers.models import RETRY_AT_MAX
-
-from .conftest import build_test_env, create_admin_and_token, get_free_port, init_archive
-
-pytestmark = pytest.mark.django_db(transaction=True)
-
-API_HOST = "api.archivebox.localhost:8000"
-
-
-def _api_headers(token: str) -> dict[str, str]:
- return {
- "HTTP_HOST": API_HOST,
- "HTTP_X_ARCHIVEBOX_API_KEY": token,
- }
-
-
-def _json_response(response):
- return json.loads(response.content.decode())
-
-
-def _post_json(client, path: str, token: str, payload: dict):
- return client.post(
- path,
- data=json.dumps(payload),
- content_type="application/json",
- **_api_headers(token),
- )
-
-
-def _patch_json(client, path: str, token: str, payload: dict):
- return client.patch(
- path,
- data=json.dumps(payload),
- content_type="application/json",
- **_api_headers(token),
- )
-
-
-def _seed_archiveresult(
- snapshot: Snapshot,
- *,
- plugin: str,
- hook_name: str,
- status: str,
- output_text: str = "",
- output_path: str | None = None,
-) -> ArchiveResult:
- output_files = {}
- output_size = 0
- output_mimetypes = ""
- if output_path is not None:
- output_bytes = output_text.encode()
- absolute_path = Path(snapshot.output_dir) / output_path
- absolute_path.parent.mkdir(parents=True, exist_ok=True)
- absolute_path.write_bytes(output_bytes)
- output_size = len(output_bytes)
- output_mimetypes = "text/plain"
- output_files[output_path] = {
- "extension": Path(output_path).suffix.lstrip("."),
- "mimetype": "text/plain",
- "size": output_size,
- }
-
- now = timezone.now()
- return ArchiveResult.objects.create(
- snapshot=snapshot,
- plugin=plugin,
- hook_name=hook_name,
- status=status,
- output_str=output_path or output_text,
- output_files=output_files,
- output_size=output_size,
- output_mimetypes=output_mimetypes,
- start_ts=now if status != ArchiveResult.StatusChoices.QUEUED else None,
- end_ts=now if status in ArchiveResult.FINAL_STATES else None,
- )
-
-
-def _snapshot_hook_name(plugin_name: str) -> str:
- from abx_dl.models import discover_plugins
-
- plugin = discover_plugins().get(plugin_name)
- assert plugin is not None, f"missing test plugin {plugin_name}"
- hooks = plugin.filter_hooks("Snapshot")
- assert hooks, f"missing Snapshot hooks for {plugin_name}"
- return hooks[0].name
-
-
-def test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snapshot_results_alone(
- tmp_path,
- client,
- recursive_test_site,
-):
- os.chdir(tmp_path)
- init_archive(tmp_path)
- api_token = create_admin_and_token(tmp_path)
-
- with use_archivebox_db(tmp_path):
- crawl_response = _post_json(
- client,
- "/api/v1/crawls/crawls",
- api_token,
- {
- "urls": [recursive_test_site["root_url"]],
- "max_depth": 0,
- "tags": ["crawl-archiveresult-pause"],
- "config": {"PLUGINS": "wget", "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*"},
- },
- )
- assert crawl_response.status_code == 200, crawl_response.content.decode()
- crawl_id = _json_response(crawl_response)["id"]
- from archivebox.services.runner import run_due_snapshot
-
- active_response = _post_json(
- client,
- "/api/v1/core/snapshots",
- api_token,
- {
- "url": recursive_test_site["root_url"],
- "crawl_id": crawl_id,
- "depth": 0,
- "title": "Active child",
- "status": "queued",
- },
- )
- assert active_response.status_code == 200, active_response.content.decode()
- active_snapshot = Snapshot.objects.get(id=_json_response(active_response)["id"])
-
- sealed_response = _post_json(
- client,
- "/api/v1/core/snapshots",
- api_token,
- {
- "url": recursive_test_site["child_urls"][0],
- "crawl_id": crawl_id,
- "depth": 0,
- "title": "Already sealed child",
- "status": "queued",
- },
- )
- assert sealed_response.status_code == 200, sealed_response.content.decode()
- sealed_snapshot_id = _json_response(sealed_response)["id"]
- sealed_snapshot = Snapshot.objects.get(id=sealed_snapshot_id)
- sealed_done = _seed_archiveresult(
- sealed_snapshot,
- plugin="sealedone",
- hook_name="on_Snapshot__sealed_done",
- status=ArchiveResult.StatusChoices.SUCCEEDED,
- output_text="sealed snapshot result remains finished",
- output_path="sealedone/final.txt",
- )
- sealed_snapshot.sm.seal()
- sealed_snapshot.refresh_from_db()
- assert sealed_snapshot.status == Snapshot.StatusChoices.SEALED
- assert sealed_snapshot.retry_at is None
-
- active_queued = _seed_archiveresult(
- active_snapshot,
- plugin="manualqueue",
- hook_name="on_Snapshot__manual_queue",
- status=ArchiveResult.StatusChoices.QUEUED,
- )
- active_started = _seed_archiveresult(
- active_snapshot,
- plugin="manualstart",
- hook_name="on_Snapshot__manual_start",
- status=ArchiveResult.StatusChoices.STARTED,
- )
- active_done = _seed_archiveresult(
- active_snapshot,
- plugin="manualdone",
- hook_name="on_Snapshot__manual_done",
- status=ArchiveResult.StatusChoices.SUCCEEDED,
- output_text="parent cascade should not rewrite finished rows",
- output_path="manualdone/cascade.txt",
- )
- pause_response = _patch_json(
- client,
- f"/api/v1/crawls/crawl/{crawl_id}",
- api_token,
- {"action": "pause"},
- )
- assert pause_response.status_code == 200, pause_response.content.decode()
- assert _json_response(pause_response)["status"] == Crawl.StatusChoices.PAUSED
-
- active_snapshot.refresh_from_db()
- sealed_snapshot.refresh_from_db()
- crawl = Crawl.objects.get(id=crawl_id)
- assert crawl.status == Crawl.StatusChoices.PAUSED
- assert crawl.retry_at == RETRY_AT_MAX
- assert active_snapshot.status == Snapshot.StatusChoices.QUEUED
- assert active_snapshot.retry_at is not None
- assert active_snapshot.retry_at <= timezone.now()
- assert ArchiveResult.objects.get(id=active_queued.id).status == ArchiveResult.StatusChoices.QUEUED
- assert ArchiveResult.objects.get(id=active_started.id).status == ArchiveResult.StatusChoices.STARTED
-
- assert run_due_snapshot(active_snapshot, lock_seconds=60) is True
- active_snapshot.refresh_from_db()
- sealed_snapshot.refresh_from_db()
- assert active_snapshot.status == Snapshot.StatusChoices.PAUSED
- assert active_snapshot.retry_at == RETRY_AT_MAX
- assert sealed_snapshot.status == Snapshot.StatusChoices.SEALED
- assert sealed_snapshot.retry_at is None
-
- paused_rows = {
- row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[active_queued.id, active_started.id])
- }
- assert paused_rows == {
- "manualqueue": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX),
- "manualstart": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX),
- }
-
- active_done_row = ArchiveResult.objects.get(id=active_done.id)
- sealed_done_row = ArchiveResult.objects.get(id=sealed_done.id)
- active_done_path = Path(active_snapshot.output_dir) / next(iter(active_done_row.output_files))
- sealed_done_path = Path(sealed_snapshot.output_dir) / next(iter(sealed_done_row.output_files))
- assert active_done_row.status == ArchiveResult.StatusChoices.SUCCEEDED
- assert active_done_row.retry_at is None
- assert active_done_path.read_text() == "parent cascade should not rewrite finished rows"
- assert sealed_done_row.status == ArchiveResult.StatusChoices.SUCCEEDED
- assert sealed_done_row.retry_at is None
- assert sealed_done_path.read_text() == "sealed snapshot result remains finished"
-
- resume_response = _patch_json(
- client,
- f"/api/v1/crawls/crawl/{crawl_id}",
- api_token,
- {"action": "resume"},
- )
- assert resume_response.status_code == 200, resume_response.content.decode()
- assert _json_response(resume_response)["status"] == Crawl.StatusChoices.QUEUED
-
- active_snapshot.refresh_from_db()
- sealed_snapshot.refresh_from_db()
- crawl.refresh_from_db()
- assert crawl.status == Crawl.StatusChoices.QUEUED
- assert crawl.retry_at is not None
- assert crawl.retry_at != RETRY_AT_MAX
- assert active_snapshot.status == Snapshot.StatusChoices.QUEUED
- assert active_snapshot.retry_at is not None
- assert active_snapshot.retry_at != RETRY_AT_MAX
- assert sealed_snapshot.status == Snapshot.StatusChoices.SEALED
- assert sealed_snapshot.retry_at is None
-
- resumed_rows = {
- row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[active_queued.id, active_started.id])
- }
- assert resumed_rows["manualqueue"][0] == ArchiveResult.StatusChoices.QUEUED
- assert resumed_rows["manualqueue"][1] is not None
- assert resumed_rows["manualqueue"][1] != RETRY_AT_MAX
- assert resumed_rows["manualstart"][0] == ArchiveResult.StatusChoices.QUEUED
- assert resumed_rows["manualstart"][1] is not None
- assert resumed_rows["manualstart"][1] != RETRY_AT_MAX
- assert ArchiveResult.objects.get(id=active_done.id).status == ArchiveResult.StatusChoices.SUCCEEDED
- assert ArchiveResult.objects.get(id=sealed_done.id).status == ArchiveResult.StatusChoices.SUCCEEDED
- assert active_done_path.read_text() == "parent cascade should not rewrite finished rows"
- assert sealed_done_path.read_text() == "sealed snapshot result remains finished"
-
-
-def test_targeted_extract_retries_one_failed_archiveresult_while_snapshot_stays_paused(
- tmp_path,
- client,
- recursive_test_site,
-):
- os.chdir(tmp_path)
- init_archive(tmp_path)
- api_token = create_admin_and_token(tmp_path)
-
- with use_archivebox_db(tmp_path):
- snapshot_response = _post_json(
- client,
- "/api/v1/core/snapshots",
- api_token,
- {
- "url": recursive_test_site["root_url"],
- "depth": 0,
- "title": "Paused targeted retry",
- "tags": ["targeted-extract-pause"],
- "status": "queued",
- },
- )
- assert snapshot_response.status_code == 200, snapshot_response.content.decode()
- snapshot_id = _json_response(snapshot_response)["id"]
- snapshot = Snapshot.objects.get(id=snapshot_id)
-
- wget_result = _seed_archiveresult(
- snapshot,
- plugin="wget",
- hook_name=_snapshot_hook_name("wget"),
- status=ArchiveResult.StatusChoices.FAILED,
- output_text="initial failure before targeted retry",
- )
- unrelated_result = _seed_archiveresult(
- snapshot,
- plugin="manualqueue",
- hook_name="on_Snapshot__manual_queue",
- status=ArchiveResult.StatusChoices.QUEUED,
- )
- finished_result = _seed_archiveresult(
- snapshot,
- plugin="manualdone",
- hook_name="on_Snapshot__manual_done",
- status=ArchiveResult.StatusChoices.SUCCEEDED,
- output_text="finished row must survive targeted retry",
- output_path="manualdone/targeted.txt",
- )
-
- pause_response = _patch_json(
- client,
- f"/api/v1/core/snapshot/{snapshot_id}",
- api_token,
- {"action": "pause"},
- )
- assert pause_response.status_code == 200, pause_response.content.decode()
- assert _json_response(pause_response)["status"] == Snapshot.StatusChoices.PAUSED
-
- snapshot = Snapshot.objects.get(id=snapshot_id)
- assert snapshot.status == Snapshot.StatusChoices.PAUSED
- assert snapshot.retry_at == RETRY_AT_MAX
- assert ArchiveResult.objects.get(id=wget_result.id).status == ArchiveResult.StatusChoices.FAILED
- assert ArchiveResult.objects.get(id=unrelated_result.id).status == ArchiveResult.StatusChoices.PAUSED
- finished_row = ArchiveResult.objects.get(id=finished_result.id)
- finished_output_path = Path(snapshot.output_dir) / next(iter(finished_row.output_files))
- assert finished_output_path.read_text() == "finished row must survive targeted retry"
-
- env = build_test_env(
- get_free_port(),
- PLUGINS="wget",
- SAVE_WGET="True",
- WGET_WARC_ENABLED="False",
- URL_ALLOWLIST=r"127\.0\.0\.1[:/].*",
- )
- extract = subprocess.run(
- [sys.executable, "-m", "archivebox", "extract", str(wget_result.id)],
- cwd=tmp_path,
- capture_output=True,
- text=True,
- env=env,
- timeout=150,
- )
- assert extract.returncode == 0, f"STDOUT:\n{extract.stdout}\nSTDERR:\n{extract.stderr}"
-
- with use_archivebox_db(tmp_path):
- snapshot = Snapshot.objects.get(id=snapshot_id)
- assert snapshot.status == Snapshot.StatusChoices.PAUSED
- assert snapshot.retry_at == RETRY_AT_MAX
-
- retried_wget = ArchiveResult.objects.get(id=wget_result.id)
- assert retried_wget.status == ArchiveResult.StatusChoices.SUCCEEDED
- assert retried_wget.output_size > 0
- assert retried_wget.output_files
-
- unrelated = ArchiveResult.objects.get(id=unrelated_result.id)
- assert unrelated.status == ArchiveResult.StatusChoices.PAUSED
- assert unrelated.retry_at == RETRY_AT_MAX
-
- finished = ArchiveResult.objects.get(id=finished_result.id)
- assert finished.status == ArchiveResult.StatusChoices.SUCCEEDED
- assert finished.retry_at is None
- assert finished_output_path.read_text() == "finished row must survive targeted retry"
+# test_crawl_pause_resume_api_cascades_archiveresults_and_leaves_finished_snapshot_results_alone moved to test_api_v1_crawls_crawl_crawl_id.py.
+# test_targeted_extract_retries_one_failed_archiveresult_while_snapshot_stays_paused moved to test_api_v1_core_snapshot_snapshot_id.py.
diff --git a/archivebox/tests/test_auth_ldap.py b/archivebox/tests/test_auth_ldap.py
index c0c92513..dc143e6d 100644
--- a/archivebox/tests/test_auth_ldap.py
+++ b/archivebox/tests/test_auth_ldap.py
@@ -132,27 +132,33 @@ class TestArchiveBoxWithLDAP:
def test_archivebox_init_without_ldap(self, tmp_path):
"""Test that archivebox init works without LDAP enabled."""
- _, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["init"],
- data_dir=tmp_path,
+ cwd=tmp_path,
timeout=45,
env={"LDAP_ENABLED": "False"},
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
# Should succeed
assert code == 0, f"archivebox init failed: {stderr}"
def test_archivebox_version_with_ldap_config(self, tmp_path):
"""Test that archivebox version works with LDAP config set."""
- _, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["version"],
- data_dir=tmp_path,
+ cwd=tmp_path,
timeout=10,
env={
"LDAP_ENABLED": "False",
"LDAP_SERVER_URI": "ldap://ldap-test.localhost:389",
},
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
# Should succeed
assert code == 0, f"archivebox version failed: {stderr}"
@@ -163,15 +169,18 @@ class TestLDAPConfigValidationInArchiveBox:
def test_archivebox_init_with_incomplete_ldap_config(self, tmp_path):
"""Test that archivebox init fails with helpful error when LDAP config is incomplete."""
- _, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["init"],
- data_dir=tmp_path,
+ cwd=tmp_path,
timeout=45,
env={
"LDAP_ENABLED": "True",
# Missing: LDAP_SERVER_URI, LDAP_BIND_DN, etc.
},
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
# Should fail with validation error
assert code != 0, "Should fail with incomplete LDAP config"
diff --git a/archivebox/tests/test_binary_service.py b/archivebox/tests/test_binary_service.py
index 9fe1e277..aa73f654 100644
--- a/archivebox/tests/test_binary_service.py
+++ b/archivebox/tests/test_binary_service.py
@@ -24,10 +24,6 @@ def _link_real_binary(bin_dir: Path, name: str, *, source: str | None = None) ->
return link
-def _binary_request(name: str, *, binproviders: str = "env") -> str:
- return json.dumps({"type": "BinaryRequest", "name": name, "binproviders": binproviders}) + "\n"
-
-
def _runtime_env(data_dir: Path, bin_dir: Path) -> dict[str, str]:
return {
"LIB_DIR": str(data_dir / "lib"),
@@ -100,13 +96,16 @@ def test_binary_request_installs_env_binary_and_recovers_stale_cache(initialized
_link_real_binary(bootstrap_bin_dir, "uv")
_link_real_binary(provider_bin_dir, name, source="rg")
- stdout, stderr, returncode = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
- data_dir=initialized_archive,
- stdin=_binary_request(name),
+ cwd=initialized_archive,
+ stdin=json.dumps({"type": "BinaryRequest", "name": name, "binproviders": "env"}) + "\n",
timeout=120,
env=_runtime_env(initialized_archive, bootstrap_bin_dir),
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, stderr
output_records = parse_jsonl_output(stdout)
@@ -135,12 +134,15 @@ def test_binary_request_installs_env_binary_and_recovers_stale_cache(initialized
assert binary_processes[-1].exit_code == 0
assert any(f"--name={name}" in arg for arg in binary_processes[-1].cmd)
- version_stdout, version_stderr, version_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["version"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=60,
env=_runtime_env(initialized_archive, bootstrap_bin_dir),
+ default_cli_env=True,
+ disable_extractors=True,
)
+ version_stdout, version_stderr, version_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert version_code == 0, version_stderr
assert name in version_stdout
assert binary.version in version_stdout
@@ -149,12 +151,15 @@ def test_binary_request_installs_env_binary_and_recovers_stale_cache(initialized
(initialized_archive / "lib" / "bin" / name).unlink(missing_ok=True)
_link_real_binary(bootstrap_bin_dir, name, source="rg")
- rerun_stdout, rerun_stderr, rerun_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run", f"--binary-id={first_binary_id}"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=_runtime_env(initialized_archive, bootstrap_bin_dir),
+ default_cli_env=True,
+ disable_extractors=True,
)
+ rerun_stdout, rerun_stderr, rerun_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert rerun_code == 0, rerun_stdout + rerun_stderr
with use_archivebox_db(initialized_archive):
@@ -174,13 +179,16 @@ def test_missing_binary_request_stays_queued_then_recovers_when_provider_can_res
provider_bin_dir = initialized_archive / "lib" / "env" / "bin"
_link_real_binary(bootstrap_bin_dir, "uv")
- stdout, stderr, returncode = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
- data_dir=initialized_archive,
- stdin=_binary_request(name),
+ cwd=initialized_archive,
+ stdin=json.dumps({"type": "BinaryRequest", "name": name, "binproviders": "env"}) + "\n",
timeout=120,
env=_runtime_env(initialized_archive, bootstrap_bin_dir),
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, stderr
assert any(record["type"] == "BinaryRequest" and record["name"] == name for record in parse_jsonl_output(stdout))
@@ -201,12 +209,15 @@ def test_missing_binary_request_stays_queued_then_recovers_when_provider_can_res
_link_real_binary(provider_bin_dir, name, source="rg")
- recover_stdout, recover_stderr, recover_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run", f"--binary-id={queued_id}"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=_runtime_env(initialized_archive, bootstrap_bin_dir),
+ default_cli_env=True,
+ disable_extractors=True,
)
+ recover_stdout, recover_stderr, recover_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert recover_code == 0, recover_stdout + recover_stderr
with use_archivebox_db(initialized_archive):
diff --git a/archivebox/tests/test_cli_add.py b/archivebox/tests/test_cli_add.py
index 3a661494..df95c552 100644
--- a/archivebox/tests/test_cli_add.py
+++ b/archivebox/tests/test_cli_add.py
@@ -5,64 +5,49 @@ Verify add creates snapshots in DB, crawls, source files, and archive directorie
"""
import os
-import subprocess
-from pathlib import Path
+import json
import pytest
-from archivebox.core.models import Snapshot
+from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
-from archivebox.tests.conftest import run_queued_crawls
+from archivebox.machine.models import Process
+from archivebox.tests.conftest import _find_system_browser, find_snapshot_dir, run_archivebox_cmd, run_queued_crawls, cli_env
+
from archivebox.tests.test_orm_helpers import use_archivebox_db
pytestmark = pytest.mark.django_db(transaction=True)
-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
-
-
-def test_add_single_url_creates_snapshot_in_db(tmp_path, process, disable_extractors_dict):
+def test_add_single_url_creates_snapshot_in_db(initialized_archive):
"""Test that adding a single URL queues a crawl whose runner creates the snapshot."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
assert result.returncode == 0
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
snapshots = list(Snapshot.objects.values_list("url", flat=True))
assert len(snapshots) == 1
assert snapshots[0] == "https://example.com"
-def test_add_bg_queues_crawl_without_creating_snapshots(tmp_path, process, disable_extractors_dict):
+def test_add_bg_queues_crawl_without_creating_snapshots(initialized_archive):
"""Background add should leave root snapshot creation to the runner."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "add", "--bg", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_cmd(
+ ["add", "--bg", "--depth=0", "https://example.com"],
+ env=env,
)
assert result.returncode == 0
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
crawl = Crawl.objects.get()
snapshot_count = Snapshot.objects.count()
@@ -71,25 +56,23 @@ def test_add_bg_queues_crawl_without_creating_snapshots(tmp_path, process, disab
assert snapshot_count == 0
-def test_add_index_only_rejected_urls_leave_empty_crawl_for_runner_to_seal(tmp_path, process, disable_extractors_dict):
+def test_add_index_only_rejected_urls_leave_empty_crawl_for_runner_to_seal(initialized_archive):
"""Index-only add only creates the crawl; rejected URLs are sealed by the runner."""
- os.chdir(tmp_path)
- result = subprocess.run(
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_cmd(
[
- "archivebox",
"add",
"--index-only",
"--depth=0",
"--url-denylist=example.com",
"https://example.com",
],
- capture_output=True,
- env=disable_extractors_dict,
+ env=env,
)
assert result.returncode == 0
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
crawl = Crawl.objects.get()
snapshot_count = Snapshot.objects.count()
@@ -97,9 +80,9 @@ def test_add_index_only_rejected_urls_leave_empty_crawl_for_runner_to_seal(tmp_p
assert crawl.retry_at is None
assert snapshot_count == 0
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
crawl = Crawl.objects.get()
snapshot_count = Snapshot.objects.count()
@@ -108,31 +91,55 @@ def test_add_index_only_rejected_urls_leave_empty_crawl_for_runner_to_seal(tmp_p
assert snapshot_count == 0
-def test_add_creates_crawl_record(tmp_path, process, disable_extractors_dict):
- """Test that add command creates a Crawl record in the database."""
- os.chdir(tmp_path)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+def test_add_index_only_rejects_archivebox_internal_urls(initialized_archive):
+ """Index-only add must apply the same internal URL guard as snapshot creation."""
+ env = cli_env(disable_extractors=True)
+ internal_urls = [
+ "http://archivebox.localhost:9292/admin/",
+ "http://web.archivebox.localhost:9292/",
+ "http://api.archivebox.localhost:9292/api/v1/docs",
+ "http://snap-2fb8e923c58c.archivebox.localhost:9292/index.html",
+ ]
+ result = run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", *internal_urls],
+ env={**env, "BASE_URL": "http://archivebox.localhost:9292"},
)
- with use_archivebox_db(tmp_path):
+ assert result.returncode == 0
+
+ with use_archivebox_db(initialized_archive):
+ crawl = Crawl.objects.get()
+ snapshot_count = Snapshot.objects.count()
+
+ assert crawl.get_urls_list() == []
+ assert crawl.status == Crawl.StatusChoices.QUEUED
+ assert crawl.retry_at is None
+ assert snapshot_count == 0
+
+
+def test_add_creates_crawl_record(initialized_archive):
+ """Test that add command creates a Crawl record in the database."""
+ env = cli_env(disable_extractors=True)
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
+ )
+
+ with use_archivebox_db(initialized_archive):
crawl_count = Crawl.objects.count()
assert crawl_count == 1
-def test_add_creates_source_file(tmp_path, process, disable_extractors_dict):
+def test_add_creates_source_file(initialized_archive):
"""Test that add creates a source file with the URL."""
- os.chdir(tmp_path)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- sources_dir = tmp_path / "sources"
+ sources_dir = initialized_archive / "sources"
assert sources_dir.exists()
source_files = list(sources_dir.glob("*cli_add.txt"))
@@ -142,19 +149,18 @@ def test_add_creates_source_file(tmp_path, process, disable_extractors_dict):
assert "https://example.com" in source_content
-def test_add_multiple_urls_single_command(tmp_path, process, disable_extractors_dict):
+def test_add_multiple_urls_single_command(initialized_archive):
"""Test adding multiple URLs in a single command."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com", "https://example.org"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com", "https://example.org"],
+ env=env,
)
assert result.returncode == 0
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
snapshot_count = Snapshot.objects.count()
urls = list(Snapshot.objects.order_by("url").values_list("url", flat=True))
@@ -163,28 +169,27 @@ def test_add_multiple_urls_single_command(tmp_path, process, disable_extractors_
assert urls[1] == "https://example.org"
-def test_add_from_file(tmp_path, process, disable_extractors_dict):
+def test_add_from_file(initialized_archive):
"""Test adding URLs from a file.
The add command should treat a file argument as URL input and create snapshots
for each URL it contains.
"""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Create a file with URLs
- urls_file = tmp_path / "urls.txt"
+ urls_file = initialized_archive / "urls.txt"
urls_file.write_text("https://example.com\nhttps://example.org\n")
- result = subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", str(urls_file)],
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", str(urls_file)],
+ env=env,
)
assert result.returncode == 0
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
crawl_count = Crawl.objects.count()
snapshot_count = Snapshot.objects.count()
@@ -193,91 +198,85 @@ def test_add_from_file(tmp_path, process, disable_extractors_dict):
assert snapshot_count == 2
-def test_add_with_depth_0_flag(tmp_path, process, disable_extractors_dict):
+def test_add_with_depth_0_flag(initialized_archive):
"""Test that --depth=0 flag is accepted and works."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
assert result.returncode == 0
- assert "unrecognized arguments: --depth" not in result.stderr.decode("utf-8")
+ assert "unrecognized arguments: --depth" not in result.stderr
-def test_add_with_depth_1_flag(tmp_path, process, disable_extractors_dict):
+def test_add_with_depth_1_flag(initialized_archive):
"""Test that --depth=1 flag is accepted."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=1", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_cmd(
+ ["add", "--index-only", "--depth=1", "https://example.com"],
+ env=env,
)
assert result.returncode == 0
- assert "unrecognized arguments: --depth" not in result.stderr.decode("utf-8")
+ assert "unrecognized arguments: --depth" not in result.stderr
-def test_add_rejects_invalid_depth_values(tmp_path, process, disable_extractors_dict):
+def test_add_rejects_invalid_depth_values(initialized_archive):
"""Test that add rejects depth values outside the supported range."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
for depth in ("5", "-1"):
- result = subprocess.run(
- ["archivebox", "add", "--index-only", f"--depth={depth}", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["add", "--index-only", f"--depth={depth}", "https://example.com"],
+ env=env,
)
- stderr = result.stderr.decode("utf-8").lower()
+ stderr = result.stderr.lower()
assert result.returncode != 0
assert "invalid" in stderr or "not one of" in stderr
-def test_add_with_tags(tmp_path, process, disable_extractors_dict):
+def test_add_with_tags(initialized_archive):
"""Test adding URL with tags stores tags_str in crawl.
With --index-only, Tag objects are not created until archiving happens.
Tags are stored as a string in the Crawl.tags_str field.
"""
- os.chdir(tmp_path)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "--tag=test,example", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "--tag=test,example", "https://example.com"],
+ env=env,
)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
tags_str = Crawl.objects.values_list("tags_str", flat=True).get()
# Tags are stored as a comma-separated string in crawl
assert "test" in tags_str or "example" in tags_str
-def test_add_records_selected_persona_on_crawl(tmp_path, process, disable_extractors_dict):
+def test_add_records_selected_persona_on_crawl(initialized_archive):
"""Test add persists the selected persona so browser config derives from it later."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "--persona=Default", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "--persona=Default", "https://example.com"],
+ env=env,
)
assert result.returncode == 0
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
crawl = Crawl.objects.get()
assert crawl.persona_id
assert "ACTIVE_PERSONA" not in crawl.config
- assert (tmp_path / "personas" / "Default" / "chrome_profile").is_dir()
+ assert (initialized_archive / "personas" / "Default" / "chrome_profile").is_dir()
-def test_add_records_url_filter_overrides_on_crawl(tmp_path, process, disable_extractors_dict):
- os.chdir(tmp_path)
- result = subprocess.run(
+def test_add_records_url_filter_overrides_on_crawl(initialized_archive):
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_cmd(
[
- "archivebox",
"add",
"--index-only",
"--depth=0",
@@ -285,45 +284,42 @@ def test_add_records_url_filter_overrides_on_crawl(tmp_path, process, disable_ex
"--domain-denylist=static.example.com",
"https://example.com",
],
- capture_output=True,
- env=disable_extractors_dict,
+ env=env,
)
assert result.returncode == 0
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
crawl = Crawl.objects.get()
assert crawl.config["URL_ALLOWLIST"] == "example.com,*.example.com"
assert crawl.config["URL_DENYLIST"] == "static.example.com"
- assert not (tmp_path / "personas" / "Default" / "chrome_extensions").exists()
+ assert not (initialized_archive / "personas" / "Default" / "chrome_extensions").exists()
-def test_add_duplicate_url_creates_separate_crawls(tmp_path, process, disable_extractors_dict):
+def test_add_duplicate_url_creates_separate_crawls(initialized_archive):
"""Test that adding the same URL twice creates separate crawls and snapshots.
Each 'add' command creates a new Crawl. Multiple crawls can archive the same URL.
This allows re-archiving URLs at different times.
"""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add URL first time
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
# Add same URL second time with --update to opt out of ONLY_NEW.
- subprocess.run(
- ["archivebox", "add", "--index-only", "--update", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--update", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
snapshot_count = Snapshot.objects.filter(url="https://example.com").count()
crawl_count = Crawl.objects.count()
@@ -332,54 +328,48 @@ def test_add_duplicate_url_creates_separate_crawls(tmp_path, process, disable_ex
assert snapshot_count == 2
-def test_add_with_overwrite_flag(tmp_path, process, disable_extractors_dict):
+def test_add_with_overwrite_flag(initialized_archive):
"""Test that --overwrite flag forces re-archiving."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add URL first time
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
# Add with overwrite
- result = subprocess.run(
- ["archivebox", "add", "--index-only", "--overwrite", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["add", "--index-only", "--overwrite", "https://example.com"],
+ env=env,
)
assert result.returncode == 0
- assert "unrecognized arguments: --overwrite" not in result.stderr.decode("utf-8")
+ assert "unrecognized arguments: --overwrite" not in result.stderr
-def test_add_creates_snapshot_output_directory(tmp_path, process, disable_extractors_dict):
+def test_add_creates_snapshot_output_directory(initialized_archive):
"""Test that add creates the current snapshot output directory on disk."""
- os.chdir(tmp_path)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
snapshot_id = str(Snapshot.objects.values_list("id", flat=True).get())
- snapshot_dir = _find_snapshot_dir(tmp_path, snapshot_id)
+ snapshot_dir = find_snapshot_dir(initialized_archive, snapshot_id)
assert snapshot_dir is not None, f"Snapshot output directory not found for {snapshot_id}"
assert snapshot_dir.is_dir()
-def test_add_help_shows_depth_and_tag_options(tmp_path, process):
+def test_add_help_shows_depth_and_tag_options(initialized_archive):
"""Test that add --help documents the main filter and crawl options."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "add", "--help"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["add", "--help"],
)
assert result.returncode == 0
@@ -391,11 +381,10 @@ def test_add_help_shows_depth_and_tag_options(tmp_path, process):
assert "--tag" in result.stdout
-def test_add_records_max_url_and_size_limits_on_crawl(tmp_path, process, disable_extractors_dict):
- os.chdir(tmp_path)
- result = subprocess.run(
+def test_add_records_max_url_and_size_limits_on_crawl(initialized_archive):
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_cmd(
[
- "archivebox",
"add",
"--index-only",
"--depth=1",
@@ -405,14 +394,13 @@ def test_add_records_max_url_and_size_limits_on_crawl(tmp_path, process, disable
"--snapshot-max-size=5mb",
"https://example.com",
],
- capture_output=True,
- env=disable_extractors_dict,
+ env=env,
)
assert result.returncode == 0
columns = {field.name for field in Crawl._meta.local_fields}
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
config = Crawl.objects.values_list("config", flat=True).get() or {}
assert {"max_urls", "crawl_max_size", "crawl_timeout", "snapshot_max_size"}.isdisjoint(columns)
@@ -422,14 +410,11 @@ def test_add_records_max_url_and_size_limits_on_crawl(tmp_path, process, disable
assert config["SNAPSHOT_MAX_SIZE"] == 5 * 1024 * 1024
-def test_add_without_args_shows_usage(tmp_path, process):
+def test_add_without_args_shows_usage(initialized_archive):
"""Test that add without URLs fails with a usage hint instead of crashing."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "add"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["add"],
)
combined = result.stdout + result.stderr
@@ -437,19 +422,18 @@ def test_add_without_args_shows_usage(tmp_path, process):
assert "usage" in combined.lower() or "url" in combined.lower()
-def test_add_index_only_queues_crawl_without_starting_runner(tmp_path, process, disable_extractors_dict):
+def test_add_index_only_queues_crawl_without_starting_runner(initialized_archive):
"""Test that --index-only creates only a queued crawl and returns fast."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
timeout=30, # Should be fast
)
assert result.returncode == 0
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
crawl = Crawl.objects.get()
snapshot_count = Snapshot.objects.count()
@@ -458,35 +442,260 @@ def test_add_index_only_queues_crawl_without_starting_runner(tmp_path, process,
assert snapshot_count == 0
-def test_add_links_snapshot_to_crawl(tmp_path, process, disable_extractors_dict):
+def test_add_links_snapshot_to_crawl(initialized_archive):
"""Test that add links the snapshot to the crawl via crawl_id."""
- os.chdir(tmp_path)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
crawl_id = Crawl.objects.values_list("id", flat=True).get()
snapshot_crawl = Snapshot.objects.values_list("crawl_id", flat=True).get()
assert snapshot_crawl == crawl_id
-def test_add_sets_snapshot_timestamp(tmp_path, process, disable_extractors_dict):
+def test_add_sets_snapshot_timestamp(initialized_archive):
"""Test that add sets a timestamp on the snapshot."""
- os.chdir(tmp_path)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
timestamp = Snapshot.objects.values_list("timestamp", flat=True).get()
assert timestamp is not None
assert len(str(timestamp)) > 0
+
+
+@pytest.mark.timeout(180)
+def test_cli_add_real_urls_with_options_writes_inspectable_outputs(initialized_archive):
+
+ wget_urls = [
+ "https://example.com",
+ "https://pirate.github.io/stress-tests/challenge.html",
+ ]
+ chrome_url = "https://example.com/?archivebox-chrome-flow=1"
+ env = os.environ.copy()
+ env.pop("CHROME_BINARY", None)
+ env.update(
+ {
+ "USE_COLOR": "false",
+ "SHOW_PROGRESS": "false",
+ "TIMEOUT": "60",
+ "SAVE_WGET": "true",
+ "SAVE_HEADERS": "false",
+ "SAVE_TITLE": "false",
+ "SAVE_READABILITY": "false",
+ "SAVE_SINGLEFILE": "false",
+ "SAVE_MERCURY": "false",
+ "SAVE_SCREENSHOT": "false",
+ "SAVE_PDF": "false",
+ "SAVE_DOM": "false",
+ "SAVE_ARCHIVEDOTORG": "false",
+ "SAVE_GIT": "false",
+ "SAVE_YTDLP": "false",
+ "SAVE_FAVICON": "false",
+ },
+ )
+ _cmd_result = run_archivebox_cmd(
+ [
+ "add",
+ "--depth=0",
+ "--max-urls=2",
+ "--crawl-max-size=10mb",
+ "--tag=real-flow,challenge",
+ "--parser=url_list",
+ "--plugins=wget",
+ *wget_urls,
+ ],
+ cwd=initialized_archive,
+ env=env,
+ timeout=180,
+ )
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert returncode == 0, stderr or stdout
+
+ chrome_env = env | {
+ "SAVE_WGET": "false",
+ "SAVE_HEADERS": "true",
+ "SAVE_TITLE": "true",
+ "CHROME_HEADLESS": "true",
+ "CHROME_SANDBOX": "false",
+ "CHROME_ISOLATION": "snapshot",
+ }
+ system_browser = _find_system_browser()
+ if system_browser:
+ chrome_env["CHROME_BINARY"] = str(system_browser)
+ else:
+ _cmd_result = run_archivebox_cmd(
+ ["install", "chrome"],
+ cwd=initialized_archive,
+ env=chrome_env,
+ timeout=600,
+ )
+ install_stdout, install_stderr, install_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert install_returncode == 0, install_stderr or install_stdout
+ _cmd_result = run_archivebox_cmd(
+ [
+ "add",
+ "--depth=0",
+ "--max-urls=1",
+ "--crawl-max-size=10mb",
+ "--tag=chrome-flow",
+ "--parser=url_list",
+ "--plugins=chrome,wget,headers,title",
+ chrome_url,
+ ],
+ cwd=initialized_archive,
+ env=chrome_env,
+ timeout=180,
+ )
+ chrome_stdout, chrome_stderr, chrome_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert chrome_returncode == 0, chrome_stderr or chrome_stdout
+
+ _cmd_result = run_archivebox_cmd(
+ ["list", "--tag=real-flow"],
+ cwd=initialized_archive,
+ env=env,
+ timeout=60,
+ )
+ list_stdout, list_stderr, list_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert list_returncode == 0, list_stderr or list_stdout
+ listed = [json.loads(line) for line in list_stdout.splitlines() if line.strip()]
+ assert {item["url"] for item in listed} >= set(wget_urls)
+
+ with use_archivebox_db(initialized_archive):
+ crawl = Crawl.objects.order_by("-created_at").values_list("max_depth", "tags_str", "config").first()
+ real_flow_crawl = Crawl.objects.filter(tags_str="real-flow,challenge").values_list("max_depth", "tags_str", "config").first()
+ snapshots = list(Snapshot.objects.order_by("url").values_list("id", "url", "depth", "status", "title"))
+ archive_results = list(
+ ArchiveResult.objects.select_related("snapshot")
+ .order_by("snapshot__url", "plugin")
+ .values_list("snapshot__url", "plugin", "status", "output_files", "output_size", "output_str"),
+ )
+ processes = list(Process.objects.filter(process_type="hook").values_list("process_type", "status", "exit_code", "pwd", "cmd"))
+
+ assert real_flow_crawl is not None
+ assert real_flow_crawl[0] == 0
+ assert real_flow_crawl[1] == "real-flow,challenge"
+ real_flow_config = real_flow_crawl[2] or {}
+ assert real_flow_config["CRAWL_MAX_URLS"] == 2
+ assert real_flow_config["CRAWL_MAX_SIZE"] == 10 * 1024 * 1024
+ assert real_flow_config.get("SNAPSHOT_MAX_SIZE", 0) == 0
+ assert "wget" in real_flow_config["PLUGINS"]
+ assert crawl is not None
+ assert crawl[1] == "chrome-flow"
+ assert "wget,headers,title" in json.dumps(crawl[2] or {})
+
+ snapshot_urls = {url for _id, url, _depth, _status, _title in snapshots}
+ assert snapshot_urls >= {*wget_urls, chrome_url}
+ assert all(depth == 0 for _id, _url, depth, _status, _title in snapshots)
+
+ by_url_plugin = {(url, plugin): status for url, plugin, status, _files, _size, _output in archive_results}
+ assert by_url_plugin[("https://example.com", "wget")] == "succeeded"
+ assert by_url_plugin[("https://pirate.github.io/stress-tests/challenge.html", "wget")] == "succeeded"
+ assert (chrome_url, "headers") in by_url_plugin
+ assert (chrome_url, "title") in by_url_plugin
+ failed_results = [(url, plugin, output) for url, plugin, status, _files, _size, output in archive_results if status == "failed"]
+ assert len(failed_results) <= 2, failed_results
+
+ snapshot_root = initialized_archive / "archive/users/system/snapshots"
+ html_outputs = [path for path in snapshot_root.rglob("wget/**/*.html") if path.is_file()]
+ header_outputs = [path for path in snapshot_root.rglob("headers/**/headers.json") if path.is_file() and path.stat().st_size > 0]
+ title_outputs = [path for path in snapshot_root.rglob("title/title.txt") if path.is_file() and path.stat().st_size > 0]
+ index_outputs = [path for path in snapshot_root.rglob("index.jsonl") if path.is_file()]
+ assert html_outputs
+ if by_url_plugin[(chrome_url, "headers")] == "succeeded":
+ assert header_outputs
+ if by_url_plugin[(chrome_url, "title")] == "succeeded":
+ assert title_outputs
+ assert any("Example Domain" in path.read_text(errors="ignore") for path in title_outputs)
+ assert len(index_outputs) >= len(wget_urls) + 1
+
+ combined_html = "\n".join(path.read_text(errors="ignore") for path in html_outputs)
+ assert "Example Domain" in combined_html
+ assert "Browser Agent Challenge for AI Browser Drivers" in combined_html
+
+ assert processes
+ assert any("wget" in (pwd or "") or "wget" in (cmd or "") for _type, _status, _exit, pwd, cmd in processes)
+ assert any("headers" in (pwd or "") or "headers" in (cmd or "") for _type, _status, _exit, pwd, cmd in processes)
+
+
+@pytest.mark.timeout(180)
+def test_cli_recursive_crawl_processes_discovered_html_urls(initialized_archive):
+
+ env = os.environ.copy()
+ env.update(
+ {
+ "USE_COLOR": "false",
+ "SHOW_PROGRESS": "false",
+ "TIMEOUT": "60",
+ "SAVE_WGET": "true",
+ "SAVE_HEADERS": "false",
+ "SAVE_TITLE": "false",
+ "SAVE_READABILITY": "false",
+ "SAVE_SINGLEFILE": "false",
+ "SAVE_MERCURY": "false",
+ "SAVE_SCREENSHOT": "false",
+ "SAVE_PDF": "false",
+ "SAVE_DOM": "false",
+ "SAVE_ARCHIVEDOTORG": "false",
+ "SAVE_GIT": "false",
+ "SAVE_YTDLP": "false",
+ "SAVE_FAVICON": "false",
+ "PARSE_HTML_URLS_ENABLED": "true",
+ "PARSE_DOM_OUTLINKS_ENABLED": "false",
+ },
+ )
+
+ _cmd_result = run_archivebox_cmd(
+ [
+ "add",
+ "--depth=2",
+ "--max-urls=2",
+ "--crawl-max-size=50mb",
+ "--tag=recursive-flow",
+ "--parser=url_list",
+ "--plugins=wget,parse_html_urls",
+ "https://example.com",
+ ],
+ cwd=initialized_archive,
+ env=env,
+ timeout=180,
+ )
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert returncode == 0, stderr or stdout
+
+ with use_archivebox_db(initialized_archive):
+ crawl = Crawl.objects.order_by("-created_at").values_list("max_depth", "tags_str", "config").first()
+ snapshots = list(Snapshot.objects.order_by("depth", "url").values_list("url", "depth", "status"))
+ archive_results = list(
+ ArchiveResult.objects.select_related("snapshot")
+ .order_by("snapshot__depth", "snapshot__url", "plugin")
+ .values_list("snapshot__url", "plugin", "status", "output_files"),
+ )
+
+ assert crawl[0] == 2
+ assert crawl[1] == "recursive-flow"
+ crawl_config = crawl[2] or {}
+ assert crawl_config["CRAWL_MAX_URLS"] == 2
+ assert crawl_config["CRAWL_MAX_SIZE"] == 50 * 1024 * 1024
+ assert crawl_config.get("SNAPSHOT_MAX_SIZE", 0) == 0
+ assert ("https://example.com", 0, "sealed") in snapshots
+ assert any(url == "https://iana.org/domains/example" and depth == 1 and status == "sealed" for url, depth, status in snapshots)
+
+ by_url_plugin = {(url, plugin): status for url, plugin, status, _files in archive_results}
+ assert by_url_plugin[("https://example.com", "wget")] == "succeeded"
+ assert by_url_plugin[("https://example.com", "parse_html_urls")] == "succeeded"
+ assert by_url_plugin[("https://iana.org/domains/example", "wget")] == "succeeded"
+
+ urls_outputs = list((initialized_archive / "archive/users/system/snapshots").rglob("parse_html_urls/urls.jsonl"))
+ assert urls_outputs
+ assert any("https://iana.org/domains/example" in path.read_text() for path in urls_outputs)
diff --git a/archivebox/tests/test_cli_archiveresult.py b/archivebox/tests/test_cli_archiveresult.py
index 3a4174b6..681a8a1f 100644
--- a/archivebox/tests/test_cli_archiveresult.py
+++ b/archivebox/tests/test_cli_archiveresult.py
@@ -32,15 +32,24 @@ class TestArchiveResultCreate:
url = create_test_url()
# Create a snapshot first
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
# Pipe snapshot to archiveresult create
- stdout2, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create", "--plugin=title"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, f"Command failed: {stderr}"
@@ -57,14 +66,23 @@ class TestArchiveResultCreate:
def test_create_with_specific_plugin(self, initialized_archive):
"""Create archive result for specific plugin."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout2, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create", "--plugin=screenshot"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout2)
@@ -77,21 +95,28 @@ class TestArchiveResultCreate:
url = create_test_url()
# Create crawl and snapshot
- stdout1, _, _ = run_archivebox_cmd(["crawl", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
crawl = parse_jsonl_output(stdout1)[0]
- stdout2, _, _ = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "create"],
stdin=json.dumps(crawl),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
# Now pipe all to archiveresult create
- stdout3, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create", "--plugin=title"],
stdin=stdout2,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout3, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout3)
@@ -105,11 +130,14 @@ class TestArchiveResultCreate:
"""Only pass-through records but no new snapshots returns success."""
crawl_record = {"type": "Crawl", "id": "fake-id", "urls": "https://example.com"}
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create"],
stdin=json.dumps(crawl_record),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Passed through" in stderr
@@ -120,10 +148,13 @@ class TestArchiveResultList:
def test_list_empty(self, initialized_archive):
"""List with no archive results returns empty."""
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "list"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Listed 0 archive results" in stderr
@@ -132,36 +163,53 @@ class TestArchiveResultList:
"""Filter archive results by status."""
# Create snapshot and materialize an archive result via the runner
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout2, _, _ = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create", "--plugin=favicon"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
run_archivebox_cmd(
["run"],
stdin=stdout2,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=PROJECTOR_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
- created = parse_jsonl_output(
- run_archivebox_cmd(
- ["archiveresult", "list", "--plugin=favicon"],
- data_dir=initialized_archive,
- )[0],
- )[0]
+ _cmd_result = run_archivebox_cmd(
+ ["archiveresult", "list", "--plugin=favicon"],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ created = parse_jsonl_output(_cmd_result.stdout)[0]
run_archivebox_cmd(
["archiveresult", "update", "--status=queued"],
stdin=json.dumps(created),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "list", "--status=queued"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -171,25 +219,39 @@ class TestArchiveResultList:
def test_list_filter_by_plugin(self, initialized_archive):
"""Filter archive results by plugin."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout2, _, _ = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create", "--plugin=favicon"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
run_archivebox_cmd(
["run"],
stdin=stdout2,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=PROJECTOR_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "list", "--plugin=favicon"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -201,25 +263,39 @@ class TestArchiveResultList:
# Create multiple archive results
for _ in range(3):
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout2, _, _ = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create", "--plugin=favicon"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
run_archivebox_cmd(
["run"],
stdin=stdout2,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=PROJECTOR_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "list", "--limit=2"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -232,32 +308,50 @@ class TestArchiveResultUpdate:
def test_update_status(self, initialized_archive):
"""Update archive result status."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout2, _, _ = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create", "--plugin=favicon"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
- stdout_run, _, _ = run_archivebox_cmd(
+ stdout2, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=stdout2,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=PROJECTOR_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
- stdout_list, _, _ = run_archivebox_cmd(
+ _stdout_run, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "list", "--plugin=favicon"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout_list, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
ar = parse_jsonl_output(stdout_list)[0]
- stdout3, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "update", "--status=failed"],
stdin=json.dumps(ar),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout3, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Updated 1 archive results" in stderr
@@ -272,32 +366,50 @@ class TestArchiveResultDelete:
def test_delete_requires_yes(self, initialized_archive):
"""Delete requires --yes flag."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout2, _, _ = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create", "--plugin=favicon"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
- stdout_run, _, _ = run_archivebox_cmd(
+ stdout2, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=stdout2,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=PROJECTOR_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
- stdout_list, _, _ = run_archivebox_cmd(
+ _stdout_run, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "list", "--plugin=favicon"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout_list, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
ar = parse_jsonl_output(stdout_list)[0]
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "delete"],
stdin=json.dumps(ar),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 1
assert "--yes" in stderr
@@ -305,32 +417,50 @@ class TestArchiveResultDelete:
def test_delete_with_yes(self, initialized_archive):
"""Delete with --yes flag works."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout2, _, _ = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create", "--plugin=favicon"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
- stdout_run, _, _ = run_archivebox_cmd(
+ stdout2, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=stdout2,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=PROJECTOR_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
- stdout_list, _, _ = run_archivebox_cmd(
+ _stdout_run, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "list", "--plugin=favicon"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout_list, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
ar = parse_jsonl_output(stdout_list)[0]
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "delete", "--yes"],
stdin=json.dumps(ar),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Deleted 1 archive results" in stderr
diff --git a/archivebox/tests/test_cli_binary.py b/archivebox/tests/test_cli_binary.py
new file mode 100644
index 00000000..480728ea
--- /dev/null
+++ b/archivebox/tests/test_cli_binary.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""
+Tests for archivebox binary command.
+
+TODO: expand beyond command discovery into create/list/update/delete behavior.
+"""
+
+from archivebox.tests.conftest import run_archivebox_cmd
+
+
+def test_binary_help_runs_successfully(tmp_path):
+ """The binary command should be registered and expose help."""
+
+ result = run_archivebox_cmd(["binary", "--help"])
+
+ assert result.returncode == 0
+ assert "binary" in result.stdout.lower()
+ assert "list" in result.stdout
diff --git a/archivebox/tests/test_cli_config.py b/archivebox/tests/test_cli_config.py
index 5cb28a48..ba017bba 100644
--- a/archivebox/tests/test_cli_config.py
+++ b/archivebox/tests/test_cli_config.py
@@ -4,14 +4,12 @@ Comprehensive tests for archivebox config command.
Verify config reads/writes ArchiveBox.conf file correctly.
"""
-import os
-import subprocess
+from archivebox.tests.conftest import run_archivebox_cmd
-def test_config_displays_all_config(tmp_path, process):
+def test_config_displays_all_config(initialized_archive):
"""Test that config without args displays all configuration."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "config"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["config"])
assert result.returncode == 0
output = result.stdout
@@ -21,180 +19,232 @@ def test_config_displays_all_config(tmp_path, process):
assert "TIMEOUT" in output or "OUTPUT_PERMISSIONS" in output
-def test_config_get_specific_key(tmp_path, process):
+def test_config_shows_derived_collection_paths_but_not_runtime_dirs(initialized_archive):
+ """The CLI should expose collection paths, not per-crawl/per-snapshot runtime dirs."""
+ run_archivebox_cmd(["init"], cwd=initialized_archive, check=True)
+
+ result = run_archivebox_cmd(["config"], cwd=initialized_archive)
+
+ assert result.returncode == 0, result.stderr
+ unwrapped_output = result.stdout.replace("\n", "")
+ assert "DATA_DIR" in result.stdout
+ assert result.stdout.count("\nDATA_DIR =") == 1
+ assert str(initialized_archive) in unwrapped_output
+ assert "PERSONAS_DIR" in result.stdout
+ assert result.stdout.count("\nPERSONAS_DIR =") == 1
+ assert "SNAP_DIR" not in result.stdout
+ assert "CRAWL_DIR" not in result.stdout
+
+
+def test_config_get_derived_path_but_rejects_runtime_dir(initialized_archive):
+ run_archivebox_cmd(["init"], cwd=initialized_archive, check=True)
+
+ data_dir = run_archivebox_cmd(["config", "--get", "DATA_DIR"], cwd=initialized_archive)
+ snap_dir = run_archivebox_cmd(["config", "--get", "SNAP_DIR"], cwd=initialized_archive)
+
+ assert data_dir.returncode == 0, data_dir.stderr
+ assert "DATA_DIR" in data_dir.stdout
+ assert str(initialized_archive) in data_dir.stdout.replace("\n", "")
+ assert snap_dir.returncode != 0
+ assert "SNAP_DIR =" not in snap_dir.stdout
+
+
+def test_config_set_rejects_readonly_and_runtime_dirs(initialized_archive):
+ run_archivebox_cmd(["init"], cwd=initialized_archive, check=True)
+
+ data_dir = run_archivebox_cmd(
+ ["config", "--set", f"DATA_DIR={initialized_archive / 'other'}"],
+ cwd=initialized_archive,
+ )
+ crawl_dir = run_archivebox_cmd(
+ ["config", "--set", f"CRAWL_DIR={initialized_archive / 'crawl'}"],
+ cwd=initialized_archive,
+ )
+
+ assert data_dir.returncode != 0
+ assert crawl_dir.returncode != 0
+ content = (initialized_archive / "ArchiveBox.conf").read_text()
+ assert "DATA_DIR" not in content
+ assert "CRAWL_DIR" not in content
+
+
+def test_config_get_specific_key(initialized_archive):
"""Test that config --get KEY retrieves specific value."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "config", "--get", "TIMEOUT"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["config", "--get", "TIMEOUT"],
)
assert result.returncode == 0
assert "TIMEOUT" in result.stdout
-def test_config_set_writes_to_file(tmp_path, process):
+def test_config_set_writes_to_file(initialized_archive):
"""Test that config --set KEY=VALUE writes to ArchiveBox.conf."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "config", "--set", "TIMEOUT=120"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["config", "--set", "TIMEOUT=120"],
)
assert result.returncode == 0
# Verify config file was updated
- config_file = tmp_path / "ArchiveBox.conf"
+ config_file = initialized_archive / "ArchiveBox.conf"
assert config_file.exists()
content = config_file.read_text()
assert "TIMEOUT" in content or "120" in content
-def test_config_set_and_get_roundtrip(tmp_path, process):
+def test_config_set_and_get_roundtrip(initialized_archive):
"""Test that set value can be retrieved with get."""
- os.chdir(tmp_path)
# Set a unique value
- subprocess.run(
- ["archivebox", "config", "--set", "TIMEOUT=987"],
- capture_output=True,
- text=True,
+ run_archivebox_cmd(
+ ["config", "--set", "TIMEOUT=987"],
)
# Get the value back
- result = subprocess.run(
- ["archivebox", "config", "--get", "TIMEOUT"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["config", "--get", "TIMEOUT"],
)
assert "987" in result.stdout
-def test_config_set_multiple_values(tmp_path, process):
+def test_config_set_multiple_values(initialized_archive):
"""Test setting multiple config values at once."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "config", "--set", "TIMEOUT=111", "YTDLP_TIMEOUT=222"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["config", "--set", "TIMEOUT=111", "YTDLP_TIMEOUT=222"],
)
assert result.returncode == 0
# Verify both were written
- config_file = tmp_path / "ArchiveBox.conf"
+ config_file = initialized_archive / "ArchiveBox.conf"
content = config_file.read_text()
assert "111" in content
assert "222" in content
-def test_config_set_invalid_key_fails(tmp_path, process):
+def test_config_set_invalid_key_fails(initialized_archive):
"""Test that setting invalid config key fails."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "config", "--set", "TOTALLY_INVALID_KEY_XYZ=value"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["config", "--set", "TOTALLY_INVALID_KEY_XYZ=value"],
)
assert result.returncode != 0
-def test_config_set_requires_equals_sign(tmp_path, process):
+def test_config_set_requires_equals_sign(initialized_archive):
"""Test that set requires KEY=VALUE format."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "config", "--set", "TIMEOUT"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["config", "--set", "TIMEOUT"],
)
assert result.returncode != 0
-def test_config_search_finds_keys(tmp_path, process):
+def test_config_search_finds_keys(initialized_archive):
"""Test that config --search finds matching keys."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "config", "--search", "TIMEOUT"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["config", "--search", "TIMEOUT"],
)
# Should find timeout-related config
assert "TIMEOUT" in result.stdout
-def test_config_preserves_existing_values(tmp_path, process):
+def test_config_preserves_existing_values(initialized_archive):
"""Test that setting new values preserves existing ones."""
- os.chdir(tmp_path)
# Set first value
- subprocess.run(
- ["archivebox", "config", "--set", "TIMEOUT=100"],
- capture_output=True,
+ run_archivebox_cmd(
+ ["config", "--set", "TIMEOUT=100"],
)
# Set second value
- subprocess.run(
- ["archivebox", "config", "--set", "YTDLP_TIMEOUT=200"],
- capture_output=True,
+ run_archivebox_cmd(
+ ["config", "--set", "YTDLP_TIMEOUT=200"],
)
# Verify both are in config file
- config_file = tmp_path / "ArchiveBox.conf"
+ config_file = initialized_archive / "ArchiveBox.conf"
content = config_file.read_text()
assert "TIMEOUT" in content
assert "YTDLP_TIMEOUT" in content
-def test_config_file_is_valid_toml(tmp_path, process):
+def test_config_file_is_valid_toml(initialized_archive):
"""Test that config file remains valid TOML after set."""
- os.chdir(tmp_path)
- subprocess.run(
- ["archivebox", "config", "--set", "TIMEOUT=150"],
- capture_output=True,
+ run_archivebox_cmd(
+ ["config", "--set", "TIMEOUT=150"],
)
- config_file = tmp_path / "ArchiveBox.conf"
+ config_file = initialized_archive / "ArchiveBox.conf"
content = config_file.read_text()
# Basic TOML validation - should have sections and key=value pairs
assert "[" in content or "=" in content
-def test_config_updates_existing_value(tmp_path, process):
+def test_config_updates_existing_value(initialized_archive):
"""Test that setting same key twice updates the value."""
- os.chdir(tmp_path)
# Set initial value
- subprocess.run(
- ["archivebox", "config", "--set", "TIMEOUT=100"],
- capture_output=True,
+ run_archivebox_cmd(
+ ["config", "--set", "TIMEOUT=100"],
)
# Update to new value
- subprocess.run(
- ["archivebox", "config", "--set", "TIMEOUT=200"],
- capture_output=True,
+ run_archivebox_cmd(
+ ["config", "--set", "TIMEOUT=200"],
)
# Get current value
- result = subprocess.run(
- ["archivebox", "config", "--get", "TIMEOUT"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["config", "--get", "TIMEOUT"],
)
# Should show updated value
assert "200" in result.stdout
+
+
+def test_config_ignores_legacy_unknown_keys(tmp_path, initialized_archive):
+ """Old ArchiveBox.conf keys should not prevent startup during upgrades."""
+ (tmp_path / "ArchiveBox.conf").write_text(
+ """
+[ARCHIVING_CONFIG]
+MAX_MEDIA_SIZE = "750m"
+
+[SEARCH_BACKEND_CONFIG]
+SEARCH_BACKEND_HOST_NAME = "sonic"
+SEARCH_BACKEND_PASSWORD = "SecretPassword"
+""",
+ )
+
+ result = run_archivebox_cmd(
+ ["version"],
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert "Extra inputs are not permitted" not in result.stderr
+
+
+class TestConfigCLI:
+ """Test the CLI interface for config command."""
+
+ def test_cli_help(self, tmp_path, initialized_archive):
+ """Test that --help works for config command."""
+
+ result = run_archivebox_cmd(
+ ["config", "--help"],
+ )
+
+ assert result.returncode == 0
+ assert "--get" in result.stdout
+ assert "--set" in result.stdout
diff --git a/archivebox/tests/test_cli_crawl.py b/archivebox/tests/test_cli_crawl.py
index 62482b10..a4ba957f 100644
--- a/archivebox/tests/test_cli_crawl.py
+++ b/archivebox/tests/test_cli_crawl.py
@@ -10,11 +10,20 @@ Tests cover:
import json
+import pytest
+
+from archivebox.core.models import Snapshot
+from archivebox.crawls.models import Crawl
from archivebox.tests.conftest import (
- run_archivebox_cmd,
- parse_jsonl_output,
+ cli_env,
create_test_url,
+ parse_jsonl_output,
+ run_archivebox_cmd,
+ run_queued_crawls,
)
+from archivebox.tests.test_orm_helpers import use_archivebox_db
+
+pytestmark = pytest.mark.django_db(transaction=True)
class TestCrawlCreate:
@@ -24,10 +33,13 @@ class TestCrawlCreate:
"""Create crawl from URL arguments."""
url = create_test_url()
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "create", url],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, f"Command failed: {stderr}"
assert "Created crawl" in stderr
@@ -43,11 +55,14 @@ class TestCrawlCreate:
urls = [create_test_url() for _ in range(3)]
stdin = "\n".join(urls)
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "create"],
stdin=stdin,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, f"Command failed: {stderr}"
@@ -63,10 +78,13 @@ class TestCrawlCreate:
"""Create crawl with --depth flag."""
url = create_test_url()
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "create", "--depth=2", url],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -76,10 +94,13 @@ class TestCrawlCreate:
"""Create crawl with --tag flag."""
url = create_test_url()
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "create", "--tag=test-tag", url],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -91,11 +112,14 @@ class TestCrawlCreate:
url = create_test_url()
stdin = json.dumps(tag_record) + "\n" + json.dumps({"url": url})
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "create"],
stdin=stdin,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -109,15 +133,19 @@ class TestCrawlCreate:
"""Existing Crawl records (with id) are passed through."""
# First create a crawl
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["crawl", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
crawl = parse_jsonl_output(stdout1)[0]
# Now pipe it back - should pass through
- stdout2, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "create"],
stdin=json.dumps(crawl),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout2)
@@ -130,10 +158,13 @@ class TestCrawlList:
def test_list_empty(self, initialized_archive):
"""List with no crawls returns empty."""
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "list"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Listed 0 crawls" in stderr
@@ -141,12 +172,15 @@ class TestCrawlList:
def test_list_returns_created(self, initialized_archive):
"""List returns previously created crawls."""
url = create_test_url()
- run_archivebox_cmd(["crawl", "create", url], data_dir=initialized_archive)
+ run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "list"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -156,12 +190,15 @@ class TestCrawlList:
def test_list_filter_by_status(self, initialized_archive):
"""Filter crawls by status."""
url = create_test_url()
- run_archivebox_cmd(["crawl", "create", url], data_dir=initialized_archive)
+ run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "list", "--status=queued"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -172,12 +209,20 @@ class TestCrawlList:
"""Limit number of results."""
# Create multiple crawls
for _ in range(3):
- run_archivebox_cmd(["crawl", "create", create_test_url()], data_dir=initialized_archive)
+ run_archivebox_cmd(
+ ["crawl", "create", create_test_url()],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "list", "--limit=2"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -191,15 +236,19 @@ class TestCrawlUpdate:
"""Update crawl status."""
# Create a crawl
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["crawl", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
crawl = parse_jsonl_output(stdout1)[0]
# Update it
- stdout2, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "update", "--status=started"],
stdin=json.dumps(crawl),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Updated 1 crawls" in stderr
@@ -214,14 +263,18 @@ class TestCrawlDelete:
def test_delete_requires_yes(self, initialized_archive):
"""Delete requires --yes flag."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["crawl", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
crawl = parse_jsonl_output(stdout1)[0]
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "delete"],
stdin=json.dumps(crawl),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 1
assert "--yes" in stderr
@@ -229,14 +282,18 @@ class TestCrawlDelete:
def test_delete_with_yes(self, initialized_archive):
"""Delete with --yes flag works."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["crawl", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
crawl = parse_jsonl_output(stdout1)[0]
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "delete", "--yes"],
stdin=json.dumps(crawl),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Deleted 1 crawls" in stderr
@@ -244,15 +301,172 @@ class TestCrawlDelete:
def test_delete_dry_run(self, initialized_archive):
"""Dry run shows what would be deleted."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["crawl", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
crawl = parse_jsonl_output(stdout1)[0]
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "delete", "--dry-run"],
stdin=json.dumps(crawl),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Would delete" in stderr
assert "dry run" in stderr.lower()
+
+
+def test_crawl_creates_crawl_object(initialized_archive):
+ """Test that crawl command creates a Crawl object."""
+ env = cli_env(disable_extractors=True)
+
+ run_archivebox_cmd(
+ ["crawl", "create", "https://example.com"],
+ cwd=initialized_archive,
+ env=env,
+ check=True,
+ )
+
+ with use_archivebox_db(initialized_archive):
+ crawl = Crawl.objects.order_by("-created_at").first()
+
+ assert crawl is not None, "Crawl object should be created"
+
+
+def test_crawl_depth_sets_max_depth_in_crawl(initialized_archive):
+ """Test that --depth option sets max_depth in the Crawl object."""
+ env = cli_env(disable_extractors=True)
+
+ run_archivebox_cmd(
+ ["crawl", "create", "--depth=2", "https://example.com"],
+ cwd=initialized_archive,
+ env=env,
+ check=True,
+ )
+
+ with use_archivebox_db(initialized_archive):
+ crawl = Crawl.objects.order_by("-created_at").first()
+
+ assert crawl is not None
+ assert crawl.max_depth == 2, "Crawl max_depth should match --depth=2"
+
+
+def test_crawl_creates_snapshot_for_url(initialized_archive):
+ """Test that crawl creates a Snapshot for the input URL."""
+ env = cli_env(disable_extractors=True)
+
+ run_archivebox_cmd(
+ ["crawl", "create", "https://example.com"],
+ cwd=initialized_archive,
+ env=env,
+ check=True,
+ )
+ run_queued_crawls(initialized_archive, env)
+
+ with use_archivebox_db(initialized_archive):
+ snapshot = Snapshot.objects.filter(url="https://example.com").first()
+
+ assert snapshot is not None, "Snapshot should be created for input URL"
+
+
+def test_crawl_links_snapshot_to_crawl(initialized_archive):
+ """Test that Snapshot is linked to Crawl via crawl_id."""
+ env = cli_env(disable_extractors=True)
+
+ run_archivebox_cmd(
+ ["crawl", "create", "https://example.com"],
+ cwd=initialized_archive,
+ env=env,
+ check=True,
+ )
+ run_queued_crawls(initialized_archive, env)
+
+ with use_archivebox_db(initialized_archive):
+ crawl = Crawl.objects.order_by("-created_at").first()
+ assert crawl is not None
+ snapshot = Snapshot.objects.filter(url="https://example.com").first()
+
+ assert snapshot is not None
+ assert snapshot.crawl_id == crawl.id, "Snapshot should be linked to Crawl"
+
+
+def test_crawl_multiple_urls_creates_multiple_snapshots(initialized_archive):
+ """Test that crawling multiple URLs creates multiple snapshots."""
+ env = cli_env(disable_extractors=True)
+
+ run_archivebox_cmd(
+ [
+ "crawl",
+ "create",
+ "https://example.com",
+ "https://iana.org",
+ ],
+ cwd=initialized_archive,
+ env=env,
+ check=True,
+ )
+ run_queued_crawls(initialized_archive, env)
+
+ with use_archivebox_db(initialized_archive):
+ urls = list(Snapshot.objects.order_by("url").values_list("url", flat=True))
+
+ assert "https://example.com" in urls
+ assert "https://iana.org" in urls
+
+
+def test_crawl_from_file_creates_snapshot(initialized_archive):
+ """Test that crawl can create snapshots from a file of URLs."""
+ env = cli_env(disable_extractors=True)
+
+ # Write URLs to a file
+ urls_file = initialized_archive / "urls.txt"
+ urls_file.write_text("https://example.com\n")
+
+ run_archivebox_cmd(
+ ["crawl", "create", str(urls_file)],
+ cwd=initialized_archive,
+ env=env,
+ check=True,
+ )
+ run_queued_crawls(initialized_archive, env)
+
+ with use_archivebox_db(initialized_archive):
+ snapshot = Snapshot.objects.first()
+
+ # Should create at least one snapshot (the source file or the URL)
+ assert snapshot is not None, "Should create at least one snapshot"
+
+
+def test_crawl_persists_input_urls_on_crawl(initialized_archive):
+ """Test that crawl input URLs are stored on the Crawl record."""
+ env = cli_env(disable_extractors=True)
+
+ run_archivebox_cmd(
+ ["crawl", "create", "https://example.com"],
+ cwd=initialized_archive,
+ env=env,
+ check=True,
+ )
+
+ with use_archivebox_db(initialized_archive):
+ crawl = Crawl.objects.order_by("-created_at").first()
+
+ assert crawl is not None, "Crawl should be created for crawl input"
+ assert "https://example.com" in crawl.urls, "Crawl should persist input URLs"
+
+
+class TestCrawlCLI:
+ """Test the CLI interface for crawl command."""
+
+ def test_cli_help(self, tmp_path, initialized_archive):
+ """Test that --help works for crawl command."""
+
+ result = run_archivebox_cmd(
+ ["crawl", "--help"],
+ )
+
+ assert result.returncode == 0
+ assert "create" in result.stdout
diff --git a/archivebox/tests/test_cli_extract.py b/archivebox/tests/test_cli_extract.py
index f754fa06..b271f180 100644
--- a/archivebox/tests/test_cli_extract.py
+++ b/archivebox/tests/test_cli_extract.py
@@ -4,35 +4,31 @@ Tests for archivebox extract command.
Verify extract re-runs extractors on existing snapshots.
"""
-import os
-import subprocess
-
import pytest
from archivebox.core.models import Snapshot
-from archivebox.tests.conftest import run_queued_crawls
+from archivebox.tests.conftest import run_queued_crawls, run_archivebox_cmd, cli_env
+
from archivebox.tests.test_orm_helpers import use_archivebox_db
pytestmark = pytest.mark.django_db(transaction=True)
-def test_extract_runs_on_existing_snapshots(tmp_path, process, disable_extractors_dict):
+def test_extract_runs_on_existing_snapshots(initialized_archive):
"""Test that extract command runs on existing snapshots."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add a snapshot first
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
# Run extract
- result = subprocess.run(
- ["archivebox", "extract"],
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["extract"],
+ env=env,
timeout=30,
)
@@ -40,30 +36,28 @@ def test_extract_runs_on_existing_snapshots(tmp_path, process, disable_extractor
assert result.returncode in [0, 1]
-def test_extract_preserves_snapshot_count(tmp_path, process, disable_extractors_dict):
+def test_extract_preserves_snapshot_count(initialized_archive):
"""Test that extract doesn't change snapshot count."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add snapshot
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
count_before = Snapshot.objects.count()
# Run extract
- subprocess.run(
- ["archivebox", "extract", "--overwrite"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["extract", "--overwrite"],
+ env=env,
timeout=30,
)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
count_after = Snapshot.objects.count()
assert count_after == count_before
diff --git a/archivebox/tests/test_cli_extract_input.py b/archivebox/tests/test_cli_extract_input.py
index 9138ee63..19427cdb 100644
--- a/archivebox/tests/test_cli_extract_input.py
+++ b/archivebox/tests/test_cli_extract_input.py
@@ -1,221 +1,210 @@
"""Tests for archivebox extract input handling and pipelines."""
-import os
import subprocess
import json
import pytest
from archivebox.core.models import ArchiveResult, Snapshot
-from archivebox.tests.conftest import run_queued_crawls
+from archivebox.tests.conftest import run_archivebox_cmd, run_queued_crawls, cli_env
+
from archivebox.tests.test_orm_helpers import use_archivebox_db
pytestmark = pytest.mark.django_db(transaction=True)
-def _snapshot_id(data_dir):
- with use_archivebox_db(data_dir):
- return Snapshot.objects.values_list("id", flat=True).first()
-
-
-def test_extract_runs_on_snapshot_id(tmp_path, process, disable_extractors_dict):
+def test_extract_runs_on_snapshot_id(initialized_archive):
"""Test that extract command accepts a snapshot ID."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# First create a snapshot
- subprocess.run(
- ["archivebox", "add", "--index-only", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- snapshot_id = _snapshot_id(tmp_path)
+ with use_archivebox_db(initialized_archive):
+ snapshot_id = Snapshot.objects.values_list("id", flat=True).first()
# Run extract on the snapshot
- result = subprocess.run(
- ["archivebox", "extract", "--no-wait", str(snapshot_id)],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["extract", "--no-wait", str(snapshot_id)],
+ env=env,
)
# Should not error about invalid snapshot ID
assert "not found" not in result.stderr.lower()
-def test_extract_with_enabled_extractor_creates_archiveresult(tmp_path, process, disable_extractors_dict):
+def test_extract_with_enabled_extractor_creates_archiveresult(initialized_archive):
"""Test that extract creates ArchiveResult when extractor is enabled."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# First create a snapshot
- subprocess.run(
- ["archivebox", "add", "--index-only", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- snapshot_id = _snapshot_id(tmp_path)
+ with use_archivebox_db(initialized_archive):
+ snapshot_id = Snapshot.objects.values_list("id", flat=True).first()
# Run extract with title extractor enabled
- env = disable_extractors_dict.copy()
+ env = env.copy()
env["SAVE_TITLE"] = "true"
- subprocess.run(
- ["archivebox", "extract", "--no-wait", str(snapshot_id)],
- capture_output=True,
- text=True,
+ run_archivebox_cmd(
+ ["extract", "--no-wait", str(snapshot_id)],
env=env,
)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
count = ArchiveResult.objects.filter(snapshot_id=snapshot_id).count()
# May or may not have results depending on timing
assert count >= 0
-def test_extract_plugin_option_accepted(tmp_path, process, disable_extractors_dict):
+def test_extract_plugin_option_accepted(initialized_archive):
"""Test that --plugin option is accepted."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# First create a snapshot
- subprocess.run(
- ["archivebox", "add", "--index-only", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- snapshot_id = _snapshot_id(tmp_path)
+ with use_archivebox_db(initialized_archive):
+ snapshot_id = Snapshot.objects.values_list("id", flat=True).first()
- result = subprocess.run(
- ["archivebox", "extract", "--plugin=title", "--no-wait", str(snapshot_id)],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["extract", "--plugin=title", "--no-wait", str(snapshot_id)],
+ env=env,
)
assert "unrecognized arguments: --plugin" not in result.stderr
-def test_extract_stdin_snapshot_id(tmp_path, process, disable_extractors_dict):
+def test_extract_stdin_snapshot_id(initialized_archive):
"""Test that extract reads snapshot IDs from stdin."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# First create a snapshot
- subprocess.run(
- ["archivebox", "add", "--index-only", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- snapshot_id = _snapshot_id(tmp_path)
+ with use_archivebox_db(initialized_archive):
+ snapshot_id = Snapshot.objects.values_list("id", flat=True).first()
- result = subprocess.run(
- ["archivebox", "extract", "--no-wait"],
+ result = run_archivebox_cmd(
+ ["extract", "--no-wait"],
input=f"{snapshot_id}\n",
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
+ env=env,
)
# Should not show "not found" error
assert "not found" not in result.stderr.lower() or result.returncode == 0
-def test_extract_stdin_jsonl_input(tmp_path, process, disable_extractors_dict):
+def test_extract_stdin_jsonl_input(initialized_archive):
"""Test that extract reads JSONL records from stdin."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# First create a snapshot
- subprocess.run(
- ["archivebox", "add", "--index-only", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- snapshot_id = _snapshot_id(tmp_path)
+ with use_archivebox_db(initialized_archive):
+ snapshot_id = Snapshot.objects.values_list("id", flat=True).first()
jsonl_input = json.dumps({"type": "Snapshot", "id": str(snapshot_id)}) + "\n"
- result = subprocess.run(
- ["archivebox", "extract", "--no-wait"],
+ result = run_archivebox_cmd(
+ ["extract", "--no-wait"],
input=jsonl_input,
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
+ env=env,
)
# Should not show "not found" error
assert "not found" not in result.stderr.lower() or result.returncode == 0
-def test_extract_pipeline_from_snapshot(tmp_path, process, disable_extractors_dict):
+def test_extract_pipeline_from_snapshot(initialized_archive):
"""Test piping snapshot output to extract."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Create snapshot and pipe to extract
- snapshot_proc = subprocess.Popen(
- ["archivebox", "snapshot", "https://example.com"],
+ snapshot_proc = run_archivebox_cmd(
+ ["snapshot", "https://example.com"],
+ cwd=initialized_archive,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
- env=disable_extractors_dict,
+ env=env,
+ wait=False,
)
- subprocess.run(
- ["archivebox", "extract", "--no-wait"],
+ extract_proc = run_archivebox_cmd(
+ ["extract", "--no-wait"],
+ cwd=initialized_archive,
stdin=snapshot_proc.stdout,
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=env,
+ wait=False,
)
+ if snapshot_proc.stdout is not None:
+ snapshot_proc.stdout.close()
- snapshot_proc.wait()
+ extract_stdout, extract_stderr = extract_proc.communicate(timeout=60)
+ snapshot_stdout, snapshot_stderr = snapshot_proc.communicate(timeout=60)
+ assert snapshot_proc.returncode == 0, (snapshot_stdout or "") + (snapshot_stderr or "")
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
snapshot = Snapshot.objects.filter(url="https://example.com").first()
assert snapshot is not None, "Snapshot should be created by pipeline"
-def test_extract_multiple_snapshots(tmp_path, process, disable_extractors_dict):
+def test_extract_multiple_snapshots(initialized_archive):
"""Test extracting from multiple snapshots."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Create multiple snapshots one at a time to avoid deduplication issues
- subprocess.run(
- ["archivebox", "add", "--index-only", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "https://example.com"],
+ env=env,
)
- subprocess.run(
- ["archivebox", "add", "--index-only", "https://iana.org"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "https://iana.org"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
snapshot_ids = list(Snapshot.objects.values_list("id", flat=True))
assert len(snapshot_ids) >= 2, "Should have at least 2 snapshots"
# Extract from all snapshots
ids_input = "\n".join(str(snapshot_id) for snapshot_id in snapshot_ids) + "\n"
- result = subprocess.run(
- ["archivebox", "extract", "--no-wait"],
+ result = run_archivebox_cmd(
+ ["extract", "--no-wait"],
input=ids_input,
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
+ env=env,
)
assert result.returncode == 0, result.stderr
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
count = Snapshot.objects.count()
assert count >= 2, "Both snapshots should still exist after extraction"
@@ -224,29 +213,23 @@ def test_extract_multiple_snapshots(tmp_path, process, disable_extractors_dict):
class TestExtractCLI:
"""Test the CLI interface for extract command."""
- def test_cli_help(self, tmp_path, process):
+ def test_cli_help(self, initialized_archive):
"""Test that --help works for extract command."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "extract", "--help"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["extract", "--help"],
)
assert result.returncode == 0
assert "--plugin" in result.stdout or "-p" in result.stdout
assert "--wait" in result.stdout or "--no-wait" in result.stdout
- def test_cli_no_snapshots_shows_warning(self, tmp_path, process):
+ def test_cli_no_snapshots_shows_warning(self, initialized_archive):
"""Test that running without snapshots shows a warning."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "extract", "--no-wait"],
+ result = run_archivebox_cmd(
+ ["extract", "--no-wait"],
input="",
- capture_output=True,
- text=True,
)
# Should show warning about no snapshots or exit normally (empty input)
diff --git a/archivebox/tests/test_cli_help.py b/archivebox/tests/test_cli_help.py
index 772e2a08..bf2ee195 100644
--- a/archivebox/tests/test_cli_help.py
+++ b/archivebox/tests/test_cli_help.py
@@ -4,14 +4,12 @@ Tests for archivebox help command.
Verify command runs successfully and produces output.
"""
-import os
-import subprocess
+from archivebox.tests.conftest import run_archivebox_cmd
def test_help_runs_successfully(tmp_path):
"""Test that help command runs and produces output."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "help"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["help"])
assert result.returncode == 0
combined = result.stdout + result.stderr
@@ -19,10 +17,9 @@ def test_help_runs_successfully(tmp_path):
assert "archivebox" in combined.lower()
-def test_help_in_initialized_dir(tmp_path, process):
+def test_help_in_initialized_dir(initialized_archive):
"""Test help command in initialized data directory."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "help"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["help"])
assert result.returncode == 0
combined = result.stdout + result.stderr
diff --git a/archivebox/tests/test_cli_init.py b/archivebox/tests/test_cli_init.py
index 89f9db6c..e43cb2c1 100644
--- a/archivebox/tests/test_cli_init.py
+++ b/archivebox/tests/test_cli_init.py
@@ -4,9 +4,6 @@ Comprehensive tests for archivebox init command.
Verify init creates correct database schema, filesystem structure, and config.
"""
-import os
-import subprocess
-
import pytest
from django.utils import timezone
from django.db import connections
@@ -16,7 +13,8 @@ from archivebox.config.common import get_config
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
from archivebox.machine.models import Machine
-from archivebox.tests.conftest import run_queued_crawls
+from archivebox.tests.conftest import run_queued_crawls, run_archivebox_cmd, cli_env
+
from archivebox.tests.test_orm_helpers import use_archivebox_db
pytestmark = pytest.mark.django_db(transaction=True)
@@ -27,8 +25,7 @@ DIR_PERMISSIONS = get_config().OUTPUT_PERMISSIONS.replace("6", "7").replace("4",
def test_init_creates_database_file(tmp_path):
"""Test that init creates index.sqlite3 database file."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "init"], capture_output=True)
+ result = run_archivebox_cmd(["init"])
assert result.returncode == 0
db_path = tmp_path / "index.sqlite3"
@@ -38,8 +35,7 @@ def test_init_creates_database_file(tmp_path):
def test_init_creates_archive_directory(tmp_path):
"""Test that init creates archive directory."""
- os.chdir(tmp_path)
- subprocess.run(["archivebox", "init"], capture_output=True)
+ run_archivebox_cmd(["init"])
archive_dir = tmp_path / "archive"
assert archive_dir.exists()
@@ -48,9 +44,8 @@ def test_init_creates_archive_directory(tmp_path):
def test_init_uses_cwd_archive_and_users_dirs(tmp_path):
"""Test that init creates archive/users storage roots under cwd."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "init"], capture_output=True)
+ result = run_archivebox_cmd(["init"])
assert result.returncode == 0
assert (tmp_path / "archive").is_dir()
@@ -59,8 +54,7 @@ def test_init_uses_cwd_archive_and_users_dirs(tmp_path):
def test_init_creates_sources_directory(tmp_path):
"""Test that init creates sources directory."""
- os.chdir(tmp_path)
- subprocess.run(["archivebox", "init"], capture_output=True)
+ run_archivebox_cmd(["init"])
sources_dir = tmp_path / "sources"
assert sources_dir.exists()
@@ -69,8 +63,7 @@ def test_init_creates_sources_directory(tmp_path):
def test_init_creates_logs_directory(tmp_path):
"""Test that init creates logs directory."""
- os.chdir(tmp_path)
- subprocess.run(["archivebox", "init"], capture_output=True)
+ run_archivebox_cmd(["init"])
logs_dir = tmp_path / "logs"
assert logs_dir.exists()
@@ -79,8 +72,7 @@ def test_init_creates_logs_directory(tmp_path):
def test_init_creates_config_file(tmp_path):
"""Test that init creates ArchiveBox.conf config file."""
- os.chdir(tmp_path)
- subprocess.run(["archivebox", "init"], capture_output=True)
+ run_archivebox_cmd(["init"])
config_file = tmp_path / "ArchiveBox.conf"
assert config_file.exists()
@@ -89,8 +81,7 @@ def test_init_creates_config_file(tmp_path):
def test_init_runs_migrations(tmp_path):
"""Test that init runs Django migrations and creates core tables."""
- os.chdir(tmp_path)
- subprocess.run(["archivebox", "init"], capture_output=True)
+ run_archivebox_cmd(["init"])
with use_archivebox_db(tmp_path):
migration_count = MigrationRecorder.Migration.objects.count()
@@ -100,8 +91,7 @@ def test_init_runs_migrations(tmp_path):
def test_init_creates_core_snapshot_table(tmp_path):
"""Test that init creates core_snapshot table."""
- os.chdir(tmp_path)
- subprocess.run(["archivebox", "init"], capture_output=True)
+ run_archivebox_cmd(["init"])
assert Snapshot._meta.db_table == "core_snapshot"
with use_archivebox_db(tmp_path):
@@ -110,8 +100,7 @@ def test_init_creates_core_snapshot_table(tmp_path):
def test_init_creates_crawls_crawl_table(tmp_path):
"""Test that init creates crawls_crawl table."""
- os.chdir(tmp_path)
- subprocess.run(["archivebox", "init"], capture_output=True)
+ run_archivebox_cmd(["init"])
assert Crawl._meta.db_table == "crawls_crawl"
with use_archivebox_db(tmp_path):
@@ -120,8 +109,7 @@ def test_init_creates_crawls_crawl_table(tmp_path):
def test_init_creates_core_archiveresult_table(tmp_path):
"""Test that init creates core_archiveresult table."""
- os.chdir(tmp_path)
- subprocess.run(["archivebox", "init"], capture_output=True)
+ run_archivebox_cmd(["init"])
assert ArchiveResult._meta.db_table == "core_archiveresult"
with use_archivebox_db(tmp_path):
@@ -130,8 +118,7 @@ def test_init_creates_core_archiveresult_table(tmp_path):
def test_init_sets_correct_file_permissions(tmp_path):
"""Test that init sets correct permissions on created files."""
- os.chdir(tmp_path)
- subprocess.run(["archivebox", "init"], capture_output=True)
+ run_archivebox_cmd(["init"])
# Check database permissions
db_path = tmp_path / "index.sqlite3"
@@ -144,15 +131,14 @@ def test_init_sets_correct_file_permissions(tmp_path):
def test_init_is_idempotent(tmp_path):
"""Test that running init multiple times is safe (idempotent)."""
- os.chdir(tmp_path)
# First init
- result1 = subprocess.run(["archivebox", "init"], capture_output=True, text=True)
+ result1 = run_archivebox_cmd(["init"])
assert result1.returncode == 0
assert "Initializing a new ArchiveBox" in result1.stdout
# Second init should update, not fail
- result2 = subprocess.run(["archivebox", "init"], capture_output=True, text=True)
+ result2 = run_archivebox_cmd(["init"])
assert result2.returncode == 0
assert "updating existing ArchiveBox" in result2.stdout or "up-to-date" in result2.stdout.lower()
@@ -164,15 +150,14 @@ def test_init_is_idempotent(tmp_path):
def test_init_refuses_database_migrated_by_newer_code(tmp_path):
"""A downgraded ArchiveBox build must fail before serving a newer DB schema."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "init"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["init"])
assert result.returncode == 0
with use_archivebox_db(tmp_path):
MigrationRecorder.Migration.objects.create(app="crawls", name="9999_future_test", applied=timezone.now())
connections["default"].commit()
- result = subprocess.run(["archivebox", "init"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["init"])
assert result.returncode == 3
assert "migrated by a newer version of ArchiveBox" in result.stderr
assert "crawls.9999_future_test" in result.stderr
@@ -183,8 +168,7 @@ def test_init_recovers_from_pre_squash_dev_history(tmp_path):
"""Pre-squash dev DBs (rows for migrations now absorbed by ``replaces=``)
must NOT trip the newer-DB guard — every historical squash would otherwise
brick beta-tester collections that pre-date the squash commit."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "init"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["init"])
assert result.returncode == 0
# Sampling — one name per affected app, all listed in the ``replaces=``
@@ -205,43 +189,41 @@ def test_init_recovers_from_pre_squash_dev_history(tmp_path):
MigrationRecorder.Migration.objects.create(app=app, name=name, applied=timezone.now())
connections["default"].commit()
- result = subprocess.run(["archivebox", "init"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["init"])
assert result.returncode == 0, f"init refused to recover pre-squash dev DB.\nstdout={result.stdout}\nstderr={result.stderr}"
assert "migrated by a newer version of ArchiveBox" not in result.stderr
-def test_init_with_existing_data_preserves_snapshots(tmp_path, process, disable_extractors_dict):
+def test_init_with_existing_data_preserves_snapshots(initialized_archive):
"""Test that re-running init preserves existing snapshot data."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add a snapshot
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
# Check snapshot was created
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
count_before = Snapshot.objects.count()
assert count_before == 1
# Run init again
- result = subprocess.run(["archivebox", "init"], capture_output=True)
+ result = run_archivebox_cmd(["init"])
assert result.returncode == 0
# Snapshot should still exist
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
count_after = Snapshot.objects.count()
assert count_after == count_before
def test_init_quick_flag_skips_checks(tmp_path):
"""Test that init --quick runs faster by skipping some checks."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "init", "--quick"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["init", "--quick"])
assert result.returncode == 0
# Database should still be created
@@ -251,8 +233,7 @@ def test_init_quick_flag_skips_checks(tmp_path):
def test_init_creates_machine_table(tmp_path):
"""Test that init creates the machine_machine table."""
- os.chdir(tmp_path)
- subprocess.run(["archivebox", "init"], capture_output=True)
+ run_archivebox_cmd(["init"])
assert Machine._meta.db_table == "machine_machine"
with use_archivebox_db(tmp_path):
@@ -261,31 +242,27 @@ def test_init_creates_machine_table(tmp_path):
def test_init_output_shows_collection_info(tmp_path):
"""Test that init output shows helpful collection information."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "init"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["init"])
output = result.stdout
# Should show some helpful info about the collection
assert "ArchiveBox" in output or "collection" in output.lower() or "Initializing" in output
-def test_init_ignores_unrecognized_archive_directories(tmp_path, process, disable_extractors_dict):
+def test_init_ignores_unrecognized_archive_directories(initialized_archive):
"""Test that init upgrades existing dirs without choking on extra folders."""
- os.chdir(tmp_path)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
check=True,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
- (tmp_path / "archive" / "some_random_folder").mkdir(parents=True, exist_ok=True)
+ run_queued_crawls(initialized_archive, env)
+ (initialized_archive / "archive" / "some_random_folder").mkdir(parents=True, exist_ok=True)
- result = subprocess.run(
- ["archivebox", "init"],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["init"],
+ env=env,
)
assert result.returncode == 0, result.stdout + result.stderr
diff --git a/archivebox/tests/test_cli_install.py b/archivebox/tests/test_cli_install.py
index cbc2db10..59161cc5 100644
--- a/archivebox/tests/test_cli_install.py
+++ b/archivebox/tests/test_cli_install.py
@@ -5,8 +5,8 @@ Verify install detects and records binary dependencies in DB.
"""
import os
-import subprocess
from pathlib import Path
+from archivebox.tests.conftest import run_archivebox_cmd
import pytest
@@ -18,13 +18,10 @@ from archivebox.tests.test_orm_helpers import use_archivebox_db
pytestmark = pytest.mark.django_db(transaction=True)
-def test_install_runs_successfully(tmp_path, process):
+def test_install_runs_successfully(initialized_archive):
"""Test that install command runs without error."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "install", "--dry-run"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["install", "--dry-run"],
timeout=60,
)
@@ -32,28 +29,23 @@ def test_install_runs_successfully(tmp_path, process):
assert result.returncode in [0, 1] # May return 1 if binaries missing
-def test_install_creates_binary_records_in_db(tmp_path, process):
+def test_install_creates_binary_records_in_db(initialized_archive):
"""Test that install creates Binary records in database."""
- os.chdir(tmp_path)
- subprocess.run(
- ["archivebox", "install", "--dry-run"],
- capture_output=True,
+ run_archivebox_cmd(
+ ["install", "--dry-run"],
timeout=60,
)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
Binary.objects.count()
-def test_install_dry_run_does_not_install(tmp_path, process):
+def test_install_dry_run_does_not_install(initialized_archive):
"""Test that --dry-run doesn't actually install anything."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "install", "--dry-run"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["install", "--dry-run"],
timeout=60,
)
@@ -61,14 +53,11 @@ def test_install_dry_run_does_not_install(tmp_path, process):
assert "dry" in result.stdout.lower() or result.returncode in [0, 1]
-def test_install_detects_system_binaries(tmp_path, process):
+def test_install_detects_system_binaries(initialized_archive):
"""Test that install detects existing system binaries."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "install", "--dry-run"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["install", "--dry-run"],
timeout=60,
)
@@ -76,14 +65,11 @@ def test_install_detects_system_binaries(tmp_path, process):
assert result.returncode in [0, 1]
-def test_install_shows_binary_status(tmp_path, process):
+def test_install_shows_binary_status(initialized_archive):
"""Test that install shows status of binaries."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "install", "--dry-run"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["install", "--dry-run"],
timeout=60,
)
@@ -92,13 +78,10 @@ def test_install_shows_binary_status(tmp_path, process):
assert len(output) > 50
-def test_install_dry_run_prints_dry_run_message(tmp_path, process):
+def test_install_dry_run_prints_dry_run_message(initialized_archive):
"""Test that install --dry-run clearly reports that no changes will be made."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "install", "--dry-run"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["install", "--dry-run"],
timeout=60,
)
@@ -108,11 +91,8 @@ def test_install_dry_run_prints_dry_run_message(tmp_path, process):
def test_install_help_lists_dry_run_flag(tmp_path):
"""Test that install --help documents the dry-run option."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "install", "--help"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["install", "--help"],
)
assert result.returncode == 0
@@ -121,11 +101,8 @@ def test_install_help_lists_dry_run_flag(tmp_path):
def test_install_invalid_option_fails(tmp_path):
"""Test that invalid install options fail cleanly."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "install", "--invalid-option"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["install", "--invalid-option"],
)
assert result.returncode != 0
@@ -133,11 +110,8 @@ def test_install_invalid_option_fails(tmp_path):
def test_install_from_empty_dir_initializes_collection(tmp_path):
"""Test that install bootstraps an empty dir before performing work."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "install", "--dry-run"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["install", "--dry-run"],
)
output = result.stdout + result.stderr
@@ -145,11 +119,10 @@ def test_install_from_empty_dir_initializes_collection(tmp_path):
assert "Initializing" in output or "Dry run" in output or "init" in output.lower()
-def test_install_updates_binary_table(tmp_path, process):
+def test_install_updates_binary_table(initialized_archive):
"""Test that install completes and only mutates dependency state."""
- os.chdir(tmp_path)
env = os.environ.copy()
- tmp_short = Path("/tmp") / f"abx-install-{tmp_path.name}"
+ tmp_short = Path("/tmp") / f"abx-install-{initialized_archive.name}"
tmp_short.mkdir(parents=True, exist_ok=True)
env.update(
{
@@ -158,10 +131,8 @@ def test_install_updates_binary_table(tmp_path, process):
},
)
- result = subprocess.run(
- ["archivebox", "install", "git"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["install", "git"],
timeout=120,
env=env,
)
@@ -169,7 +140,7 @@ def test_install_updates_binary_table(tmp_path, process):
output = result.stdout + result.stderr
assert result.returncode == 0, output
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
binary_counts = {
status: Binary.objects.filter(status=status).count() for status in Binary.objects.values_list("status", flat=True).distinct()
}
diff --git a/archivebox/tests/test_cli_list.py b/archivebox/tests/test_cli_list.py
index eb33612a..bb791cef 100644
--- a/archivebox/tests/test_cli_list.py
+++ b/archivebox/tests/test_cli_list.py
@@ -5,42 +5,135 @@ Verify list emits snapshot JSONL and applies the documented filters.
"""
import json
-import os
-import subprocess
+import sys
import pytest
+from django.db import connection
+from django.utils import timezone
from archivebox.core.models import Snapshot
-from archivebox.tests.conftest import create_test_url, parse_jsonl_output, run_archivebox_cmd, run_queued_crawls
+from archivebox.tests.conftest import create_test_url, parse_jsonl_output, run_archivebox_cmd, run_queued_crawls, cli_env
+
from archivebox.tests.test_orm_helpers import use_archivebox_db
pytestmark = pytest.mark.django_db(transaction=True)
-def _parse_jsonl(stdout: str) -> list[dict]:
- return [json.loads(line) for line in stdout.splitlines() if line.strip().startswith("{")]
+class CountingStdout:
+ encoding = "utf-8"
+
+ def __init__(self):
+ self.rows = 0
+ self._pending = ""
+
+ def isatty(self):
+ return False
+
+ def write(self, text):
+ self._pending += text
+ lines = self._pending.split("\n")
+ self._pending = lines.pop()
+ self.rows += sum(1 for line in lines if line.startswith("{"))
+ return len(text)
+
+ def flush(self):
+ return None
-def test_list_outputs_existing_snapshots_as_jsonl(tmp_path, process, disable_extractors_dict):
+def test_list_limit_zero_streams_one_million_snapshots_without_materializing(admin_user, monkeypatch):
+ """Regression: archivebox list --limit=0 must stream unbounded result sets."""
+ from archivebox.cli.archivebox_snapshot import list_snapshots
+ from archivebox.crawls.models import Crawl
+
+ crawl = Crawl.objects.create(
+ urls="https://example.com",
+ created_by=admin_user,
+ status=Crawl.StatusChoices.SEALED,
+ retry_at=None,
+ )
+ now = timezone.now().isoformat()
+ with connection.cursor() as cursor:
+ cursor.execute(
+ """
+ WITH RECURSIVE seq(n) AS (
+ SELECT 1
+ UNION ALL
+ SELECT n + 1 FROM seq WHERE n < 1000000
+ )
+ INSERT INTO core_snapshot (
+ id,
+ url,
+ timestamp,
+ title,
+ bookmarked_at,
+ created_at,
+ modified_at,
+ downloaded_at,
+ fs_version,
+ crawl_id,
+ config,
+ current_step,
+ depth,
+ notes,
+ num_uses_failed,
+ num_uses_succeeded,
+ retry_at,
+ status,
+ delete_at,
+ output_size,
+ parent_snapshot_id
+ )
+ SELECT
+ lower(hex(randomblob(16))),
+ 'https://example.com/page-' || n,
+ printf('9%031d', n),
+ '',
+ %s,
+ %s,
+ %s,
+ NULL,
+ '0.9.0',
+ %s,
+ '{}',
+ 0,
+ 0,
+ '',
+ 0,
+ 0,
+ NULL,
+ 'sealed',
+ NULL,
+ 0,
+ NULL
+ FROM seq
+ """,
+ [now, now, now, str(crawl.id).replace("-", "")],
+ )
+
+ stdout = CountingStdout()
+ monkeypatch.setattr(sys, "stdout", stdout)
+
+ assert list_snapshots(limit=0) == 0
+ assert stdout.rows == 1000000
+
+
+def test_list_outputs_existing_snapshots_as_jsonl(initialized_archive):
"""Test that list prints one JSON object per stored snapshot."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
for url in ["https://example.com", "https://iana.org"]:
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", url],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", url],
+ env=env,
check=True,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- result = subprocess.run(
- ["archivebox", "list"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["list"],
timeout=30,
)
- rows = _parse_jsonl(result.stdout)
+ rows = parse_jsonl_output(result.stdout)
urls = {row["url"] for row in rows}
assert result.returncode == 0, result.stderr
@@ -48,95 +141,83 @@ def test_list_outputs_existing_snapshots_as_jsonl(tmp_path, process, disable_ext
assert "https://iana.org" in urls
-def test_list_filters_by_url_icontains(tmp_path, process, disable_extractors_dict):
+def test_list_filters_by_url_icontains(initialized_archive):
"""Test that list --url__icontains returns only matching snapshots."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
for url in ["https://example.com", "https://iana.org"]:
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", url],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", url],
+ env=env,
check=True,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- result = subprocess.run(
- ["archivebox", "list", "--url__icontains", "example.com"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["list", "--url__icontains", "example.com"],
timeout=30,
)
- rows = _parse_jsonl(result.stdout)
+ rows = parse_jsonl_output(result.stdout)
assert result.returncode == 0, result.stderr
assert len(rows) == 1
assert rows[0]["url"] == "https://example.com"
-def test_list_filters_by_crawl_id_and_limit(tmp_path, process, disable_extractors_dict):
+def test_list_filters_by_crawl_id_and_limit(initialized_archive):
"""Test that crawl-id and limit filters constrain the result set."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
for url in ["https://example.com", "https://iana.org"]:
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", url],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", url],
+ env=env,
check=True,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
crawl_id = str(Snapshot.objects.values_list("crawl_id", flat=True).get(url="https://example.com"))
- result = subprocess.run(
- ["archivebox", "list", "--crawl-id", crawl_id, "--limit", "1"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["list", "--crawl-id", crawl_id, "--limit", "1"],
timeout=30,
)
- rows = _parse_jsonl(result.stdout)
+ rows = parse_jsonl_output(result.stdout)
assert result.returncode == 0, result.stderr
assert len(rows) == 1
assert rows[0]["crawl_id"].replace("-", "") == crawl_id.replace("-", "")
assert rows[0]["url"] == "https://example.com"
-def test_list_filters_by_status(tmp_path, process, disable_extractors_dict):
+def test_list_filters_by_status(initialized_archive):
"""Test that list can filter using the current snapshot status."""
- os.chdir(tmp_path)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
check=True,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
status = Snapshot.objects.values_list("status", flat=True).get()
- result = subprocess.run(
- ["archivebox", "list", "--status", status],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["list", "--status", status],
timeout=30,
)
- rows = _parse_jsonl(result.stdout)
+ rows = parse_jsonl_output(result.stdout)
assert result.returncode == 0, result.stderr
assert len(rows) == 1
assert rows[0]["status"] == status
-def test_list_help_lists_filter_options(tmp_path, process):
+def test_list_help_lists_filter_options(initialized_archive):
"""Test that list --help documents the supported filter flags."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "list", "--help"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["list", "--help"],
timeout=30,
)
@@ -145,28 +226,28 @@ def test_list_help_lists_filter_options(tmp_path, process):
assert "--crawl-id" in result.stdout
assert "--limit" in result.stdout
assert "--search" in result.stdout
+ assert "--json" in result.stdout
+ assert "--html" in result.stdout
+ assert "--with-headers" in result.stdout
-def test_list_allows_sort_with_limit(tmp_path, process, disable_extractors_dict):
+def test_list_allows_sort_with_limit(initialized_archive):
"""Test that list can sort and then apply limit without queryset slicing errors."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
for url in ["https://example.com", "https://iana.org", "https://example.net"]:
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", url],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", url],
+ env=env,
check=True,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- result = subprocess.run(
- ["archivebox", "list", "--limit", "2", "--sort", "-created_at"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["list", "--limit", "2", "--sort", "-created_at"],
timeout=30,
)
- rows = _parse_jsonl(result.stdout)
+ rows = parse_jsonl_output(result.stdout)
assert result.returncode == 0, result.stderr
assert len(rows) == 2
@@ -174,12 +255,15 @@ def test_list_allows_sort_with_limit(tmp_path, process, disable_extractors_dict)
def test_snapshot_list_search_meta(initialized_archive):
"""snapshot list should support metadata search mode."""
url = create_test_url(domain="meta-search-example.com")
- run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "list", "--search=meta", "meta-search-example.com"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, f"Command failed: {stderr}"
records = parse_jsonl_output(stdout)
@@ -190,12 +274,15 @@ def test_snapshot_list_search_meta(initialized_archive):
def test_list_search_meta_matches_metadata(initialized_archive):
"""top-level list --search=meta should apply metadata search to the queryset."""
url = create_test_url(domain="top-level-meta-search-example.com")
- run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["list", "--search=meta", "top-level-meta-search-example.com"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, f"Command failed: {stderr}"
records = parse_jsonl_output(stdout)
@@ -204,62 +291,128 @@ def test_list_search_meta_matches_metadata(initialized_archive):
def test_search_command_finds_snapshots(initialized_archive):
- run_archivebox_cmd(["snapshot", "create", "https://example.com"], data_dir=initialized_archive)
+ run_archivebox_cmd(
+ ["snapshot", "create", "https://example.com"],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
- stdout, stderr, code = run_archivebox_cmd(["search", "example"], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(["search", "example"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, stderr
assert "example" in stdout
def test_search_command_returns_no_results_for_missing_term(initialized_archive):
- run_archivebox_cmd(["snapshot", "create", "https://example.com"], data_dir=initialized_archive)
+ run_archivebox_cmd(
+ ["snapshot", "create", "https://example.com"],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
- _stdout, _stderr, code = run_archivebox_cmd(["search", "nonexistentterm12345"], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["search", "nonexistentterm12345"],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ _stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code in [0, 1]
def test_search_command_on_empty_archive(initialized_archive):
- _stdout, _stderr, code = run_archivebox_cmd(["search", "anything"], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(["search", "anything"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+ _stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code in [0, 1]
-def test_search_command_json_outputs_matching_snapshots(initialized_archive):
- run_archivebox_cmd(["snapshot", "create", "https://example.com"], data_dir=initialized_archive)
+def test_search_command_outputs_matching_snapshots_as_jsonl(initialized_archive):
+ run_archivebox_cmd(
+ ["snapshot", "create", "https://example.com"],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
- stdout, stderr, code = run_archivebox_cmd(["search", "--json"], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(["search"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, stderr
- payload = json.loads(stdout)
+ records = parse_jsonl_output(stdout)
+ assert any("example.com" in row.get("url", "") for row in records)
+
+
+def test_search_command_json_outputs_matching_snapshots(initialized_archive):
+ run_archivebox_cmd(
+ ["snapshot", "create", "https://example.com"],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+
+ result = run_archivebox_cmd(["search", "--json"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+
+ assert result.returncode == 0, result.stderr
+ payload = json.loads(result.stdout)
assert any("example.com" in row.get("url", "") for row in payload)
def test_search_command_json_with_headers_wraps_links_payload(initialized_archive):
- run_archivebox_cmd(["snapshot", "create", "https://example.com"], data_dir=initialized_archive)
+ run_archivebox_cmd(
+ ["snapshot", "create", "https://example.com"],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
- stdout, stderr, code = run_archivebox_cmd(["search", "--json", "--with-headers"], data_dir=initialized_archive)
+ result = run_archivebox_cmd(
+ ["search", "--json", "--with-headers"],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
- assert code == 0, stderr
- payload = json.loads(stdout)
- links = payload.get("links", payload)
- assert any("example.com" in row.get("url", "") for row in links)
+ assert result.returncode == 0, result.stderr
+ payload = json.loads(result.stdout)
+ assert "links" in payload
+ assert any("example.com" in row.get("url", "") for row in payload["links"])
def test_search_command_html_outputs_markup(initialized_archive):
- run_archivebox_cmd(["snapshot", "create", "https://example.com"], data_dir=initialized_archive)
+ run_archivebox_cmd(
+ ["snapshot", "create", "https://example.com"],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
- stdout, stderr, code = run_archivebox_cmd(["search", "--html"], data_dir=initialized_archive)
+ result = run_archivebox_cmd(["search", "--html"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
- assert code == 0, stderr
- assert "<" in stdout
+ assert result.returncode == 0, result.stderr
+ assert "<" in result.stdout
+ assert "example.com" in result.stdout
def test_search_command_csv_outputs_requested_column(initialized_archive):
- run_archivebox_cmd(["snapshot", "create", "https://example.com"], data_dir=initialized_archive)
+ run_archivebox_cmd(
+ ["snapshot", "create", "https://example.com"],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
- stdout, stderr, code = run_archivebox_cmd(["search", "--csv", "url", "--with-headers"], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["search", "--csv", "url", "--with-headers"],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, stderr
assert "url" in stdout
@@ -267,26 +420,38 @@ def test_search_command_csv_outputs_requested_column(initialized_archive):
def test_search_command_with_headers_requires_structured_output_format(initialized_archive):
- _stdout, stderr, code = run_archivebox_cmd(["search", "--with-headers"], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(["search", "--with-headers"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code != 0
- assert "requires" in stderr.lower() or "json" in stderr.lower()
+ assert "requires" in stderr.lower()
+ assert "json" in stderr.lower()
def test_search_command_sort_option_runs_successfully(initialized_archive):
for url in ["https://iana.org", "https://example.com"]:
- run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
- stdout, stderr, code = run_archivebox_cmd(["search", "--csv", "url", "--sort=url"], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["search", "--csv", "url", "--sort=url"],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, stderr
assert "example.com" in stdout or "iana.org" in stdout
def test_search_command_help_lists_supported_filters(initialized_archive):
- stdout, _stderr, code = run_archivebox_cmd(["search", "--help"], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(["search", "--help"], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
- assert "--filter-type" in stdout or "-f" in stdout
+ assert "--url__icontains" in stdout
+ assert "--crawl-id" in stdout
assert "--status" in stdout
assert "--sort" in stdout
+ assert "--json" in stdout
+ assert "--html" in stdout
diff --git a/archivebox/tests/test_cli_machine.py b/archivebox/tests/test_cli_machine.py
new file mode 100644
index 00000000..602ea12d
--- /dev/null
+++ b/archivebox/tests/test_cli_machine.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""
+Tests for archivebox machine command.
+
+TODO: expand beyond command discovery into list/filter behavior.
+"""
+
+from archivebox.tests.conftest import run_archivebox_cmd
+
+
+def test_machine_help_runs_successfully(tmp_path):
+ """The machine command should be registered and expose help."""
+
+ result = run_archivebox_cmd(["machine", "--help"])
+
+ assert result.returncode == 0
+ assert "machine" in result.stdout.lower()
+ assert "list" in result.stdout
diff --git a/archivebox/tests/test_cli_manage.py b/archivebox/tests/test_cli_manage.py
index 9634b632..4f0f73e7 100644
--- a/archivebox/tests/test_cli_manage.py
+++ b/archivebox/tests/test_cli_manage.py
@@ -4,18 +4,14 @@ Tests for archivebox manage command.
Verify manage command runs Django management commands.
"""
-import os
-import subprocess
+from archivebox.tests.conftest import run_archivebox_cmd
-def test_manage_help_works(tmp_path, process):
+def test_manage_help_works(initialized_archive):
"""Test that manage help command works."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "manage", "help"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["manage", "help"],
timeout=30,
)
@@ -23,14 +19,11 @@ def test_manage_help_works(tmp_path, process):
assert len(result.stdout) > 100
-def test_manage_showmigrations_works(tmp_path, process):
+def test_manage_showmigrations_works(initialized_archive):
"""Test that manage showmigrations works."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "manage", "showmigrations"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["manage", "showmigrations"],
timeout=30,
)
@@ -39,14 +32,11 @@ def test_manage_showmigrations_works(tmp_path, process):
assert "core" in result.stdout or "[" in result.stdout
-def test_manage_dbshell_command_exists(tmp_path, process):
+def test_manage_dbshell_command_exists(initialized_archive):
"""Test that manage dbshell command is recognized."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "manage", "help", "dbshell"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["manage", "help", "dbshell"],
timeout=30,
)
@@ -55,14 +45,11 @@ def test_manage_dbshell_command_exists(tmp_path, process):
assert "dbshell" in result.stdout or "database" in result.stdout.lower()
-def test_manage_check_works(tmp_path, process):
+def test_manage_check_works(initialized_archive):
"""Test that manage check works."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "manage", "check"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["manage", "check"],
timeout=30,
)
diff --git a/archivebox/tests/test_cli_mcp.py b/archivebox/tests/test_cli_mcp.py
new file mode 100644
index 00000000..82b2c97b
--- /dev/null
+++ b/archivebox/tests/test_cli_mcp.py
@@ -0,0 +1,17 @@
+#!/usr/bin/env python3
+"""
+Tests for archivebox mcp command.
+
+TODO: expand beyond command discovery into JSON-RPC stdio behavior.
+"""
+
+from archivebox.tests.conftest import run_archivebox_cmd
+
+
+def test_mcp_help_runs_successfully(tmp_path):
+ """The mcp command should be registered and expose help."""
+
+ result = run_archivebox_cmd(["mcp", "--help"])
+
+ assert result.returncode == 0
+ assert "mcp" in result.stdout.lower()
diff --git a/archivebox/tests/test_cli_persona.py b/archivebox/tests/test_cli_persona.py
new file mode 100644
index 00000000..6f45cc54
--- /dev/null
+++ b/archivebox/tests/test_cli_persona.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""
+Tests for archivebox persona command.
+
+TODO: expand beyond command discovery into create/list/update/delete behavior.
+"""
+
+from archivebox.tests.conftest import run_archivebox_cmd
+
+
+def test_persona_help_runs_successfully(tmp_path):
+ """The persona command should be registered and expose help."""
+
+ result = run_archivebox_cmd(["persona", "--help"])
+
+ assert result.returncode == 0
+ assert "persona" in result.stdout.lower()
+ assert "list" in result.stdout
diff --git a/archivebox/tests/test_cli_piping.py b/archivebox/tests/test_cli_piping.py
index a58f24ef..34d1e0bc 100644
--- a/archivebox/tests/test_cli_piping.py
+++ b/archivebox/tests/test_cli_piping.py
@@ -15,6 +15,7 @@ import pytest
from archivebox.core.models import Snapshot
from archivebox.machine.models import Binary
from archivebox.tests.conftest import (
+ assert_jsonl_only,
create_test_url,
parse_jsonl_output,
run_archivebox_cmd,
@@ -41,20 +42,6 @@ class MockTTYStringIO(StringIO):
return self._is_tty
-def _stdout_lines(stdout: str) -> list[str]:
- return [line for line in stdout.splitlines() if line.strip()]
-
-
-def _assert_stdout_is_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 _uuid(value: str) -> uuid.UUID:
- return uuid.UUID(value)
-
-
def test_parse_line_accepts_supported_piping_inputs():
"""The JSONL parser should normalize the input forms CLI pipes accept."""
from archivebox.misc.jsonl import TYPE_CRAWL, TYPE_SNAPSHOT, parse_line
@@ -196,30 +183,36 @@ def test_crawl_create_stdout_pipes_into_run(initialized_archive):
"""`archivebox crawl create | archivebox run` should queue and materialize snapshots."""
url = create_test_url()
- create_stdout, create_stderr, create_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "create", url],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ create_stdout, create_stderr, create_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert create_code == 0, create_stderr
- _assert_stdout_is_jsonl_only(create_stdout)
+ assert_jsonl_only(create_stdout)
crawl = next(record for record in parse_jsonl_output(create_stdout) if record.get("type") == "Crawl")
- run_stdout, run_stderr, run_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=create_stdout,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=PIPE_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ run_stdout, run_stderr, run_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert run_code == 0, run_stderr
- _assert_stdout_is_jsonl_only(run_stdout)
+ assert_jsonl_only(run_stdout)
run_records = parse_jsonl_output(run_stdout)
assert any(record.get("type") == "Crawl" and record.get("id") == crawl["id"] for record in run_records)
with use_archivebox_db(initialized_archive):
- snapshot_count = Snapshot.objects.filter(crawl_id=_uuid(crawl["id"])).count()
+ snapshot_count = Snapshot.objects.filter(crawl_id=uuid.UUID(crawl["id"])).count()
assert isinstance(snapshot_count, int)
assert snapshot_count >= 1
@@ -228,40 +221,52 @@ def test_snapshot_list_stdout_pipes_into_run(initialized_archive):
"""`archivebox snapshot list | archivebox run` should requeue listed snapshots."""
url = create_test_url()
- create_stdout, create_stderr, create_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "create", url],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ create_stdout, create_stderr, create_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert create_code == 0, create_stderr
snapshot = next(record for record in parse_jsonl_output(create_stdout) if record.get("type") == "Snapshot")
- list_stdout, list_stderr, list_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "list", "--status=queued", f"--url__icontains={snapshot['id']}"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ list_stdout, list_stderr, list_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
if list_code != 0 or not parse_jsonl_output(list_stdout):
- list_stdout, list_stderr, list_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "list", f"--url__icontains={url}"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ list_stdout, list_stderr, list_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert list_code == 0, list_stderr
- _assert_stdout_is_jsonl_only(list_stdout)
+ assert_jsonl_only(list_stdout)
- run_stdout, run_stderr, run_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=list_stdout,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=PIPE_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ run_stdout, run_stderr, run_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert run_code == 0, run_stderr
- _assert_stdout_is_jsonl_only(run_stdout)
+ assert_jsonl_only(run_stdout)
run_records = parse_jsonl_output(run_stdout)
assert any(record.get("type") == "Snapshot" and record.get("id") == snapshot["id"] for record in run_records)
with use_archivebox_db(initialized_archive):
- snapshot_status = Snapshot.objects.values_list("status", flat=True).get(pk=_uuid(snapshot["id"]))
+ snapshot_status = Snapshot.objects.values_list("status", flat=True).get(pk=uuid.UUID(snapshot["id"]))
assert snapshot_status == "sealed"
@@ -269,48 +274,62 @@ def test_archiveresult_list_stdout_pipes_into_run(initialized_archive):
"""`archivebox archiveresult list | archivebox run` should preserve clean JSONL stdout."""
url = create_test_url()
- snapshot_stdout, snapshot_stderr, snapshot_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "create", url],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
env=PIPE_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ snapshot_stdout, snapshot_stderr, snapshot_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert snapshot_code == 0, snapshot_stderr
- ar_create_stdout, ar_create_stderr, ar_create_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create", "--plugin=favicon"],
stdin=snapshot_stdout,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
env=PIPE_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ ar_create_stdout, ar_create_stderr, ar_create_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert ar_create_code == 0, ar_create_stderr
run_archivebox_cmd(
["run"],
stdin=ar_create_stdout,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=PIPE_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
- list_stdout, list_stderr, list_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "list", "--plugin=favicon"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
env=PIPE_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ list_stdout, list_stderr, list_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert list_code == 0, list_stderr
- _assert_stdout_is_jsonl_only(list_stdout)
+ assert_jsonl_only(list_stdout)
listed_records = parse_jsonl_output(list_stdout)
archiveresult = next(record for record in listed_records if record.get("type") == "ArchiveResult")
- run_stdout, run_stderr, run_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=list_stdout,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=PIPE_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ run_stdout, run_stderr, run_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert run_code == 0, run_stderr
- _assert_stdout_is_jsonl_only(run_stdout)
+ assert_jsonl_only(run_stdout)
run_records = parse_jsonl_output(run_stdout)
assert any(record.get("type") == "ArchiveResult" and record.get("id") == archiveresult["id"] for record in run_records)
@@ -318,29 +337,35 @@ def test_archiveresult_list_stdout_pipes_into_run(initialized_archive):
def test_binary_create_stdout_pipes_into_run(initialized_archive):
"""`archivebox binary create | archivebox run` should queue the binary record for processing."""
- create_stdout, create_stderr, create_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["binary", "create", "--name=python3", f"--abspath={sys.executable}", "--version=test"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ create_stdout, create_stderr, create_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert create_code == 0, create_stderr
- _assert_stdout_is_jsonl_only(create_stdout)
+ assert_jsonl_only(create_stdout)
binary = next(record for record in parse_jsonl_output(create_stdout) if record.get("type") in {"BinaryRequest", "Binary"})
- run_stdout, run_stderr, run_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=create_stdout,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ run_stdout, run_stderr, run_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert run_code == 0, run_stderr
- _assert_stdout_is_jsonl_only(run_stdout)
+ assert_jsonl_only(run_stdout)
run_records = parse_jsonl_output(run_stdout)
assert any(record.get("type") in {"BinaryRequest", "Binary"} and record.get("id") == binary["id"] for record in run_records)
with use_archivebox_db(initialized_archive):
- status = Binary.objects.values_list("status", flat=True).get(pk=_uuid(binary["id"]))
+ status = Binary.objects.values_list("status", flat=True).get(pk=uuid.UUID(binary["id"]))
assert status in {"queued", "installed"}
@@ -348,43 +373,55 @@ def test_multi_stage_pipeline_into_run(initialized_archive):
"""`crawl create | snapshot create | archiveresult create | run` should preserve JSONL and finish work."""
url = create_test_url()
- crawl_stdout, crawl_stderr, crawl_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["crawl", "create", url],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ crawl_stdout, crawl_stderr, crawl_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert crawl_code == 0, crawl_stderr
- _assert_stdout_is_jsonl_only(crawl_stdout)
+ assert_jsonl_only(crawl_stdout)
- snapshot_stdout, snapshot_stderr, snapshot_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "create"],
stdin=crawl_stdout,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ snapshot_stdout, snapshot_stderr, snapshot_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert snapshot_code == 0, snapshot_stderr
- _assert_stdout_is_jsonl_only(snapshot_stdout)
+ assert_jsonl_only(snapshot_stdout)
- archiveresult_stdout, archiveresult_stderr, archiveresult_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create", "--plugin=favicon"],
stdin=snapshot_stdout,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ archiveresult_stdout, archiveresult_stderr, archiveresult_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert archiveresult_code == 0, archiveresult_stderr
- _assert_stdout_is_jsonl_only(archiveresult_stdout)
+ assert_jsonl_only(archiveresult_stdout)
- run_stdout, run_stderr, run_code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=archiveresult_stdout,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=PIPE_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ run_stdout, run_stderr, run_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert run_code == 0, run_stderr
- _assert_stdout_is_jsonl_only(run_stdout)
+ assert_jsonl_only(run_stdout)
run_records = parse_jsonl_output(run_stdout)
snapshot = next(record for record in run_records if record.get("type") == "Snapshot")
assert any(record.get("type") == "ArchiveResult" for record in run_records)
with use_archivebox_db(initialized_archive):
- snapshot_status = Snapshot.objects.values_list("status", flat=True).get(pk=_uuid(snapshot["id"]))
+ snapshot_status = Snapshot.objects.values_list("status", flat=True).get(pk=uuid.UUID(snapshot["id"]))
assert snapshot_status == "sealed"
diff --git a/archivebox/tests/test_cli_pluginmap.py b/archivebox/tests/test_cli_pluginmap.py
new file mode 100644
index 00000000..d7266ec3
--- /dev/null
+++ b/archivebox/tests/test_cli_pluginmap.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""
+Tests for archivebox pluginmap command.
+
+TODO: expand beyond command discovery into quiet/event output behavior.
+"""
+
+from archivebox.tests.conftest import run_archivebox_cmd
+
+
+def test_pluginmap_help_runs_successfully(tmp_path):
+ """The pluginmap command should be registered and expose help."""
+
+ result = run_archivebox_cmd(["pluginmap", "--help"])
+
+ assert result.returncode == 0
+ assert "pluginmap" in result.stdout.lower()
+ assert "--event" in result.stdout
diff --git a/archivebox/tests/test_cli_process.py b/archivebox/tests/test_cli_process.py
new file mode 100644
index 00000000..1968fc50
--- /dev/null
+++ b/archivebox/tests/test_cli_process.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""
+Tests for archivebox process command.
+
+TODO: expand beyond command discovery into list/filter behavior.
+"""
+
+from archivebox.tests.conftest import run_archivebox_cmd
+
+
+def test_process_help_runs_successfully(tmp_path):
+ """The process command should be registered and expose help."""
+
+ result = run_archivebox_cmd(["process", "--help"])
+
+ assert result.returncode == 0
+ assert "process" in result.stdout.lower()
+ assert "list" in result.stdout
diff --git a/archivebox/tests/test_cli_real_flows.py b/archivebox/tests/test_cli_real_flows.py
deleted file mode 100644
index 54f08e5f..00000000
--- a/archivebox/tests/test_cli_real_flows.py
+++ /dev/null
@@ -1,1091 +0,0 @@
-#!/usr/bin/env python3
-"""Real user-facing archive flows against live URLs."""
-
-import json
-import os
-import re
-import signal
-import socket
-import subprocess
-import sys
-import time
-from pathlib import Path
-
-import pytest
-
-from archivebox.core.models import ArchiveResult, Snapshot
-from archivebox.crawls.models import Crawl
-from archivebox.machine.models import Process
-from archivebox.tests.test_orm_helpers import use_archivebox_db
-
-from .conftest import _find_system_browser, wait_for_process
-
-pytestmark = pytest.mark.django_db(transaction=True)
-
-
-def _pid_is_alive(pid: int) -> bool:
- try:
- os.kill(pid, 0)
- except ProcessLookupError:
- return False
- except PermissionError:
- return True
- return True
-
-
-def _wait_for_pid_exit(pid: int, *, timeout: float = 5.0) -> None:
- deadline = time.time() + timeout
- while time.time() < deadline:
- if not _pid_is_alive(pid):
- return
- time.sleep(0.05)
- raise AssertionError(f"PID {pid} is still alive")
-
-
-def _cleanup_process_group(group_pid: int | None, *child_pids: int | None) -> None:
- if group_pid and _pid_is_alive(group_pid):
- try:
- os.killpg(group_pid, signal.SIGKILL)
- except ProcessLookupError:
- pass
- except OSError:
- try:
- os.kill(group_pid, signal.SIGKILL)
- except ProcessLookupError:
- pass
- for pid in child_pids:
- if pid and _pid_is_alive(pid):
- try:
- os.kill(pid, signal.SIGKILL)
- except ProcessLookupError:
- pass
-
-
-def _free_port() -> int:
- with socket.socket() as sock:
- sock.bind(("127.0.0.1", 0))
- return int(sock.getsockname()[1])
-
-
-def _live_exit_env(data_dir, *, plugins_root=None, extra=None):
- env = os.environ.copy()
- env.update(
- {
- "DATA_DIR": str(data_dir),
- "USE_COLOR": "false",
- "SHOW_PROGRESS": "false",
- "SAVE_ARCHIVEDOTORG": "false",
- "SAVE_FAVICON": "false",
- "SAVE_HEADERS": "false",
- "SAVE_TITLE": "false",
- "SAVE_READABILITY": "false",
- "SAVE_SINGLEFILE": "false",
- "SAVE_MERCURY": "false",
- "SAVE_SCREENSHOT": "false",
- "SAVE_PDF": "false",
- "SAVE_DOM": "false",
- "SAVE_GIT": "false",
- "SAVE_YTDLP": "false",
- "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 plugins_root is not None:
- env["ABX_PLUGINS_DIR"] = str(plugins_root)
- if extra:
- env.update(extra)
- return env
-
-
-def _wait_for_port(host: str, port: int, *, timeout: float = 30.0) -> None:
- deadline = time.time() + timeout
- while time.time() < deadline:
- try:
- with socket.create_connection((host, port), timeout=0.25):
- return
- except OSError:
- time.sleep(0.1)
- raise AssertionError(f"server did not listen on {host}:{port}")
-
-
-def _wait_for_log(log_path: Path, text: str, *, timeout: float = 30.0) -> str:
- deadline = time.time() + timeout
- content = ""
- while time.time() < deadline:
- if log_path.exists():
- content = log_path.read_text(encoding="utf-8", errors="replace")
- if text in content:
- return content
- time.sleep(0.1)
- raise AssertionError(f"timed out waiting for {text!r} in {log_path}:\n{content}")
-
-
-def _wait_for_log_count(log_path: Path, text: str, count: int, *, timeout: float = 30.0) -> str:
- deadline = time.time() + timeout
- content = ""
- while time.time() < deadline:
- if log_path.exists():
- content = log_path.read_text(encoding="utf-8", errors="replace")
- if content.count(text) >= count:
- return content
- time.sleep(0.1)
- raise AssertionError(f"timed out waiting for {count} occurrences of {text!r} in {log_path}:\n{content}")
-
-
-def _wait_for_pid_to_disappear(pid: int, *, timeout: float = 20.0) -> None:
- deadline = time.time() + timeout
- while time.time() < deadline:
- if not _pid_is_alive(pid):
- return
- time.sleep(0.1)
- raise AssertionError(f"PID {pid} is still running")
-
-
-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 _pgrep_data_dir(data_dir) -> list[str]:
- result = subprocess.run(["pgrep", "-af", str(data_dir)], capture_output=True, text=True, timeout=5)
- lines = [line for line in result.stdout.splitlines() if "pgrep -af" not in line]
-
- # A foreground ArchiveBox process can be killed with SIGKILL before Python
- # cleanup runs. Supervisord's command line only points at its generated
- # config file, so catch orphaned supervisors by resolving pidfiles whose
- # configs still reference this real test DATA_DIR.
- for runtime_root in (Path("/tmp/archivebox"), Path(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
- ps_line = subprocess.run(
- ["ps", "-p", str(pid), "-o", "pid=,ppid=,command="],
- capture_output=True,
- text=True,
- timeout=5,
- ).stdout.strip()
- if ps_line:
- lines.append(ps_line)
-
- return sorted(set(lines))
-
-
-def _assert_no_processes_for_data_dir(data_dir, *, timeout: float = 10.0) -> None:
- deadline = time.time() + timeout
- remaining: list[str] = []
- while time.time() < deadline:
- remaining = _pgrep_data_dir(data_dir)
- if not remaining:
- return
- time.sleep(0.25)
- raise AssertionError("processes still reference test DATA_DIR:\n" + "\n".join(remaining))
-
-
-def _kill_processes_for_data_dir(data_dir) -> None:
- 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.SIGKILL)
- except ProcessLookupError:
- pass
-
-
-def _start_server(data_dir, *, port: int, log_name: str, env: dict[str, str] | None = None) -> tuple[subprocess.Popen[str], Path]:
- log_path = data_dir / log_name
- log = log_path.open("w", encoding="utf-8")
- proc = subprocess.Popen(
- [sys.executable, "-m", "archivebox", "server", f"127.0.0.1:{port}"],
- cwd=data_dir,
- env=env or _live_exit_env(data_dir),
- stdout=log,
- stderr=subprocess.STDOUT,
- text=True,
- start_new_session=True,
- )
- log.close()
- _wait_for_port("127.0.0.1", port)
- _wait_for_log(log_path, "Tailing worker logs", timeout=30.0)
- return proc, log_path
-
-
-def _stop_process(proc: subprocess.Popen[str], sig=signal.SIGTERM, *, timeout: float = 15.0) -> str:
- if proc.poll() is None:
- try:
- os.killpg(proc.pid, sig)
- except (ProcessLookupError, OSError):
- try:
- os.kill(proc.pid, sig)
- except ProcessLookupError:
- pass
- try:
- stdout, _stderr = proc.communicate(timeout=timeout)
- return stdout or ""
- except subprocess.TimeoutExpired:
- try:
- os.killpg(proc.pid, signal.SIGKILL)
- except (ProcessLookupError, OSError):
- try:
- os.kill(proc.pid, signal.SIGKILL)
- except ProcessLookupError:
- pass
- stdout, _stderr = proc.communicate(timeout=5)
- return stdout or ""
-
-
-def _write_slow_snapshot_plugin(plugins_root, marker_dir):
- plugin_dir = plugins_root / "slow_exit"
- plugin_dir.mkdir(parents=True, exist_ok=True)
- hook = plugin_dir / "on_Snapshot__09_slow_exit.finite.bg.sh"
- hook.write_text(
- "\n".join(
- [
- "#!/usr/bin/env bash",
- "set -euo pipefail",
- f"marker_dir={str(marker_dir)!r}",
- 'mkdir -p "$marker_dir"',
- 'echo $$ >> "$marker_dir/hook-pids.txt"',
- 'touch "$marker_dir/hook-started"',
- "trap 'touch \"$marker_dir/hook-stopped\"; exit 0' TERM INT HUP",
- "while true; do sleep 1; done",
- "",
- ],
- ),
- encoding="utf-8",
- )
- hook.chmod(0o755)
- return plugin_dir
-
-
-def _wait_for_crawl_state(data_dir, predicate, *, timeout: float = 30.0):
- deadline = time.time() + timeout
- last = None
- while time.time() < deadline:
- with use_archivebox_db(data_dir):
- last = {
- "crawls": list(Crawl.objects.order_by("created_at").values("id", "status", "retry_at")),
- "snapshots": list(Snapshot.objects.order_by("created_at").values("id", "url", "status", "retry_at")),
- "results": list(ArchiveResult.objects.order_by("created_at").values("id", "plugin", "status")),
- "processes": list(Process.objects.order_by("created_at").values("id", "process_type", "status", "pid", "cmd")),
- }
- if predicate(last):
- return last
- time.sleep(0.25)
- raise AssertionError(f"timed out waiting for crawl state, last={last}")
-
-
-def _wait_for_hook_runs(marker_dir: Path, count: int, *, timeout: float = 45.0) -> list[int]:
- pid_file = marker_dir / "hook-pids.txt"
- deadline = time.time() + timeout
- pids: list[int] = []
- while time.time() < deadline:
- if pid_file.exists():
- pids = [int(line.strip()) for line in pid_file.read_text().splitlines() if line.strip()]
- if len(pids) >= count:
- return pids
- time.sleep(0.25)
- raise AssertionError(f"timed out waiting for {count} slow hook runs, got {pids}")
-
-
-def _start_live_add(
- data_dir,
- env,
- *,
- url="https://example.com",
- max_urls="2",
- log_name="archivebox-add.log",
-) -> tuple[subprocess.Popen[str], Path]:
- log_path = data_dir / log_name
- log = log_path.open("w", encoding="utf-8")
- urls = [url] if isinstance(url, str) else url
- proc = subprocess.Popen(
- [
- sys.executable,
- "-m",
- "archivebox",
- "add",
- "--depth=1",
- f"--max-urls={max_urls}",
- "--crawl-max-size=50mb",
- "--plugins=wget,parse_html_urls,slow_exit",
- *urls,
- ],
- cwd=data_dir,
- env=env,
- stdout=log,
- stderr=subprocess.STDOUT,
- text=True,
- start_new_session=True,
- )
- log.close()
- return proc, log_path
-
-
-@pytest.mark.timeout(90)
-def test_cli_run_signal_cleans_background_hook_process_group(tmp_path, process):
- os.chdir(tmp_path)
- assert process.returncode == 0, process.stderr
-
- plugins_root = tmp_path / "runtime_plugins"
- plugin_dir = plugins_root / "cancel_group"
- plugin_dir.mkdir(parents=True)
- daemon_hook = plugin_dir / "on_CrawlSetup__10_daemon.daemon.bg.sh"
- foreground_hook = plugin_dir / "on_CrawlSetup__20_foreground.sh"
- daemon_hook.write_text(
- "\n".join(
- [
- "#!/usr/bin/env bash",
- "set -euo pipefail",
- 'test_dir="${LEAK_TEST_DIR:?}"',
- "sleep 600 &",
- 'echo $$ > "$test_dir/daemon.pid"',
- 'echo $! > "$test_dir/daemon-child.pid"',
- 'echo ready > "$test_dir/daemon.ready"',
- "trap 'echo cleaned > \"$test_dir/daemon.cleaned\"; exit 0' TERM INT",
- "wait",
- "",
- ],
- ),
- )
- foreground_hook.write_text(
- "\n".join(
- [
- "#!/usr/bin/env bash",
- "set -euo pipefail",
- 'test_dir="${LEAK_TEST_DIR:?}"',
- 'echo $$ > "$test_dir/foreground.pid"',
- 'echo ready > "$test_dir/foreground.ready"',
- "trap 'echo cleaned > \"$test_dir/foreground.cleaned\"; exit 0' TERM INT",
- "while true; do sleep 1; done",
- "",
- ],
- ),
- )
- daemon_hook.chmod(0o755)
- foreground_hook.chmod(0o755)
-
- leak_test_dir = tmp_path / "leak-check"
- leak_test_dir.mkdir()
- env = os.environ.copy()
- env.update(
- {
- "ABX_PLUGINS_DIR": str(plugins_root),
- "LEAK_TEST_DIR": str(leak_test_dir),
- "PLUGINS": "cancel_group",
- "TIMEOUT": "30",
- "USE_COLOR": "false",
- "SHOW_PROGRESS": "false",
- },
- )
-
- create_result = subprocess.run(
- [sys.executable, "-m", "archivebox", "crawl", "create", "https://example.com"],
- cwd=tmp_path,
- capture_output=True,
- text=True,
- env=env,
- timeout=60,
- )
- assert create_result.returncode == 0, create_result.stderr or create_result.stdout
- crawl_records = [json.loads(line) for line in create_result.stdout.splitlines() if line.strip().startswith("{")]
- crawl_id = next(record["id"] for record in crawl_records if record.get("type") == "Crawl")
-
- daemon_pid: int | None = None
- daemon_child_pid: int | None = None
- foreground_pid: int | None = None
- run_process = subprocess.Popen(
- [sys.executable, "-m", "archivebox", "run", f"--crawl-id={crawl_id}"],
- cwd=tmp_path,
- env=env,
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT,
- text=True,
- start_new_session=True,
- )
- try:
- deadline = time.time() + 20
- while time.time() < deadline:
- if (leak_test_dir / "daemon.ready").exists() and (leak_test_dir / "foreground.ready").exists():
- break
- if run_process.poll() is not None:
- output = run_process.communicate(timeout=1)[0]
- raise AssertionError(f"archivebox run exited before hooks were ready:\n{output}")
- time.sleep(0.05)
- assert (leak_test_dir / "daemon.ready").exists()
- assert (leak_test_dir / "foreground.ready").exists()
-
- daemon_pid = int((leak_test_dir / "daemon.pid").read_text().strip())
- daemon_child_pid = int((leak_test_dir / "daemon-child.pid").read_text().strip())
- foreground_pid = int((leak_test_dir / "foreground.pid").read_text().strip())
- assert _pid_is_alive(daemon_pid)
- assert _pid_is_alive(daemon_child_pid)
- assert _pid_is_alive(foreground_pid)
-
- run_process.send_signal(signal.SIGTERM)
- time.sleep(0.1)
- if run_process.poll() is None:
- run_process.send_signal(signal.SIGTERM)
- output = run_process.communicate(timeout=20)[0]
- assert "Runner error" not in output
-
- _wait_for_pid_exit(daemon_pid)
- _wait_for_pid_exit(daemon_child_pid)
- _wait_for_pid_exit(foreground_pid)
- assert (leak_test_dir / "daemon.cleaned").read_text().strip() == "cleaned"
- assert (leak_test_dir / "foreground.cleaned").read_text().strip() == "cleaned"
- finally:
- if run_process.poll() is None:
- try:
- os.killpg(run_process.pid, signal.SIGKILL)
- except ProcessLookupError:
- pass
- run_process.communicate(timeout=5)
- _cleanup_process_group(daemon_pid, daemon_child_pid)
- _cleanup_process_group(foreground_pid)
-
-
-@pytest.mark.timeout(300)
-@pytest.mark.parametrize(
- ("stop_signal", "expected_notice"),
- [
- (signal.SIGHUP, "Got SIGHUP"),
- (signal.SIGINT, "Got SIGINT"),
- (signal.SIGTERM, "Got SIGTERM"),
- (signal.SIGKILL, None),
- ],
-)
-def test_live_server_signal_exit_and_resume_uses_existing_supervisor_state(tmp_path, process, stop_signal, expected_notice):
- os.chdir(tmp_path)
- assert process.returncode == 0, process.stderr
-
- env = _live_exit_env(tmp_path)
- port = _free_port()
- server = None
- resumed = None
- try:
- server, server_log = _start_server(tmp_path, port=port, log_name=f"server-{stop_signal.name}.log", env=env)
-
- os.kill(server.pid, stop_signal)
- try:
- server.wait(timeout=20 if stop_signal != signal.SIGKILL else 5)
- except subprocess.TimeoutExpired:
- os.kill(server.pid, signal.SIGKILL)
- server.wait(timeout=5)
-
- if expected_notice:
- log_text = server_log.read_text(encoding="utf-8", errors="replace")
- assert expected_notice in log_text
- assert "ArchiveBox server shut down gracefully" in log_text
- _assert_no_processes_for_data_dir(tmp_path, timeout=12)
-
- resumed, resumed_log = _start_server(tmp_path, port=port, log_name=f"server-{stop_signal.name}-resumed.log", env=env)
- status = subprocess.run(
- [sys.executable, "-m", "archivebox", "status"],
- cwd=tmp_path,
- env=env,
- capture_output=True,
- text=True,
- timeout=60,
- )
- assert status.returncode == 0, status.stderr or status.stdout
-
- os.kill(resumed.pid, signal.SIGTERM)
- resumed.wait(timeout=20)
- resumed_text = resumed_log.read_text(encoding="utf-8", errors="replace")
- assert "Got SIGTERM" in resumed_text
- assert "ArchiveBox server shut down gracefully" in resumed_text
- _assert_no_processes_for_data_dir(tmp_path, timeout=12)
- finally:
- for proc in (server, resumed):
- if proc is not None and proc.poll() is None:
- _stop_process(proc, signal.SIGKILL)
- _kill_processes_for_data_dir(tmp_path)
-
-
-@pytest.mark.timeout(180)
-def test_live_daemonized_server_keeps_supervisord_owned_by_archivebox_parent(tmp_path, process):
- os.chdir(tmp_path)
- assert process.returncode == 0, process.stderr
-
- env = _live_exit_env(tmp_path)
- port = _free_port()
- bind_url = f"http://127.0.0.1:{port}"
- try:
- result = subprocess.run(
- [sys.executable, "-m", "archivebox", "server", "--daemonize", f"127.0.0.1:{port}"],
- cwd=tmp_path,
- env=env,
- capture_output=True,
- text=True,
- timeout=90,
- )
- assert result.returncode == 0, result.stderr or result.stdout
- _wait_for_port("127.0.0.1", port, timeout=30)
-
- server_process = wait_for_process(
- lambda _proc, command: "archivebox" in command and " server " in f" {command} " and bind_url.replace("http://", "") in command,
- )
- supervisord = wait_for_process(
- lambda proc, command: proc.ppid() == server_process.pid and "supervisord" in command,
- )
- wait_for_process(
- lambda proc, command: proc.ppid() == supervisord.pid and "supervisord_watchdog" in command,
- )
-
- os.kill(server_process.pid, signal.SIGKILL)
- _wait_for_pid_to_disappear(server_process.pid, timeout=10)
- _wait_for_pid_to_disappear(supervisord.pid, timeout=20)
- _assert_no_processes_for_data_dir(tmp_path, timeout=12)
- finally:
- _kill_processes_for_data_dir(tmp_path)
- _assert_no_processes_for_data_dir(tmp_path, timeout=12)
-
-
-@pytest.mark.timeout(240)
-def test_live_second_server_takes_over_existing_server_process(tmp_path, process):
- os.chdir(tmp_path)
- assert process.returncode == 0, process.stderr
-
- env = _live_exit_env(tmp_path)
- port = _free_port()
- first = None
- second = None
- try:
- first, first_log = _start_server(tmp_path, port=port, log_name="server-first.log", env=env)
- second, second_log = _start_server(tmp_path, port=port, log_name="server-second.log", env=env)
-
- assert first.poll() is None
- first_text = first_log.read_text(encoding="utf-8", errors="replace")
- second_text = second_log.read_text(encoding="utf-8", errors="replace")
- assert "A newer archivebox process took over the orchestrator, server" in first_text
- assert "Starting orchestrator, server" in second_text
-
- status = subprocess.run(
- [sys.executable, "-m", "archivebox", "status"],
- cwd=tmp_path,
- env=env,
- capture_output=True,
- text=True,
- timeout=60,
- )
- assert status.returncode == 0, status.stderr or status.stdout
-
- first_resumes = first_log.read_text(encoding="utf-8", errors="replace").count("Other newer archivebox process")
- _stop_process(second, signal.SIGTERM)
- second = None
- _wait_for_log_count(first_log, "Other newer archivebox process", first_resumes + 1, timeout=35)
- assert first.poll() is None
- finally:
- if second is not None and second.poll() is None:
- _stop_process(second, signal.SIGTERM)
- if first is not None and first.poll() is None:
- _stop_process(first, signal.SIGKILL)
- _kill_processes_for_data_dir(tmp_path)
- _assert_no_processes_for_data_dir(tmp_path, timeout=12)
-
-
-@pytest.mark.timeout(420)
-def test_live_repeated_server_startups_take_over_cleanly(tmp_path, process):
- os.chdir(tmp_path)
- assert process.returncode == 0, process.stderr
-
- env = _live_exit_env(tmp_path)
- port = _free_port()
- servers: list[subprocess.Popen[str]] = []
- server_pids: list[int] = []
- daphne_pids: list[int] = []
- runner_pids: list[int] = []
- try:
- for index in range(5):
- server, log_path = _start_server(tmp_path, port=port, log_name=f"server-chaos-{index}.log", env=env)
- servers.append(server)
- server_pids.append(server.pid)
- daphne_pids.append(_worker_pid_from_log(log_path, "worker_daphne"))
- runner_pids.append(_worker_pid_from_log(log_path, "worker_runner"))
-
- if index > 0:
- previous_server = servers[index - 1]
- previous_log = (tmp_path / f"server-chaos-{index - 1}.log").read_text(encoding="utf-8", errors="replace")
- current_log = log_path.read_text(encoding="utf-8", errors="replace")
- assert previous_server.poll() is None
- assert _pid_is_alive(server_pids[index - 1])
- assert "A newer archivebox process took over the orchestrator, server" in previous_log
- assert "Starting orchestrator, server" in current_log
- _wait_for_pid_to_disappear(daphne_pids[index - 1], timeout=15)
- _wait_for_pid_to_disappear(runner_pids[index - 1], timeout=15)
-
- status = subprocess.run(
- [sys.executable, "-m", "archivebox", "status"],
- cwd=tmp_path,
- env=env,
- capture_output=True,
- text=True,
- timeout=60,
- )
- assert status.returncode == 0, status.stderr or status.stdout
- time.sleep(5)
-
- assert servers[-1].poll() is None
- assert all(server.poll() is None for server in servers)
- listener = subprocess.run(
- ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN"],
- capture_output=True,
- text=True,
- timeout=10,
- )
- assert listener.returncode == 0, listener.stderr or listener.stdout
- assert listener.stdout.count(f":{port} (LISTEN)") == 1
-
- previous_log_path = tmp_path / "server-chaos-3.log"
- previous_takeovers = previous_log_path.read_text(encoding="utf-8", errors="replace").count(
- "Other newer archivebox process",
- )
- _stop_process(servers[-1], signal.SIGTERM)
- _wait_for_log_count(previous_log_path, "Other newer archivebox process", previous_takeovers + 1, timeout=35)
- assert servers[3].poll() is None
- finally:
- for server in reversed(servers):
- if server.poll() is None:
- _stop_process(server, signal.SIGTERM)
- _kill_processes_for_data_dir(tmp_path)
- _assert_no_processes_for_data_dir(tmp_path, timeout=12)
-
-
-@pytest.mark.timeout(240)
-def test_live_servers_in_different_data_dirs_do_not_interfere(tmp_path, process):
- os.chdir(tmp_path)
- assert process.returncode == 0, process.stderr
-
- first_data_dir = tmp_path
- second_data_dir = tmp_path.parent / f"{tmp_path.name}-second"
- second_data_dir.mkdir()
- second_env = _live_exit_env(second_data_dir)
- second_init = subprocess.run(
- [sys.executable, "-m", "archivebox", "init"],
- cwd=second_data_dir,
- env=second_env,
- capture_output=True,
- text=True,
- timeout=90,
- )
- assert second_init.returncode == 0, second_init.stderr or second_init.stdout
-
- first_port = _free_port()
- second_port = _free_port()
- first = None
- second = None
- first_resumed = None
- try:
- first = _start_server(first_data_dir, port=first_port, log_name="server-first-data-dir.log", env=_live_exit_env(first_data_dir))[0]
- second = _start_server(second_data_dir, port=second_port, log_name="server-second-data-dir.log", env=second_env)[0]
-
- first_status = subprocess.run(
- [sys.executable, "-m", "archivebox", "status"],
- cwd=first_data_dir,
- env=_live_exit_env(first_data_dir),
- capture_output=True,
- text=True,
- timeout=60,
- )
- second_status = subprocess.run(
- [sys.executable, "-m", "archivebox", "status"],
- cwd=second_data_dir,
- env=second_env,
- capture_output=True,
- text=True,
- timeout=60,
- )
- assert first_status.returncode == 0, first_status.stderr or first_status.stdout
- assert second_status.returncode == 0, second_status.stderr or second_status.stdout
-
- _stop_process(first, signal.SIGTERM)
- first = None
- assert second.poll() is None, "stopping one DATA_DIR server must not stop another DATA_DIR server"
-
- first_resumed = _start_server(
- first_data_dir,
- port=first_port,
- log_name="server-first-data-dir-resumed.log",
- env=_live_exit_env(first_data_dir),
- )[0]
- assert second.poll() is None, "restarting one DATA_DIR server must not take over another DATA_DIR supervisor"
- finally:
- for proc in (first, first_resumed, second):
- if proc is not None and proc.poll() is None:
- _stop_process(proc, signal.SIGTERM)
- _kill_processes_for_data_dir(first_data_dir)
- _kill_processes_for_data_dir(second_data_dir)
- _assert_no_processes_for_data_dir(first_data_dir, timeout=12)
- _assert_no_processes_for_data_dir(second_data_dir, timeout=12)
-
-
-@pytest.mark.timeout(420)
-def test_live_add_update_jobs_survive_server_and_cli_owner_exits(tmp_path, process):
- os.chdir(tmp_path)
- assert process.returncode == 0, process.stderr
-
- plugins_root = tmp_path / "runtime_plugins"
- marker_dir = tmp_path / "slow-plugin-markers"
- _write_slow_snapshot_plugin(plugins_root, marker_dir)
- env = _live_exit_env(tmp_path, plugins_root=plugins_root)
- port = _free_port()
- server = None
- server2 = None
- server3 = None
- add_proc = None
- add_proc2 = None
- try:
- server, server_log = _start_server(tmp_path, port=port, log_name="server-add-owner-1.log", env=env)
- supervisor_pid_before = _supervisor_pid_from_log(server_log)
-
- update_result = subprocess.run(
- [sys.executable, "-m", "archivebox", "update", "--index-only", "--batch-size=10"],
- cwd=tmp_path,
- env=env,
- capture_output=True,
- text=True,
- timeout=90,
- )
- assert update_result.returncode == 0, update_result.stderr or update_result.stdout
- assert server.poll() is None
- assert _pid_is_alive(supervisor_pid_before)
- assert _supervisor_pid_from_log(server_log) == supervisor_pid_before
-
- add_proc, add_log = _start_live_add(
- tmp_path,
- env,
- url=["https://example.com", "https://blog.sweeting.me"],
- log_name="archivebox-add-1.log",
- )
- _wait_for_hook_runs(marker_dir, 1)
- _wait_for_crawl_state(
- tmp_path,
- lambda state: any(snapshot["status"] == Snapshot.StatusChoices.STARTED for snapshot in state["snapshots"]),
- timeout=30,
- )
-
- os.kill(server.pid, signal.SIGTERM)
- server.wait(timeout=20)
- assert add_proc.poll() is None, "foreground add should keep owning its crawl after the server exits"
- assert "Got SIGTERM" in server_log.read_text(encoding="utf-8", errors="replace")
-
- server2, _server2_log = _start_server(tmp_path, port=port, log_name="server-add-owner-2.log", env=env)
- os.killpg(add_proc.pid, signal.SIGTERM)
- add_proc.wait(timeout=30)
- add_output = add_log.read_text(encoding="utf-8", errors="replace")
- assert "Runner error" not in add_output
- _wait_for_hook_runs(marker_dir, 2, timeout=60)
- _wait_for_crawl_state(
- tmp_path,
- lambda state: (
- any(crawl["status"] in (Crawl.StatusChoices.STARTED, Crawl.StatusChoices.QUEUED) for crawl in state["crawls"])
- and any(result["plugin"] == "slow_exit" for result in state["results"])
- ),
- timeout=30,
- )
-
- add_proc2, add_log2 = _start_live_add(
- tmp_path,
- env,
- url="https://example.com/?exit-resume=2",
- max_urls="1",
- log_name="archivebox-add-2.log",
- )
- _wait_for_hook_runs(marker_dir, 3, timeout=60)
- os.killpg(add_proc2.pid, signal.SIGTERM)
- os.kill(server2.pid, signal.SIGTERM)
- add_proc2.wait(timeout=30)
- add_output2 = add_log2.read_text(encoding="utf-8", errors="replace")
- server2.wait(timeout=20)
- assert "Runner error" not in add_output2
-
- server3, _server3_log = _start_server(tmp_path, port=port, log_name="server-add-owner-3.log", env=env)
- _wait_for_hook_runs(marker_dir, 4, timeout=70)
-
- with use_archivebox_db(tmp_path):
- crawls = list(Crawl.objects.order_by("created_at").values_list("status", "retry_at"))
- snapshots = list(Snapshot.objects.order_by("created_at").values_list("url", "status", "retry_at"))
- failed_results = list(
- ArchiveResult.objects.filter(status=ArchiveResult.StatusChoices.FAILED).values_list("plugin", "output_str"),
- )
- assert crawls
- assert snapshots
- assert not failed_results
- finally:
- for proc in (add_proc, add_proc2, server, server2, server3):
- if proc is not None and proc.poll() is None:
- _stop_process(proc, signal.SIGTERM, timeout=10)
- _kill_processes_for_data_dir(tmp_path)
- _assert_no_processes_for_data_dir(tmp_path, timeout=12)
-
-
-@pytest.mark.timeout(180)
-def test_cli_add_real_urls_with_options_writes_inspectable_outputs(tmp_path, process):
- os.chdir(tmp_path)
- assert process.returncode == 0, process.stderr
-
- wget_urls = [
- "https://example.com",
- "https://pirate.github.io/stress-tests/challenge.html",
- ]
- chrome_url = "https://example.com/?archivebox-chrome-flow=1"
- env = os.environ.copy()
- env.pop("CHROME_BINARY", None)
- env.update(
- {
- "USE_COLOR": "false",
- "SHOW_PROGRESS": "false",
- "TIMEOUT": "60",
- "SAVE_WGET": "true",
- "SAVE_HEADERS": "false",
- "SAVE_TITLE": "false",
- "SAVE_READABILITY": "false",
- "SAVE_SINGLEFILE": "false",
- "SAVE_MERCURY": "false",
- "SAVE_SCREENSHOT": "false",
- "SAVE_PDF": "false",
- "SAVE_DOM": "false",
- "SAVE_ARCHIVEDOTORG": "false",
- "SAVE_GIT": "false",
- "SAVE_YTDLP": "false",
- "SAVE_FAVICON": "false",
- },
- )
- result = subprocess.run(
- [
- sys.executable,
- "-m",
- "archivebox",
- "add",
- "--depth=0",
- "--max-urls=2",
- "--crawl-max-size=10mb",
- "--tag=real-flow,challenge",
- "--parser=url_list",
- "--plugins=wget",
- *wget_urls,
- ],
- cwd=tmp_path,
- capture_output=True,
- text=True,
- env=env,
- timeout=180,
- )
- assert result.returncode == 0, result.stderr or result.stdout
-
- chrome_env = env | {
- "SAVE_WGET": "false",
- "SAVE_HEADERS": "true",
- "SAVE_TITLE": "true",
- "CHROME_HEADLESS": "true",
- "CHROME_SANDBOX": "false",
- "CHROME_ISOLATION": "snapshot",
- }
- system_browser = _find_system_browser()
- if system_browser:
- chrome_env["CHROME_BINARY"] = str(system_browser)
- else:
- install_result = subprocess.run(
- [sys.executable, "-m", "archivebox", "install", "chrome"],
- cwd=tmp_path,
- capture_output=True,
- text=True,
- env=chrome_env,
- timeout=600,
- )
- assert install_result.returncode == 0, install_result.stderr or install_result.stdout
- chrome_result = subprocess.run(
- [
- sys.executable,
- "-m",
- "archivebox",
- "add",
- "--depth=0",
- "--max-urls=1",
- "--crawl-max-size=10mb",
- "--tag=chrome-flow",
- "--parser=url_list",
- "--plugins=chrome,wget,headers,title",
- chrome_url,
- ],
- cwd=tmp_path,
- capture_output=True,
- text=True,
- env=chrome_env,
- timeout=180,
- )
- assert chrome_result.returncode == 0, chrome_result.stderr or chrome_result.stdout
-
- list_result = subprocess.run(
- [sys.executable, "-m", "archivebox", "list", "--tag=real-flow"],
- cwd=tmp_path,
- capture_output=True,
- text=True,
- env=env,
- timeout=60,
- )
- assert list_result.returncode == 0, list_result.stderr or list_result.stdout
- listed = [json.loads(line) for line in list_result.stdout.splitlines() if line.strip()]
- assert {item["url"] for item in listed} >= set(wget_urls)
-
- with use_archivebox_db(tmp_path):
- crawl = Crawl.objects.order_by("-created_at").values_list("max_depth", "tags_str", "config").first()
- real_flow_crawl = Crawl.objects.filter(tags_str="real-flow,challenge").values_list("max_depth", "tags_str", "config").first()
- snapshots = list(Snapshot.objects.order_by("url").values_list("id", "url", "depth", "status", "title"))
- archive_results = list(
- ArchiveResult.objects.select_related("snapshot")
- .order_by("snapshot__url", "plugin")
- .values_list("snapshot__url", "plugin", "status", "output_files", "output_size", "output_str"),
- )
- processes = list(Process.objects.filter(process_type="hook").values_list("process_type", "status", "exit_code", "pwd", "cmd"))
-
- assert real_flow_crawl is not None
- assert real_flow_crawl[0] == 0
- assert real_flow_crawl[1] == "real-flow,challenge"
- real_flow_config = real_flow_crawl[2] or {}
- assert real_flow_config["CRAWL_MAX_URLS"] == 2
- assert real_flow_config["CRAWL_MAX_SIZE"] == 10 * 1024 * 1024
- assert real_flow_config.get("SNAPSHOT_MAX_SIZE", 0) == 0
- assert "wget" in real_flow_config["PLUGINS"]
- assert crawl is not None
- assert crawl[1] == "chrome-flow"
- assert "wget,headers,title" in json.dumps(crawl[2] or {})
-
- snapshot_urls = {url for _id, url, _depth, _status, _title in snapshots}
- assert snapshot_urls >= {*wget_urls, chrome_url}
- assert all(depth == 0 for _id, _url, depth, _status, _title in snapshots)
-
- by_url_plugin = {(url, plugin): status for url, plugin, status, _files, _size, _output in archive_results}
- assert by_url_plugin[("https://example.com", "wget")] == "succeeded"
- assert by_url_plugin[("https://pirate.github.io/stress-tests/challenge.html", "wget")] == "succeeded"
- assert (chrome_url, "headers") in by_url_plugin
- assert (chrome_url, "title") in by_url_plugin
- failed_results = [(url, plugin, output) for url, plugin, status, _files, _size, output in archive_results if status == "failed"]
- assert len(failed_results) <= 2, failed_results
-
- snapshot_root = tmp_path / "archive/users/system/snapshots"
- html_outputs = [path for path in snapshot_root.rglob("wget/**/*.html") if path.is_file()]
- header_outputs = [path for path in snapshot_root.rglob("headers/**/headers.json") if path.is_file() and path.stat().st_size > 0]
- title_outputs = [path for path in snapshot_root.rglob("title/title.txt") if path.is_file() and path.stat().st_size > 0]
- index_outputs = [path for path in snapshot_root.rglob("index.jsonl") if path.is_file()]
- assert html_outputs
- if by_url_plugin[(chrome_url, "headers")] == "succeeded":
- assert header_outputs
- if by_url_plugin[(chrome_url, "title")] == "succeeded":
- assert title_outputs
- assert any("Example Domain" in path.read_text(errors="ignore") for path in title_outputs)
- assert len(index_outputs) >= len(wget_urls) + 1
-
- combined_html = "\n".join(path.read_text(errors="ignore") for path in html_outputs)
- assert "Example Domain" in combined_html
- assert "Browser Agent Challenge for AI Browser Drivers" in combined_html
-
- assert processes
- assert any("wget" in (pwd or "") or "wget" in (cmd or "") for _type, _status, _exit, pwd, cmd in processes)
- assert any("headers" in (pwd or "") or "headers" in (cmd or "") for _type, _status, _exit, pwd, cmd in processes)
-
-
-@pytest.mark.timeout(180)
-def test_cli_recursive_crawl_processes_discovered_html_urls(tmp_path, process):
- os.chdir(tmp_path)
- assert process.returncode == 0, process.stderr
-
- env = os.environ.copy()
- env.update(
- {
- "USE_COLOR": "false",
- "SHOW_PROGRESS": "false",
- "TIMEOUT": "60",
- "SAVE_WGET": "true",
- "SAVE_HEADERS": "false",
- "SAVE_TITLE": "false",
- "SAVE_READABILITY": "false",
- "SAVE_SINGLEFILE": "false",
- "SAVE_MERCURY": "false",
- "SAVE_SCREENSHOT": "false",
- "SAVE_PDF": "false",
- "SAVE_DOM": "false",
- "SAVE_ARCHIVEDOTORG": "false",
- "SAVE_GIT": "false",
- "SAVE_YTDLP": "false",
- "SAVE_FAVICON": "false",
- "PARSE_HTML_URLS_ENABLED": "true",
- "PARSE_DOM_OUTLINKS_ENABLED": "false",
- },
- )
-
- result = subprocess.run(
- [
- sys.executable,
- "-m",
- "archivebox",
- "add",
- "--depth=2",
- "--max-urls=2",
- "--crawl-max-size=50mb",
- "--tag=recursive-flow",
- "--parser=url_list",
- "--plugins=wget,parse_html_urls",
- "https://example.com",
- ],
- cwd=tmp_path,
- capture_output=True,
- text=True,
- env=env,
- timeout=180,
- )
- assert result.returncode == 0, result.stderr or result.stdout
-
- with use_archivebox_db(tmp_path):
- crawl = Crawl.objects.order_by("-created_at").values_list("max_depth", "tags_str", "config").first()
- snapshots = list(Snapshot.objects.order_by("depth", "url").values_list("url", "depth", "status"))
- archive_results = list(
- ArchiveResult.objects.select_related("snapshot")
- .order_by("snapshot__depth", "snapshot__url", "plugin")
- .values_list("snapshot__url", "plugin", "status", "output_files"),
- )
-
- assert crawl[0] == 2
- assert crawl[1] == "recursive-flow"
- crawl_config = crawl[2] or {}
- assert crawl_config["CRAWL_MAX_URLS"] == 2
- assert crawl_config["CRAWL_MAX_SIZE"] == 50 * 1024 * 1024
- assert crawl_config.get("SNAPSHOT_MAX_SIZE", 0) == 0
- assert ("https://example.com", 0, "sealed") in snapshots
- assert any(url == "https://iana.org/domains/example" and depth == 1 and status == "sealed" for url, depth, status in snapshots)
-
- by_url_plugin = {(url, plugin): status for url, plugin, status, _files in archive_results}
- assert by_url_plugin[("https://example.com", "wget")] == "succeeded"
- assert by_url_plugin[("https://example.com", "parse_html_urls")] == "succeeded"
- assert by_url_plugin[("https://iana.org/domains/example", "wget")] == "succeeded"
-
- urls_outputs = list((tmp_path / "archive/users/system/snapshots").rglob("parse_html_urls/urls.jsonl"))
- assert urls_outputs
- assert any("https://iana.org/domains/example" in path.read_text() for path in urls_outputs)
diff --git a/archivebox/tests/test_cli_remove.py b/archivebox/tests/test_cli_remove.py
index 6e071980..9e3c3435 100644
--- a/archivebox/tests/test_cli_remove.py
+++ b/archivebox/tests/test_cli_remove.py
@@ -4,26 +4,10 @@ Comprehensive tests for archivebox remove command.
Verify remove deletes snapshots from DB and filesystem.
"""
-import os
import json
-import subprocess
from pathlib import Path
-from archivebox.tests.conftest import run_queued_crawls
-
-
-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
+from archivebox.tests.conftest import find_snapshot_dir, run_archivebox_cmd, run_queued_crawls, cli_env
def _snapshot_rows(data_dir: Path, env: dict) -> list[dict]:
@@ -35,260 +19,218 @@ print(json.dumps([
for snapshot in Snapshot.objects.order_by("url")
]))
"""
- result = subprocess.run(
- ["archivebox", "manage", "shell", "-c", script],
+ result = run_archivebox_cmd(
+ ["manage", "shell", "-c", script],
cwd=data_dir,
- capture_output=True,
env=env,
timeout=30,
check=True,
)
- return json.loads(result.stdout.decode("utf-8").strip().splitlines()[-1])
+ return json.loads(result.stdout.strip().splitlines()[-1])
-def test_remove_deletes_snapshot_from_db(tmp_path, process, disable_extractors_dict):
+def test_remove_deletes_snapshot_from_db(initialized_archive):
"""Test that remove command deletes snapshot from database."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add a snapshot
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- rows = _snapshot_rows(tmp_path, disable_extractors_dict)
+ rows = _snapshot_rows(initialized_archive, env)
assert len(rows) == 1
snapshot_id = rows[0]["id"]
- snapshot_dir = _find_snapshot_dir(tmp_path, snapshot_id)
+ snapshot_dir = find_snapshot_dir(initialized_archive, snapshot_id)
assert snapshot_dir is not None, f"Snapshot output directory not found for {snapshot_id}"
# Remove it
- subprocess.run(
- ["archivebox", "remove", "https://example.com", "--yes"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["remove", "https://example.com", "--yes"],
+ env=env,
)
- assert len(_snapshot_rows(tmp_path, disable_extractors_dict)) == 0
+ assert len(_snapshot_rows(initialized_archive, env)) == 0
assert not snapshot_dir.exists()
-def test_remove_deletes_archive_directory(tmp_path, process, disable_extractors_dict):
+def test_remove_deletes_archive_directory(initialized_archive):
"""Test that remove --yes removes the current snapshot output directory."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add a snapshot
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- rows = _snapshot_rows(tmp_path, disable_extractors_dict)
+ rows = _snapshot_rows(initialized_archive, env)
assert len(rows) == 1
snapshot_id = rows[0]["id"]
- snapshot_dir = _find_snapshot_dir(tmp_path, snapshot_id)
+ snapshot_dir = find_snapshot_dir(initialized_archive, snapshot_id)
assert snapshot_dir is not None, f"Snapshot output directory not found for {snapshot_id}"
- subprocess.run(
- ["archivebox", "remove", "https://example.com", "--yes"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["remove", "https://example.com", "--yes"],
+ env=env,
)
assert not snapshot_dir.exists()
-def test_remove_yes_flag_skips_confirmation(tmp_path, process, disable_extractors_dict):
+def test_remove_yes_flag_skips_confirmation(initialized_archive):
"""Test that --yes flag skips confirmation prompt."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
# Remove with --yes should complete without interaction
- result = subprocess.run(
- ["archivebox", "remove", "https://example.com", "--yes"],
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["remove", "https://example.com", "--yes"],
+ env=env,
timeout=30,
)
assert result.returncode == 0
- output = result.stdout.decode("utf-8") + result.stderr.decode("utf-8")
+ output = result.stdout + result.stderr
assert "Index now contains 0 links." in output
-def test_remove_without_yes_prompts_and_keeps_snapshot(tmp_path, process, disable_extractors_dict):
+def test_remove_without_yes_prompts_and_keeps_snapshot(initialized_archive):
"""Test that omitting --yes prompts for confirmation and keeps data when declined."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
check=True,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- rows = _snapshot_rows(tmp_path, disable_extractors_dict)
+ rows = _snapshot_rows(initialized_archive, env)
assert len(rows) == 1
- snapshot_dir = _find_snapshot_dir(tmp_path, rows[0]["id"])
+ snapshot_dir = find_snapshot_dir(initialized_archive, rows[0]["id"])
assert snapshot_dir is not None
- result = subprocess.run(
- ["archivebox", "remove", "https://example.com"],
- input=b"n\n",
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["remove", "https://example.com"],
+ input="n\n",
+ env=env,
timeout=30,
)
- output = result.stdout.decode("utf-8") + result.stderr.decode("utf-8")
+ output = result.stdout + result.stderr
assert result.returncode == 0
assert "Do you want to proceed" in output or "y/[n]" in output
- assert len(_snapshot_rows(tmp_path, disable_extractors_dict)) == 1
+ assert len(_snapshot_rows(initialized_archive, env)) == 1
assert snapshot_dir.exists()
-def test_remove_multiple_snapshots(tmp_path, process, disable_extractors_dict):
+def test_remove_multiple_snapshots(initialized_archive):
"""Test removing multiple snapshots at once."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add multiple snapshots
for url in ["https://example.com", "https://example.org"]:
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", url],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", url],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- assert len(_snapshot_rows(tmp_path, disable_extractors_dict)) == 2
+ assert len(_snapshot_rows(initialized_archive, env)) == 2
# Remove both
- subprocess.run(
- ["archivebox", "remove", "https://example.com", "https://example.org", "--yes"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["remove", "https://example.com", "https://example.org", "--yes"],
+ env=env,
)
- assert len(_snapshot_rows(tmp_path, disable_extractors_dict)) == 0
+ assert len(_snapshot_rows(initialized_archive, env)) == 0
-def test_remove_with_filter(tmp_path, process, disable_extractors_dict):
- """Test removing snapshots using filter."""
- os.chdir(tmp_path)
-
- # Add snapshots
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
- )
- run_queued_crawls(tmp_path, disable_extractors_dict)
-
- # Remove using filter
- result = subprocess.run(
- ["archivebox", "remove", "--filter-type=search", "--filter=example.com", "--yes"],
- capture_output=True,
- env=disable_extractors_dict,
- timeout=30,
- )
-
- # Should complete (exit code depends on implementation)
- assert result.returncode in [0, 1, 2]
-
-
-def test_remove_with_regex_filter_deletes_all_matches(tmp_path, process, disable_extractors_dict):
+def test_remove_with_regex_filter_deletes_all_matches(initialized_archive):
"""Test regex filters remove every matching snapshot."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
for url in ["https://example.com", "https://iana.org"]:
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", url],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", url],
+ env=env,
check=True,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- result = subprocess.run(
- ["archivebox", "remove", "--filter-type=regex", ".*", "--yes"],
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["remove", "--filter-type=regex", ".*", "--yes"],
+ env=env,
check=True,
)
- output = result.stdout.decode("utf-8") + result.stderr.decode("utf-8")
- assert len(_snapshot_rows(tmp_path, disable_extractors_dict)) == 0
+ output = result.stdout + result.stderr
+ assert len(_snapshot_rows(initialized_archive, env)) == 0
assert "Removed" in output or "Found" in output
-def test_remove_nonexistent_url_fails_gracefully(tmp_path, process, disable_extractors_dict):
+def test_remove_nonexistent_url_fails_gracefully(initialized_archive):
"""Test that removing non-existent URL fails gracefully."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
- result = subprocess.run(
- ["archivebox", "remove", "https://nonexistent-url-12345.com", "--yes"],
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["remove", "https://nonexistent-url-12345.com", "--yes"],
+ env=env,
)
# Should fail or show error
- stdout_text = result.stdout.decode("utf-8", errors="replace").lower()
+ stdout_text = result.stdout.lower()
assert result.returncode != 0 or "not found" in stdout_text or "no matches" in stdout_text
-def test_remove_reports_remaining_link_count_correctly(tmp_path, process, disable_extractors_dict):
+def test_remove_reports_remaining_link_count_correctly(initialized_archive):
"""Test remove reports the remaining snapshot count after deletion."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
for url in ["https://example.com", "https://example.org"]:
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", url],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", url],
+ env=env,
check=True,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- result = subprocess.run(
- ["archivebox", "remove", "https://example.org", "--yes"],
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["remove", "https://example.org", "--yes"],
+ env=env,
check=True,
)
- output = result.stdout.decode("utf-8") + result.stderr.decode("utf-8")
+ output = result.stdout + result.stderr
assert "Removed 1 out of 2 links" in output
assert "Index now contains 1 links." in output
-def test_remove_after_flag(tmp_path, process, disable_extractors_dict):
+def test_remove_after_flag(initialized_archive):
"""Test remove --after flag removes snapshots after date."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
# Try remove with --after flag (should work or show usage)
- result = subprocess.run(
- ["archivebox", "remove", "--after=2020-01-01", "--yes"],
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["remove", "--after=2020-01-01", "--yes"],
+ env=env,
timeout=30,
)
diff --git a/archivebox/tests/test_cli_run.py b/archivebox/tests/test_cli_run.py
index c15b9047..4eb5686d 100644
--- a/archivebox/tests/test_cli_run.py
+++ b/archivebox/tests/test_cli_run.py
@@ -17,11 +17,15 @@ import time
import pytest
from archivebox.tests.conftest import (
+ cleanup_process_group,
+ cli_env,
run_archivebox_cmd,
parse_jsonl_output,
create_test_url,
create_test_crawl_json,
create_test_snapshot_json,
+ pid_is_alive,
+ wait_for_pid_to_disappear,
)
RUN_TEST_ENV = {
@@ -30,6 +34,124 @@ RUN_TEST_ENV = {
}
+@pytest.mark.django_db(transaction=True)
+@pytest.mark.timeout(90)
+def test_cli_run_signal_cleans_background_hook_process_group(initialized_archive):
+
+ plugins_root = initialized_archive / "runtime_plugins"
+ plugin_dir = plugins_root / "cancel_group"
+ plugin_dir.mkdir(parents=True)
+ daemon_hook = plugin_dir / "on_CrawlSetup__10_daemon.daemon.bg.sh"
+ foreground_hook = plugin_dir / "on_CrawlSetup__20_foreground.sh"
+ daemon_hook.write_text(
+ "\n".join(
+ [
+ "#!/usr/bin/env bash",
+ "set -euo pipefail",
+ 'test_dir="${LEAK_TEST_DIR:?}"',
+ "sleep 600 &",
+ 'echo $$ > "$test_dir/daemon.pid"',
+ 'echo $! > "$test_dir/daemon-child.pid"',
+ 'echo ready > "$test_dir/daemon.ready"',
+ "trap 'echo cleaned > \"$test_dir/daemon.cleaned\"; exit 0' TERM INT",
+ "wait",
+ "",
+ ],
+ ),
+ )
+ foreground_hook.write_text(
+ "\n".join(
+ [
+ "#!/usr/bin/env bash",
+ "set -euo pipefail",
+ 'test_dir="${LEAK_TEST_DIR:?}"',
+ 'echo $$ > "$test_dir/foreground.pid"',
+ 'echo ready > "$test_dir/foreground.ready"',
+ "trap 'echo cleaned > \"$test_dir/foreground.cleaned\"; exit 0' TERM INT",
+ "while true; do sleep 1; done",
+ "",
+ ],
+ ),
+ )
+ daemon_hook.chmod(0o755)
+ foreground_hook.chmod(0o755)
+
+ leak_test_dir = initialized_archive / "leak-check"
+ leak_test_dir.mkdir()
+ env = os.environ.copy()
+ env.update(
+ {
+ "ABX_PLUGINS_DIR": str(plugins_root),
+ "LEAK_TEST_DIR": str(leak_test_dir),
+ "PLUGINS": "cancel_group",
+ "TIMEOUT": "30",
+ "USE_COLOR": "false",
+ "SHOW_PROGRESS": "false",
+ },
+ )
+
+ _cmd_result = run_archivebox_cmd(
+ ["crawl", "create", "https://example.com"],
+ cwd=initialized_archive,
+ env=env,
+ timeout=60,
+ )
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert returncode == 0, stderr or stdout
+ crawl_records = [json.loads(line) for line in stdout.splitlines() if line.strip().startswith("{")]
+ crawl_id = next(record["id"] for record in crawl_records if record.get("type") == "Crawl")
+
+ daemon_pid: int | None = None
+ daemon_child_pid: int | None = None
+ foreground_pid: int | None = None
+ run_process = run_archivebox_cmd(
+ ["run", f"--crawl-id={crawl_id}"],
+ cwd=initialized_archive,
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ start_new_session=True,
+ wait=False,
+ )
+ try:
+ deadline = time.time() + 20
+ while time.time() < deadline:
+ if (leak_test_dir / "daemon.ready").exists() and (leak_test_dir / "foreground.ready").exists():
+ break
+ if run_process.poll() is not None:
+ output = run_process.communicate(timeout=1)[0]
+ raise AssertionError(f"archivebox run exited before hooks were ready:\n{output}")
+ time.sleep(0.05)
+ assert (leak_test_dir / "daemon.ready").exists()
+ assert (leak_test_dir / "foreground.ready").exists()
+
+ daemon_pid = int((leak_test_dir / "daemon.pid").read_text().strip())
+ daemon_child_pid = int((leak_test_dir / "daemon-child.pid").read_text().strip())
+ foreground_pid = int((leak_test_dir / "foreground.pid").read_text().strip())
+ assert pid_is_alive(daemon_pid)
+ assert pid_is_alive(daemon_child_pid)
+ assert pid_is_alive(foreground_pid)
+
+ run_process.send_signal(signal.SIGTERM)
+ output = run_process.communicate(timeout=20)[0]
+ assert "Runner error" not in output
+
+ wait_for_pid_to_disappear(daemon_pid, timeout=5)
+ wait_for_pid_to_disappear(daemon_child_pid, timeout=5)
+ wait_for_pid_to_disappear(foreground_pid, timeout=5)
+ assert (leak_test_dir / "daemon.cleaned").read_text().strip() == "cleaned"
+ assert (leak_test_dir / "foreground.cleaned").read_text().strip() == "cleaned"
+ finally:
+ if run_process.poll() is None:
+ try:
+ os.killpg(run_process.pid, signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+ run_process.communicate(timeout=5)
+ cleanup_process_group(daemon_pid, daemon_child_pid)
+ cleanup_process_group(foreground_pid)
+
+
class TestRunWithCrawl:
"""Tests for `archivebox run` with Crawl input."""
@@ -37,13 +159,16 @@ class TestRunWithCrawl:
"""Run creates and processes a new Crawl (no id)."""
crawl_record = create_test_crawl_json()
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=json.dumps(crawl_record),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, f"Command failed: {stderr}"
@@ -58,17 +183,27 @@ class TestRunWithCrawl:
url = create_test_url()
# First create a crawl
- stdout1, _, _ = run_archivebox_cmd(["crawl", "create", url], data_dir=initialized_archive, env=RUN_TEST_ENV)
+ _cmd_result = run_archivebox_cmd(
+ ["crawl", "create", url],
+ cwd=initialized_archive,
+ env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
crawl = parse_jsonl_output(stdout1)[0]
# Run with the existing crawl
- stdout2, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=json.dumps(crawl),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout2)
@@ -82,13 +217,16 @@ class TestRunWithSnapshot:
"""Run creates and processes a new Snapshot (no id, just url)."""
snapshot_record = create_test_snapshot_json()
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=json.dumps(snapshot_record),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, f"Command failed: {stderr}"
@@ -102,17 +240,27 @@ class TestRunWithSnapshot:
url = create_test_url()
# First create a snapshot
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive, env=RUN_TEST_ENV)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
# Run with the existing snapshot
- stdout2, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout2)
@@ -123,13 +271,16 @@ class TestRunWithSnapshot:
url = create_test_url()
url_record = {"url": url}
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=json.dumps(url_record),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -144,15 +295,25 @@ class TestRunWithArchiveResult:
url = create_test_url()
# Create snapshot and archive result
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive, env=RUN_TEST_ENV)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout2, _, _ = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["archiveresult", "create", "--plugin=favicon"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
ar = next(r for r in parse_jsonl_output(stdout2) if r.get("type") == "ArchiveResult")
# Update to failed
@@ -160,18 +321,23 @@ class TestRunWithArchiveResult:
run_archivebox_cmd(
["archiveresult", "update", "--status=failed"],
stdin=json.dumps(ar),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
# Now run should re-queue it
- stdout3, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=json.dumps(ar),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout3, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout3)
@@ -210,12 +376,15 @@ class TestRunRecovery:
crawl_id = crawl.id
snapshot_id = snapshot.id
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run", "--maintenance-only"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=90,
env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, stdout + stderr
assert "Repairing" in stderr
@@ -239,11 +408,14 @@ class TestRunPassThrough:
"""Run passes through records with unknown types."""
unknown_record = {"type": "Unknown", "id": "fake-id", "data": "test"}
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=json.dumps(unknown_record),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -256,13 +428,16 @@ class TestRunPassThrough:
url = create_test_url()
crawl_record = create_test_crawl_json(urls=[url])
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=json.dumps(crawl_record),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -287,13 +462,16 @@ class TestRunMixedInput:
],
)
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=stdin,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
timeout=120,
env=RUN_TEST_ENV,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -308,11 +486,14 @@ class TestRunEmpty:
def test_run_empty_stdin(self, initialized_archive):
"""Run with empty stdin returns success."""
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin="",
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
@@ -320,11 +501,14 @@ class TestRunEmpty:
"""Run with only pass-through records shows message."""
unknown = {"type": "Unknown", "id": "fake"}
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["run"],
stdin=json.dumps(unknown),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "No records to process" in stderr
@@ -344,23 +528,16 @@ class TestRunDaemonMode:
else:
piped_stdin = "{this is not jsonl}\n"
- env = os.environ.copy()
- env.update(
- {
- "DATA_DIR": str(initialized_archive),
- "USE_COLOR": "False",
- "SHOW_PROGRESS": "False",
- },
- )
- proc = subprocess.Popen(
- [sys.executable, "-m", "archivebox", "run", "--daemon"],
+ env = cli_env()
+ proc = run_archivebox_cmd(
+ ["run", "--daemon"],
cwd=initialized_archive,
env=env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
- text=True,
start_new_session=True,
+ wait=False,
)
assert proc.stdin is not None
assert proc.stdout is not None
@@ -410,24 +587,17 @@ class TestRunDaemonMode:
from archivebox.core.takeover_util import RUNNER_ACTIVE_WORKER_TYPE
from archivebox.tests.test_orm_helpers import use_archivebox_db
- env = os.environ.copy()
- env.update(
- {
- "DATA_DIR": str(initialized_archive),
- "USE_COLOR": "False",
- "SHOW_PROGRESS": "False",
- },
- )
+ env = cli_env()
procs = [
- subprocess.Popen(
- [sys.executable, "-m", "archivebox", "run", "--daemon"],
+ run_archivebox_cmd(
+ ["run", "--daemon"],
cwd=initialized_archive,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
- text=True,
start_new_session=True,
+ wait=False,
)
for _ in range(2)
]
@@ -468,15 +638,15 @@ class TestRunDaemonMode:
assert len(active) == 1
os.killpg(active_pid, signal.SIGKILL)
- replacement = subprocess.Popen(
- [sys.executable, "-m", "archivebox", "run", "--daemon"],
+ replacement = run_archivebox_cmd(
+ ["run", "--daemon"],
cwd=initialized_archive,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
- text=True,
start_new_session=True,
+ wait=False,
)
procs.append(replacement)
deadline = time.monotonic() + 30
@@ -518,7 +688,7 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
crawl = Crawl.objects.create(
urls="https://example.com",
@@ -544,7 +714,8 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
- from archivebox.services.runner import recover_orchestrator_state, run_due_crawl
+ from archivebox.core.recovery_util import recover_orchestrator_state
+ from archivebox.services.runner import run_due_crawl
crawl = Crawl.objects.create(
urls="https://example.com",
@@ -579,7 +750,7 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
user_id = get_or_create_system_user_pk()
queued_crawl = Crawl.objects.create(
@@ -629,7 +800,7 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
crawl = Crawl.objects.create(
urls="https://example.com",
@@ -666,7 +837,7 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
crawl = Crawl.objects.create(
urls="https://example.com",
@@ -708,7 +879,8 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
- from archivebox.services.runner import recover_orchestrator_state, run_due_crawl, run_due_snapshot
+ from archivebox.core.recovery_util import recover_orchestrator_state
+ from archivebox.services.runner import run_due_crawl, run_due_snapshot
old = timezone.now() - timedelta(hours=13)
crawl = Crawl.objects.create(
@@ -944,7 +1116,7 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
old = timezone.now() - timedelta(hours=13)
crawl = Crawl.objects.create(
@@ -970,7 +1142,7 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
future = timezone.now() + timedelta(seconds=45)
crawl = Crawl.objects.create(
@@ -1007,7 +1179,7 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
future = timezone.now() + timedelta(seconds=45)
crawl = Crawl.objects.create(
@@ -1046,7 +1218,7 @@ class TestRecoverOrchestratorState:
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.machine.models import Machine, NetworkInterface, Process
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
worker = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(60)"],
@@ -1109,7 +1281,7 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
from archivebox.workers.models import RETRY_AT_MAX
crawl = Crawl.objects.create(
@@ -1143,7 +1315,7 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
crawl = Crawl.objects.create(
urls="https://example.com",
@@ -1319,7 +1491,7 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
crawl = Crawl.objects.create(
urls="https://example.com",
@@ -1350,7 +1522,8 @@ class TestRecoverOrchestratorState:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
- from archivebox.services.runner import recover_orchestrator_state, run_due_snapshot
+ from archivebox.core.recovery_util import recover_orchestrator_state
+ from archivebox.services.runner import run_due_snapshot
crawl = Crawl.objects.create(
urls="https://example.com",
@@ -1719,7 +1892,7 @@ class TestRecoverOrchestratorStateRedFailureModes:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
future = timezone.now() + timedelta(days=1)
crawl = Crawl.objects.create(
@@ -1755,7 +1928,7 @@ class TestRecoverOrchestratorStateRedFailureModes:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
future = timezone.now() + timedelta(days=1)
crawl = Crawl.objects.create(
@@ -1780,7 +1953,7 @@ class TestRecoverOrchestratorStateRedFailureModes:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
future = timezone.now() + timedelta(days=1)
crawl = Crawl.objects.create(
@@ -1801,7 +1974,7 @@ class TestRecoverOrchestratorStateRedFailureModes:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
crawl = Crawl.objects.create(
urls="https://www.mathjax.org/",
@@ -1834,7 +2007,7 @@ class TestRecoverOrchestratorStateRedFailureModes:
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.machine.models import Machine, NetworkInterface, Process
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
crawl = Crawl.objects.create(
urls="https://revealjs.com/",
@@ -1875,7 +2048,7 @@ class TestRecoverOrchestratorStateRedFailureModes:
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.machine.models import Machine, NetworkInterface, Process
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
crawl = Crawl.objects.create(
urls="https://pdfobject.com/pdf/sample-3pp.pdf",
@@ -1921,7 +2094,7 @@ class TestRecoverOrchestratorStateRedFailureModes:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.core.models import ArchiveResult, Snapshot
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
crawl = Crawl.objects.create(
urls="https://mermaid-js.github.io/mermaid/",
@@ -2227,7 +2400,7 @@ class TestRecoverOrchestratorStateRedFailureModes:
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
from archivebox.machine.models import Machine, NetworkInterface, Process
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
old = timezone.now() - timedelta(hours=13)
crawl = Crawl.objects.create(
@@ -2263,7 +2436,7 @@ class TestRecoverOrchestratorStateRedFailureModes:
from django.utils import timezone
from archivebox.machine.models import Machine, NetworkInterface, Process
- from archivebox.services.runner import recover_orchestrator_state
+ from archivebox.core.recovery_util import recover_orchestrator_state
runtime_dir = tmp_path / "https_example_com" / ".hooks" / "on_Snapshot__01_title.py"
runtime_dir.mkdir(parents=True)
diff --git a/archivebox/tests/test_cli_schedule.py b/archivebox/tests/test_cli_schedule.py
index 7075b844..81bae29e 100644
--- a/archivebox/tests/test_cli_schedule.py
+++ b/archivebox/tests/test_cli_schedule.py
@@ -1,21 +1,19 @@
#!/usr/bin/env python3
"""CLI-specific tests for archivebox schedule."""
-import os
-import subprocess
-import sys
+from archivebox.tests.conftest import run_archivebox_cmd
import pytest
from archivebox.crawls.models import Crawl, CrawlSchedule
from archivebox.tests.test_orm_helpers import use_archivebox_db
from .conftest import (
- build_test_env,
+ cli_env,
get_counts,
get_free_port,
init_archive,
make_latest_schedule_due,
- start_server,
+ start_archivebox_server,
stop_server,
wait_for_http,
wait_for_snapshot_capture,
@@ -24,27 +22,23 @@ from .conftest import (
pytestmark = pytest.mark.django_db(transaction=True)
-def test_schedule_run_all_enqueues_scheduled_crawl(tmp_path, process, disable_extractors_dict):
- os.chdir(tmp_path)
+def test_schedule_run_all_enqueues_scheduled_crawl(initialized_archive):
- subprocess.run(
- ["archivebox", "schedule", "--every=daily", "--depth=0", "https://example.com"],
- capture_output=True,
- text=True,
+ env = cli_env(disable_extractors=True)
+ run_archivebox_cmd(
+ ["schedule", "--every=daily", "--depth=0", "https://example.com"],
check=True,
)
- result = subprocess.run(
- ["archivebox", "schedule", "--run-all"],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["schedule", "--run-all"],
+ env=env,
)
assert result.returncode == 0
assert "Enqueued 1 scheduled crawl" in result.stdout
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
crawl_count = Crawl.objects.count()
queued_count = Crawl.objects.filter(status="queued").count()
@@ -52,36 +46,30 @@ def test_schedule_run_all_enqueues_scheduled_crawl(tmp_path, process, disable_ex
assert queued_count >= 1
-def test_schedule_without_import_path_creates_maintenance_schedule(tmp_path, process):
- os.chdir(tmp_path)
+def test_schedule_without_import_path_creates_maintenance_schedule(initialized_archive):
- result = subprocess.run(
- ["archivebox", "schedule", "--every=day"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["schedule", "--every=day"],
)
assert result.returncode == 0
assert "Created scheduled maintenance update" in result.stdout
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
row = Crawl.objects.order_by("-created_at").values_list("urls", "status").first()
assert row == ("archivebox://update", "sealed")
-def test_schedule_creates_enabled_db_schedule(tmp_path, process):
- os.chdir(tmp_path)
+def test_schedule_creates_enabled_db_schedule(initialized_archive):
- result = subprocess.run(
- ["archivebox", "schedule", "--every=daily", "--depth=1", "https://example.com/feed.xml"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["schedule", "--every=daily", "--depth=1", "https://example.com/feed.xml"],
)
assert result.returncode == 0
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
schedule_row = CrawlSchedule.objects.order_by("-created_at").values_list("schedule", "is_enabled", "label").first()
crawl = Crawl.objects.order_by("-created_at").first()
@@ -92,20 +80,15 @@ def test_schedule_creates_enabled_db_schedule(tmp_path, process):
assert crawl.max_depth == 1
-def test_schedule_show_lists_enabled_schedules(tmp_path, process):
- os.chdir(tmp_path)
+def test_schedule_show_lists_enabled_schedules(initialized_archive):
- subprocess.run(
- ["archivebox", "schedule", "--every=weekly", "https://example.com/feed.xml"],
- capture_output=True,
- text=True,
+ run_archivebox_cmd(
+ ["schedule", "--every=weekly", "https://example.com/feed.xml"],
check=True,
)
- result = subprocess.run(
- ["archivebox", "schedule", "--show"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["schedule", "--show"],
)
assert result.returncode == 0
@@ -114,26 +97,21 @@ def test_schedule_show_lists_enabled_schedules(tmp_path, process):
assert "weekly" in result.stdout
-def test_schedule_clear_disables_existing_schedules(tmp_path, process):
- os.chdir(tmp_path)
+def test_schedule_clear_disables_existing_schedules(initialized_archive):
- subprocess.run(
- ["archivebox", "schedule", "--every=daily", "https://example.com/feed.xml"],
- capture_output=True,
- text=True,
+ run_archivebox_cmd(
+ ["schedule", "--every=daily", "https://example.com/feed.xml"],
check=True,
)
- result = subprocess.run(
- ["archivebox", "schedule", "--clear"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["schedule", "--clear"],
)
assert result.returncode == 0
assert "Disabled 1 scheduled crawl" in result.stdout
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
disabled_count = CrawlSchedule.objects.filter(is_enabled=False).count()
enabled_count = CrawlSchedule.objects.filter(is_enabled=True).count()
@@ -141,26 +119,20 @@ def test_schedule_clear_disables_existing_schedules(tmp_path, process):
assert enabled_count == 0
-def test_schedule_every_requires_valid_period(tmp_path, process):
- os.chdir(tmp_path)
+def test_schedule_every_requires_valid_period(initialized_archive):
- result = subprocess.run(
- ["archivebox", "schedule", "--every=invalid_period", "https://example.com/feed.xml"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["schedule", "--every=invalid_period", "https://example.com/feed.xml"],
)
assert result.returncode != 0
assert "Invalid schedule" in result.stderr or "Invalid schedule" in result.stdout
-def test_schedule_help_lists_schedule_options(tmp_path, process):
- os.chdir(tmp_path)
+def test_schedule_help_lists_schedule_options(initialized_archive):
- result = subprocess.run(
- ["archivebox", "schedule", "--help"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["schedule", "--help"],
)
assert result.returncode == 0
@@ -172,17 +144,14 @@ def test_schedule_help_lists_schedule_options(tmp_path, process):
@pytest.mark.timeout(180)
def test_schedule_due_crawl_runs_over_server_and_saves_real_content(tmp_path, recursive_test_site):
- os.chdir(tmp_path)
init_archive(tmp_path)
port = get_free_port()
- env = build_test_env(port)
+ env = cli_env(port=port, server=True)
- schedule_result = subprocess.run(
- [sys.executable, "-m", "archivebox", "schedule", "--every=daily", "--depth=0", recursive_test_site["root_url"]],
+ schedule_result = run_archivebox_cmd(
+ ["schedule", "--every=daily", "--depth=0", recursive_test_site["root_url"]],
cwd=tmp_path,
- capture_output=True,
- text=True,
env=env,
timeout=60,
)
@@ -192,7 +161,7 @@ def test_schedule_due_crawl_runs_over_server_and_saves_real_content(tmp_path, re
make_latest_schedule_due(tmp_path)
try:
- start_server(tmp_path, env=env, port=port)
+ start_archivebox_server(tmp_path, env=env, port=port)
wait_for_http(port, host=f"web.archivebox.localhost:{port}")
captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180)
assert "Root" in captured_text
@@ -203,19 +172,16 @@ def test_schedule_due_crawl_runs_over_server_and_saves_real_content(tmp_path, re
@pytest.mark.timeout(180)
def test_add_remains_one_shot_when_schedule_is_due(tmp_path, recursive_test_site):
- os.chdir(tmp_path)
init_archive(tmp_path)
port = get_free_port()
- env = build_test_env(port)
+ env = cli_env(port=port, server=True)
scheduled_url = recursive_test_site["root_url"]
one_shot_url = recursive_test_site["child_urls"][0]
- schedule_result = subprocess.run(
- [sys.executable, "-m", "archivebox", "schedule", "--every=daily", "--depth=0", scheduled_url],
+ schedule_result = run_archivebox_cmd(
+ ["schedule", "--every=daily", "--depth=0", scheduled_url],
cwd=tmp_path,
- capture_output=True,
- text=True,
env=env,
timeout=60,
)
@@ -223,11 +189,9 @@ def test_add_remains_one_shot_when_schedule_is_due(tmp_path, recursive_test_site
make_latest_schedule_due(tmp_path)
- add_result = subprocess.run(
- [sys.executable, "-m", "archivebox", "add", "--depth=0", "--plugins=wget", one_shot_url],
+ add_result = run_archivebox_cmd(
+ ["add", "--depth=0", "--plugins=wget", one_shot_url],
cwd=tmp_path,
- capture_output=True,
- text=True,
env=env,
timeout=120,
)
diff --git a/archivebox/tests/test_cli_search.py b/archivebox/tests/test_cli_search.py
new file mode 100644
index 00000000..f3780796
--- /dev/null
+++ b/archivebox/tests/test_cli_search.py
@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""
+Tests for archivebox search command.
+
+TODO: keep search-specific tests here instead of folding all coverage into test_cli_list.py.
+"""
+
+import json
+
+from archivebox.tests.conftest import cli_env, run_archivebox_cmd
+
+
+def test_search_help_runs_successfully(tmp_path):
+ """The search alias should be registered and expose list/search filters."""
+
+ result = run_archivebox_cmd(["search", "--help"])
+
+ assert result.returncode == 0
+ assert "search" in result.stdout.lower()
+ assert "--csv" in result.stdout
+
+
+def test_cli_search_status_filters_snapshot_status_column(tmp_path, initialized_archive):
+ env = cli_env(disable_extractors=True)
+ for url in (
+ "https://example.com/search-status-queued",
+ "https://example.com/search-status-paused",
+ "https://example.com/search-status-sealed",
+ ):
+ result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ env=env,
+ timeout=30,
+ )
+ assert result.returncode == 0, result.stderr
+
+ for status, needle in (
+ ("paused", "search-status-paused"),
+ ("sealed", "search-status-sealed"),
+ ):
+ listed = run_archivebox_cmd(
+ ["snapshot", "list", "--url__icontains", needle],
+ env=env,
+ timeout=30,
+ )
+ assert listed.returncode == 0, listed.stderr
+ updated = run_archivebox_cmd(
+ ["snapshot", "update", "--status", status],
+ input=listed.stdout,
+ env=env,
+ timeout=30,
+ )
+ assert updated.returncode == 0, updated.stderr
+
+ result = run_archivebox_cmd(
+ ["search", "--status", "sealed", "search-status"],
+ env=env,
+ timeout=30,
+ )
+
+ assert result.returncode == 0, result.stderr
+ rows = [json.loads(line) for line in result.stdout.splitlines() if line.strip().startswith("{")]
+ assert [row["status"] for row in rows] == ["sealed"]
+ assert [row["url"] for row in rows] == ["https://example.com/search-status-sealed"]
+
+ legacy_result = run_archivebox_cmd(
+ ["search", "--status", "unarchived", "search-status"],
+ env=env,
+ timeout=30,
+ )
+
+ assert legacy_result.returncode != 0
+ assert "Invalid snapshot status" in legacy_result.stderr
diff --git a/archivebox/tests/test_cli_server.py b/archivebox/tests/test_cli_server.py
index f31650b5..0d349d74 100644
--- a/archivebox/tests/test_cli_server.py
+++ b/archivebox/tests/test_cli_server.py
@@ -15,12 +15,26 @@ import time
from datetime import datetime
from types import SimpleNamespace
+import pytest
+
+from archivebox.tests.conftest import (
+ assert_no_processes_for_data_dir,
+ get_free_port,
+ kill_processes_for_data_dir,
+ cli_env,
+ start_archivebox_server,
+ stop_archivebox_process,
+ wait_for_pid_to_disappear,
+ wait_for_port_open,
+ wait_for_process,
+ run_archivebox_cmd,
+)
+
def test_server_auth_secret_and_cookie_settings_are_restart_stable(tmp_path, monkeypatch):
"""Admin sessions must survive `archivebox server` restarts for a collection."""
from archivebox.config.collection import write_config_file
- os.chdir(tmp_path)
(tmp_path / ".archivebox_id").write_text("testcoll")
monkeypatch.setenv("BASE_URL", "http://archivebox.localhost:9292")
@@ -91,16 +105,13 @@ def test_sqlite_connections_use_explicit_busy_timeout():
assert "PRAGMA journal_mode = WAL;" in SQLITE_CONNECTION_OPTIONS["OPTIONS"]["init_command"]
-def test_server_shows_usage_info(tmp_path, process):
+def test_server_shows_usage_info(initialized_archive):
"""Test that server command shows usage or starts."""
- os.chdir(tmp_path)
# Just check that the command is recognized
# We won't actually start a full server in tests
- result = subprocess.run(
- ["archivebox", "server", "--help"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["server", "--help"],
timeout=10,
)
@@ -108,15 +119,12 @@ def test_server_shows_usage_info(tmp_path, process):
assert "server" in result.stdout.lower() or "http" in result.stdout.lower()
-def test_server_help_lists_runtime_options(tmp_path, process):
+def test_server_help_lists_runtime_options(initialized_archive):
"""Test that server help exposes the current runtime options."""
- os.chdir(tmp_path)
# Check init flag is recognized
- result = subprocess.run(
- ["archivebox", "server", "--help"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["server", "--help"],
timeout=10,
)
@@ -158,14 +166,6 @@ def test_reload_workers_use_current_interpreter_and_supervisord_managed_runner()
assert watcher["command"] == f"{sys.executable} -m archivebox manage runner_watch --bind-url=http://127.0.0.1:8000"
-def _free_port():
- import socket
-
- with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
- sock.bind(("127.0.0.1", 0))
- return int(sock.getsockname()[1])
-
-
def test_server_daemon_starts_real_plugin_owned_sonic_worker(archivebox_daemon_server):
server = archivebox_daemon_server(
SEARCH_BACKEND_ENGINE="sqlite",
@@ -279,7 +279,7 @@ def test_sonic_worker_is_disabled_when_sonic_disabled(tmp_path):
DATA_DIR=str(tmp_path),
SEARCH_BACKEND_SONIC_ENABLED=False,
SEARCH_BACKEND_SONIC_HOST_NAME="127.0.0.1",
- SEARCH_BACKEND_SONIC_PORT=_free_port(),
+ SEARCH_BACKEND_SONIC_PORT=get_free_port(),
SEARCH_BACKEND_SONIC_PASSWORD="SecretPassword",
SONIC_BINARY="sonic",
),
@@ -294,7 +294,7 @@ def test_sonic_daemon_event_handler_accepts_real_running_worker(archivebox_daemo
from archivebox.search.sonic_daemon import register_sonic_daemon_event_handler
from abx_plugins.plugins.search_backend_sonic.daemon import prepare_sonic_daemon
- sonic_port = _free_port()
+ sonic_port = get_free_port()
server = archivebox_daemon_server(
SEARCH_BACKEND_ENGINE="sonic",
SEARCH_BACKEND_SONIC_PORT=str(sonic_port),
@@ -329,7 +329,7 @@ def test_sonic_daemon_event_handler_accepts_real_running_worker(archivebox_daemo
asyncio.run(run_test())
-def test_supervisord_sync_does_not_start_duplicate_sonic_listener(tmp_path, process, db):
+def test_supervisord_sync_does_not_start_duplicate_sonic_listener(initialized_archive, db):
from abx_plugins.plugins.search_backend_sonic.daemon import get_sonic_supervisord_worker
from archivebox.tests.test_orm_helpers import use_archivebox_db
from archivebox.workers.supervisord_util import (
@@ -339,9 +339,6 @@ def test_supervisord_sync_does_not_start_duplicate_sonic_listener(tmp_path, proc
sync_supervisord_workers,
)
- os.chdir(tmp_path)
- assert process.returncode == 0, process.stderr
-
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", 0))
@@ -349,7 +346,7 @@ def test_supervisord_sync_does_not_start_duplicate_sonic_listener(tmp_path, proc
sonic_port = listener.getsockname()[1]
worker = get_sonic_supervisord_worker(
SimpleNamespace(
- DATA_DIR=str(tmp_path),
+ DATA_DIR=str(initialized_archive),
SEARCH_BACKEND_ENGINE="sonic",
SEARCH_BACKEND_SONIC_HOST_NAME="127.0.0.1",
SEARCH_BACKEND_SONIC_PORT=sonic_port,
@@ -360,7 +357,7 @@ def test_supervisord_sync_does_not_start_duplicate_sonic_listener(tmp_path, proc
assert worker is not None
try:
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
supervisor = get_or_create_supervisord_process(daemonize=False)
state = sync_supervisord_workers(supervisor, [(worker, False)], prune=True)
sonic_state = state["worker_sonic"]
@@ -368,11 +365,11 @@ def test_supervisord_sync_does_not_start_duplicate_sonic_listener(tmp_path, proc
assert get_worker(supervisor, "worker_sonic")["statename"] != "RUNNING"
finally:
listener.close()
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
stop_existing_supervisord_process()
-def test_supervisord_takeover_stops_all_live_process_rows(tmp_path, process, db):
+def test_supervisord_takeover_stops_all_live_process_rows(initialized_archive, db):
import psutil
from django.utils import timezone
@@ -380,23 +377,22 @@ def test_supervisord_takeover_stops_all_live_process_rows(tmp_path, process, db)
from archivebox.machine.models import Machine, Process
from archivebox.tests.test_orm_helpers import use_archivebox_db
- assert process.returncode == 0, process.stderr
- env = os.environ.copy()
- env.update({"DATA_DIR": str(tmp_path), "USE_COLOR": "False", "SHOW_PROGRESS": "False"})
+ env = cli_env()
procs = []
try:
for _index in range(2):
- proc = subprocess.Popen(
- [sys.executable, "-m", "archivebox", "run", "--daemon"],
- cwd=tmp_path,
+ proc = run_archivebox_cmd(
+ ["run", "--daemon"],
+ cwd=initialized_archive,
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
+ wait=False,
)
procs.append(proc)
started_at = datetime.fromtimestamp(psutil.Process(proc.pid).create_time(), tz=timezone.get_current_timezone())
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
Process.objects.create(
machine=Machine.current(),
process_type=Process.TypeChoices.SUPERVISORD,
@@ -408,14 +404,14 @@ def test_supervisord_takeover_stops_all_live_process_rows(tmp_path, process, db)
status=Process.StatusChoices.RUNNING,
)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
from archivebox.workers.supervisord_util import stop_existing_supervisord_process
stop_existing_supervisord_process()
for proc in procs:
proc.wait(timeout=10)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
assert not Process.objects.filter(
process_type=Process.TypeChoices.SUPERVISORD,
status=Process.StatusChoices.RUNNING,
@@ -425,3 +421,154 @@ def test_supervisord_takeover_stops_all_live_process_rows(tmp_path, process, db)
for proc in procs:
if proc.poll() is None:
os.killpg(proc.pid, signal.SIGKILL)
+
+
+@pytest.mark.timeout(300)
+@pytest.mark.parametrize(
+ ("stop_signal", "expected_notice"),
+ [
+ (signal.SIGHUP, "Got SIGHUP"),
+ (signal.SIGINT, "Got SIGINT"),
+ (signal.SIGTERM, "Got SIGTERM"),
+ (signal.SIGKILL, None),
+ ],
+)
+def test_live_server_signal_exit_and_resume_uses_existing_supervisor_state(initialized_archive, stop_signal, expected_notice):
+
+ env = cli_env(live=True)
+ port = get_free_port()
+ server = None
+ resumed = None
+ try:
+ server = start_archivebox_server(initialized_archive, port=port, log_name=f"server-{stop_signal.name}.log", env=env)
+ server_log = server.log_path
+
+ os.kill(server.pid, stop_signal)
+ try:
+ server.wait(timeout=20 if stop_signal != signal.SIGKILL else 5)
+ except subprocess.TimeoutExpired:
+ os.kill(server.pid, signal.SIGKILL)
+ server.wait(timeout=5)
+
+ if expected_notice:
+ log_text = server_log.read_text(encoding="utf-8", errors="replace")
+ assert expected_notice in log_text
+ assert "ArchiveBox server shut down gracefully" in log_text
+ assert_no_processes_for_data_dir(initialized_archive, timeout=12)
+
+ resumed = start_archivebox_server(initialized_archive, port=port, log_name=f"server-{stop_signal.name}-resumed.log", env=env)
+ resumed_log = resumed.log_path
+ _cmd_result = run_archivebox_cmd(["status"], cwd=initialized_archive, env=env, timeout=60)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert returncode == 0, stderr or stdout
+
+ os.kill(resumed.pid, signal.SIGTERM)
+ resumed.wait(timeout=20)
+ resumed_text = resumed_log.read_text(encoding="utf-8", errors="replace")
+ assert "Got SIGTERM" in resumed_text
+ assert "ArchiveBox server shut down gracefully" in resumed_text
+ assert_no_processes_for_data_dir(initialized_archive, timeout=12)
+ finally:
+ for proc in (server, resumed):
+ if proc is not None and proc.poll() is None:
+ stop_archivebox_process(proc, signal.SIGKILL)
+ kill_processes_for_data_dir(initialized_archive)
+
+
+@pytest.mark.timeout(180)
+def test_live_daemonized_server_keeps_supervisord_owned_by_archivebox_parent(initialized_archive):
+
+ env = cli_env(live=True)
+ port = get_free_port()
+ bind_url = f"http://127.0.0.1:{port}"
+ try:
+ _cmd_result = run_archivebox_cmd(
+ ["server", "--daemonize", f"127.0.0.1:{port}"],
+ cwd=initialized_archive,
+ env=env,
+ timeout=90,
+ )
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert returncode == 0, stderr or stdout
+ wait_for_port_open("127.0.0.1", port, timeout=30)
+
+ server_process = wait_for_process(
+ lambda _proc, command: "archivebox" in command and " server " in f" {command} " and bind_url.replace("http://", "") in command,
+ )
+ supervisord = wait_for_process(
+ lambda proc, command: proc.ppid() == server_process.pid and "supervisord" in command,
+ )
+ wait_for_process(
+ lambda proc, command: proc.ppid() == supervisord.pid and "supervisord_watchdog" in command,
+ )
+
+ os.kill(server_process.pid, signal.SIGKILL)
+ wait_for_pid_to_disappear(server_process.pid, timeout=10)
+ wait_for_pid_to_disappear(supervisord.pid, timeout=20)
+ assert_no_processes_for_data_dir(initialized_archive, timeout=12)
+ finally:
+ kill_processes_for_data_dir(initialized_archive)
+ assert_no_processes_for_data_dir(initialized_archive, timeout=12)
+
+
+@pytest.mark.timeout(240)
+def test_live_servers_in_different_data_dirs_do_not_interfere(initialized_archive):
+
+ first_data_dir = initialized_archive
+ second_data_dir = initialized_archive.parent / f"{initialized_archive.name}-second"
+ second_data_dir.mkdir()
+ second_env = cli_env(live=True)
+ _cmd_result = run_archivebox_cmd(["init"], cwd=second_data_dir, env=second_env, timeout=90)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert returncode == 0, stderr or stdout
+
+ first_port = get_free_port()
+ second_port = get_free_port()
+ first = None
+ second = None
+ first_resumed = None
+ try:
+ first = start_archivebox_server(
+ first_data_dir,
+ port=first_port,
+ log_name="server-first-data-dir.log",
+ env=cli_env(live=True),
+ )
+ second = start_archivebox_server(second_data_dir, port=second_port, log_name="server-second-data-dir.log", env=second_env)
+
+ _cmd_result = run_archivebox_cmd(
+ ["status"],
+ cwd=first_data_dir,
+ env=cli_env(live=True),
+ timeout=60,
+ )
+ first_stdout, first_stderr, first_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ _cmd_result = run_archivebox_cmd(
+ ["status"],
+ cwd=second_data_dir,
+ env=second_env,
+ timeout=60,
+ )
+ second_stdout, second_stderr, second_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert first_returncode == 0, first_stderr or first_stdout
+ assert second_returncode == 0, second_stderr or second_stdout
+
+ stop_archivebox_process(first, signal.SIGTERM)
+ first = None
+ assert second.poll() is None, "stopping one DATA_DIR server must not stop another DATA_DIR server"
+
+ first_resumed = start_archivebox_server(
+ first_data_dir,
+ port=first_port,
+ log_name="server-first-data-dir-resumed.log",
+ env=cli_env(live=True),
+ )
+ assert second.poll() is None, "restarting one DATA_DIR server must not take over another DATA_DIR supervisor"
+ finally:
+ for proc in (first, first_resumed, second):
+ if proc is not None and proc.poll() is None:
+ stop_archivebox_process(proc, signal.SIGTERM)
+ kill_processes_for_data_dir(first_data_dir)
+ kill_processes_for_data_dir(second_data_dir)
+ assert_no_processes_for_data_dir(first_data_dir, timeout=12)
+ assert_no_processes_for_data_dir(second_data_dir, timeout=12)
diff --git a/archivebox/tests/test_cli_shell.py b/archivebox/tests/test_cli_shell.py
index c2a8142c..c9aaeb80 100644
--- a/archivebox/tests/test_cli_shell.py
+++ b/archivebox/tests/test_cli_shell.py
@@ -4,19 +4,15 @@ Tests for archivebox shell command.
Verify shell command starts Django shell (basic smoke tests only).
"""
-import os
-import subprocess
+from archivebox.tests.conftest import run_archivebox_cmd
-def test_shell_command_exists(tmp_path, process):
+def test_shell_command_exists(initialized_archive):
"""Test that shell command is recognized."""
- os.chdir(tmp_path)
# Test that the command exists (will fail without input but should recognize command)
- result = subprocess.run(
- ["archivebox", "shell", "--help"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["shell", "--help"],
timeout=10,
)
@@ -24,14 +20,11 @@ def test_shell_command_exists(tmp_path, process):
assert result.returncode in [0, 1, 2]
-def test_shell_c_executes_python(tmp_path, process):
+def test_shell_c_executes_python(initialized_archive):
"""shell -c should fully initialize Django and run the provided command."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "shell", "-c", 'print("shell-ok")'],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["shell", "-c", 'print("shell-ok")'],
timeout=30,
)
diff --git a/archivebox/tests/test_cli_snapshot.py b/archivebox/tests/test_cli_snapshot.py
index 23202929..21a12078 100644
--- a/archivebox/tests/test_cli_snapshot.py
+++ b/archivebox/tests/test_cli_snapshot.py
@@ -9,12 +9,21 @@ Tests cover:
"""
import json
+import os
+import pytest
+
+from archivebox.core.models import Snapshot, Tag
+from archivebox.machine.models import Process
from archivebox.tests.conftest import (
- run_archivebox_cmd,
- parse_jsonl_output,
+ cli_env,
create_test_url,
+ parse_jsonl_output,
+ run_archivebox_cmd,
)
+from archivebox.tests.test_orm_helpers import use_archivebox_db
+
+pytestmark = pytest.mark.django_db(transaction=True)
class TestSnapshotCreate:
@@ -24,10 +33,13 @@ class TestSnapshotCreate:
"""Create snapshot from URL arguments."""
url = create_test_url()
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "create", url],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, f"Command failed: {stderr}"
assert "Created" in stderr
@@ -42,15 +54,19 @@ class TestSnapshotCreate:
url = create_test_url()
# First create a crawl
- stdout1, _, _ = run_archivebox_cmd(["crawl", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(["crawl", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
crawl = parse_jsonl_output(stdout1)[0]
# Pipe crawl to snapshot create
- stdout2, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "create"],
stdin=json.dumps(crawl),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, f"Command failed: {stderr}"
@@ -67,10 +83,13 @@ class TestSnapshotCreate:
"""Create snapshot with --tag flag."""
url = create_test_url()
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "create", "--tag=test-tag", url],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -82,11 +101,14 @@ class TestSnapshotCreate:
url = create_test_url()
stdin = json.dumps(tag_record) + "\n" + json.dumps({"url": url})
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "create"],
stdin=stdin,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -99,10 +121,13 @@ class TestSnapshotCreate:
"""Create snapshots from multiple URLs."""
urls = [create_test_url() for _ in range(3)]
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "create"] + urls,
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -118,10 +143,13 @@ class TestSnapshotList:
def test_list_empty(self, initialized_archive):
"""List with no snapshots returns empty."""
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "list"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Listed 0 snapshots" in stderr
@@ -129,12 +157,15 @@ class TestSnapshotList:
def test_list_returns_created(self, initialized_archive):
"""List returns previously created snapshots."""
url = create_test_url()
- run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "list"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -144,12 +175,15 @@ class TestSnapshotList:
def test_list_filter_by_status(self, initialized_archive):
"""Filter snapshots by status."""
url = create_test_url()
- run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "list", "--status=queued"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -159,12 +193,15 @@ class TestSnapshotList:
def test_list_filter_by_url_contains(self, initialized_archive):
"""Filter snapshots by URL contains."""
url = create_test_url(domain="unique-domain-12345.com")
- run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ run_archivebox_cmd(["snapshot", "create", url], cwd=initialized_archive, default_cli_env=True, disable_extractors=True)
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "list", "--url__icontains=unique-domain-12345"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -174,12 +211,20 @@ class TestSnapshotList:
def test_list_with_limit(self, initialized_archive):
"""Limit number of results."""
for _ in range(3):
- run_archivebox_cmd(["snapshot", "create", create_test_url()], data_dir=initialized_archive)
+ run_archivebox_cmd(
+ ["snapshot", "create", create_test_url()],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "list", "--limit=2"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, _stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
records = parse_jsonl_output(stdout)
@@ -188,12 +233,20 @@ class TestSnapshotList:
def test_list_with_sort_and_limit(self, initialized_archive):
"""Sorting should be applied before limiting."""
for _ in range(3):
- run_archivebox_cmd(["snapshot", "create", create_test_url()], data_dir=initialized_archive)
+ run_archivebox_cmd(
+ ["snapshot", "create", create_test_url()],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "list", "--limit=2", "--sort=-created_at"],
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0, f"Command failed: {stderr}"
records = parse_jsonl_output(stdout)
@@ -206,14 +259,23 @@ class TestSnapshotUpdate:
def test_update_status(self, initialized_archive):
"""Update snapshot status."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout2, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "update", "--status=started"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ stdout2, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Updated 1 snapshots" in stderr
@@ -224,14 +286,23 @@ class TestSnapshotUpdate:
def test_update_add_tag(self, initialized_archive):
"""Update snapshot by adding tag."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout2, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "update", "--tag=new-tag"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout2, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Updated 1 snapshots" in stderr
@@ -243,14 +314,23 @@ class TestSnapshotDelete:
def test_delete_requires_yes(self, initialized_archive):
"""Delete requires --yes flag."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "delete"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 1
assert "--yes" in stderr
@@ -258,14 +338,23 @@ class TestSnapshotDelete:
def test_delete_with_yes(self, initialized_archive):
"""Delete with --yes flag works."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "delete", "--yes"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Deleted 1 snapshots" in stderr
@@ -273,14 +362,181 @@ class TestSnapshotDelete:
def test_delete_dry_run(self, initialized_archive):
"""Dry run shows what would be deleted."""
url = create_test_url()
- stdout1, _, _ = run_archivebox_cmd(["snapshot", "create", url], data_dir=initialized_archive)
+ _cmd_result = run_archivebox_cmd(
+ ["snapshot", "create", url],
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
+ )
+ stdout1, _, _ = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
snapshot = parse_jsonl_output(stdout1)[0]
- stdout, stderr, code = run_archivebox_cmd(
+ _cmd_result = run_archivebox_cmd(
["snapshot", "delete", "--dry-run"],
stdin=json.dumps(snapshot),
- data_dir=initialized_archive,
+ cwd=initialized_archive,
+ default_cli_env=True,
+ disable_extractors=True,
)
+ _stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert code == 0
assert "Would delete" in stderr
+
+
+def test_snapshot_creates_snapshot_with_correct_url(tmp_path, initialized_archive):
+ """Test that snapshot stores the exact URL in the database."""
+ env = cli_env(disable_extractors=True)
+
+ run_archivebox_cmd(
+ ["snapshot", "create", "https://example.com"],
+ cwd=tmp_path,
+ env=env,
+ )
+
+ with use_archivebox_db(tmp_path):
+ snapshot = Snapshot.objects.select_related("crawl__created_by").get(url="https://example.com")
+ username = snapshot.crawl.created_by.username
+
+ # Verify the crawl tree contains a relative symlink to the user-scoped snapshot output.
+ snapshots_root = tmp_path / "archive" / "users" / username / "snapshots"
+ crawl_root = tmp_path / "archive" / "users" / username / "crawls"
+ symlinks = [p for p in crawl_root.rglob("*") if p.is_symlink() and p.resolve().is_dir() and p.resolve().is_relative_to(snapshots_root)]
+ assert symlinks, "Snapshot symlink should exist under crawl dir"
+ link_path = symlinks[0]
+
+ assert link_path.is_symlink(), "Snapshot symlink should exist under crawl dir"
+ link_target = os.readlink(link_path)
+ assert not os.path.isabs(link_target), "Symlink should be relative"
+
+
+def test_snapshot_multiple_urls_creates_multiple_records(tmp_path, initialized_archive):
+ """Test that multiple URLs each get their own snapshot record."""
+ env = cli_env(disable_extractors=True)
+
+ run_archivebox_cmd(
+ [
+ "snapshot",
+ "create",
+ "https://example.com",
+ "https://iana.org",
+ ],
+ cwd=tmp_path,
+ env=env,
+ )
+
+ with use_archivebox_db(tmp_path):
+ urls = list(Snapshot.objects.order_by("url").values_list("url", flat=True))
+
+ assert "https://example.com" in urls
+ assert "https://iana.org" in urls
+ assert len(urls) >= 2
+
+
+def test_snapshot_tag_creates_tag_and_links_to_snapshot(tmp_path, initialized_archive):
+ """Test that --tag creates tag record and links it to the snapshot."""
+ env = cli_env(disable_extractors=True)
+
+ run_archivebox_cmd(
+ [
+ "snapshot",
+ "create",
+ "--tag=mytesttag",
+ "https://example.com",
+ ],
+ cwd=tmp_path,
+ env=env,
+ )
+
+ with use_archivebox_db(tmp_path):
+ tag = Tag.objects.filter(name="mytesttag").first()
+ assert tag is not None, "Tag 'mytesttag' should exist in core_tag"
+ snapshot = Snapshot.objects.filter(url="https://example.com").first()
+ assert snapshot is not None
+ assert snapshot.tags.filter(pk=tag.pk).exists(), "Tag should be linked to snapshot via core_snapshot_tags"
+
+
+def test_snapshot_jsonl_output_has_correct_structure(tmp_path, initialized_archive):
+ """Test that JSONL output contains required fields with correct types."""
+ env = cli_env(disable_extractors=True)
+
+ # Pass URL as argument instead of stdin for more reliable behavior
+ result = run_archivebox_cmd(
+ ["snapshot", "create", "https://example.com"],
+ cwd=tmp_path,
+ env=env,
+ )
+
+ # Parse JSONL output lines
+ records = Process.parse_records_from_text(result.stdout)
+ snapshot_records = [r for r in records if r.get("type") == "Snapshot"]
+
+ assert len(snapshot_records) >= 1, "Should output at least one Snapshot JSONL record"
+
+ record = snapshot_records[0]
+ assert record.get("type") == "Snapshot"
+ assert "id" in record, "Snapshot record should have 'id' field"
+ assert "url" in record, "Snapshot record should have 'url' field"
+ assert record["url"] == "https://example.com"
+
+
+def test_snapshot_with_tag_stores_tag_name(tmp_path, initialized_archive):
+ """Test that title is stored when provided via tag option."""
+ env = cli_env(disable_extractors=True)
+
+ # Use command line args instead of stdin
+ run_archivebox_cmd(
+ ["snapshot", "create", "--tag=customtag", "https://example.com"],
+ cwd=tmp_path,
+ env=env,
+ )
+
+ with use_archivebox_db(tmp_path):
+ tag = Tag.objects.filter(name="customtag").first()
+
+ assert tag is not None
+ assert tag.name == "customtag"
+
+
+def test_snapshot_with_depth_sets_snapshot_depth(tmp_path, initialized_archive):
+ """Test that --depth sets snapshot depth when creating snapshots."""
+ env = cli_env(disable_extractors=True)
+
+ run_archivebox_cmd(
+ [
+ "snapshot",
+ "create",
+ "--depth=1",
+ "https://example.com",
+ ],
+ cwd=tmp_path,
+ env=env,
+ )
+
+ with use_archivebox_db(tmp_path):
+ snapshot = Snapshot.objects.order_by("-created_at").first()
+
+ assert snapshot is not None, "Snapshot should be created when depth is provided"
+ assert snapshot.depth == 1, "Snapshot depth should match --depth value"
+
+
+def test_snapshot_allows_duplicate_urls_across_crawls(tmp_path, initialized_archive):
+ """Snapshot create auto-creates a crawl per run; same URL can appear multiple times."""
+ env = cli_env(disable_extractors=True)
+
+ # Add same URL twice
+ run_archivebox_cmd(
+ ["snapshot", "create", "https://example.com"],
+ cwd=tmp_path,
+ env=env,
+ )
+ run_archivebox_cmd(
+ ["snapshot", "create", "https://example.com"],
+ cwd=tmp_path,
+ env=env,
+ )
+
+ with use_archivebox_db(tmp_path):
+ count = Snapshot.objects.filter(url="https://example.com").count()
+
+ assert count == 2, "Same URL should create separate snapshots across different crawls"
diff --git a/archivebox/tests/test_cli_status.py b/archivebox/tests/test_cli_status.py
index 5495cdc5..ec5da8ba 100644
--- a/archivebox/tests/test_cli_status.py
+++ b/archivebox/tests/test_cli_status.py
@@ -4,69 +4,49 @@ Comprehensive tests for archivebox status command.
Verify status reports accurate collection state from DB and filesystem.
"""
-import os
-import subprocess
-from pathlib import Path
-
import pytest
from archivebox.core.models import Snapshot
-from archivebox.tests.conftest import run_queued_crawls
+from archivebox.tests.conftest import find_snapshot_dir, run_archivebox_cmd, run_queued_crawls, cli_env
+
from archivebox.tests.test_orm_helpers import use_archivebox_db
pytestmark = pytest.mark.django_db(transaction=True)
-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
-
-
-def test_status_runs_successfully(tmp_path, process):
+def test_status_runs_successfully(initialized_archive):
"""Test that status command runs without error."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "status"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["status"])
assert result.returncode == 0
assert len(result.stdout) > 100
-def test_status_shows_zero_snapshots_in_empty_archive(tmp_path, process):
+def test_status_shows_zero_snapshots_in_empty_archive(initialized_archive):
"""Test status shows 0 snapshots in empty archive."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "status"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["status"])
output = result.stdout
# Should indicate empty/zero state
assert "0" in output
-def test_status_shows_correct_snapshot_count(tmp_path, process, disable_extractors_dict):
+def test_status_shows_correct_snapshot_count(initialized_archive):
"""Test that status shows accurate snapshot count from DB."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add 3 snapshots
for url in ["https://example.com", "https://example.org", "https://example.net"]:
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", url],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", url],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- result = subprocess.run(["archivebox", "status"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["status"])
# Verify DB has 3 snapshots
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
db_count = Snapshot.objects.count()
assert db_count == 3
@@ -74,159 +54,147 @@ def test_status_shows_correct_snapshot_count(tmp_path, process, disable_extracto
assert "3" in result.stdout
-def test_status_shows_archived_count(tmp_path, process, disable_extractors_dict):
+def test_status_shows_archived_count(initialized_archive):
"""Test status distinguishes archived vs unarchived snapshots."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- result = subprocess.run(["archivebox", "status"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["status"])
# Should show archived/unarchived categories
assert "archived" in result.stdout.lower() or "queued" in result.stdout.lower()
-def test_status_shows_archive_directory_size(tmp_path, process):
+def test_status_shows_archive_directory_size(initialized_archive):
"""Test status reports archive directory size."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "status"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["status"])
output = result.stdout
# Should show size info
assert "Size" in output or "size" in output
-def test_status_counts_archive_directories(tmp_path, process, disable_extractors_dict):
+def test_status_counts_archive_directories(initialized_archive):
"""Test status counts directories in archive/ folder."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
- result = subprocess.run(["archivebox", "status"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["status"])
# Should show directory count
assert "present" in result.stdout.lower() or "directories" in result.stdout
-def test_status_detects_orphaned_directories(tmp_path, process, disable_extractors_dict):
+def test_status_detects_orphaned_directories(initialized_archive):
"""Test status detects directories not in DB (orphaned)."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add a snapshot
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
# Create an orphaned directory
- (tmp_path / "archive" / "fake_orphaned_dir").mkdir(parents=True, exist_ok=True)
+ (initialized_archive / "archive" / "fake_orphaned_dir").mkdir(parents=True, exist_ok=True)
- result = subprocess.run(["archivebox", "status"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["status"])
# Should mention orphaned dirs
assert "orphan" in result.stdout.lower() or "1" in result.stdout
-def test_status_counts_new_snapshot_output_dirs_as_archived(tmp_path, process, disable_extractors_dict):
+def test_status_counts_new_snapshot_output_dirs_as_archived(initialized_archive):
"""Test status reads archived/present counts from the current snapshot output layout."""
- os.chdir(tmp_path)
- env = disable_extractors_dict.copy()
+ env = cli_env(disable_extractors=True)
+ env = env.copy()
env["ARCHIVEBOX_ALLOW_NO_UNIX_SOCKETS"] = "true"
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
env=env,
check=True,
)
- run_queued_crawls(tmp_path, env)
+ run_queued_crawls(initialized_archive, env)
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
snapshot_id = Snapshot.objects.values_list("id", flat=True).get(url="https://example.com")
- snapshot_dir = _find_snapshot_dir(tmp_path, str(snapshot_id))
+ snapshot_dir = find_snapshot_dir(initialized_archive, str(snapshot_id))
assert snapshot_dir is not None, f"Snapshot output directory not found for {snapshot_id}"
title_dir = snapshot_dir / "title"
title_dir.mkdir(parents=True, exist_ok=True)
(title_dir / "title.txt").write_text("Example Domain")
- result = subprocess.run(["archivebox", "status"], capture_output=True, text=True, env=env)
+ result = run_archivebox_cmd(["status"], env=env)
assert result.returncode == 0, result.stdout + result.stderr
assert "archived: 1" in result.stdout
assert "present: 1" in result.stdout
-def test_status_shows_user_info(tmp_path, process):
+def test_status_shows_user_info(initialized_archive):
"""Test status shows user/login information."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "status"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["status"])
output = result.stdout
# Should show user section
assert "user" in output.lower() or "login" in output.lower()
-def test_status_reads_from_db_not_filesystem(tmp_path, process, disable_extractors_dict):
+def test_status_reads_from_db_not_filesystem(initialized_archive):
"""Test that status uses DB as source of truth, not filesystem."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add snapshot to DB
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
# Verify DB has snapshot
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
db_count = Snapshot.objects.count()
assert db_count == 1
# Status should reflect DB count
- result = subprocess.run(["archivebox", "status"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["status"])
assert "1" in result.stdout
-def test_status_shows_index_file_info(tmp_path, process):
+def test_status_shows_index_file_info(initialized_archive):
"""Test status shows index file information."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "status"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["status"])
# Should mention index
assert "index" in result.stdout.lower() or "Index" in result.stdout
-def test_status_help_lists_available_options(tmp_path, process):
+def test_status_help_lists_available_options(initialized_archive):
"""Test that status --help works and documents the command."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "status", "--help"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["status", "--help"],
)
assert result.returncode == 0
assert "status" in result.stdout.lower() or "statistic" in result.stdout.lower()
-def test_status_shows_data_directory_path(tmp_path, process):
+def test_status_shows_data_directory_path(initialized_archive):
"""Test that status reports which collection directory it is inspecting."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "status"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["status"])
- assert "archive" in result.stdout.lower() or str(tmp_path) in result.stdout
+ assert "archive" in result.stdout.lower() or str(initialized_archive) in result.stdout
diff --git a/archivebox/tests/test_cli_tag.py b/archivebox/tests/test_cli_tag.py
new file mode 100644
index 00000000..2af1f78d
--- /dev/null
+++ b/archivebox/tests/test_cli_tag.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+"""
+Tests for archivebox tag command.
+
+TODO: expand beyond command discovery into create/list/update/delete behavior.
+"""
+
+from archivebox.tests.conftest import run_archivebox_cmd
+
+
+def test_tag_help_runs_successfully(tmp_path):
+ """The tag command should be registered and expose help."""
+
+ result = run_archivebox_cmd(["tag", "--help"])
+
+ assert result.returncode == 0
+ assert "tag" in result.stdout.lower()
+ assert "list" in result.stdout
diff --git a/archivebox/tests/test_cli_update.py b/archivebox/tests/test_cli_update.py
index f6b7f2e7..41bc5c26 100644
--- a/archivebox/tests/test_cli_update.py
+++ b/archivebox/tests/test_cli_update.py
@@ -4,25 +4,20 @@ Comprehensive tests for archivebox update command.
Verify update drains old dirs, reconciles DB, and queues snapshots.
"""
-import os
-import subprocess
-
import pytest
from archivebox.core.models import Snapshot
-from archivebox.tests.conftest import run_queued_crawls
+from archivebox.tests.conftest import run_queued_crawls, run_archivebox_cmd, cli_env
+
from archivebox.tests.test_orm_helpers import use_archivebox_db
pytestmark = pytest.mark.django_db(transaction=True)
-def test_update_runs_successfully_on_empty_archive(tmp_path, process):
+def test_update_runs_successfully_on_empty_archive(initialized_archive):
"""Test that update runs without error on empty archive."""
- os.chdir(tmp_path)
- result = subprocess.run(
- ["archivebox", "update"],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["update"],
timeout=30,
)
@@ -30,53 +25,48 @@ def test_update_runs_successfully_on_empty_archive(tmp_path, process):
assert result.returncode == 0
-def test_update_reconciles_existing_snapshots(tmp_path, process, disable_extractors_dict):
+def test_update_reconciles_existing_snapshots(initialized_archive):
"""Test that update command reconciles existing snapshots."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add a snapshot (index-only for faster test)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
# Run update - should reconcile and queue
- result = subprocess.run(
- ["archivebox", "update"],
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["update"],
+ env=env,
timeout=30,
)
assert result.returncode == 0
-def test_update_specific_snapshot_by_filter(tmp_path, process, disable_extractors_dict):
+def test_update_specific_snapshot_by_filter(initialized_archive):
"""Test updating specific snapshot using filter."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add multiple snapshots
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
timeout=90,
)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.org"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.org"],
+ env=env,
timeout=90,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
# Update with filter pattern (uses filter_patterns argument)
- result = subprocess.run(
- ["archivebox", "update", "--filter-type=substring", "example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["update", "--filter-type=substring", "example.com"],
+ env=env,
timeout=30,
)
@@ -84,65 +74,61 @@ def test_update_specific_snapshot_by_filter(tmp_path, process, disable_extractor
assert result.returncode == 0
-def test_update_preserves_snapshot_count(tmp_path, process, disable_extractors_dict):
+def test_update_preserves_snapshot_count(initialized_archive):
"""Test that update doesn't change snapshot count."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
# Add snapshots
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
timeout=90,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
# Count before update
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
count_before = Snapshot.objects.count()
assert count_before == 1
# Run update (should reconcile + queue, not create new snapshots)
- subprocess.run(
- ["archivebox", "update"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["update"],
+ env=env,
timeout=30,
)
# Count after update
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
count_after = Snapshot.objects.count()
# Snapshot count should remain the same
assert count_after == count_before
-def test_update_seals_migrated_snapshots(tmp_path, process, disable_extractors_dict):
+def test_update_seals_migrated_snapshots(initialized_archive):
"""Test that full update reconciles migrated snapshots without re-queuing them."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
- subprocess.run(
- ["archivebox", "add", "--index-only", "--depth=0", "https://example.com"],
- capture_output=True,
- env=disable_extractors_dict,
+ run_archivebox_cmd(
+ ["add", "--index-only", "--depth=0", "https://example.com"],
+ env=env,
timeout=90,
)
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(initialized_archive, env)
# Run update
- result = subprocess.run(
- ["archivebox", "update"],
- capture_output=True,
- env=disable_extractors_dict,
+ result = run_archivebox_cmd(
+ ["update"],
+ env=env,
timeout=30,
)
assert result.returncode == 0
# Check that snapshot remains archived instead of being queued for a full re-crawl.
- with use_archivebox_db(tmp_path):
+ with use_archivebox_db(initialized_archive):
status = Snapshot.objects.values_list("status", flat=True).get()
assert status == "sealed"
diff --git a/archivebox/tests/test_cli_update_reindex_snapshots.py b/archivebox/tests/test_cli_update_reindex_snapshots.py
index 550234cb..411e9be2 100644
--- a/archivebox/tests/test_cli_update_reindex_snapshots.py
+++ b/archivebox/tests/test_cli_update_reindex_snapshots.py
@@ -1,22 +1,20 @@
import json
import os
-import subprocess
from datetime import datetime, timedelta
+from archivebox.tests.conftest import run_archivebox_cmd, cli_env
import pytest
from django.utils import timezone
from archivebox.core.models import Snapshot
from archivebox.tests.test_orm_helpers import use_archivebox_db
-from .fixtures import disable_extractors_dict, process
pytestmark = pytest.mark.django_db(transaction=True)
-FIXTURES = (disable_extractors_dict, process)
-
-def test_update_imports_orphaned_snapshots(tmp_path, process, disable_extractors_dict):
+def test_update_imports_orphaned_snapshots(tmp_path, initialized_archive):
"""Test that archivebox update imports real legacy archive directories."""
+ env = cli_env(disable_extractors=True)
legacy_timestamp = "1710000000"
legacy_dir = tmp_path / "archive" / legacy_timestamp
legacy_dir.mkdir(parents=True, exist_ok=True)
@@ -34,11 +32,9 @@ def test_update_imports_orphaned_snapshots(tmp_path, process, disable_extractors
)
# Run the migration phase only; default update also runs queued crawl work.
- update_process = subprocess.run(
- ["archivebox", "update", "--migrate-only"],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
+ update_process = run_archivebox_cmd(
+ ["update", "--migrate-only"],
+ env=env,
timeout=60,
)
assert update_process.returncode == 0, update_process.stderr
@@ -46,7 +42,7 @@ def test_update_imports_orphaned_snapshots(tmp_path, process, disable_extractors
with use_archivebox_db(tmp_path):
row = Snapshot.objects.values_list("url", "fs_version").get()
- assert row == ("https://example.com", "0.9.0")
+ assert row == ("https://example.com", Snapshot._fs_current_version())
assert legacy_dir.is_symlink()
migrated_dir = legacy_dir.resolve()
diff --git a/archivebox/tests/test_cli_version.py b/archivebox/tests/test_cli_version.py
index 0d524004..f94f79dc 100644
--- a/archivebox/tests/test_cli_version.py
+++ b/archivebox/tests/test_cli_version.py
@@ -6,44 +6,9 @@ Verify version output and system information reporting.
import os
import re
-import sys
import tempfile
-import subprocess
from pathlib import Path
-
-from .fixtures import process
-
-FIXTURES = (process,)
-
-
-def _archivebox_cli() -> str:
- cli = Path(sys.executable).with_name("archivebox")
- return str(cli if cli.exists() else "archivebox")
-
-
-def _run_real_cli(
- args: list[str],
- cwd: Path,
- *,
- home_dir: Path,
- timeout: int = 180,
- extra_env: dict[str, str] | None = None,
-) -> subprocess.CompletedProcess[str]:
- env = os.environ.copy()
- env.pop("DATA_DIR", None)
- env["HOME"] = str(home_dir)
- env["USE_COLOR"] = "False"
- env["SHOW_PROGRESS"] = "False"
- if extra_env:
- env.update(extra_env)
- return subprocess.run(
- [_archivebox_cli(), *args],
- capture_output=True,
- text=True,
- cwd=cwd,
- env=env,
- timeout=timeout,
- )
+from archivebox.tests.conftest import run_archivebox_cmd
def _make_deep_collection_dir(tmp_path: Path) -> Path:
@@ -66,8 +31,7 @@ def _extract_location_path(output: str, key: str) -> Path:
def test_version_quiet_outputs_version_number(tmp_path):
"""Test that version --quiet outputs just the version number."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "version", "--quiet"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["version", "--quiet"])
assert result.returncode == 0
version = result.stdout.strip()
@@ -79,8 +43,7 @@ def test_version_quiet_outputs_version_number(tmp_path):
def test_version_flag_outputs_version_number(tmp_path):
"""Test that top-level --version reports the package version."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "--version"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["--version"])
assert result.returncode == 0
version = result.stdout.strip()
@@ -88,10 +51,9 @@ def test_version_flag_outputs_version_number(tmp_path):
assert len(version.split(".")) >= 2
-def test_version_shows_system_info_in_initialized_dir(tmp_path, process):
+def test_version_shows_system_info_in_initialized_dir(tmp_path, initialized_archive):
"""Test that version shows system metadata in initialized directory."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "version"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["version"])
output = result.stdout
assert "ArchiveBox" in output
@@ -99,20 +61,18 @@ def test_version_shows_system_info_in_initialized_dir(tmp_path, process):
assert any(x in output for x in ["ARCH=", "OS=", "PYTHON="])
-def test_version_shows_binaries_after_init(tmp_path, process):
+def test_version_shows_binaries_after_init(tmp_path, initialized_archive):
"""Test that version shows binary dependencies in initialized directory."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "version"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["version"])
output = result.stdout
# Should show binary section
assert "Binary" in output or "Dependencies" in output
-def test_version_shows_data_locations(tmp_path, process):
+def test_version_shows_data_locations(tmp_path, initialized_archive):
"""Test that version shows data directory locations."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "version"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["version"])
output = result.stdout
# Should show paths
@@ -123,9 +83,8 @@ def test_version_in_uninitialized_dir_still_works(tmp_path):
"""Test that version command works even without initialized data dir."""
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
- os.chdir(empty_dir)
- result = subprocess.run(["archivebox", "version", "--quiet"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["version", "--quiet"], cwd=empty_dir)
# Should still output version
assert result.returncode == 0
@@ -140,11 +99,17 @@ def test_version_auto_selects_short_tmp_dir_for_deep_collection_path(tmp_path):
with tempfile.TemporaryDirectory(prefix="abx-home-") as home_tmp:
home_dir = Path(home_tmp)
+ env = {
+ "HOME": str(home_dir),
+ "USE_COLOR": "False",
+ "SHOW_PROGRESS": "False",
+ **extra_env,
+ }
- init_result = _run_real_cli(["init", "--quick"], cwd=data_dir, home_dir=home_dir, extra_env=extra_env)
+ init_result = run_archivebox_cmd(["init", "--quick"], cwd=data_dir, env=env, timeout=180)
assert init_result.returncode == 0, init_result.stdout + init_result.stderr
- version_result = _run_real_cli(["version"], cwd=data_dir, home_dir=home_dir, extra_env=extra_env)
+ version_result = run_archivebox_cmd(["version"], cwd=data_dir, env=env, timeout=180)
output = version_result.stdout + version_result.stderr
assert version_result.returncode == 0, output
@@ -163,8 +128,7 @@ def test_version_auto_selects_short_tmp_dir_for_deep_collection_path(tmp_path):
def test_version_help_lists_quiet_flag(tmp_path):
"""Test that version --help documents the quiet output mode."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "version", "--help"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["version", "--help"])
assert result.returncode == 0
assert "--quiet" in result.stdout or "-q" in result.stdout
@@ -172,7 +136,6 @@ def test_version_help_lists_quiet_flag(tmp_path):
def test_version_invalid_option_fails(tmp_path):
"""Test that invalid version options fail cleanly."""
- os.chdir(tmp_path)
- result = subprocess.run(["archivebox", "version", "--invalid-option"], capture_output=True, text=True)
+ result = run_archivebox_cmd(["version", "--invalid-option"])
assert result.returncode != 0
diff --git a/archivebox/tests/test_config.py b/archivebox/tests/test_config.py
deleted file mode 100644
index eec85da2..00000000
--- a/archivebox/tests/test_config.py
+++ /dev/null
@@ -1,172 +0,0 @@
-#!/usr/bin/env python3
-"""Integration tests for archivebox config command."""
-
-import os
-import subprocess
-
-import pytest
-
-
-def test_config_shows_all_config_values(tmp_path, process):
- """Test that config without args shows all config values."""
- os.chdir(tmp_path)
-
- result = subprocess.run(
- ["archivebox", "config"],
- capture_output=True,
- text=True,
- )
-
- # Should show various config sections
- assert "TIMEOUT" in result.stdout or "timeout" in result.stdout.lower()
- # Config should show some output
- assert len(result.stdout) > 100
-
-
-def test_config_get_specific_key(tmp_path, process):
- """Test that --get retrieves a specific config value."""
- os.chdir(tmp_path)
-
- result = subprocess.run(
- ["archivebox", "config", "--get", "TIMEOUT"],
- capture_output=True,
- text=True,
- )
-
- # Should show the TIMEOUT value
- assert "TIMEOUT" in result.stdout or result.returncode == 0
-
-
-def test_config_set_value_writes_to_config_file(tmp_path, process):
- """Test that --set writes config value to ArchiveBox.conf file."""
- os.chdir(tmp_path)
-
- # Set a config value
- result = subprocess.run(
- ["archivebox", "config", "--set", "TIMEOUT=120"],
- capture_output=True,
- text=True,
- )
- assert result.returncode == 0, result.stderr
-
- # Read the config file directly to verify it was written
- config_file = tmp_path / "ArchiveBox.conf"
- if config_file.exists():
- config_content = config_file.read_text()
- # Config should contain the set value
- assert "TIMEOUT" in config_content or "timeout" in config_content.lower()
-
-
-def test_config_set_and_get_roundtrip(tmp_path, process):
- """Test that a value set with --set can be retrieved with --get."""
- os.chdir(tmp_path)
-
- # Set a value
- set_result = subprocess.run(
- ["archivebox", "config", "--set", "TIMEOUT=999"],
- capture_output=True,
- text=True,
- )
-
- # Verify set was successful
- assert set_result.returncode == 0 or "999" in set_result.stdout
-
- # Read the config file directly to verify
- config_file = tmp_path / "ArchiveBox.conf"
- if config_file.exists():
- config_content = config_file.read_text()
- assert "999" in config_content or "TIMEOUT" in config_content
-
-
-def test_config_search_finds_matching_keys(tmp_path, process):
- """Test that --search finds config keys matching a pattern."""
- os.chdir(tmp_path)
-
- result = subprocess.run(
- ["archivebox", "config", "--search", "TIMEOUT"],
- capture_output=True,
- text=True,
- )
-
- # Should find TIMEOUT-related config
- assert "TIMEOUT" in result.stdout or result.returncode == 0
-
-
-def test_config_invalid_key_fails(tmp_path, process):
- """Test that setting an invalid config key fails."""
- os.chdir(tmp_path)
-
- result = subprocess.run(
- ["archivebox", "config", "--set", "INVALID_KEY_THAT_DOES_NOT_EXIST=value"],
- capture_output=True,
- text=True,
- )
-
- # Should fail
- assert result.returncode != 0 or "failed" in result.stdout.lower()
-
-
-def test_config_ignores_legacy_unknown_keys(tmp_path, process):
- """Old ArchiveBox.conf keys should not prevent startup during upgrades."""
- os.chdir(tmp_path)
- (tmp_path / "ArchiveBox.conf").write_text(
- """
-[ARCHIVING_CONFIG]
-MAX_MEDIA_SIZE = "750m"
-
-[SEARCH_BACKEND_CONFIG]
-SEARCH_BACKEND_HOST_NAME = "sonic"
-SEARCH_BACKEND_PASSWORD = "SecretPassword"
-""",
- )
-
- result = subprocess.run(
- ["archivebox", "version"],
- capture_output=True,
- text=True,
- )
-
- assert result.returncode == 0, result.stderr
- assert "Extra inputs are not permitted" not in result.stderr
-
-
-def test_sonic_dir_is_allowed_inside_data_dir():
- from archivebox.config import CONSTANTS
-
- assert "sonic" in CONSTANTS.ALLOWED_IN_DATA_DIR
-
-
-def test_config_set_requires_equals_sign(tmp_path, process):
- """Test that --set requires KEY=VALUE format."""
- os.chdir(tmp_path)
-
- result = subprocess.run(
- ["archivebox", "config", "--set", "TIMEOUT"],
- capture_output=True,
- text=True,
- )
-
- # Should fail because there's no = sign
- assert result.returncode != 0
-
-
-class TestConfigCLI:
- """Test the CLI interface for config command."""
-
- def test_cli_help(self, tmp_path, process):
- """Test that --help works for config command."""
- os.chdir(tmp_path)
-
- result = subprocess.run(
- ["archivebox", "config", "--help"],
- capture_output=True,
- text=True,
- )
-
- assert result.returncode == 0
- assert "--get" in result.stdout
- assert "--set" in result.stdout
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/archivebox/tests/test_retention.py b/archivebox/tests/test_config_DELETE_AFTER.py
similarity index 87%
rename from archivebox/tests/test_retention.py
rename to archivebox/tests/test_config_DELETE_AFTER.py
index a17972bc..f75af8e2 100644
--- a/archivebox/tests/test_retention.py
+++ b/archivebox/tests/test_config_DELETE_AFTER.py
@@ -1,12 +1,11 @@
import json
-import os
from pathlib import Path
import pytest
from django.contrib.auth import get_user_model
from django.urls import reverse
-from archivebox.tests.conftest import run_archivebox_cmd_cwd, run_queued_crawls
+from archivebox.tests.conftest import run_archivebox_cmd, run_queued_crawls, cli_env
pytestmark = pytest.mark.django_db(transaction=True)
@@ -15,26 +14,28 @@ ADMIN_HOST = "admin.archivebox.localhost:8000"
API_HOST = "api.archivebox.localhost:8000"
-def test_delete_after_real_cli_and_orchestrator_paths_cover_all_retained_models(tmp_path, disable_extractors_dict):
- os.chdir(tmp_path)
- stdout, stderr, returncode = run_archivebox_cmd_cwd(["init", "--quick"], cwd=tmp_path, timeout=90)
+def test_delete_after_real_cli_and_orchestrator_paths_cover_all_retained_models(tmp_path):
+ env = cli_env(disable_extractors=True)
+ _cmd_result = run_archivebox_cmd(["init", "--quick"], cwd=tmp_path, timeout=90)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, stderr
- cli_env = {
- **disable_extractors_dict,
+ run_env = {
+ **env,
"DELETE_AFTER": "1hr",
"USE_COLOR": "False",
"SHOW_PROGRESS": "False",
}
url = "https://example.com/delete-after-cli"
- stdout, stderr, returncode = run_archivebox_cmd_cwd(
+ _cmd_result = run_archivebox_cmd(
["add", "--index-only", "--depth=0", url],
cwd=tmp_path,
timeout=120,
- env=cli_env,
+ env=run_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}"
- run_queued_crawls(tmp_path, cli_env)
+ run_queued_crawls(tmp_path, run_env)
lookup_script = f"""
import json
@@ -47,12 +48,13 @@ print(json.dumps({{
"snapshot_delete_at": bool(snapshot.delete_at),
}}))
"""
- stdout, stderr, returncode = run_archivebox_cmd_cwd(
+ _cmd_result = run_archivebox_cmd(
["manage", "shell", "-c", lookup_script],
cwd=tmp_path,
timeout=90,
- env=cli_env,
+ env=run_env,
)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, f"retention lookup failed:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"
created = json.loads(stdout.strip().splitlines()[-1])
assert created["crawl_delete_at"]
@@ -141,17 +143,19 @@ print(json.dumps({{
"archiveresult_dir": str(result.output_dir),
}}))
"""
- stdout, stderr, returncode = run_archivebox_cmd_cwd(
+ _cmd_result = run_archivebox_cmd(
["manage", "shell", "-c", setup_script],
cwd=tmp_path,
timeout=90,
- env=cli_env,
+ env=run_env,
)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, f"retention setup failed:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"
retained = json.loads(stdout.strip().splitlines()[-1])
assert retained["crawl_id"] == created["crawl_id"]
- stdout, stderr, returncode = run_archivebox_cmd_cwd(["run", "--crawl-id", retained["crawl_id"]], cwd=tmp_path, timeout=120, env=cli_env)
+ _cmd_result = run_archivebox_cmd(["run", "--crawl-id", retained["crawl_id"]], cwd=tmp_path, timeout=120, env=run_env)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, f"archivebox run failed:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"
remaining_script = f"""
@@ -166,12 +170,13 @@ print(json.dumps({{
"process": Process.objects.filter(id="{retained["process_id"]}").count(),
}}))
"""
- stdout, stderr, returncode = run_archivebox_cmd_cwd(
+ _cmd_result = run_archivebox_cmd(
["manage", "shell", "-c", remaining_script],
cwd=tmp_path,
timeout=90,
- env=cli_env,
+ env=run_env,
)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, f"retention remaining lookup failed:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"
remaining = json.loads(stdout.strip().splitlines()[-1])
assert remaining == {"crawl": 0, "snapshot": 0, "archiveresult": 0, "process": 0}
diff --git a/archivebox/tests/test_config_MAX_limits.py b/archivebox/tests/test_config_MAX_limits.py
new file mode 100644
index 00000000..b3e44163
--- /dev/null
+++ b/archivebox/tests/test_config_MAX_limits.py
@@ -0,0 +1,295 @@
+"""Tests for MAX/SIZE crawl limit config behavior."""
+
+import asyncio
+import json
+from pathlib import Path
+
+import pytest
+
+from archivebox.core.models import Snapshot
+from archivebox.crawls.models import Crawl
+from archivebox.tests.conftest import cli_env, run_archivebox_cmd
+from archivebox.tests.test_orm_helpers import use_archivebox_db
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def test_create_snapshots_from_urls_respects_max_urls(admin_user):
+ crawl = Crawl.objects.create(
+ urls="\n".join(
+ [
+ "https://example.com/root",
+ "https://example.com/about",
+ "https://example.com/contact",
+ ],
+ ),
+ config={"CRAWL_MAX_URLS": 2},
+ created_by=admin_user,
+ )
+
+ created = crawl.create_snapshots_from_urls()
+
+ assert [snapshot.url for snapshot in created] == [
+ "https://example.com/root",
+ "https://example.com/about",
+ ]
+ assert crawl.snapshot_set.count() == 2
+ assert crawl.remaining_snapshot_capacity() == 0
+ assert crawl.limit_stop_reason() == "crawl_max_urls"
+ assert crawl.add_url({"url": "https://example.com/extra", "depth": 1}) is False
+
+
+def test_crawl_stop_reason_keeps_specific_limit_reason_over_lifecycle_fallback(admin_user):
+ crawl = Crawl.objects.create(
+ urls="\n".join(
+ [
+ "https://example.com/root",
+ "https://example.com/about",
+ ],
+ ),
+ config={"CRAWL_MAX_URLS": 1},
+ status=Crawl.StatusChoices.SEALED,
+ retry_at=None,
+ created_by=admin_user,
+ )
+ Snapshot.objects.create(
+ url="https://example.com/root",
+ crawl=crawl,
+ status=Snapshot.StatusChoices.SEALED,
+ timestamp="1700000000.011",
+ )
+
+ assert crawl.stop_reason() == "crawl_max_urls"
+
+
+def test_enqueue_discovered_snapshots_refreshes_crawl_limits(tmp_path):
+ from archivebox.base_models.models import get_or_create_system_user_pk
+ from archivebox.crawls.models import Crawl
+ from archivebox.core.models import Snapshot
+ from archivebox.services.runner import CrawlRunner
+
+ crawl = Crawl.objects.create(
+ urls="https://example.com",
+ max_depth=0,
+ config={"CRAWL_MAX_URLS": 5},
+ created_by_id=get_or_create_system_user_pk(),
+ )
+ snapshot = Snapshot.objects.create(
+ url="https://example.com",
+ crawl=crawl,
+ status=Snapshot.StatusChoices.SEALED,
+ depth=0,
+ )
+ parser_dir = Path(snapshot.output_dir) / "parse_html_urls"
+ parser_dir.mkdir(parents=True, exist_ok=True)
+ (parser_dir / "urls.jsonl").write_text(
+ "\n".join(
+ [
+ json.dumps({"type": "Snapshot", "url": "https://example.com/child-a", "depth": 1}),
+ json.dumps({"type": "Snapshot", "url": "https://example.com/child-b", "depth": 1}),
+ "",
+ ],
+ ),
+ )
+
+ runner = CrawlRunner(crawl)
+ Crawl.objects.filter(id=crawl.id).update(max_depth=1)
+ payload = runner.load_snapshot_payload(str(snapshot.id))
+
+ asyncio.run(runner.enqueue_discovered_snapshots_from_outputs(payload))
+
+ child_snapshots = list(crawl.snapshot_set.filter(depth=1).order_by("url").values_list("url", "status"))
+ assert child_snapshots == [
+ ("https://example.com/child-a", Snapshot.StatusChoices.QUEUED),
+ ("https://example.com/child-b", Snapshot.StatusChoices.QUEUED),
+ ]
+
+
+def test_run_snapshot_seals_descendant_when_crawl_max_size_is_reached(tmp_path):
+ from abx_dl.events import CrawlStartEvent, SnapshotEvent
+ from archivebox.base_models.models import get_or_create_system_user_pk
+ from archivebox.crawls.models import Crawl
+ from archivebox.core.models import Snapshot
+ from archivebox.services.runner import CrawlRunner
+
+ crawl = Crawl.objects.create(
+ urls="https://example.com",
+ config={
+ "LIB_DIR": str(tmp_path / "lib"),
+ "PLUGINS": "__archivebox_test_no_plugins__",
+ "CHROME_BINARY": "",
+ "CRAWL_MAX_SIZE": 16,
+ },
+ created_by_id=get_or_create_system_user_pk(),
+ )
+ root = Snapshot.objects.create(
+ url="https://example.com",
+ crawl=crawl,
+ depth=0,
+ status=Snapshot.StatusChoices.SEALED,
+ )
+ child = Snapshot.objects.create(
+ url="https://example.com/child",
+ crawl=crawl,
+ depth=1,
+ parent_snapshot=root,
+ status=Snapshot.StatusChoices.QUEUED,
+ )
+ state_dir = Path(crawl.output_dir) / ".abx-dl"
+ state_dir.mkdir(parents=True, exist_ok=True)
+ (state_dir / "limits.json").write_text(
+ json.dumps(
+ {
+ "admitted_snapshot_ids": [str(root.id), str(child.id)],
+ "counted_event_ids": ["proc-1"],
+ "total_size": 32,
+ "stop_reason": "crawl_max_size",
+ },
+ ),
+ encoding="utf-8",
+ )
+
+ runner = CrawlRunner(crawl)
+ runner.load_run_state()
+
+ async def run_child_from_crawl_start() -> list[SnapshotEvent]:
+ async def on_crawl_start(_event: CrawlStartEvent) -> None:
+ await runner.run_snapshot(str(child.id))
+
+ runner.bus.on(CrawlStartEvent, on_crawl_start)
+ crawl_start = runner.bus.emit(
+ CrawlStartEvent(
+ url=root.url,
+ snapshot_id=str(root.id),
+ output_dir=str(crawl.output_dir),
+ event_timeout=30,
+ event_handler_timeout=30,
+ ),
+ )
+ await crawl_start.now()
+ await crawl_start.event_results_list()
+ await runner.bus.wait_until_idle()
+ return await runner.bus.filter(SnapshotEvent, child_of=crawl_start, past=True)
+
+ snapshot_events = asyncio.run(run_child_from_crawl_start())
+
+ child.refresh_from_db()
+ assert child.status == Snapshot.StatusChoices.SEALED
+ assert child.retry_at is None
+ assert snapshot_events == []
+
+
+def test_seal_snapshot_cancels_queued_descendants_after_crawl_max_size():
+ from archivebox.base_models.models import get_or_create_system_user_pk
+ from archivebox.crawls.models import Crawl
+ from archivebox.core.models import Snapshot
+ from archivebox.services.snapshot_service import SnapshotService
+ from abx_dl.events import SnapshotCompletedEvent
+ from abx_dl.orchestrator import create_bus
+
+ crawl = Crawl.objects.create(
+ urls="https://example.com",
+ created_by_id=get_or_create_system_user_pk(),
+ config={"CRAWL_MAX_SIZE": 16},
+ )
+ root = Snapshot.objects.create(
+ url="https://example.com",
+ crawl=crawl,
+ status=Snapshot.StatusChoices.STARTED,
+ )
+ child = Snapshot.objects.create(
+ url="https://example.com/child",
+ crawl=crawl,
+ depth=1,
+ parent_snapshot_id=root.id,
+ status=Snapshot.StatusChoices.QUEUED,
+ )
+
+ state_dir = Path(crawl.output_dir) / ".abx-dl"
+ state_dir.mkdir(parents=True, exist_ok=True)
+ (state_dir / "limits.json").write_text(
+ json.dumps(
+ {
+ "admitted_snapshot_ids": [str(root.id), str(child.id)],
+ "counted_event_ids": ["proc-1"],
+ "total_size": 32,
+ "stop_reason": "crawl_max_size",
+ },
+ ),
+ encoding="utf-8",
+ )
+
+ bus = create_bus(name=f"test_snapshot_limit_cancel_{str(crawl.id).replace('-', '_')}")
+ service = SnapshotService(bus, crawl_id=str(crawl.id), schedule_snapshot=lambda snapshot_id: None)
+ try:
+
+ async def emit_event() -> None:
+ await service.on_SnapshotCompletedEvent(
+ SnapshotCompletedEvent(
+ url=root.url,
+ snapshot_id=str(root.id),
+ output_dir=str(root.output_dir),
+ ),
+ )
+
+ asyncio.run(emit_event())
+ finally:
+ asyncio.run(bus.wait_until_idle())
+ asyncio.run(bus.destroy())
+
+ root.refresh_from_db()
+ child.refresh_from_db()
+ assert root.status == Snapshot.StatusChoices.SEALED
+ assert child.status == Snapshot.StatusChoices.SEALED
+ assert child.retry_at is None
+
+
+def test_recursive_crawl_respects_max_urls(tmp_path, initialized_archive, recursive_test_site):
+ """Test that recursive discovery stops creating snapshots at max_urls."""
+ env = cli_env(disable_extractors=True)
+
+ env = env.copy()
+ env.update(
+ {
+ "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*",
+ "SAVE_WGET": "true",
+ "USE_CHROME": "false",
+ "USE_COLOR": "false",
+ "SHOW_PROGRESS": "false",
+ },
+ )
+
+ result = run_archivebox_cmd(
+ [
+ "add",
+ "--depth=2",
+ "--max-urls=4",
+ "--plugins=wget,parse_html_urls",
+ recursive_test_site["root_url"],
+ ],
+ env=env,
+ timeout=120,
+ )
+ stdout, stderr = result.stdout, result.stderr
+
+ if stderr:
+ print(f"\n=== STDERR ===\n{stderr}\n=== END STDERR ===\n")
+ if stdout:
+ print(f"\n=== STDOUT (last 2000 chars) ===\n{stdout[-2000:]}\n=== END STDOUT ===\n")
+
+ assert result.returncode == 0, result.stderr
+
+ with use_archivebox_db(tmp_path):
+ crawl_obj = Crawl.objects.order_by("-created_at").first()
+ crawl = (crawl_obj.max_depth, crawl_obj.config["CRAWL_MAX_URLS"]) if crawl_obj else None
+ snapshot_rows = list(Snapshot.objects.order_by("depth", "url").values_list("url", "depth", "parent_snapshot_id"))
+ depth_counts = {
+ depth: Snapshot.objects.filter(depth=depth).count() for depth in set(Snapshot.objects.values_list("depth", flat=True))
+ }
+
+ assert crawl == (2, 4)
+ assert len(snapshot_rows) == 4
+ assert depth_counts.get(0, 0) == 1
+ assert depth_counts.get(1, 0) == 3
+ assert depth_counts.get(2, 0) == 0
+ assert set(recursive_test_site["child_urls"]).issubset({url for url, depth, _parent in snapshot_rows if depth == 1})
diff --git a/archivebox/tests/test_config_ONLY_NEW.py b/archivebox/tests/test_config_ONLY_NEW.py
new file mode 100644
index 00000000..6df7b312
--- /dev/null
+++ b/archivebox/tests/test_config_ONLY_NEW.py
@@ -0,0 +1,87 @@
+"""Tests for ONLY_NEW crawl config behavior."""
+
+import pytest
+
+from archivebox.core.models import Snapshot
+from archivebox.crawls.models import Crawl
+
+pytestmark = pytest.mark.django_db
+
+
+def test_create_snapshots_from_urls_respects_only_new_exact_url_matches(admin_user):
+ existing_crawl = Crawl.objects.create(urls="https://example.com/existing", created_by=admin_user)
+ Snapshot.objects.create(
+ url="https://example.com/existing",
+ crawl=existing_crawl,
+ timestamp="1700000000.001",
+ )
+ crawl = Crawl.objects.create(
+ urls="\n".join(
+ [
+ "https://example.com/existing",
+ "https://example.com/existing/",
+ "https://example.com/fresh",
+ ],
+ ),
+ config={"ONLY_NEW": True},
+ created_by=admin_user,
+ )
+
+ created = crawl.create_snapshots_from_urls()
+
+ assert [snapshot.url for snapshot in created] == [
+ "https://example.com/existing/",
+ "https://example.com/fresh",
+ ]
+ assert Snapshot.objects.filter(url="https://example.com/existing").count() == 1
+
+
+def test_create_snapshots_from_urls_allows_existing_exact_url_when_only_new_false(admin_user):
+ existing_crawl = Crawl.objects.create(urls="https://example.com/existing", created_by=admin_user)
+ Snapshot.objects.create(
+ url="https://example.com/existing",
+ crawl=existing_crawl,
+ timestamp="1700000000.002",
+ )
+ crawl = Crawl.objects.create(
+ urls="https://example.com/existing",
+ config={"ONLY_NEW": False},
+ created_by=admin_user,
+ )
+
+ created = crawl.create_snapshots_from_urls()
+
+ assert [snapshot.url for snapshot in created] == ["https://example.com/existing"]
+ assert Snapshot.objects.filter(url="https://example.com/existing").count() == 2
+
+
+def test_create_discovered_snapshots_respects_only_new_exact_url_matches(admin_user):
+ existing_crawl = Crawl.objects.create(urls="https://example.com/existing", created_by=admin_user)
+ Snapshot.objects.create(
+ url="https://example.com/existing",
+ crawl=existing_crawl,
+ timestamp="1700000000.003",
+ )
+ crawl = Crawl.objects.create(
+ urls="https://example.com/root",
+ max_depth=1,
+ config={"ONLY_NEW": True},
+ created_by=admin_user,
+ )
+ parent = crawl.create_snapshots_from_urls()[0]
+
+ created = crawl.create_discovered_snapshots(
+ parent,
+ [
+ {"url": "https://example.com/existing"},
+ {"url": "https://example.com/existing/"},
+ {"url": "https://example.com/fresh"},
+ ],
+ depth=1,
+ )
+
+ assert [snapshot.url for snapshot in created] == [
+ "https://example.com/existing/",
+ "https://example.com/fresh",
+ ]
+ assert Snapshot.objects.filter(url="https://example.com/existing").count() == 1
diff --git a/archivebox/tests/test_title.py b/archivebox/tests/test_config_SAVE_TITLE.py
similarity index 51%
rename from archivebox/tests/test_title.py
rename to archivebox/tests/test_config_SAVE_TITLE.py
index 59257d77..34fcf93b 100644
--- a/archivebox/tests/test_title.py
+++ b/archivebox/tests/test_config_SAVE_TITLE.py
@@ -1,17 +1,13 @@
-import subprocess
-import sys
+from archivebox.tests.conftest import run_archivebox_cmd, cli_env
import pytest
from archivebox.core.models import Snapshot
from archivebox.tests.test_orm_helpers import use_archivebox_db
from .conftest import _find_system_browser
-from .fixtures import disable_extractors_dict, process
pytestmark = pytest.mark.django_db(transaction=True)
-FIXTURES = (disable_extractors_dict, process)
-
def _install_chrome(tmp_path, env):
env["CHROME_ISOLATION"] = "snapshot"
@@ -20,26 +16,23 @@ def _install_chrome(tmp_path, env):
env["CHROME_BINARY"] = str(system_browser)
return
- install_process = subprocess.run(
- [sys.executable, "-m", "archivebox", "install", "chrome"],
+ install_process = run_archivebox_cmd(
+ ["install", "chrome"],
cwd=tmp_path,
- capture_output=True,
- text=True,
env=env,
timeout=600,
)
assert install_process.returncode == 0, install_process.stderr or install_process.stdout
-def test_title_is_extracted(tmp_path, process, disable_extractors_dict):
+def test_title_is_extracted(tmp_path, initialized_archive):
"""Test that title is extracted from the page."""
- disable_extractors_dict.update({"SAVE_TITLE": "true"})
- _install_chrome(tmp_path, disable_extractors_dict)
- add_process = subprocess.run(
- ["archivebox", "add", "--plugins=chrome,wget,title", "https://example.com"],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ env.update({"SAVE_TITLE": "true"})
+ _install_chrome(tmp_path, env)
+ add_process = run_archivebox_cmd(
+ ["add", "--plugins=chrome,wget,title", "https://example.com"],
+ env=env,
)
assert add_process.returncode == 0, add_process.stderr or add_process.stdout
@@ -50,28 +43,25 @@ def test_title_is_extracted(tmp_path, process, disable_extractors_dict):
assert "Example" in title
-def test_title_is_htmlencoded_in_index_html(tmp_path, process, disable_extractors_dict):
+def test_title_is_listed_by_search_alias(tmp_path, initialized_archive):
"""
https://github.com/ArchiveBox/ArchiveBox/issues/330
Unencoded content should not be rendered as it facilitates xss injections
and breaks the layout.
"""
- disable_extractors_dict.update({"SAVE_TITLE": "true"})
- _install_chrome(tmp_path, disable_extractors_dict)
- add_process = subprocess.run(
- ["archivebox", "add", "--plugins=chrome,wget,title", "https://example.com"],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
+ env = cli_env(disable_extractors=True)
+ env.update({"SAVE_TITLE": "true"})
+ _install_chrome(tmp_path, env)
+ add_process = run_archivebox_cmd(
+ ["add", "--plugins=chrome,wget,title", "https://example.com"],
+ env=env,
)
assert add_process.returncode == 0, add_process.stderr or add_process.stdout
- list_process = subprocess.run(
- ["archivebox", "search", "--html"],
- capture_output=True,
- text=True,
+ list_process = run_archivebox_cmd(
+ ["search"],
+ env=env,
)
assert list_process.returncode == 0, list_process.stderr or list_process.stdout
- # Should not contain unescaped HTML tags in output
output = list_process.stdout
assert "https://example.com" in output
diff --git a/archivebox/tests/test_config_URL_filters.py b/archivebox/tests/test_config_URL_filters.py
new file mode 100644
index 00000000..38d8964a
--- /dev/null
+++ b/archivebox/tests/test_config_URL_filters.py
@@ -0,0 +1,60 @@
+"""Tests for URL_ALLOWLIST and URL_DENYLIST behavior."""
+
+import pytest
+
+from archivebox.crawls.models import Crawl
+
+pytestmark = pytest.mark.django_db
+
+
+def test_create_snapshots_from_urls_respects_url_allowlist_and_denylist(admin_user):
+ crawl = Crawl.objects.create(
+ urls="\n".join(
+ [
+ "https://example.com/root",
+ "https://static.example.com/app.js",
+ "https://other.test/page",
+ ],
+ ),
+ created_by=admin_user,
+ config={
+ "URL_ALLOWLIST": "example.com",
+ "URL_DENYLIST": "static.example.com",
+ },
+ )
+
+ created = crawl.create_snapshots_from_urls()
+
+ assert [snapshot.url for snapshot in created] == ["https://example.com/root"]
+
+
+def test_url_filter_regex_lists_preserve_commas_and_split_on_newlines_only(admin_user):
+ crawl = Crawl.objects.create(
+ urls="\n".join(
+ [
+ "https://example.com/root",
+ "https://example.com/path,with,commas",
+ "https://other.test/page",
+ ],
+ ),
+ created_by=admin_user,
+ config={
+ "URL_ALLOWLIST": r"^https://example\.com/(root|path,with,commas)$" + "\n" + r"^https://other\.test/page$",
+ "URL_DENYLIST": r"^https://example\.com/path,with,commas$",
+ },
+ )
+
+ assert crawl.get_url_allowlist(use_effective_config=False) == [
+ r"^https://example\.com/(root|path,with,commas)$",
+ r"^https://other\.test/page$",
+ ]
+ assert crawl.get_url_denylist(use_effective_config=False) == [
+ r"^https://example\.com/path,with,commas$",
+ ]
+
+ created = crawl.create_snapshots_from_urls()
+
+ assert [snapshot.url for snapshot in created] == [
+ "https://example.com/root",
+ "https://other.test/page",
+ ]
diff --git a/archivebox/tests/test_core_config.py b/archivebox/tests/test_core_config.py
new file mode 100644
index 00000000..6b6c9438
--- /dev/null
+++ b/archivebox/tests/test_core_config.py
@@ -0,0 +1,5 @@
+from archivebox.config import CONSTANTS
+
+
+def test_sonic_dir_is_allowed_inside_data_dir():
+ assert "sonic" in CONSTANTS.ALLOWED_IN_DATA_DIR
diff --git a/archivebox/tests/test_crawl.py b/archivebox/tests/test_crawl.py
deleted file mode 100644
index 603b0425..00000000
--- a/archivebox/tests/test_crawl.py
+++ /dev/null
@@ -1,175 +0,0 @@
-#!/usr/bin/env python3
-"""Integration tests for archivebox crawl command."""
-
-import os
-import subprocess
-
-import pytest
-
-from archivebox.core.models import Snapshot
-from archivebox.crawls.models import Crawl
-from archivebox.tests.conftest import run_queued_crawls
-from archivebox.tests.test_orm_helpers import use_archivebox_db
-
-pytestmark = pytest.mark.django_db(transaction=True)
-
-
-def test_crawl_creates_crawl_object(tmp_path, process, disable_extractors_dict):
- """Test that crawl command creates a Crawl object."""
- os.chdir(tmp_path)
-
- subprocess.run(
- ["archivebox", "crawl", "--no-wait", "https://example.com"],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
- )
-
- with use_archivebox_db(tmp_path):
- crawl = Crawl.objects.order_by("-created_at").first()
-
- assert crawl is not None, "Crawl object should be created"
-
-
-def test_crawl_depth_sets_max_depth_in_crawl(tmp_path, process, disable_extractors_dict):
- """Test that --depth option sets max_depth in the Crawl object."""
- os.chdir(tmp_path)
-
- subprocess.run(
- ["archivebox", "crawl", "--depth=2", "--no-wait", "https://example.com"],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
- )
-
- with use_archivebox_db(tmp_path):
- crawl = Crawl.objects.order_by("-created_at").first()
-
- assert crawl is not None
- assert crawl.max_depth == 2, "Crawl max_depth should match --depth=2"
-
-
-def test_crawl_creates_snapshot_for_url(tmp_path, process, disable_extractors_dict):
- """Test that crawl creates a Snapshot for the input URL."""
- os.chdir(tmp_path)
-
- subprocess.run(
- ["archivebox", "crawl", "--no-wait", "https://example.com"],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
- )
- run_queued_crawls(tmp_path, disable_extractors_dict)
-
- with use_archivebox_db(tmp_path):
- snapshot = Snapshot.objects.filter(url="https://example.com").first()
-
- assert snapshot is not None, "Snapshot should be created for input URL"
-
-
-def test_crawl_links_snapshot_to_crawl(tmp_path, process, disable_extractors_dict):
- """Test that Snapshot is linked to Crawl via crawl_id."""
- os.chdir(tmp_path)
-
- subprocess.run(
- ["archivebox", "crawl", "--no-wait", "https://example.com"],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
- )
- run_queued_crawls(tmp_path, disable_extractors_dict)
-
- with use_archivebox_db(tmp_path):
- crawl = Crawl.objects.order_by("-created_at").first()
- assert crawl is not None
- snapshot = Snapshot.objects.filter(url="https://example.com").first()
-
- assert snapshot is not None
- assert snapshot.crawl_id == crawl.id, "Snapshot should be linked to Crawl"
-
-
-def test_crawl_multiple_urls_creates_multiple_snapshots(tmp_path, process, disable_extractors_dict):
- """Test that crawling multiple URLs creates multiple snapshots."""
- os.chdir(tmp_path)
-
- subprocess.run(
- [
- "archivebox",
- "crawl",
- "--no-wait",
- "https://example.com",
- "https://iana.org",
- ],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
- )
- run_queued_crawls(tmp_path, disable_extractors_dict)
-
- with use_archivebox_db(tmp_path):
- urls = list(Snapshot.objects.order_by("url").values_list("url", flat=True))
-
- assert "https://example.com" in urls
- assert "https://iana.org" in urls
-
-
-def test_crawl_from_file_creates_snapshot(tmp_path, process, disable_extractors_dict):
- """Test that crawl can create snapshots from a file of URLs."""
- os.chdir(tmp_path)
-
- # Write URLs to a file
- urls_file = tmp_path / "urls.txt"
- urls_file.write_text("https://example.com\n")
-
- subprocess.run(
- ["archivebox", "crawl", "--no-wait", str(urls_file)],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
- )
- run_queued_crawls(tmp_path, disable_extractors_dict)
-
- with use_archivebox_db(tmp_path):
- snapshot = Snapshot.objects.first()
-
- # Should create at least one snapshot (the source file or the URL)
- assert snapshot is not None, "Should create at least one snapshot"
-
-
-def test_crawl_persists_input_urls_on_crawl(tmp_path, process, disable_extractors_dict):
- """Test that crawl input URLs are stored on the Crawl record."""
- os.chdir(tmp_path)
-
- subprocess.run(
- ["archivebox", "crawl", "--no-wait", "https://example.com"],
- capture_output=True,
- text=True,
- env=disable_extractors_dict,
- )
-
- with use_archivebox_db(tmp_path):
- crawl = Crawl.objects.order_by("-created_at").first()
-
- assert crawl is not None, "Crawl should be created for crawl input"
- assert "https://example.com" in crawl.urls, "Crawl should persist input URLs"
-
-
-class TestCrawlCLI:
- """Test the CLI interface for crawl command."""
-
- def test_cli_help(self, tmp_path, process):
- """Test that --help works for crawl command."""
- os.chdir(tmp_path)
-
- result = subprocess.run(
- ["archivebox", "crawl", "--help"],
- capture_output=True,
- text=True,
- )
-
- assert result.returncode == 0
- assert "create" in result.stdout
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/archivebox/tests/test_crawl_pause.py b/archivebox/tests/test_crawl_pause.py
index 29382555..c73cd25a 100644
--- a/archivebox/tests/test_crawl_pause.py
+++ b/archivebox/tests/test_crawl_pause.py
@@ -1,251 +1,2 @@
-import os
-import subprocess
-import sys
-import time
-
-import pytest
-import requests
-
-from .conftest import (
- build_test_env,
- create_admin_and_token,
- get_crawl_runtime_state,
- get_free_port,
- init_archive,
- start_server,
- stop_server,
- wait_for_http,
- wait_for_snapshot_capture,
-)
-
-pytestmark = pytest.mark.django_db(transaction=True)
-
-
-def wait_for_crawl_snapshot_rows(cwd, crawl_id, timeout=45):
- deadline = time.time() + timeout
- latest_state = None
- while time.time() < deadline:
- latest_state = get_crawl_runtime_state(cwd, crawl_id)
- if latest_state["snapshots"]:
- return latest_state
- time.sleep(0.2)
- raise AssertionError(f"timed out waiting for runner to create snapshots for crawl {crawl_id}: {latest_state}")
-
-
-def wait_for_crawl_child_snapshots_paused_or_sealed(cwd, crawl_id, timeout=45):
- deadline = time.time() + timeout
- latest_state = None
- while time.time() < deadline:
- latest_state = get_crawl_runtime_state(cwd, crawl_id)
- snapshots = latest_state["snapshots"]
- if snapshots and all(snapshot["status"] in {"paused", "sealed"} for snapshot in snapshots):
- return latest_state
- time.sleep(0.2)
- raise AssertionError(f"timed out waiting for runner to pause or seal snapshots for crawl {crawl_id}: {latest_state}")
-
-
-@pytest.mark.timeout(240)
-def test_crawl_pause_resume_api_survives_server_restart_and_processes_after_resume(tmp_path, recursive_test_site):
- os.chdir(tmp_path)
- init_archive(tmp_path)
-
- port = get_free_port()
- env = build_test_env(port, PLUGINS="wget", SAVE_WGET="True")
- api_token = create_admin_and_token(tmp_path)
- api_headers = {
- "Host": f"api.archivebox.localhost:{port}",
- "X-ArchiveBox-API-Key": api_token,
- }
-
- try:
- start_server(tmp_path, env=env, port=port)
- wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
-
- crawl_response = requests.post(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawls",
- headers=api_headers,
- json={
- "urls": [recursive_test_site["root_url"]],
- "max_depth": 0,
- "tags": ["pause-resume-e2e"],
- "config": {"PLUGINS": "wget", "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*"},
- },
- timeout=10,
- )
- assert crawl_response.status_code == 200, crawl_response.text
- crawl_id = crawl_response.json()["id"]
- wait_for_crawl_snapshot_rows(tmp_path, crawl_id)
-
- pause_response = requests.patch(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawl/{crawl_id}",
- headers=api_headers,
- json={"action": "pause"},
- timeout=10,
- )
- assert pause_response.status_code == 200, pause_response.text
- assert pause_response.json()["status"] == "paused"
-
- paused_state = wait_for_crawl_child_snapshots_paused_or_sealed(tmp_path, crawl_id)
- assert paused_state["crawl_status"] == "paused"
- assert paused_state["crawl_retry_at"] == paused_state["retry_at_max"]
- assert len(paused_state["snapshots"]) == 1
- snapshot_finished_before_pause = paused_state["snapshots"][0]["status"] == "sealed"
- if snapshot_finished_before_pause:
- assert any(result["status"] == "succeeded" for result in paused_state["results"])
- else:
- assert paused_state["snapshots"][0]["status"] == "paused"
- assert paused_state["snapshots"][0]["retry_at"] == paused_state["retry_at_max"]
-
- stop_server(tmp_path)
- start_server(tmp_path, env=env, port=port)
- wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
-
- restarted_state = get_crawl_runtime_state(tmp_path, crawl_id)
- assert restarted_state["crawl_status"] == "paused"
- assert restarted_state["crawl_retry_at"] == restarted_state["retry_at_max"]
- if snapshot_finished_before_pause:
- assert restarted_state["snapshots"][0]["status"] == "sealed"
- assert any(result["status"] == "succeeded" for result in restarted_state["results"])
- return
- assert restarted_state["snapshots"][0]["status"] == "paused"
- assert restarted_state["snapshots"][0]["retry_at"] == restarted_state["retry_at_max"]
- assert not any(result["status"] == "succeeded" for result in restarted_state["results"])
-
- resume_response = requests.patch(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawl/{crawl_id}",
- headers=api_headers,
- json={"action": "resume"},
- timeout=10,
- )
- assert resume_response.status_code == 200, resume_response.text
- assert resume_response.json()["status"] == "queued"
-
- captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180)
- assert "Root" in captured_text
- assert "About" in captured_text
-
- final_state = get_crawl_runtime_state(tmp_path, crawl_id)
- assert final_state["snapshots"][0]["status"] == "sealed"
- wget_results = [result for result in final_state["results"] if result["plugin"] == "wget"]
- assert wget_results
- assert any(result["status"] == "succeeded" and result["output_size"] > 0 for result in wget_results)
- finally:
- stop_server(tmp_path)
-
-
-@pytest.mark.timeout(180)
-def test_update_index_only_runs_paused_search_rows_and_resume_later_runs_crawl(tmp_path, recursive_test_site):
- os.chdir(tmp_path)
- init_archive(tmp_path)
-
- port = get_free_port()
- env = build_test_env(port, PLUGINS="wget", SAVE_WGET="True")
- api_token = create_admin_and_token(tmp_path)
- api_headers = {
- "Host": f"api.archivebox.localhost:{port}",
- "X-ArchiveBox-API-Key": api_token,
- }
-
- try:
- start_server(tmp_path, env=env, port=port)
- wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
-
- crawl_response = requests.post(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawls",
- headers=api_headers,
- json={
- "urls": [recursive_test_site["root_url"]],
- "max_depth": 0,
- "tags": ["paused-index-e2e"],
- "config": {"PLUGINS": "wget", "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*"},
- },
- timeout=10,
- )
- assert crawl_response.status_code == 200, crawl_response.text
- crawl_id = crawl_response.json()["id"]
- wait_for_crawl_snapshot_rows(tmp_path, crawl_id)
-
- pause_response = requests.patch(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawl/{crawl_id}",
- headers=api_headers,
- json={"action": "pause"},
- timeout=10,
- )
- assert pause_response.status_code == 200, pause_response.text
- assert pause_response.json()["status"] == "paused"
- paused_state = wait_for_crawl_child_snapshots_paused_or_sealed(tmp_path, crawl_id)
- snapshot_finished_before_pause = paused_state["snapshots"][0]["status"] == "sealed"
- finally:
- stop_server(tmp_path)
-
- if snapshot_finished_before_pause:
- indexed_state = get_crawl_runtime_state(tmp_path, crawl_id)
- assert indexed_state["crawl_status"] == "paused"
- assert indexed_state["snapshots"][0]["status"] == "sealed"
- return
-
- update_env = build_test_env(
- port,
- PLUGINS="search_backend_sqlite",
- SEARCH_BACKEND_ENGINE="sqlite",
- )
- update_process = subprocess.run(
- [
- sys.executable,
- "-m",
- "archivebox",
- "update",
- "--index-only",
- "--crawl-id",
- crawl_id,
- "--limit",
- "1",
- "--batch-size",
- "1",
- ],
- cwd=tmp_path,
- capture_output=True,
- text=True,
- env=update_env,
- timeout=120,
- )
- assert update_process.returncode == 0, update_process.stderr
-
- indexed_state = get_crawl_runtime_state(tmp_path, crawl_id)
- assert indexed_state["crawl_status"] == "paused"
- assert indexed_state["crawl_retry_at"] == indexed_state["retry_at_max"]
- assert indexed_state["snapshots"][0]["status"] == "paused"
- assert indexed_state["snapshots"][0]["retry_at"] == indexed_state["retry_at_max"]
- search_results = [result for result in indexed_state["results"] if result["plugin"] == "search_backend_sqlite"]
- assert search_results
- assert all(result["status"] not in {"queued", "started", "paused"} for result in search_results)
-
- try:
- start_server(tmp_path, env=env, port=port)
- wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
-
- still_paused_state = get_crawl_runtime_state(tmp_path, crawl_id)
- assert still_paused_state["crawl_status"] == "paused"
- assert still_paused_state["snapshots"][0]["status"] == "paused"
- assert not any(result["plugin"] == "wget" and result["status"] == "succeeded" for result in still_paused_state["results"])
-
- resume_response = requests.patch(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawl/{crawl_id}",
- headers=api_headers,
- json={"action": "resume"},
- timeout=10,
- )
- assert resume_response.status_code == 200, resume_response.text
- assert resume_response.json()["status"] == "queued"
-
- captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180)
- assert "Root" in captured_text
- assert "About" in captured_text
-
- resumed_state = get_crawl_runtime_state(tmp_path, crawl_id)
- assert resumed_state["snapshots"][0]["status"] == "sealed"
- wget_results = [result for result in resumed_state["results"] if result["plugin"] == "wget"]
- assert wget_results
- assert any(result["status"] == "succeeded" and result["output_size"] > 0 for result in wget_results)
- finally:
- stop_server(tmp_path)
+# test_crawl_pause_resume_api_survives_server_restart_and_processes_after_resume moved to test_api_v1_crawls_crawl_crawl_id.py.
+# test_update_index_only_runs_paused_search_rows_and_resume_later_runs_crawl moved to test_api_v1_crawls_crawl_crawl_id.py.
diff --git a/archivebox/tests/test_crawl_runner.py b/archivebox/tests/test_crawl_runner.py
index fdb27f5a..fea7d285 100644
--- a/archivebox/tests/test_crawl_runner.py
+++ b/archivebox/tests/test_crawl_runner.py
@@ -1,11 +1,9 @@
import asyncio
-import json
import sys
from pathlib import Path
import pytest
from asgiref.sync import sync_to_async
-from django.test import RequestFactory
pytestmark = pytest.mark.django_db
@@ -61,49 +59,6 @@ def test_cancelled_crawl_projection_emits_abort_event_from_runner_bus():
@pytest.mark.django_db(transaction=True)
-def test_enqueue_discovered_snapshots_refreshes_crawl_limits(tmp_path):
- from archivebox.base_models.models import get_or_create_system_user_pk
- from archivebox.crawls.models import Crawl
- from archivebox.core.models import Snapshot
- from archivebox.services.runner import CrawlRunner
-
- crawl = Crawl.objects.create(
- urls="https://example.com",
- max_depth=0,
- config={"CRAWL_MAX_URLS": 5},
- created_by_id=get_or_create_system_user_pk(),
- )
- snapshot = Snapshot.objects.create(
- url="https://example.com",
- crawl=crawl,
- status=Snapshot.StatusChoices.SEALED,
- depth=0,
- )
- parser_dir = Path(snapshot.output_dir) / "parse_html_urls"
- parser_dir.mkdir(parents=True, exist_ok=True)
- (parser_dir / "urls.jsonl").write_text(
- "\n".join(
- [
- json.dumps({"type": "Snapshot", "url": "https://example.com/child-a", "depth": 1}),
- json.dumps({"type": "Snapshot", "url": "https://example.com/child-b", "depth": 1}),
- "",
- ],
- ),
- )
-
- runner = CrawlRunner(crawl)
- Crawl.objects.filter(id=crawl.id).update(max_depth=1)
- payload = runner.load_snapshot_payload(str(snapshot.id))
-
- asyncio.run(runner.enqueue_discovered_snapshots_from_outputs(payload))
-
- child_snapshots = list(crawl.snapshot_set.filter(depth=1).order_by("url").values_list("url", "status"))
- assert child_snapshots == [
- ("https://example.com/child-a", Snapshot.StatusChoices.QUEUED),
- ("https://example.com/child-b", Snapshot.StatusChoices.QUEUED),
- ]
-
-
@pytest.mark.django_db(transaction=True)
def test_snapshot_payload_uses_crawl_chrome_dirs_by_default():
from archivebox.base_models.models import get_or_create_system_user_pk
@@ -723,80 +678,6 @@ def test_crawl_runner_resolves_persona_and_crawl_config_for_each_live_snapshot()
@pytest.mark.django_db(transaction=True)
-def test_run_snapshot_seals_descendant_when_crawl_max_size_is_reached(tmp_path):
- from abx_dl.events import CrawlStartEvent, SnapshotEvent
- from archivebox.base_models.models import get_or_create_system_user_pk
- from archivebox.crawls.models import Crawl
- from archivebox.core.models import Snapshot
- from archivebox.services.runner import CrawlRunner
-
- crawl = Crawl.objects.create(
- urls="https://example.com",
- config={
- "LIB_DIR": str(tmp_path / "lib"),
- "PLUGINS": "__archivebox_test_no_plugins__",
- "CHROME_BINARY": "",
- "CRAWL_MAX_SIZE": 16,
- },
- created_by_id=get_or_create_system_user_pk(),
- )
- root = Snapshot.objects.create(
- url="https://example.com",
- crawl=crawl,
- depth=0,
- status=Snapshot.StatusChoices.SEALED,
- )
- child = Snapshot.objects.create(
- url="https://example.com/child",
- crawl=crawl,
- depth=1,
- parent_snapshot=root,
- status=Snapshot.StatusChoices.QUEUED,
- )
- state_dir = Path(crawl.output_dir) / ".abx-dl"
- state_dir.mkdir(parents=True, exist_ok=True)
- (state_dir / "limits.json").write_text(
- json.dumps(
- {
- "admitted_snapshot_ids": [str(root.id), str(child.id)],
- "counted_event_ids": ["proc-1"],
- "total_size": 32,
- "stop_reason": "crawl_max_size",
- },
- ),
- encoding="utf-8",
- )
-
- runner = CrawlRunner(crawl)
- runner.load_run_state()
-
- async def run_child_from_crawl_start() -> list[SnapshotEvent]:
- async def on_crawl_start(_event: CrawlStartEvent) -> None:
- await runner.run_snapshot(str(child.id))
-
- runner.bus.on(CrawlStartEvent, on_crawl_start)
- crawl_start = runner.bus.emit(
- CrawlStartEvent(
- url=root.url,
- snapshot_id=str(root.id),
- output_dir=str(crawl.output_dir),
- event_timeout=30,
- event_handler_timeout=30,
- ),
- )
- await crawl_start.now()
- await crawl_start.event_results_list()
- await runner.bus.wait_until_idle()
- return await runner.bus.filter(SnapshotEvent, child_of=crawl_start, past=True)
-
- snapshot_events = asyncio.run(run_child_from_crawl_start())
-
- child.refresh_from_db()
- assert child.status == Snapshot.StatusChoices.SEALED
- assert child.retry_at is None
- assert snapshot_events == []
-
-
@pytest.mark.django_db(transaction=True)
def test_run_pending_crawls_processes_queued_crawl_before_missing_binary_backlog(tmp_path):
from archivebox.base_models.models import get_or_create_system_user_pk
@@ -839,71 +720,6 @@ def test_run_pending_crawls_processes_queued_crawl_before_missing_binary_backlog
@pytest.mark.django_db(transaction=True)
-def test_seal_snapshot_cancels_queued_descendants_after_crawl_max_size():
- from archivebox.base_models.models import get_or_create_system_user_pk
- from archivebox.crawls.models import Crawl
- from archivebox.core.models import Snapshot
- from archivebox.services.snapshot_service import SnapshotService
- from abx_dl.events import SnapshotCompletedEvent
- from abx_dl.orchestrator import create_bus
-
- crawl = Crawl.objects.create(
- urls="https://example.com",
- created_by_id=get_or_create_system_user_pk(),
- config={"CRAWL_MAX_SIZE": 16},
- )
- root = Snapshot.objects.create(
- url="https://example.com",
- crawl=crawl,
- status=Snapshot.StatusChoices.STARTED,
- )
- child = Snapshot.objects.create(
- url="https://example.com/child",
- crawl=crawl,
- depth=1,
- parent_snapshot_id=root.id,
- status=Snapshot.StatusChoices.QUEUED,
- )
-
- state_dir = Path(crawl.output_dir) / ".abx-dl"
- state_dir.mkdir(parents=True, exist_ok=True)
- (state_dir / "limits.json").write_text(
- json.dumps(
- {
- "admitted_snapshot_ids": [str(root.id), str(child.id)],
- "counted_event_ids": ["proc-1"],
- "total_size": 32,
- "stop_reason": "crawl_max_size",
- },
- ),
- encoding="utf-8",
- )
-
- bus = create_bus(name=f"test_snapshot_limit_cancel_{str(crawl.id).replace('-', '_')}")
- service = SnapshotService(bus, crawl_id=str(crawl.id), schedule_snapshot=lambda snapshot_id: None)
- try:
-
- async def emit_event() -> None:
- await service.on_SnapshotCompletedEvent(
- SnapshotCompletedEvent(
- url=root.url,
- snapshot_id=str(root.id),
- output_dir=str(root.output_dir),
- ),
- )
-
- asyncio.run(emit_event())
- finally:
- asyncio.run(bus.wait_until_idle())
- asyncio.run(bus.destroy())
-
- root.refresh_from_db()
- child.refresh_from_db()
- assert root.status == Snapshot.StatusChoices.SEALED
- assert child.status == Snapshot.StatusChoices.SEALED
- assert child.retry_at is None
-
-
def test_sealed_crawl_does_not_create_discovered_snapshots():
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
@@ -928,35 +744,7 @@ def test_sealed_crawl_does_not_create_discovered_snapshots():
assert crawl.snapshot_set.count() == 1
-def test_create_crawl_api_queues_crawl_without_spawning_runner():
- from django.contrib.auth import get_user_model
- from archivebox.api.v1_crawls import CrawlCreateSchema, create_crawl
-
- user = get_user_model().objects.create_superuser(
- username="runner-api-admin",
- email="runner-api-admin@example.com",
- password="testpassword",
- )
- request = RequestFactory().post("/api/v1/crawls")
- request.user = user
-
- crawl = create_crawl(
- request,
- CrawlCreateSchema(
- urls=["https://example.com"],
- max_depth=0,
- tags=[],
- tags_str="",
- label="",
- notes="",
- config={},
- ),
- )
-
- assert str(crawl.id)
- assert crawl.status == "queued"
- assert crawl.retry_at is not None
- assert crawl.snapshot_set.count() == 0
+# test_create_crawl_api_queues_crawl_without_spawning_runner moved to test_api_v1_crawls_crawls.py.
def test_wait_for_snapshot_tasks_surfaces_already_failed_task():
diff --git a/archivebox/tests/test_crawl_service.py b/archivebox/tests/test_crawl_service.py
index f44094d0..0783fa8e 100644
--- a/archivebox/tests/test_crawl_service.py
+++ b/archivebox/tests/test_crawl_service.py
@@ -1,28 +1,16 @@
-import os
from pathlib import Path
import pytest
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
-from archivebox.tests.conftest import run_archivebox_cmd_cwd
+from archivebox.tests.conftest import run_archivebox_cmd
from archivebox.tests.test_orm_helpers import use_archivebox_db
-from .conftest import build_test_env, get_free_port, init_archive
+from .conftest import cli_env, get_free_port, init_archive
pytestmark = pytest.mark.django_db(transaction=True)
-def _assert_command_ok(command: str, stdout: str, stderr: str, code: int) -> None:
- assert code == 0, f"{command} failed with code {code}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"
-
-
-def _latest_crawl_id(cwd: Path) -> str:
- with use_archivebox_db(cwd):
- crawl_id = Crawl.objects.order_by("-created_at").values_list("id", flat=True).first()
- assert crawl_id is not None
- return str(crawl_id)
-
-
def _crawl_state(cwd: Path, crawl_id: str) -> dict[str, object]:
with use_archivebox_db(cwd):
crawl = Crawl.objects.select_related("created_by").get(id=crawl_id)
@@ -49,11 +37,10 @@ def _crawl_state(cwd: Path, crawl_id: str) -> dict[str, object]:
@pytest.mark.timeout(240)
def test_crawl_service_run_processes_queued_crawl_and_applies_crawl_config(tmp_path, recursive_test_site):
- os.chdir(tmp_path)
init_archive(tmp_path)
port = get_free_port()
- env = build_test_env(
+ env = cli_env(
port,
PLUGINS="wget,parse_html_urls",
SAVE_WGET="True",
@@ -64,7 +51,7 @@ def test_crawl_service_run_processes_queued_crawl_and_applies_crawl_config(tmp_p
about_url = recursive_test_site["child_urls"][0]
contact_url = recursive_test_site["child_urls"][2]
- add_stdout, add_stderr, add_code = run_archivebox_cmd_cwd(
+ _cmd_result = run_archivebox_cmd(
[
"add",
"--bg",
@@ -81,9 +68,13 @@ def test_crawl_service_run_processes_queued_crawl_and_applies_crawl_config(tmp_p
env=env,
timeout=120,
)
- _assert_command_ok("archivebox add --bg", add_stdout, add_stderr, add_code)
+ add_stdout, add_stderr, add_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert add_code == 0, f"archivebox add --bg failed with code {add_code}\nSTDOUT:\n{add_stdout}\nSTDERR:\n{add_stderr}"
- crawl_id = _latest_crawl_id(tmp_path)
+ with use_archivebox_db(tmp_path):
+ latest_crawl_id = Crawl.objects.order_by("-created_at").values_list("id", flat=True).first()
+ assert latest_crawl_id is not None
+ crawl_id = str(latest_crawl_id)
queued_state = _crawl_state(tmp_path, crawl_id)
assert queued_state["status"] == Crawl.StatusChoices.QUEUED
assert queued_state["retry_at"] is not None
@@ -91,13 +82,14 @@ def test_crawl_service_run_processes_queued_crawl_and_applies_crawl_config(tmp_p
assert queued_state["config"]["URL_DENYLIST"] == "/contact$"
assert queued_state["snapshots"] == []
- run_stdout, run_stderr, run_code = run_archivebox_cmd_cwd(
+ _cmd_result = run_archivebox_cmd(
["run", "--crawl-id", crawl_id],
cwd=tmp_path,
env=env,
timeout=240,
)
- _assert_command_ok("archivebox run --crawl-id", run_stdout, run_stderr, run_code)
+ run_stdout, run_stderr, run_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert run_code == 0, f"archivebox run --crawl-id failed with code {run_code}\nSTDOUT:\n{run_stdout}\nSTDERR:\n{run_stderr}"
state = _crawl_state(tmp_path, crawl_id)
snapshots = state["snapshots"]
diff --git a/archivebox/tests/test_frozen_crawl_config.py b/archivebox/tests/test_frozen_crawl_config.py
index 68e98b15..278967bf 100644
--- a/archivebox/tests/test_frozen_crawl_config.py
+++ b/archivebox/tests/test_frozen_crawl_config.py
@@ -2,7 +2,6 @@ import time
from types import SimpleNamespace
import pytest
-from django.test import RequestFactory
from django.utils import timezone
pytestmark = pytest.mark.django_db(transaction=True)
@@ -266,43 +265,7 @@ def test_crawl_config_projections_stay_under_hot_path_budget():
assert average_seconds < max_average_seconds, f"{name} averaged {average_seconds * 1000:.3f}ms"
-def test_api_create_and_cli_add_store_full_frozen_config(archivebox_db):
- from archivebox.api.v1_crawls import CrawlCreateSchema, CrawlSchema, create_crawl
- from archivebox.cli.archivebox_add import add
- from archivebox.config.common import SENSITIVE_CONFIG_VALUE_REDACTED
-
- user = _user("frozen-config-api-admin")
- request = RequestFactory().post("/api/v1/crawls")
- request.user = user
-
- api_crawl = create_crawl(
- request,
- CrawlCreateSchema(
- urls=["https://example.com/api"],
- max_depth=0,
- tags=[],
- tags_str="",
- label="API frozen config",
- notes="",
- config={"TWOCAPTCHA_API_KEY": SENSITIVE_SECRET, "TIMEOUT": 33, "SECRET_KEY": "must-not-freeze", "PUBLIC_ADD_VIEW": True},
- ),
- )
- assert "CHECK_SSL_VALIDITY" in api_crawl.config
- assert api_crawl.config["TIMEOUT"] == 33
- assert api_crawl.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
- assert "SECRET_KEY" not in api_crawl.config
- assert "PUBLIC_ADD_VIEW" not in api_crawl.config
- assert CrawlSchema.resolve_config(api_crawl)["TWOCAPTCHA_API_KEY"] == SENSITIVE_CONFIG_VALUE_REDACTED
-
- cli_crawl, _snapshots = add(
- "https://example.com/cli",
- bg=True,
- created_by_id=user.pk,
- config={"TWOCAPTCHA_API_KEY": SENSITIVE_SECRET, "TIMEOUT": 44},
- )
- assert "CHECK_SSL_VALIDITY" in cli_crawl.config
- assert cli_crawl.config["TIMEOUT"] == 44
- assert cli_crawl.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
+# test_api_create_and_cli_add_store_full_frozen_config moved to test_api_v1_workflow_frozen_crawl_config_sources.py.
def test_schedule_enqueue_refreezes_using_current_template_persona_defaults(archivebox_db):
diff --git a/archivebox/tests/test_machine_service.py b/archivebox/tests/test_machine_service.py
index d95abca3..cb5d8db9 100644
--- a/archivebox/tests/test_machine_service.py
+++ b/archivebox/tests/test_machine_service.py
@@ -6,7 +6,7 @@ from pathlib import Path
import pytest
from archivebox.machine.models import Binary, Machine, Process
-from archivebox.tests.conftest import run_archivebox_cmd_cwd
+from archivebox.tests.conftest import run_archivebox_cmd
from archivebox.tests.test_orm_helpers import use_archivebox_db
pytestmark = pytest.mark.django_db(transaction=True)
@@ -47,12 +47,13 @@ def test_install_persists_machine_binary_config_and_recovers_stale_path(initiali
_write_tool_shim(provider_bin_dir, "lit", "2.5.9")
_link_real_tool(provider_bin_dir, "node")
- stdout, stderr, returncode = run_archivebox_cmd_cwd(
+ _cmd_result = run_archivebox_cmd(
["install", "--binproviders=env", "liteparse"],
cwd=initialized_archive,
timeout=120,
env=_runtime_env(initialized_archive, bootstrap_bin_dir),
)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, stdout + stderr
assert "liteparse" in stdout
@@ -114,12 +115,13 @@ def test_install_persists_machine_binary_config_and_recovers_stale_path(initiali
print("MACHINE_SERVICE_E2E_DONE")
""",
)
- shell_stdout, shell_stderr, shell_code = run_archivebox_cmd_cwd(
+ _cmd_result = run_archivebox_cmd(
["shell", "-c", machine_event_script],
cwd=initialized_archive,
timeout=60,
env=_runtime_env(initialized_archive, bootstrap_bin_dir),
)
+ shell_stdout, shell_stderr, shell_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert shell_code == 0, shell_stdout + shell_stderr
assert "MACHINE_SERVICE_E2E_DONE" in shell_stdout
@@ -129,24 +131,26 @@ def test_install_persists_machine_binary_config_and_recovers_stale_path(initiali
assert machine.config["LITEPARSE_BINARY"] == str(installed_liteparse_path)
assert machine.config["LITEPARSE_BINARY"] != "/tmp/user-config-must-not-persist"
- version_stdout, version_stderr, version_code = run_archivebox_cmd_cwd(
+ _cmd_result = run_archivebox_cmd(
["version"],
cwd=initialized_archive,
timeout=60,
env=_runtime_env(initialized_archive, bootstrap_bin_dir),
)
+ version_stdout, version_stderr, version_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert version_code == 0, version_stderr
assert "lit" in version_stdout
installed_liteparse_path.unlink()
(initialized_archive / "lib" / "bin" / installed_liteparse_path.name).unlink(missing_ok=True)
- cleanup_stdout, cleanup_stderr, cleanup_code = run_archivebox_cmd_cwd(
+ _cmd_result = run_archivebox_cmd(
["version"],
cwd=initialized_archive,
timeout=60,
env=_runtime_env(initialized_archive, bootstrap_bin_dir),
)
+ cleanup_stdout, cleanup_stderr, cleanup_code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert cleanup_code == 0, cleanup_stdout + cleanup_stderr
with use_archivebox_db(initialized_archive):
diff --git a/archivebox/tests/test_migrations_04_to_09.py b/archivebox/tests/test_migrations_04_to_09.py
index 0a173a74..cada3774 100644
--- a/archivebox/tests/test_migrations_04_to_09.py
+++ b/archivebox/tests/test_migrations_04_to_09.py
@@ -14,7 +14,7 @@ import pytest
from .migrations_helpers import (
SCHEMA_0_4,
create_data_dir_structure,
- run_archivebox,
+ run_archivebox_migration_cmd,
seed_0_4_data,
verify_snapshot_count,
verify_snapshot_urls,
@@ -46,7 +46,7 @@ def test_migration_preserves_snapshot_count(archive_04):
work_dir, db_path, original_data = archive_04
expected_count = len(original_data["snapshots"])
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_snapshot_count(db_path, expected_count)
@@ -58,7 +58,7 @@ def test_migration_preserves_snapshot_urls(archive_04):
work_dir, db_path, original_data = archive_04
expected_urls = [s["url"] for s in original_data["snapshots"]]
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_snapshot_urls(db_path, expected_urls)
@@ -69,7 +69,7 @@ def test_migration_converts_string_tags_to_model(archive_04):
"""Migration should convert comma-separated tags to Tag model instances."""
work_dir, db_path, original_data = archive_04
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
# Collect unique tags from original data
@@ -88,7 +88,7 @@ def test_migration_preserves_snapshot_titles(archive_04):
"""Migration should preserve all snapshot titles."""
work_dir, db_path, original_data = archive_04
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -105,10 +105,10 @@ def test_status_works_after_migration(archive_04):
"""Status command should work after migration."""
work_dir, _db_path, _original_data = archive_04
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["status"])
+ result = run_archivebox_migration_cmd(work_dir, ["status"])
assert result.returncode == 0, f"Status failed after migration: {result.stderr}"
@@ -116,10 +116,10 @@ def test_list_works_after_migration(archive_04):
"""List command should work and show ALL migrated snapshots."""
work_dir, _db_path, original_data = archive_04
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["list"])
+ result = run_archivebox_migration_cmd(work_dir, ["list"])
assert result.returncode == 0, f"List failed after migration: {result.stderr}"
# Verify ALL snapshots appear in output
@@ -133,13 +133,13 @@ def test_add_works_after_migration(archive_04):
"""Adding new URLs should work after migration from 0.4.x."""
work_dir, db_path, _original_data = archive_04
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
# Try to add a new URL after migration
- result = run_archivebox(work_dir, ["add", "--index-only", "https://example.com/new-page"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["add", "--index-only", "https://example.com/new-page"], timeout=45)
assert result.returncode == 0, f"Add failed after migration: {result.stderr}"
- result = run_archivebox(work_dir, ["run"], timeout=90)
+ result = run_archivebox_migration_cmd(work_dir, ["run"], timeout=90)
assert result.returncode == 0, f"Run failed after migration: {result.stderr}"
# Verify add queued the new crawl after migration.
@@ -156,7 +156,7 @@ def test_new_schema_elements_created(archive_04):
"""Migration should create new 0.9.x schema elements."""
work_dir, db_path, _original_data = archive_04
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -175,7 +175,7 @@ def test_snapshots_have_new_fields(archive_04):
"""Migrated snapshots should have new 0.9.x fields."""
work_dir, db_path, _original_data = archive_04
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
diff --git a/archivebox/tests/test_migrations_07_to_09.py b/archivebox/tests/test_migrations_07_to_09.py
index 07dd7b27..f3afa70f 100644
--- a/archivebox/tests/test_migrations_07_to_09.py
+++ b/archivebox/tests/test_migrations_07_to_09.py
@@ -15,7 +15,7 @@ import pytest
from .migrations_helpers import (
SCHEMA_0_7,
create_data_dir_structure,
- run_archivebox,
+ run_archivebox_migration_cmd,
seed_0_7_data,
verify_all_snapshots_in_output,
verify_archiveresult_count,
@@ -51,7 +51,7 @@ def test_migration_preserves_snapshot_count(archive_07):
work_dir, db_path, original_data = archive_07
expected_count = len(original_data["snapshots"])
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_snapshot_count(db_path, expected_count)
@@ -63,7 +63,7 @@ def test_migration_preserves_snapshot_urls(archive_07):
work_dir, db_path, original_data = archive_07
expected_urls = [s["url"] for s in original_data["snapshots"]]
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_snapshot_urls(db_path, expected_urls)
@@ -75,7 +75,7 @@ def test_migration_preserves_snapshot_titles(archive_07):
work_dir, db_path, original_data = archive_07
expected_titles = {s["url"]: s["title"] for s in original_data["snapshots"]}
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_snapshot_titles(db_path, expected_titles)
@@ -87,7 +87,7 @@ def test_migration_preserves_tags(archive_07):
work_dir, db_path, original_data = archive_07
expected_count = len(original_data["tags"])
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_tag_count(db_path, expected_count)
@@ -103,7 +103,7 @@ def test_migration_preserves_archiveresults(archive_07):
key = (result["extractor"], result["status"])
expected_counts[key] = expected_counts.get(key, 0) + 1
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_archiveresult_count(db_path, expected_count)
@@ -128,7 +128,7 @@ def test_migration_preserves_foreign_keys(archive_07):
"""Migration should maintain foreign key relationships."""
work_dir, db_path, _original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_foreign_keys(db_path)
@@ -156,7 +156,7 @@ def test_migration_preserves_legacy_timestamp_meanings(archive_07):
conn.commit()
conn.close()
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -179,10 +179,10 @@ def test_update_saves_migrated_snapshots_without_foreign_key_errors(archive_07):
"""Migrated 0.7.x snapshots should be writable through the current ORM."""
work_dir, _db_path, _original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["update"], timeout=60)
+ result = run_archivebox_migration_cmd(work_dir, ["update"], timeout=60)
output = result.stdout + result.stderr
assert result.returncode == 0, f"Update failed after migration: {result.stderr}"
assert "FOREIGN KEY constraint failed" not in output
@@ -193,10 +193,10 @@ def test_status_works_after_migration(archive_07):
"""Status command should work after migration."""
work_dir, _db_path, _original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["status"])
+ result = run_archivebox_migration_cmd(work_dir, ["status"])
assert result.returncode == 0, f"Status failed after migration: {result.stderr}"
@@ -204,10 +204,10 @@ def test_search_works_after_migration(archive_07):
"""Search command should find ALL migrated snapshots."""
work_dir, _db_path, original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["search"])
+ result = run_archivebox_migration_cmd(work_dir, ["search"])
assert result.returncode == 0, f"Search failed after migration: {result.stderr}"
# Verify ALL snapshots appear in output
@@ -220,10 +220,10 @@ def test_list_works_after_migration(archive_07):
"""List command should work and show ALL migrated data."""
work_dir, _db_path, original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["snapshot", "list"])
+ result = run_archivebox_migration_cmd(work_dir, ["snapshot", "list"])
assert result.returncode == 0, f"List failed after migration: {result.stderr}"
# Verify ALL snapshots appear in output
@@ -236,7 +236,7 @@ def test_new_schema_elements_created_after_migration(archive_07):
"""Migration should create new 0.9.x schema elements (crawls_crawl, etc.)."""
work_dir, db_path, _original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -255,7 +255,7 @@ def test_snapshots_have_new_fields_after_migration(archive_07):
"""Migrated snapshots should have new 0.9.x fields (status, depth, etc.)."""
work_dir, db_path, _original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -276,7 +276,7 @@ def test_add_works_after_migration(archive_07):
"""Adding new URLs should work after migration from 0.7.x."""
work_dir, db_path, _original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
# Verify that init created the crawls_crawl table before proceeding
@@ -288,7 +288,7 @@ def test_add_works_after_migration(archive_07):
assert table_exists, f"Init failed to create crawls_crawl table. Init stderr: {result.stderr[-500:]}"
# Try to add a new URL after migration (use --index-only for speed)
- result = run_archivebox(work_dir, ["add", "--index-only", "https://example.com/new-page"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["add", "--index-only", "https://example.com/new-page"], timeout=45)
assert result.returncode == 0, f"Add failed after migration: {result.stderr}"
# Verify a Crawl was created for the new URL
@@ -305,7 +305,7 @@ def test_archiveresult_status_preserved_after_migration(archive_07):
"""Migration should preserve archive result status values."""
work_dir, db_path, _original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -326,10 +326,10 @@ def test_version_works_after_migration(archive_07):
"""Version command should work after migration."""
work_dir, _db_path, _original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["version"])
+ result = run_archivebox_migration_cmd(work_dir, ["version"])
assert result.returncode == 0, f"Version failed after migration: {result.stderr}"
# Should show version info
@@ -341,10 +341,10 @@ def test_help_works_after_migration(archive_07):
"""Help command should work after migration."""
work_dir, _db_path, _original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["help"])
+ result = run_archivebox_migration_cmd(work_dir, ["help"])
assert result.returncode == 0, f"Help failed after migration: {result.stderr}"
# Should show available commands
@@ -356,7 +356,7 @@ def test_no_duplicate_snapshots_after_migration(archive_07):
"""Migration should not create duplicate snapshots."""
work_dir, db_path, _original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
# Check for duplicate URLs
@@ -376,7 +376,7 @@ def test_no_orphaned_archiveresults_after_migration(archive_07):
"""Migration should not leave orphaned ArchiveResults."""
work_dir, db_path, _original_data = archive_07
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_foreign_keys(db_path)
@@ -388,7 +388,7 @@ def test_timestamps_preserved_after_migration(archive_07):
work_dir, db_path, original_data = archive_07
original_timestamps = {s["url"]: s["timestamp"] for s in original_data["snapshots"]}
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -412,7 +412,7 @@ def test_tag_associations_preserved_after_migration(archive_07):
original_count = cursor.fetchone()[0]
conn.close()
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
# Count tag associations after migration
diff --git a/archivebox/tests/test_migrations_08_to_09.py b/archivebox/tests/test_migrations_08_to_09.py
index cfb0f270..f045be55 100644
--- a/archivebox/tests/test_migrations_08_to_09.py
+++ b/archivebox/tests/test_migrations_08_to_09.py
@@ -21,7 +21,7 @@ from .migrations_helpers import (
SCHEMA_0_8,
seed_0_8_data,
seed_0_7_data,
- run_archivebox,
+ run_archivebox_migration_cmd,
create_data_dir_structure,
verify_snapshot_count,
verify_snapshot_urls,
@@ -56,7 +56,7 @@ def test_migration_preserves_snapshot_count(migration_08_data):
work_dir, db_path, original_data = migration_08_data
expected_count = len(original_data["snapshots"])
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_snapshot_count(db_path, expected_count)
@@ -68,7 +68,7 @@ def test_migration_preserves_snapshot_urls(migration_08_data):
work_dir, db_path, original_data = migration_08_data
expected_urls = [s["url"] for s in original_data["snapshots"]]
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_snapshot_urls(db_path, expected_urls)
@@ -78,7 +78,7 @@ def test_migration_preserves_snapshot_urls(migration_08_data):
def test_migration_preserves_crawls(migration_08_data):
"""Migration should preserve all Crawl records and create default crawl if needed."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
# Count snapshots with NULL crawl_id in original data
@@ -96,7 +96,7 @@ def test_migration_preserves_crawls(migration_08_data):
def test_migration_preserves_snapshot_crawl_links(migration_08_data):
"""Migration should preserve snapshot-to-crawl relationships and assign default crawl to orphans."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -121,7 +121,7 @@ def test_migration_preserves_snapshot_crawl_links(migration_08_data):
def test_migration_preserves_tags(migration_08_data):
"""Migration should preserve all tags."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_tag_count(db_path, len(original_data["tags"]))
@@ -138,7 +138,7 @@ def test_migration_preserves_archiveresults(migration_08_data):
key = (result["extractor"], status)
expected_counts[key] = expected_counts.get(key, 0) + 1
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_archiveresult_count(db_path, expected_count)
@@ -162,7 +162,7 @@ def test_migration_preserves_archiveresults(migration_08_data):
def test_migration_preserves_archiveresult_status(migration_08_data):
"""Migration should preserve archive result status values."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -182,20 +182,20 @@ def test_migration_preserves_archiveresult_status(migration_08_data):
def test_status_works_after_migration(migration_08_data):
"""Status command should work after migration."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["status"])
+ result = run_archivebox_migration_cmd(work_dir, ["status"])
assert result.returncode == 0, f"Status failed after migration: {result.stderr}"
def test_list_works_after_migration(migration_08_data):
"""List command should work and show ALL migrated data."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["snapshot", "list"])
+ result = run_archivebox_migration_cmd(work_dir, ["snapshot", "list"])
assert result.returncode == 0, f"List failed after migration: {result.stderr}"
# Verify ALL snapshots appear in output
@@ -207,10 +207,10 @@ def test_list_works_after_migration(migration_08_data):
def test_search_works_after_migration(migration_08_data):
"""Search command should find ALL migrated snapshots."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["search"])
+ result = run_archivebox_migration_cmd(work_dir, ["search"])
assert result.returncode == 0, f"Search failed after migration: {result.stderr}"
# Verify ALL snapshots appear in output
@@ -224,7 +224,7 @@ def test_migration_preserves_snapshot_titles(migration_08_data):
work_dir, db_path, original_data = migration_08_data
expected_titles = {s["url"]: s["title"] for s in original_data["snapshots"]}
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_snapshot_titles(db_path, expected_titles)
@@ -234,7 +234,7 @@ def test_migration_preserves_snapshot_titles(migration_08_data):
def test_migration_preserves_foreign_keys(migration_08_data):
"""Migration should maintain foreign key relationships."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_foreign_keys(db_path)
@@ -264,7 +264,7 @@ def test_migration_preserves_08_timestamp_meanings(migration_08_data):
conn.commit()
conn.close()
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -296,7 +296,7 @@ def test_hyphenated_crawl_ids_are_normalized_before_snapshot_saves(migration_08_
conn.commit()
conn.close()
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -310,7 +310,7 @@ def test_hyphenated_crawl_ids_are_normalized_before_snapshot_saves(migration_08_
assert hyphenated_crawls == 0
assert hyphenated_snapshot_refs == 0
- result = run_archivebox(work_dir, ["update"], timeout=60)
+ result = run_archivebox_migration_cmd(work_dir, ["update"], timeout=60)
output = result.stdout + result.stderr
assert result.returncode == 0, f"Update failed after migration: {result.stderr}"
assert "FOREIGN KEY constraint failed" not in output
@@ -319,7 +319,7 @@ def test_hyphenated_crawl_ids_are_normalized_before_snapshot_saves(migration_08_
def test_migration_removes_seed_id_column(migration_08_data):
"""Migration should remove seed_id column from archivebox.crawls.crawl."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -334,7 +334,7 @@ def test_migration_removes_seed_id_column(migration_08_data):
def test_migration_removes_seed_table(migration_08_data):
"""Migration should remove crawls_seed table."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -349,7 +349,7 @@ def test_migration_removes_seed_table(migration_08_data):
def test_add_works_after_migration(migration_08_data):
"""Adding new URLs should work after migration from 0.8.x."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
# Check that init actually ran and applied migrations
assert "Applying" in result.stdout + result.stderr, (
f"Init did not apply migrations. stdout: {result.stdout[:500]}, stderr: {result.stderr[:500]}"
@@ -364,7 +364,7 @@ def test_add_works_after_migration(migration_08_data):
conn.close()
# Try to add a new URL after migration (use --index-only for speed)
- result = run_archivebox(work_dir, ["add", "--index-only", "https://example.com/new-page"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["add", "--index-only", "https://example.com/new-page"], timeout=45)
assert result.returncode == 0, f"Add failed after migration: {result.stderr}"
# Verify a new Crawl was created
@@ -380,10 +380,10 @@ def test_add_works_after_migration(migration_08_data):
def test_version_works_after_migration(migration_08_data):
"""Version command should work after migration."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["version"])
+ result = run_archivebox_migration_cmd(work_dir, ["version"])
assert result.returncode == 0, f"Version failed after migration: {result.stderr}"
# Should show version info
@@ -394,7 +394,7 @@ def test_version_works_after_migration(migration_08_data):
def test_migration_creates_process_records(migration_08_data):
"""Migration should create Process records for all ArchiveResults."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
# Verify Process records created
@@ -406,7 +406,7 @@ def test_migration_creates_process_records(migration_08_data):
def test_migration_creates_binary_records(migration_08_data):
"""Migration should create Binary records from cmd_version data."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -426,7 +426,7 @@ def test_migration_creates_binary_records(migration_08_data):
def test_migration_preserves_cmd_data(migration_08_data):
"""Migration should preserve cmd data in Process.cmd field."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -446,7 +446,7 @@ def test_migration_preserves_cmd_data(migration_08_data):
def test_no_duplicate_snapshots_after_migration(migration_08_data):
"""Migration should not create duplicate snapshots."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
# Check for duplicate URLs
@@ -465,7 +465,7 @@ def test_no_duplicate_snapshots_after_migration(migration_08_data):
def test_no_orphaned_archiveresults_after_migration(migration_08_data):
"""Migration should not leave orphaned ArchiveResults."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
ok, msg = verify_foreign_keys(db_path)
@@ -477,7 +477,7 @@ def test_timestamps_preserved_after_migration(migration_08_data):
work_dir, db_path, original_data = migration_08_data
original_timestamps = {s["url"]: s["timestamp"] for s in original_data["snapshots"]}
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -493,7 +493,7 @@ def test_timestamps_preserved_after_migration(migration_08_data):
def test_crawl_data_preserved_after_migration(migration_08_data):
"""Migration should preserve crawl metadata (urls, label, status)."""
work_dir, db_path, original_data = migration_08_data
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -520,7 +520,7 @@ def test_tag_associations_preserved_after_migration(migration_08_data):
original_count = cursor.fetchone()[0]
conn.close()
- result = run_archivebox(work_dir, ["init"], timeout=45)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=45)
assert result.returncode == 0, f"Init failed: {result.stderr}"
# Count tag associations after migration
@@ -548,9 +548,9 @@ def test_update_migrates_db_snapshot_when_legacy_index_missing(tmp_path):
snapshot_dir.mkdir(parents=True, exist_ok=True)
(snapshot_dir / "screenshot.png").write_text("existing-db-snapshot")
- result = run_archivebox(work_dir, ["init"], timeout=60)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=60)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["update"], timeout=120)
+ result = run_archivebox_migration_cmd(work_dir, ["update"], timeout=120)
assert result.returncode == 0, f"Update failed: {result.stderr}"
migrated_files = list((work_dir / "archive" / "users").glob("*/snapshots/*/*/*/screenshot.png"))
@@ -577,9 +577,9 @@ def test_update_recovers_orphan_with_corrupt_index_from_archive_org_url(tmp_path
(snapshot_dir / "archive.org.txt").write_text(f"https://web.archive.org/web/20170531210128/{original_url}\n")
(snapshot_dir / "output.pdf").write_text("orphan-output")
- result = run_archivebox(work_dir, ["init"], timeout=60)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=60)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["update"], timeout=120)
+ result = run_archivebox_migration_cmd(work_dir, ["update"], timeout=120)
assert result.returncode == 0, f"Update failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -620,9 +620,9 @@ def test_update_preserves_legacy_folder_timestamp_over_index_float_variant(tmp_p
)
(snapshot_dir / "output.html").write_text("folder timestamp output")
- result = run_archivebox(work_dir, ["init"], timeout=60)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=60)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["update"], timeout=120)
+ result = run_archivebox_migration_cmd(work_dir, ["update"], timeout=120)
assert result.returncode == 0, f"Update failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -662,9 +662,9 @@ def test_update_preserves_distinct_legacy_dirs_with_integer_and_float_timestamps
)
(snapshot_dir / f"{payload}.txt").write_text(payload)
- result = run_archivebox(work_dir, ["init"], timeout=60)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=60)
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(work_dir, ["update"], timeout=120)
+ result = run_archivebox_migration_cmd(work_dir, ["update"], timeout=120)
assert result.returncode == 0, f"Update failed: {result.stderr}"
conn = sqlite3.connect(str(db_path))
@@ -762,7 +762,7 @@ def test_archiveresult_files_preserved_after_migration(tmp_path):
print(f"[*] Sample files found: {len(sample_paths_before)}")
# Run init to trigger migration
- result = run_archivebox(work_dir, ["init"], timeout=60)
+ result = run_archivebox_migration_cmd(work_dir, ["init"], timeout=60)
assert result.returncode == 0, f"Init (migration) failed: {result.stderr}"
# Count archive directories and files AFTER migration
@@ -794,7 +794,7 @@ def test_archiveresult_files_preserved_after_migration(tmp_path):
# Run update to trigger filesystem reorganization
print("\n[*] Running archivebox update to reorganize filesystem...")
- result = run_archivebox(work_dir, ["update"], timeout=120)
+ result = run_archivebox_migration_cmd(work_dir, ["update"], timeout=120)
assert result.returncode == 0, f"Update failed: {result.stderr}"
# Check new filesystem structure
diff --git a/archivebox/tests/test_migrations_fresh.py b/archivebox/tests/test_migrations_fresh.py
index e1cbd3a9..d3cacdda 100644
--- a/archivebox/tests/test_migrations_fresh.py
+++ b/archivebox/tests/test_migrations_fresh.py
@@ -11,16 +11,16 @@ from django.db.migrations.recorder import MigrationRecorder
from archivebox.core.models import ArchiveResult, Snapshot, Tag
from archivebox.crawls.models import Crawl
from archivebox.tests.test_orm_helpers import use_archivebox_db
-from archivebox.tests.conftest import run_queued_crawls
+from archivebox.tests.conftest import run_queued_crawls, cli_env
-from .migrations_helpers import run_archivebox
+from .migrations_helpers import run_archivebox_migration_cmd
pytestmark = pytest.mark.django_db(transaction=True)
def test_init_creates_database(tmp_path):
"""Fresh init should create database and directories."""
- result = run_archivebox(tmp_path, ["init"])
+ result = run_archivebox_migration_cmd(tmp_path, ["init"])
assert result.returncode == 0, f"Init failed: {result.stderr}"
# Verify database was created
@@ -31,38 +31,40 @@ def test_init_creates_database(tmp_path):
def test_status_after_init(tmp_path):
"""Status command should work after init."""
- result = run_archivebox(tmp_path, ["init"])
+ result = run_archivebox_migration_cmd(tmp_path, ["init"])
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(tmp_path, ["status"])
+ result = run_archivebox_migration_cmd(tmp_path, ["status"])
assert result.returncode == 0, f"Status failed: {result.stderr}"
-def test_add_url_after_init(tmp_path, disable_extractors_dict):
+def test_add_url_after_init(tmp_path):
"""Should be able to add URLs after init with --index-only."""
- result = run_archivebox(tmp_path, ["init"])
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_migration_cmd(tmp_path, ["init"])
assert result.returncode == 0, f"Init failed: {result.stderr}"
# Add a URL with --index-only for speed
- result = run_archivebox(tmp_path, ["add", "--index-only", "https://example.com"])
+ result = run_archivebox_migration_cmd(tmp_path, ["add", "--index-only", "https://example.com"])
assert result.returncode == 0, f"Add command failed: {result.stderr}"
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(tmp_path, env)
with use_archivebox_db(tmp_path):
assert Crawl.objects.count() >= 1, "No Crawl was created"
assert Snapshot.objects.count() >= 1, "No Snapshot was created"
-def test_list_after_add(tmp_path, disable_extractors_dict):
+def test_list_after_add(tmp_path):
"""List command should show added snapshots."""
- result = run_archivebox(tmp_path, ["init"])
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_migration_cmd(tmp_path, ["init"])
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(tmp_path, ["add", "--index-only", "https://example.com"])
+ result = run_archivebox_migration_cmd(tmp_path, ["add", "--index-only", "https://example.com"])
assert result.returncode == 0, f"Add failed: {result.stderr}"
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(tmp_path, env)
- result = run_archivebox(tmp_path, ["list"])
+ result = run_archivebox_migration_cmd(tmp_path, ["list"])
assert result.returncode == 0, f"List failed: {result.stderr}"
# Verify the URL appears in output
@@ -72,7 +74,7 @@ def test_list_after_add(tmp_path, disable_extractors_dict):
def test_migrations_table_populated(tmp_path):
"""Django migrations table should be populated after init."""
- result = run_archivebox(tmp_path, ["init"])
+ result = run_archivebox_migration_cmd(tmp_path, ["init"])
assert result.returncode == 0, f"Init failed: {result.stderr}"
with use_archivebox_db(tmp_path):
@@ -84,7 +86,7 @@ def test_migrations_table_populated(tmp_path):
def test_core_migrations_applied(tmp_path):
"""Core app migrations should be applied."""
- result = run_archivebox(tmp_path, ["init"])
+ result = run_archivebox_migration_cmd(tmp_path, ["init"])
assert result.returncode == 0, f"Init failed: {result.stderr}"
with use_archivebox_db(tmp_path):
@@ -97,7 +99,7 @@ def test_core_migrations_applied(tmp_path):
def test_snapshot_table_has_required_columns(tmp_path):
"""Snapshot table should have all required columns."""
- result = run_archivebox(tmp_path, ["init"])
+ result = run_archivebox_migration_cmd(tmp_path, ["init"])
assert result.returncode == 0, f"Init failed: {result.stderr}"
columns = {field.column for field in Snapshot._meta.local_fields}
@@ -109,7 +111,7 @@ def test_snapshot_table_has_required_columns(tmp_path):
def test_archiveresult_table_has_required_columns(tmp_path):
"""ArchiveResult table should have all required columns."""
- result = run_archivebox(tmp_path, ["init"])
+ result = run_archivebox_migration_cmd(tmp_path, ["init"])
assert result.returncode == 0, f"Init failed: {result.stderr}"
columns = {field.column for field in ArchiveResult._meta.local_fields}
@@ -121,7 +123,7 @@ def test_archiveresult_table_has_required_columns(tmp_path):
def test_tag_table_has_required_columns(tmp_path):
"""Tag table should have all required columns."""
- result = run_archivebox(tmp_path, ["init"])
+ result = run_archivebox_migration_cmd(tmp_path, ["init"])
assert result.returncode == 0, f"Init failed: {result.stderr}"
columns = {field.column for field in Tag._meta.local_fields}
@@ -133,7 +135,7 @@ def test_tag_table_has_required_columns(tmp_path):
def test_crawl_table_has_required_columns(tmp_path):
"""Crawl table should have all required columns."""
- result = run_archivebox(tmp_path, ["init"])
+ result = run_archivebox_migration_cmd(tmp_path, ["init"])
assert result.returncode == 0, f"Init failed: {result.stderr}"
columns = {field.column for field in Crawl._meta.local_fields}
@@ -146,18 +148,19 @@ def test_crawl_table_has_required_columns(tmp_path):
assert "seed_id" not in columns, "seed_id column should not exist in 0.9.x"
-def test_add_urls_separately(tmp_path, disable_extractors_dict):
+def test_add_urls_separately(tmp_path):
"""Should be able to add multiple URLs one at a time."""
- result = run_archivebox(tmp_path, ["init"])
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_migration_cmd(tmp_path, ["init"])
assert result.returncode == 0, f"Init failed: {result.stderr}"
# Add URLs one at a time
- result = run_archivebox(tmp_path, ["add", "--index-only", "https://example.com"])
+ result = run_archivebox_migration_cmd(tmp_path, ["add", "--index-only", "https://example.com"])
assert result.returncode == 0, f"Add 1 failed: {result.stderr}"
- result = run_archivebox(tmp_path, ["add", "--index-only", "https://example.org"])
+ result = run_archivebox_migration_cmd(tmp_path, ["add", "--index-only", "https://example.org"])
assert result.returncode == 0, f"Add 2 failed: {result.stderr}"
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(tmp_path, env)
with use_archivebox_db(tmp_path):
snapshot_count = Snapshot.objects.count()
@@ -166,14 +169,15 @@ def test_add_urls_separately(tmp_path, disable_extractors_dict):
assert crawl_count == 2, f"Expected 2 Crawls, got {crawl_count}"
-def test_snapshots_linked_to_crawls(tmp_path, disable_extractors_dict):
+def test_snapshots_linked_to_crawls(tmp_path):
"""Each snapshot should be linked to a crawl."""
- result = run_archivebox(tmp_path, ["init"])
+ env = cli_env(disable_extractors=True)
+ result = run_archivebox_migration_cmd(tmp_path, ["init"])
assert result.returncode == 0, f"Init failed: {result.stderr}"
- result = run_archivebox(tmp_path, ["add", "--index-only", "https://example.com"])
+ result = run_archivebox_migration_cmd(tmp_path, ["add", "--index-only", "https://example.com"])
assert result.returncode == 0, f"Add failed: {result.stderr}"
- run_queued_crawls(tmp_path, disable_extractors_dict)
+ run_queued_crawls(tmp_path, env)
with use_archivebox_db(tmp_path):
row = Snapshot.objects.filter(url="https://example.com").values_list("crawl_id", flat=True).first()
diff --git a/archivebox/tests/test_recursive_crawl.py b/archivebox/tests/test_recursive_crawl.py
index 657f4248..acd121f7 100644
--- a/archivebox/tests/test_recursive_crawl.py
+++ b/archivebox/tests/test_recursive_crawl.py
@@ -12,6 +12,7 @@ import pytest
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
from archivebox.machine.models import Process
+from archivebox.tests.conftest import run_archivebox_cmd, cli_env
from archivebox.tests.test_orm_helpers import use_archivebox_db
pytestmark = pytest.mark.django_db(transaction=True)
@@ -39,24 +40,26 @@ def stop_process(proc):
def run_add_until(args, env, condition, timeout=120):
- proc = subprocess.Popen(
- args,
+ assert args[0] == "archivebox"
+ proc = run_archivebox_cmd(
+ args[1:],
+ cwd=Path.cwd(),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
- text=True,
env=env,
+ wait=False,
)
assert wait_for_db_condition(timeout=timeout, condition=condition), f"Timed out waiting for condition while running: {' '.join(args)}"
return stop_process(proc)
-def test_background_hooks_dont_block_parser_extractors(tmp_path, process, recursive_test_site):
+def test_background_hooks_dont_block_parser_extractors(tmp_path, initialized_archive, recursive_test_site):
"""Test that background hooks (.bg.) don't block other extractors from running."""
- os.chdir(tmp_path)
- # Verify init succeeded
- assert process.returncode == 0, f"archivebox init failed: {process.stderr}"
+ # Verify the initialized_archive fixture prepared the expected data dir.
+ assert initialized_archive == tmp_path
+ assert (initialized_archive / "index.sqlite3").exists()
# Enable only parser extractors and background hooks for this test
env = os.environ.copy()
@@ -80,12 +83,13 @@ def test_background_hooks_dont_block_parser_extractors(tmp_path, process, recurs
},
)
- proc = subprocess.Popen(
- ["archivebox", "add", "--depth=1", "--plugins=favicon,parse_html_urls", recursive_test_site["root_url"]],
+ proc = run_archivebox_cmd(
+ ["add", "--depth=1", "--plugins=favicon,parse_html_urls", recursive_test_site["root_url"]],
+ cwd=tmp_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
- text=True,
env=env,
+ wait=False,
)
assert wait_for_db_condition(
@@ -133,9 +137,8 @@ def test_background_hooks_dont_block_parser_extractors(tmp_path, process, recurs
)
-def test_parser_extractors_emit_snapshot_jsonl(tmp_path, process, recursive_test_site):
+def test_parser_extractors_emit_snapshot_jsonl(tmp_path, initialized_archive, recursive_test_site):
"""Test that parser extractors emit Snapshot JSONL to stdout."""
- os.chdir(tmp_path)
env = os.environ.copy()
env.update(
@@ -158,10 +161,8 @@ def test_parser_extractors_emit_snapshot_jsonl(tmp_path, process, recursive_test
},
)
- result = subprocess.run(
- ["archivebox", "add", "--depth=0", "--plugins=wget,parse_html_urls", recursive_test_site["root_url"]],
- capture_output=True,
- text=True,
+ result = run_archivebox_cmd(
+ ["add", "--depth=0", "--plugins=wget,parse_html_urls", recursive_test_site["root_url"]],
env=env,
timeout=60,
)
@@ -196,9 +197,8 @@ def test_parser_extractors_emit_snapshot_jsonl(tmp_path, process, recursive_test
assert all(record.get("type") == "Snapshot" for record in records), f"Expected Snapshot JSONL records, got: {records}"
-def test_recursive_crawl_creates_child_snapshots(tmp_path, process, recursive_test_site):
+def test_recursive_crawl_creates_child_snapshots(tmp_path, initialized_archive, recursive_test_site):
"""Test that recursive crawling creates child snapshots with proper depth and parent_snapshot_id."""
- os.chdir(tmp_path)
env = os.environ.copy()
env.update(
@@ -268,11 +268,11 @@ def test_recursive_crawl_creates_child_snapshots(tmp_path, process, recursive_te
assert parent_id == root_id, f"Child snapshot {child_url} should have parent_snapshot_id={root_id}, got {parent_id}"
-def test_recursive_crawl_respects_depth_limit(tmp_path, process, disable_extractors_dict, recursive_test_site):
+def test_recursive_crawl_respects_depth_limit(tmp_path, initialized_archive, recursive_test_site):
"""Test that recursive crawling stops at max_depth."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
- env = disable_extractors_dict.copy()
+ env = env.copy()
env["URL_ALLOWLIST"] = r"127\.0\.0\.1[:/].*"
stdout, stderr = run_add_until(
@@ -304,63 +304,8 @@ def test_recursive_crawl_respects_depth_limit(tmp_path, process, disable_extract
assert max_depth_found <= 1, f"Max depth should not exceed 1, got {max_depth_found}. Depth distribution: {depth_counts}"
-def test_recursive_crawl_respects_max_urls(tmp_path, process, disable_extractors_dict, recursive_test_site):
- """Test that recursive discovery stops creating snapshots at max_urls."""
- os.chdir(tmp_path)
-
- env = disable_extractors_dict.copy()
- env.update(
- {
- "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*",
- "SAVE_WGET": "true",
- "USE_CHROME": "false",
- "USE_COLOR": "false",
- "SHOW_PROGRESS": "false",
- },
- )
-
- result = subprocess.run(
- [
- "archivebox",
- "add",
- "--depth=2",
- "--max-urls=4",
- "--plugins=wget,parse_html_urls",
- recursive_test_site["root_url"],
- ],
- capture_output=True,
- text=True,
- env=env,
- timeout=120,
- )
- stdout, stderr = result.stdout, result.stderr
-
- if stderr:
- print(f"\n=== STDERR ===\n{stderr}\n=== END STDERR ===\n")
- if stdout:
- print(f"\n=== STDOUT (last 2000 chars) ===\n{stdout[-2000:]}\n=== END STDOUT ===\n")
-
- assert result.returncode == 0, result.stderr
-
- with use_archivebox_db(tmp_path):
- crawl_obj = Crawl.objects.order_by("-created_at").first()
- crawl = (crawl_obj.max_depth, crawl_obj.config["CRAWL_MAX_URLS"]) if crawl_obj else None
- snapshot_rows = list(Snapshot.objects.order_by("depth", "url").values_list("url", "depth", "parent_snapshot_id"))
- depth_counts = {
- depth: Snapshot.objects.filter(depth=depth).count() for depth in set(Snapshot.objects.values_list("depth", flat=True))
- }
-
- assert crawl == (2, 4)
- assert len(snapshot_rows) == 4
- assert depth_counts.get(0, 0) == 1
- assert depth_counts.get(1, 0) == 3
- assert depth_counts.get(2, 0) == 0
- assert set(recursive_test_site["child_urls"]).issubset({url for url, depth, _parent in snapshot_rows if depth == 1})
-
-
-def test_recursive_crawl_depth_two_writes_real_outputs_and_process_records(tmp_path, process, recursive_test_site):
+def test_recursive_crawl_depth_two_writes_real_outputs_and_process_records(tmp_path, initialized_archive, recursive_test_site):
"""Run a real depth=2 crawl and verify DB, output files, and process side effects."""
- os.chdir(tmp_path)
env = os.environ.copy()
env.update(
@@ -479,29 +424,27 @@ def test_recursive_crawl_depth_two_writes_real_outputs_and_process_records(tmp_p
assert any("wget" in (pwd or "") or "wget" in (cmd or "") for *_rest, pwd, cmd in process_rows)
-def test_crawl_snapshot_has_parent_snapshot_field(tmp_path, process, disable_extractors_dict):
+def test_crawl_snapshot_has_parent_snapshot_field(tmp_path, initialized_archive):
"""Test that Snapshot model has parent_snapshot field."""
- os.chdir(tmp_path)
column_names = {field.column for field in Snapshot._meta.local_fields}
assert "parent_snapshot_id" in column_names, f"Snapshot table should have parent_snapshot_id column. Columns: {column_names}"
-def test_snapshot_depth_field_exists(tmp_path, process, disable_extractors_dict):
+def test_snapshot_depth_field_exists(tmp_path, initialized_archive):
"""Test that Snapshot model has depth field."""
- os.chdir(tmp_path)
column_names = {field.column for field in Snapshot._meta.local_fields}
assert "depth" in column_names, f"Snapshot table should have depth column. Columns: {column_names}"
-def test_root_snapshot_has_depth_zero(tmp_path, process, disable_extractors_dict, recursive_test_site):
+def test_root_snapshot_has_depth_zero(tmp_path, initialized_archive, recursive_test_site):
"""Test that root snapshots are created with depth=0."""
- os.chdir(tmp_path)
+ env = cli_env(disable_extractors=True)
- env = disable_extractors_dict.copy()
+ env = env.copy()
env["URL_ALLOWLIST"] = r"127\.0\.0\.1[:/].*"
stdout, stderr = run_add_until(
@@ -518,9 +461,8 @@ def test_root_snapshot_has_depth_zero(tmp_path, process, disable_extractors_dict
assert snapshot[1] == 0, f"Root snapshot should have depth=0, got {snapshot[1]}"
-def test_archiveresult_worker_queue_filters_by_foreground_extractors(tmp_path, process, recursive_test_site):
+def test_archiveresult_worker_queue_filters_by_foreground_extractors(tmp_path, initialized_archive, recursive_test_site):
"""Test that background hooks don't block foreground extractors from running."""
- os.chdir(tmp_path)
env = os.environ.copy()
env.update(
diff --git a/archivebox/tests/test_search.py b/archivebox/tests/test_search.py
index d51b62d3..51c8eaf7 100644
--- a/archivebox/tests/test_search.py
+++ b/archivebox/tests/test_search.py
@@ -2,10 +2,10 @@ import os
import signal
import socket
import subprocess
-import sys
import time
from types import SimpleNamespace
from urllib.parse import urlencode
+from archivebox.tests.conftest import run_archivebox_cmd
import pytest
from asgiref.sync import async_to_sync
@@ -455,7 +455,7 @@ class TestSearchBackendsE2E:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
- def archivebox(*args: str, env: dict[str, str] | None = None, timeout: int = 120) -> subprocess.CompletedProcess[str]:
+ def archivebox(*args: str, env: dict[str, str] | None = None, timeout: int = 120):
merged_env = os.environ.copy()
merged_env.update(
{
@@ -468,12 +468,10 @@ class TestSearchBackendsE2E:
)
if env:
merged_env.update(env)
- return subprocess.run(
- [sys.executable, "-m", "archivebox", *args],
+ return run_archivebox_cmd(
+ list(args),
cwd=data_dir,
env=merged_env,
- capture_output=True,
- text=True,
timeout=timeout,
)
@@ -506,14 +504,14 @@ class TestSearchBackendsE2E:
)
server_log = data_dir / "server.log"
with server_log.open("w", encoding="utf-8") as log_file:
- server = subprocess.Popen(
- [sys.executable, "-m", "archivebox", "server", f"127.0.0.1:{http_port}"],
+ server = run_archivebox_cmd(
+ ["server", f"127.0.0.1:{http_port}"],
cwd=data_dir,
env=sonic_env,
stdout=log_file,
stderr=subprocess.STDOUT,
- text=True,
start_new_session=True,
+ wait=False,
)
try:
for _ in range(80):
diff --git a/archivebox/tests/test_server_security_browser.py b/archivebox/tests/test_server_security_browser.py
index 7882dabc..92622740 100644
--- a/archivebox/tests/test_server_security_browser.py
+++ b/archivebox/tests/test_server_security_browser.py
@@ -6,25 +6,23 @@ from __future__ import annotations
import json
import os
import shutil
-import signal
-import socket
import subprocess
-import sys
import textwrap
import time
from pathlib import Path
from urllib.parse import urlencode
import pytest
-import requests
from .conftest import _ensure_puppeteer, _find_cached_chrome, _find_system_browser, run_python_cwd
from .conftest import (
- build_test_env,
- run_archivebox_cmd_cwd,
- start_server as start_daemon_server,
+ cli_env,
+ get_free_port,
+ run_archivebox_cmd,
+ start_archivebox_server as start_daemon_server,
+ stop_archivebox_process,
stop_server as stop_daemon_server,
- wait_for_http as wait_for_daemon_http,
+ wait_for_http,
)
@@ -425,102 +423,6 @@ def _seed_archive(data_dir: Path) -> dict[str, object]:
return json.loads(stdout.strip())
-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 _wait_for_http(
- port: int,
- host: str,
- timeout: float = 30.0,
- process: subprocess.Popen[str] | None = None,
-) -> None:
- deadline = time.time() + timeout
- last_error = "server did not answer"
- while time.time() < deadline:
- if process is not None and process.poll() is not None:
- raise AssertionError(f"Server exited before becoming ready with code {process.returncode}")
- try:
- response = requests.get(
- f"http://127.0.0.1:{port}/",
- headers={"Host": host},
- timeout=2,
- allow_redirects=False,
- )
- if response.status_code < 500:
- return
- last_error = f"HTTP {response.status_code}"
- except requests.RequestException as exc:
- last_error = str(exc)
- time.sleep(0.5)
- raise AssertionError(f"Timed out waiting for {host}: {last_error}")
-
-
-def _start_server(data_dir: Path, *, mode: str, port: int) -> subprocess.Popen[str]:
- env = os.environ.copy()
- env.pop("DATA_DIR", None)
- env.update(
- {
- "PYTHONPATH": str(Path(__file__).resolve().parents[2]),
- "BIND_ADDR": f"127.0.0.1:{port}",
- "BASE_URL": f"http://archivebox.localhost:{port}",
- "ALLOWED_HOSTS": "*",
- "SERVER_SECURITY_MODE": mode,
- "USE_COLOR": "False",
- "SHOW_PROGRESS": "False",
- "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",
- "USE_CHROME": "False",
- },
- )
- process = subprocess.Popen(
- [sys.executable, "-m", "archivebox", "server", "--debug", "--nothreading", f"127.0.0.1:{port}"],
- cwd=data_dir,
- env=env,
- stdout=subprocess.PIPE,
- stderr=subprocess.STDOUT,
- text=True,
- start_new_session=True,
- )
- try:
- _wait_for_http(port, f"archivebox.localhost:{port}", process=process)
- except AssertionError as exc:
- server_log = _stop_server(process)
- raise AssertionError(f"{exc}\n\nSERVER LOG:\n{server_log}") from exc
- return process
-
-
-def _stop_server(process: subprocess.Popen[str]) -> str:
- try:
- if process.poll() is None:
- os.killpg(process.pid, signal.SIGTERM)
- try:
- stdout, _ = process.communicate(timeout=3)
- except subprocess.TimeoutExpired:
- os.killpg(process.pid, signal.SIGKILL)
- stdout, _ = process.communicate(timeout=5)
- else:
- stdout, _ = process.communicate(timeout=5)
- except ProcessLookupError:
- stdout, _ = process.communicate(timeout=5)
- return stdout
-
-
def _build_probe_config(mode: str, port: int, fixture: dict[str, object], runtime: dict[str, Path]) -> dict[str, str]:
snapshots = fixture["snapshots"]
attacker = snapshots["attacker"]
@@ -566,8 +468,51 @@ def _run_browser_probe(
fixture: dict[str, object],
tmp_path: Path,
) -> dict[str, object]:
- port = _get_free_port()
- process = _start_server(data_dir, mode=mode, port=port)
+ port = get_free_port()
+ server_env = os.environ.copy()
+ server_env.pop("DATA_DIR", None)
+ server_env.update(
+ {
+ "PYTHONPATH": str(Path(__file__).resolve().parents[2]),
+ "BIND_ADDR": f"127.0.0.1:{port}",
+ "BASE_URL": f"http://archivebox.localhost:{port}",
+ "ALLOWED_HOSTS": "*",
+ "SERVER_SECURITY_MODE": mode,
+ "USE_COLOR": "False",
+ "SHOW_PROGRESS": "False",
+ "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",
+ "USE_CHROME": "False",
+ },
+ )
+ process = run_archivebox_cmd(
+ ["server", "--debug", "--nothreading", f"127.0.0.1:{port}"],
+ cwd=data_dir,
+ env=server_env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ start_new_session=True,
+ wait=False,
+ )
+ try:
+ wait_for_http(port, f"archivebox.localhost:{port}", process=process)
+ except AssertionError as exc:
+ server_log = stop_archivebox_process(process)
+ raise AssertionError(f"{exc}\n\nSERVER LOG:\n{server_log}") from exc
+
probe_path = tmp_path / "server_security_probe.js"
probe_path.write_text(PUPPETEER_PROBE_SCRIPT, encoding="utf-8")
probe_config = _build_probe_config(mode, port, fixture, runtime)
@@ -589,7 +534,7 @@ def _run_browser_probe(
timeout=120,
)
finally:
- server_log = _stop_server(process)
+ server_log = stop_archivebox_process(process)
assert result.returncode == 0, f"{result.stderr}\n\nSERVER LOG:\n{server_log}"
return json.loads(result.stdout.strip())
@@ -797,8 +742,8 @@ def test_archivewebpage_wacz_preview_serves_real_capture_frame(initialized_archi
from archivebox.core.routes_util import get_snapshot_subdomain
url = "https://example.com"
- port = _get_free_port()
- env = build_test_env(
+ port = get_free_port()
+ env = cli_env(
port,
PLUGINS="archivewebpage",
URL_ALLOWLIST="",
@@ -817,13 +762,14 @@ def test_archivewebpage_wacz_preview_serves_real_capture_frame(initialized_archi
try:
start_daemon_server(initialized_archive, env=env, port=port)
- wait_for_daemon_http(port, host=f"archivebox.localhost:{port}", path="/")
- stdout, stderr, returncode = run_archivebox_cmd_cwd(
+ wait_for_http(port, host=f"archivebox.localhost:{port}", path="/")
+ _cmd_result = run_archivebox_cmd(
["add", "--bg", "--depth=0", "--max-urls=1", "--plugins=archivewebpage", url],
cwd=initialized_archive,
env=env,
timeout=120,
)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, f"archivebox add --bg failed:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"
capture = _wait_for_archivewebpage_capture(initialized_archive, url, timeout=360)
diff --git a/archivebox/tests/test_snapshot.py b/archivebox/tests/test_snapshot.py
deleted file mode 100644
index 668c7ca9..00000000
--- a/archivebox/tests/test_snapshot.py
+++ /dev/null
@@ -1,180 +0,0 @@
-#!/usr/bin/env python3
-"""Integration tests for archivebox snapshot command."""
-
-import os
-import subprocess
-from archivebox.machine.models import Process
-
-import pytest
-
-from archivebox.core.models import Snapshot, Tag
-from archivebox.tests.test_orm_helpers import use_archivebox_db
-
-pytestmark = pytest.mark.django_db(transaction=True)
-
-
-def test_snapshot_creates_snapshot_with_correct_url(tmp_path, process, disable_extractors_dict):
- """Test that snapshot stores the exact URL in the database."""
- os.chdir(tmp_path)
-
- subprocess.run(
- ["archivebox", "snapshot", "create", "https://example.com"],
- capture_output=True,
- env={**disable_extractors_dict, "DATA_DIR": str(tmp_path)},
- )
-
- with use_archivebox_db(tmp_path):
- snapshot = Snapshot.objects.select_related("crawl__created_by").get(url="https://example.com")
- username = snapshot.crawl.created_by.username
-
- # Verify the crawl tree contains a relative symlink to the user-scoped snapshot output.
- snapshots_root = tmp_path / "archive" / "users" / username / "snapshots"
- crawl_root = tmp_path / "archive" / "users" / username / "crawls"
- symlinks = [p for p in crawl_root.rglob("*") if p.is_symlink() and p.resolve().is_dir() and p.resolve().is_relative_to(snapshots_root)]
- assert symlinks, "Snapshot symlink should exist under crawl dir"
- link_path = symlinks[0]
-
- assert link_path.is_symlink(), "Snapshot symlink should exist under crawl dir"
- link_target = os.readlink(link_path)
- assert not os.path.isabs(link_target), "Symlink should be relative"
-
-
-def test_snapshot_multiple_urls_creates_multiple_records(tmp_path, process, disable_extractors_dict):
- """Test that multiple URLs each get their own snapshot record."""
- os.chdir(tmp_path)
-
- subprocess.run(
- [
- "archivebox",
- "snapshot",
- "create",
- "https://example.com",
- "https://iana.org",
- ],
- capture_output=True,
- env={**disable_extractors_dict, "DATA_DIR": str(tmp_path)},
- )
-
- with use_archivebox_db(tmp_path):
- urls = list(Snapshot.objects.order_by("url").values_list("url", flat=True))
-
- assert "https://example.com" in urls
- assert "https://iana.org" in urls
- assert len(urls) >= 2
-
-
-def test_snapshot_tag_creates_tag_and_links_to_snapshot(tmp_path, process, disable_extractors_dict):
- """Test that --tag creates tag record and links it to the snapshot."""
- os.chdir(tmp_path)
-
- subprocess.run(
- [
- "archivebox",
- "snapshot",
- "create",
- "--tag=mytesttag",
- "https://example.com",
- ],
- capture_output=True,
- env={**disable_extractors_dict, "DATA_DIR": str(tmp_path)},
- )
-
- with use_archivebox_db(tmp_path):
- tag = Tag.objects.filter(name="mytesttag").first()
- assert tag is not None, "Tag 'mytesttag' should exist in core_tag"
- snapshot = Snapshot.objects.filter(url="https://example.com").first()
- assert snapshot is not None
- assert snapshot.tags.filter(pk=tag.pk).exists(), "Tag should be linked to snapshot via core_snapshot_tags"
-
-
-def test_snapshot_jsonl_output_has_correct_structure(tmp_path, process, disable_extractors_dict):
- """Test that JSONL output contains required fields with correct types."""
- os.chdir(tmp_path)
-
- # Pass URL as argument instead of stdin for more reliable behavior
- result = subprocess.run(
- ["archivebox", "snapshot", "create", "https://example.com"],
- capture_output=True,
- text=True,
- env={**disable_extractors_dict, "DATA_DIR": str(tmp_path)},
- )
-
- # Parse JSONL output lines
- records = Process.parse_records_from_text(result.stdout)
- snapshot_records = [r for r in records if r.get("type") == "Snapshot"]
-
- assert len(snapshot_records) >= 1, "Should output at least one Snapshot JSONL record"
-
- record = snapshot_records[0]
- assert record.get("type") == "Snapshot"
- assert "id" in record, "Snapshot record should have 'id' field"
- assert "url" in record, "Snapshot record should have 'url' field"
- assert record["url"] == "https://example.com"
-
-
-def test_snapshot_with_tag_stores_tag_name(tmp_path, process, disable_extractors_dict):
- """Test that title is stored when provided via tag option."""
- os.chdir(tmp_path)
-
- # Use command line args instead of stdin
- subprocess.run(
- ["archivebox", "snapshot", "create", "--tag=customtag", "https://example.com"],
- capture_output=True,
- text=True,
- env={**disable_extractors_dict, "DATA_DIR": str(tmp_path)},
- )
-
- with use_archivebox_db(tmp_path):
- tag = Tag.objects.filter(name="customtag").first()
-
- assert tag is not None
- assert tag.name == "customtag"
-
-
-def test_snapshot_with_depth_sets_snapshot_depth(tmp_path, process, disable_extractors_dict):
- """Test that --depth sets snapshot depth when creating snapshots."""
- os.chdir(tmp_path)
-
- subprocess.run(
- [
- "archivebox",
- "snapshot",
- "create",
- "--depth=1",
- "https://example.com",
- ],
- capture_output=True,
- env={**disable_extractors_dict, "DATA_DIR": str(tmp_path)},
- )
-
- with use_archivebox_db(tmp_path):
- snapshot = Snapshot.objects.order_by("-created_at").first()
-
- assert snapshot is not None, "Snapshot should be created when depth is provided"
- assert snapshot.depth == 1, "Snapshot depth should match --depth value"
-
-
-def test_snapshot_allows_duplicate_urls_across_crawls(tmp_path, process, disable_extractors_dict):
- """Snapshot create auto-creates a crawl per run; same URL can appear multiple times."""
- os.chdir(tmp_path)
-
- # Add same URL twice
- subprocess.run(
- ["archivebox", "snapshot", "create", "https://example.com"],
- capture_output=True,
- env={**disable_extractors_dict, "DATA_DIR": str(tmp_path)},
- )
- subprocess.run(
- ["archivebox", "snapshot", "create", "https://example.com"],
- capture_output=True,
- env={**disable_extractors_dict, "DATA_DIR": str(tmp_path)},
- )
-
- with use_archivebox_db(tmp_path):
- count = Snapshot.objects.filter(url="https://example.com").count()
-
- assert count == 2, "Same URL should create separate snapshots across different crawls"
-
-
-if __name__ == "__main__":
- pytest.main([__file__, "-v"])
diff --git a/archivebox/tests/test_snapshot_pause.py b/archivebox/tests/test_snapshot_pause.py
index d574dbfb..2a909373 100644
--- a/archivebox/tests/test_snapshot_pause.py
+++ b/archivebox/tests/test_snapshot_pause.py
@@ -1,219 +1 @@
-import json
-import os
-from pathlib import Path
-
-import pytest
-from django.utils import timezone
-
-from archivebox.core.models import ArchiveResult, Snapshot
-from archivebox.crawls.models import Crawl
-from archivebox.tests.test_orm_helpers import use_archivebox_db
-from archivebox.workers.models import RETRY_AT_MAX
-
-from .conftest import create_admin_and_token, init_archive
-
-pytestmark = pytest.mark.django_db(transaction=True)
-
-API_HOST = "api.archivebox.localhost:8000"
-
-
-def _api_headers(token: str) -> dict[str, str]:
- return {
- "HTTP_HOST": API_HOST,
- "HTTP_X_ARCHIVEBOX_API_KEY": token,
- }
-
-
-def _json_response(response):
- return json.loads(response.content.decode())
-
-
-def _post_json(client, path: str, token: str, payload: dict):
- return client.post(
- path,
- data=json.dumps(payload),
- content_type="application/json",
- **_api_headers(token),
- )
-
-
-def _patch_json(client, path: str, token: str, payload: dict):
- return client.patch(
- path,
- data=json.dumps(payload),
- content_type="application/json",
- **_api_headers(token),
- )
-
-
-def _seed_archiveresult(
- snapshot: Snapshot,
- *,
- plugin: str,
- hook_name: str,
- status: str,
- output_text: str = "",
- output_path: str | None = None,
-) -> ArchiveResult:
- output_files = {}
- output_size = 0
- output_mimetypes = ""
- if output_path is not None:
- output_bytes = output_text.encode()
- absolute_path = Path(snapshot.output_dir) / output_path
- absolute_path.parent.mkdir(parents=True, exist_ok=True)
- absolute_path.write_bytes(output_bytes)
- output_size = len(output_bytes)
- output_mimetypes = "text/plain"
- output_files[output_path] = {
- "extension": Path(output_path).suffix.lstrip("."),
- "mimetype": "text/plain",
- "size": output_size,
- }
-
- now = timezone.now()
- return ArchiveResult.objects.create(
- snapshot=snapshot,
- plugin=plugin,
- hook_name=hook_name,
- status=status,
- output_str=output_path or output_text,
- output_files=output_files,
- output_size=output_size,
- output_mimetypes=output_mimetypes,
- start_ts=now if status != ArchiveResult.StatusChoices.QUEUED else None,
- end_ts=now if status in ArchiveResult.FINAL_STATES else None,
- )
-
-
-def test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_finished_rows(
- tmp_path,
- client,
- recursive_test_site,
-):
- os.chdir(tmp_path)
- init_archive(tmp_path)
- api_token = create_admin_and_token(tmp_path)
-
- with use_archivebox_db(tmp_path):
- create_response = _post_json(
- client,
- "/api/v1/core/snapshots",
- api_token,
- {
- "url": recursive_test_site["root_url"],
- "depth": 0,
- "title": "Snapshot pause target",
- "tags": ["snapshot-pause-e2e"],
- "status": "queued",
- },
- )
- assert create_response.status_code == 200, create_response.content.decode()
- snapshot_id = _json_response(create_response)["id"]
- snapshot = Snapshot.objects.get(id=snapshot_id)
-
- queued_result = _seed_archiveresult(
- snapshot,
- plugin="manualqueue",
- hook_name="on_Snapshot__manual_queue",
- status=ArchiveResult.StatusChoices.QUEUED,
- )
- started_result = _seed_archiveresult(
- snapshot,
- plugin="manualstart",
- hook_name="on_Snapshot__manual_start",
- status=ArchiveResult.StatusChoices.STARTED,
- )
- succeeded_result = _seed_archiveresult(
- snapshot,
- plugin="manualdone",
- hook_name="on_Snapshot__manual_done",
- status=ArchiveResult.StatusChoices.SUCCEEDED,
- output_text="finished result should stay finished",
- output_path="manualdone/final.txt",
- )
- failed_result = _seed_archiveresult(
- snapshot,
- plugin="manualfail",
- hook_name="on_Snapshot__manual_fail",
- status=ArchiveResult.StatusChoices.FAILED,
- output_text="failed result should stay failed",
- )
-
- invalid_response = _patch_json(
- client,
- f"/api/v1/core/snapshot/{snapshot_id}",
- api_token,
- {"action": "hold"},
- )
- assert invalid_response.status_code == 400
- snapshot = Snapshot.objects.get(id=snapshot_id)
- assert snapshot.status == Snapshot.StatusChoices.QUEUED
-
- pause_response = _patch_json(
- client,
- f"/api/v1/core/snapshot/{snapshot_id}",
- api_token,
- {"action": "pause"},
- )
- assert pause_response.status_code == 200, pause_response.content.decode()
- assert _json_response(pause_response)["status"] == Snapshot.StatusChoices.PAUSED
-
- snapshot.refresh_from_db()
- crawl = Crawl.objects.get(id=snapshot.crawl_id)
- assert snapshot.status == Snapshot.StatusChoices.PAUSED
- assert snapshot.retry_at == RETRY_AT_MAX
- assert crawl.status == Crawl.StatusChoices.QUEUED
-
- active_rows = {
- row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[queued_result.id, started_result.id])
- }
- assert active_rows == {
- "manualqueue": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX),
- "manualstart": (ArchiveResult.StatusChoices.PAUSED, RETRY_AT_MAX),
- }
-
- finished_rows = {
- row.plugin: (row.status, row.retry_at, row.output_size)
- for row in ArchiveResult.objects.filter(id__in=[succeeded_result.id, failed_result.id])
- }
- assert finished_rows["manualdone"][0] == ArchiveResult.StatusChoices.SUCCEEDED
- assert finished_rows["manualdone"][1] is None
- assert finished_rows["manualdone"][2] == len("finished result should stay finished")
- assert finished_rows["manualfail"] == (ArchiveResult.StatusChoices.FAILED, None, 0)
-
- succeeded_row = ArchiveResult.objects.get(id=succeeded_result.id)
- output_path = Path(snapshot.output_dir) / next(iter(succeeded_row.output_files))
- assert output_path.read_text() == "finished result should stay finished"
-
- resume_response = _patch_json(
- client,
- f"/api/v1/core/snapshot/{snapshot_id}",
- api_token,
- {"action": "resume"},
- )
- assert resume_response.status_code == 200, resume_response.content.decode()
- assert _json_response(resume_response)["status"] == Snapshot.StatusChoices.QUEUED
-
- snapshot.refresh_from_db()
- crawl.refresh_from_db()
- assert snapshot.status == Snapshot.StatusChoices.QUEUED
- assert snapshot.retry_at is not None
- assert snapshot.retry_at != RETRY_AT_MAX
- assert crawl.status == Crawl.StatusChoices.QUEUED
- assert crawl.retry_at is not None
- assert crawl.retry_at != RETRY_AT_MAX
-
- resumed_rows = {
- row.plugin: (row.status, row.retry_at) for row in ArchiveResult.objects.filter(id__in=[queued_result.id, started_result.id])
- }
- assert resumed_rows["manualqueue"][0] == ArchiveResult.StatusChoices.QUEUED
- assert resumed_rows["manualqueue"][1] is not None
- assert resumed_rows["manualqueue"][1] != RETRY_AT_MAX
- assert resumed_rows["manualstart"][0] == ArchiveResult.StatusChoices.QUEUED
- assert resumed_rows["manualstart"][1] is not None
- assert resumed_rows["manualstart"][1] != RETRY_AT_MAX
-
- assert ArchiveResult.objects.get(id=succeeded_result.id).status == ArchiveResult.StatusChoices.SUCCEEDED
- assert ArchiveResult.objects.get(id=failed_result.id).status == ArchiveResult.StatusChoices.FAILED
- assert output_path.read_text() == "finished result should stay finished"
+# test_snapshot_pause_resume_api_cascades_active_archiveresults_and_preserves_finished_rows moved to test_api_v1_core_snapshot_snapshot_id.py.
diff --git a/archivebox/tests/test_snapshot_service.py b/archivebox/tests/test_snapshot_service.py
index eeb66ac1..055f77a8 100644
--- a/archivebox/tests/test_snapshot_service.py
+++ b/archivebox/tests/test_snapshot_service.py
@@ -1,34 +1,20 @@
import json
-import os
-import time
from pathlib import Path
import pytest
-import requests
from archivebox.core.models import ArchiveResult, Snapshot
-from archivebox.tests.conftest import run_archivebox_cmd_cwd
+from archivebox.tests.conftest import run_archivebox_cmd
from archivebox.tests.test_orm_helpers import use_archivebox_db
-from archivebox.workers.models import RETRY_AT_MAX
from .conftest import (
- build_test_env,
- create_admin_and_token,
- get_crawl_runtime_state,
+ cli_env,
get_free_port,
init_archive,
- start_server,
- stop_server,
- wait_for_http,
- wait_for_snapshot_capture,
)
pytestmark = pytest.mark.django_db(transaction=True)
-def _assert_command_ok(command: str, stdout: str, stderr: str, code: int) -> None:
- assert code == 0, f"{command} failed with code {code}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"
-
-
def _snapshot_state(cwd: Path, url: str) -> dict[str, object]:
with use_archivebox_db(cwd):
snapshot = Snapshot.objects.select_related("crawl", "crawl__created_by").get(url=url)
@@ -54,57 +40,20 @@ def _snapshot_state(cwd: Path, url: str) -> dict[str, object]:
}
-def _paused_snapshot_state(cwd: Path, snapshot_id: str) -> dict[str, object]:
- with use_archivebox_db(cwd):
- snapshot = Snapshot.objects.select_related("crawl").get(id=snapshot_id)
- succeeded_results = ArchiveResult.objects.filter(snapshot=snapshot, status=ArchiveResult.StatusChoices.SUCCEEDED).count()
- return {
- "status": snapshot.status,
- "retry_at": snapshot.retry_at,
- "crawl_status": snapshot.crawl.status,
- "succeeded_results": succeeded_results,
- "snapshot_dir": Path(snapshot.output_dir),
- }
-
-
-def _wait_for_paused_scheduler_marker(cwd: Path, snapshot_id: str, timeout: int = 60) -> dict[str, object]:
- deadline = time.time() + timeout
- last_state: dict[str, object] = {}
- while time.time() < deadline:
- last_state = _paused_snapshot_state(cwd, snapshot_id)
- if last_state["status"] == Snapshot.StatusChoices.PAUSED and last_state["retry_at"] == RETRY_AT_MAX:
- return last_state
- if last_state["status"] == Snapshot.StatusChoices.SEALED:
- return last_state
- time.sleep(1)
- raise AssertionError(f"paused snapshot did not settle back to retry_at=MAX: {last_state}")
-
-
-def _wait_for_crawl_snapshot_rows(cwd: Path, crawl_id: str, timeout: int = 45) -> dict[str, object]:
- deadline = time.time() + timeout
- latest_state: dict[str, object] | None = None
- while time.time() < deadline:
- latest_state = get_crawl_runtime_state(cwd, crawl_id)
- if latest_state["snapshots"]:
- return latest_state
- time.sleep(0.2)
- raise AssertionError(f"timed out waiting for snapshot rows for crawl {crawl_id}: {latest_state}")
-
-
@pytest.mark.timeout(180)
def test_snapshot_service_cli_add_seals_snapshot_and_writes_indexes(tmp_path, recursive_test_site):
- os.chdir(tmp_path)
init_archive(tmp_path)
port = get_free_port()
- env = build_test_env(port, PLUGINS="wget", SAVE_WGET="True")
- stdout, stderr, code = run_archivebox_cmd_cwd(
+ env = cli_env(port=port, server=True, PLUGINS="wget", SAVE_WGET="True")
+ _cmd_result = run_archivebox_cmd(
["add", "--depth=0", "--plugins=wget", recursive_test_site["root_url"]],
cwd=tmp_path,
env=env,
timeout=180,
)
- _assert_command_ok("archivebox add", stdout, stderr, code)
+ stdout, stderr, code = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert code == 0, f"archivebox add failed with code {code}\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}"
state = _snapshot_state(tmp_path, recursive_test_site["root_url"])
snapshot_dir = state["snapshot_dir"]
@@ -133,85 +82,4 @@ def test_snapshot_service_cli_add_seals_snapshot_and_writes_indexes(tmp_path, re
assert any(result["plugin"] == "wget" and result["status"] == ArchiveResult.StatusChoices.SUCCEEDED for result in state["results"])
-@pytest.mark.timeout(240)
-def test_paused_snapshot_survives_server_restart_and_resumes_via_api(tmp_path, recursive_test_site):
- os.chdir(tmp_path)
- init_archive(tmp_path)
-
- port = get_free_port()
- env = build_test_env(port, PLUGINS="wget", SAVE_WGET="True")
- api_token = create_admin_and_token(tmp_path)
- api_headers = {
- "Host": f"api.archivebox.localhost:{port}",
- "X-ArchiveBox-API-Key": api_token,
- }
-
- try:
- start_server(tmp_path, env=env, port=port)
- wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
-
- crawl_response = requests.post(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawls",
- headers=api_headers,
- json={
- "urls": [recursive_test_site["root_url"]],
- "max_depth": 0,
- "tags": ["snapshot-pause-restart-e2e"],
- "config": {"PLUGINS": "wget", "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*"},
- },
- timeout=10,
- )
- assert crawl_response.status_code == 200, crawl_response.text
- crawl_id = crawl_response.json()["id"]
- crawl_state = _wait_for_crawl_snapshot_rows(tmp_path, crawl_id)
- snapshot_id = crawl_state["snapshots"][0]["id"]
-
- pause_response = requests.patch(
- f"http://127.0.0.1:{port}/api/v1/crawls/crawl/{crawl_id}",
- headers=api_headers,
- json={"action": "pause"},
- timeout=10,
- )
- assert pause_response.status_code == 200, pause_response.text
-
- current_state = _paused_snapshot_state(tmp_path, snapshot_id)
- if current_state["status"] == Snapshot.StatusChoices.SEALED:
- assert current_state["succeeded_results"] > 0
- return
-
- paused_state = _wait_for_paused_scheduler_marker(tmp_path, snapshot_id)
- if paused_state["status"] == Snapshot.StatusChoices.SEALED:
- assert paused_state["succeeded_results"] > 0
- return
- assert paused_state["succeeded_results"] == 0
- assert not list((paused_state["snapshot_dir"] / "wget").rglob("*.html"))
-
- stop_server(tmp_path)
- start_server(tmp_path, env=env, port=port)
- wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
-
- restarted_state = _wait_for_paused_scheduler_marker(tmp_path, snapshot_id)
- assert restarted_state["status"] == Snapshot.StatusChoices.PAUSED
- assert restarted_state["succeeded_results"] == 0
-
- resume_response = requests.patch(
- f"http://127.0.0.1:{port}/api/v1/core/snapshot/{snapshot_id}",
- headers=api_headers,
- json={"action": "resume"},
- timeout=10,
- )
- assert resume_response.status_code == 200, resume_response.text
- assert resume_response.json()["status"] == Snapshot.StatusChoices.QUEUED
-
- captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180)
- assert "Root" in captured_text
- assert "About" in captured_text
-
- final_state = _snapshot_state(tmp_path, recursive_test_site["root_url"])
- assert final_state["status"] == Snapshot.StatusChoices.SEALED
- assert final_state["downloaded_at"] is not None
- assert any(
- result["plugin"] == "wget" and result["status"] == ArchiveResult.StatusChoices.SUCCEEDED for result in final_state["results"]
- )
- finally:
- stop_server(tmp_path)
+# test_paused_snapshot_survives_server_restart_and_resumes_via_api moved to test_api_v1_core_snapshot_snapshot_id.py.
diff --git a/archivebox/tests/test_tag_admin.py b/archivebox/tests/test_tag_admin.py
deleted file mode 100644
index 89d362f4..00000000
--- a/archivebox/tests/test_tag_admin.py
+++ /dev/null
@@ -1,203 +0,0 @@
-import json
-from datetime import datetime
-from typing import cast
-
-import pytest
-from django.contrib.auth import get_user_model
-from django.contrib.auth.models import UserManager
-from django.urls import reverse
-from django.utils import timezone
-
-
-pytestmark = pytest.mark.django_db
-
-
-User = get_user_model()
-ADMIN_HOST = "admin.archivebox.localhost:8000"
-
-
-@pytest.fixture
-def admin_user(db):
- return cast(UserManager, User.objects).create_superuser(
- username="tagadmin",
- email="tagadmin@test.com",
- password="testpassword",
- )
-
-
-@pytest.fixture
-def api_token(admin_user):
- from archivebox.api.auth import get_or_create_api_token
-
- token = get_or_create_api_token(admin_user)
- assert token is not None
- return token.token
-
-
-@pytest.fixture
-def crawl(admin_user):
- from archivebox.crawls.models import Crawl
-
- return Crawl.objects.create(
- urls="https://example.com",
- created_by=admin_user,
- )
-
-
-@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]
-
-
-def test_tag_admin_changelist_renders_custom_ui(client, admin_user, tagged_data):
- client.login(username="tagadmin", password="testpassword")
-
- response = client.get(reverse("admin:core_tag_changelist"), HTTP_HOST=ADMIN_HOST)
-
- assert response.status_code == 200
- assert b'id="tag-live-search"' in response.content
- assert b'id="tag-sort-select"' in response.content
- assert b'id="tag-created-by-select"' in response.content
- assert b'id="tag-year-select"' in response.content
- assert b"Alpha Research" in response.content
- assert b'class="tag-card"' in response.content
-
-
-def test_tag_admin_add_view_renders_similar_tag_reference(client, admin_user):
- client.login(username="tagadmin", password="testpassword")
-
- response = client.get(reverse("admin:core_tag_add"), HTTP_HOST=ADMIN_HOST)
-
- assert response.status_code == 200
- assert b"Similar Tags" in response.content
- assert b'data-tag-name-input="1"' in response.content
-
-
-def test_tag_search_api_returns_card_payload(client, api_token, tagged_data):
- tag, snapshots = tagged_data
-
- response = client.get(
- reverse("api-1:search_tags"),
- {"q": "Alpha", "api_key": api_token},
- HTTP_HOST=ADMIN_HOST,
- )
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["sort"] == "created_desc"
- assert payload["created_by"] == ""
- assert payload["year"] == ""
- assert payload["has_snapshots"] == "all"
- assert payload["tags"][0]["id"] == tag.id
- assert payload["tags"][0]["name"] == "Alpha Research"
- assert payload["tags"][0]["num_snapshots"] == 2
- assert payload["tags"][0]["snapshots"] == []
- assert payload["tags"][0]["export_jsonl_url"].endswith(f"/api/v1/core/tag/{tag.id}/snapshots.jsonl")
- assert payload["tags"][0]["filter_url"].endswith(f"/admin/core/snapshot/?tags__id__exact={tag.id}")
- assert {snap.url for snap in snapshots} == {"https://example.com/one", "https://example.com/two"}
-
-
-def test_tag_search_api_respects_sort_and_filters(client, api_token, admin_user, crawl, tagged_data):
- from archivebox.core.models import Snapshot, Tag
-
- other_user = cast(UserManager, User.objects).create_user(
- username="tagother",
- email="tagother@test.com",
- password="unused",
- )
- tag_with_snapshots = tagged_data[0]
- empty_tag = Tag.objects.create(name="Zulu Empty", created_by=other_user)
- alpha_tag = Tag.objects.create(name="Alpha Empty", created_by=other_user)
- Snapshot.objects.create(
- url="https://example.com/three",
- title="Example Three",
- crawl=crawl,
- ).tags.add(alpha_tag)
-
- Tag.objects.filter(pk=empty_tag.pk).update(created_at=timezone.make_aware(datetime(2024, 1, 1, 12, 0, 0)))
- Tag.objects.filter(pk=alpha_tag.pk).update(created_at=timezone.make_aware(datetime(2025, 1, 1, 12, 0, 0)))
- Tag.objects.filter(pk=tag_with_snapshots.pk).update(created_at=timezone.make_aware(datetime(2026, 1, 1, 12, 0, 0)))
-
- response = client.get(
- reverse("api-1:search_tags"),
- {
- "sort": "name_desc",
- "created_by": str(other_user.pk),
- "year": "2024",
- "has_snapshots": "no",
- "api_key": api_token,
- },
- HTTP_HOST=ADMIN_HOST,
- )
-
- assert response.status_code == 200
- payload = response.json()
- assert payload["sort"] == "name_desc"
- assert payload["created_by"] == str(other_user.pk)
- assert payload["year"] == "2024"
- assert payload["has_snapshots"] == "no"
- assert [tag["name"] for tag in payload["tags"]] == ["Zulu Empty"]
-
-
-def test_tag_rename_api_updates_name(client, api_token, tagged_data):
- tag, _ = tagged_data
-
- response = client.post(
- f"{reverse('api-1:rename_tag', args=[tag.id])}?api_key={api_token}",
- data=json.dumps({"name": "Alpha Archive"}),
- content_type="application/json",
- HTTP_HOST=ADMIN_HOST,
- )
-
- assert response.status_code == 200
-
- tag.refresh_from_db()
- assert tag.name == "Alpha Archive"
-
-
-def test_tag_snapshots_export_returns_jsonl(client, api_token, tagged_data):
- tag, _ = tagged_data
-
- response = client.get(
- reverse("api-1:tag_snapshots_export", args=[tag.id]),
- {"api_key": api_token},
- HTTP_HOST=ADMIN_HOST,
- )
-
- assert response.status_code == 200
- assert response["Content-Type"].startswith("application/x-ndjson")
- assert f"tag-{tag.slug}-snapshots.jsonl" in response["Content-Disposition"]
- body = response.content.decode()
- assert '"type": "Snapshot"' in body
- assert '"tags": "Alpha Research"' in body
-
-
-def test_tag_urls_export_returns_plain_text_urls(client, api_token, tagged_data):
- tag, snapshots = tagged_data
-
- response = client.get(
- reverse("api-1:tag_urls_export", args=[tag.id]),
- {"api_key": api_token},
- HTTP_HOST=ADMIN_HOST,
- )
-
- assert response.status_code == 200
- assert response["Content-Type"].startswith("text/plain")
- assert f"tag-{tag.slug}-urls.txt" in response["Content-Disposition"]
- exported_urls = set(filter(None, response.content.decode().splitlines()))
- assert exported_urls == {snapshot.url for snapshot in snapshots}
diff --git a/archivebox/tests/test_takeover_util.py b/archivebox/tests/test_takeover_util.py
index 7b847d26..34bf5c47 100644
--- a/archivebox/tests/test_takeover_util.py
+++ b/archivebox/tests/test_takeover_util.py
@@ -1,13 +1,766 @@
+#!/usr/bin/env python3
+"""Takeover utility tests and live command handoff flows."""
+
import os
import signal
import subprocess
import sys
import time
+from pathlib import Path
import pytest
+from archivebox.core.models import ArchiveResult, Snapshot
+from archivebox.crawls.models import Crawl
+from archivebox.machine.models import Process
+from archivebox.tests.conftest import (
+ assert_no_processes_for_data_dir,
+ get_free_port,
+ kill_processes_for_data_dir,
+ cli_env,
+ pid_is_alive,
+ run_archivebox_cmd,
+ start_archivebox_server,
+ stop_archivebox_process,
+ supervisor_pid_from_log,
+ wait_for_http,
+ wait_for_log,
+ wait_for_log_count,
+ wait_for_log_pattern,
+ wait_for_pid_to_disappear,
+ wait_for_snapshot_capture,
+ wait_for_worker_pid_from_log,
+ worker_pid_from_log,
+)
+from archivebox.tests.test_orm_helpers import use_archivebox_db
-pytestmark = pytest.mark.django_db
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+def _archive_pages_for_sqlite_reindexing(data_dir: Path, env: dict[str, str], root_url: str) -> None:
+ add_env = dict(env)
+ add_env["SEARCH_BACKEND_ENGINE"] = "ripgrep"
+ _cmd_result = run_archivebox_cmd(
+ [
+ "add",
+ "--depth=2",
+ "--max-urls=20",
+ "--crawl-max-size=50mb",
+ "--plugins=wget,parse_html_urls",
+ root_url,
+ ],
+ cwd=data_dir,
+ env=add_env,
+ timeout=240,
+ )
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert returncode == 0, stderr or stdout
+
+ with use_archivebox_db(data_dir):
+ assert Snapshot.objects.filter(status=Snapshot.StatusChoices.SEALED).count() >= 1
+ assert not ArchiveResult.objects.filter(plugin="search_backend_sqlite").exists()
+
+
+@pytest.mark.timeout(360)
+def test_behavior_update_index_only_keeps_server_http_and_search_visible(tmp_path, initialized_archive, recursive_test_site):
+
+ env = cli_env(
+ live=True,
+ SEARCH_BACKEND_ENGINE="sqlite",
+ SEARCH_BACKEND_SONIC_PORT=str(get_free_port()),
+ )
+ root_url = recursive_test_site["root_url"]
+ _archive_pages_for_sqlite_reindexing(tmp_path, env, root_url)
+
+ port = get_free_port()
+ server = None
+ try:
+ server = start_archivebox_server(tmp_path, port=port, log_name="behavior-server-update.log", env=env)
+ assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500
+
+ update = run_archivebox_cmd(
+ ["update", "--index-only", "--batch-size=1"],
+ cwd=tmp_path,
+ env=env,
+ timeout=180,
+ )
+
+ assert update.returncode == 0, update.stderr or update.stdout
+ assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500
+
+ search = run_archivebox_cmd(
+ ["list", "--search=contents", "--csv=url", "Root"],
+ cwd=tmp_path,
+ env=env,
+ timeout=60,
+ )
+
+ assert search.returncode == 0, search.stderr or search.stdout
+ assert root_url in search.stdout
+ finally:
+ if server is not None and server.poll() is None:
+ stop_archivebox_process(server, signal.SIGTERM)
+ kill_processes_for_data_dir(tmp_path)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+
+
+@pytest.mark.timeout(420)
+def test_behavior_update_yields_to_server_then_finishes_visible_indexing(tmp_path, initialized_archive, recursive_test_site):
+
+ env = cli_env(
+ live=True,
+ SEARCH_BACKEND_ENGINE="sqlite",
+ SEARCH_BACKEND_SONIC_PORT=str(get_free_port()),
+ )
+ root_url = recursive_test_site["root_url"]
+ _archive_pages_for_sqlite_reindexing(tmp_path, env, root_url)
+
+ port = get_free_port()
+ update_proc = None
+ server = None
+ try:
+ update_log = tmp_path / "behavior-update-yields.log"
+ update_log_handle = update_log.open("w", encoding="utf-8")
+ update_proc = run_archivebox_cmd(
+ ["update", "--index-only", "--batch-size=1"],
+ cwd=tmp_path,
+ env=env,
+ stdout=update_log_handle,
+ stderr=subprocess.STDOUT,
+ start_new_session=True,
+ wait=False,
+ )
+ update_log_handle.close()
+ wait_for_log(update_log, "[*] Reindexing", timeout=90)
+
+ server = start_archivebox_server(tmp_path, port=port, log_name="behavior-server-takes-update.log", env=env)
+ assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500
+ wait_for_log(update_log, "A newer archivebox process took over the orchestrator, sonic", timeout=90)
+
+ stop_archivebox_process(server, signal.SIGTERM)
+ server = None
+ update_proc.wait(timeout=180)
+ update_text = update_log.read_text(encoding="utf-8", errors="replace")
+ assert update_proc.returncode == 0, update_text
+
+ search = run_archivebox_cmd(
+ ["list", "--search=contents", "--csv=url", "Root"],
+ cwd=tmp_path,
+ env=env,
+ timeout=60,
+ )
+
+ assert search.returncode == 0, search.stderr or search.stdout
+ assert root_url in search.stdout
+ finally:
+ if update_proc is not None and update_proc.poll() is None:
+ stop_archivebox_process(update_proc, signal.SIGTERM)
+ if server is not None and server.poll() is None:
+ stop_archivebox_process(server, signal.SIGTERM)
+ kill_processes_for_data_dir(tmp_path)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+
+
+@pytest.mark.timeout(300)
+def test_behavior_foreground_add_keeps_existing_server_http_visible(tmp_path, initialized_archive, recursive_test_site):
+
+ port = get_free_port()
+ env = cli_env(live=True, server=True, port=port, SEARCH_BACKEND_ENGINE="ripgrep")
+ server = None
+ try:
+ server = start_archivebox_server(tmp_path, port=port, log_name="behavior-server-add.log", env=env)
+ assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500
+
+ add = run_archivebox_cmd(
+ [
+ "add",
+ "--depth=0",
+ "--plugins=wget",
+ recursive_test_site["root_url"],
+ ],
+ cwd=tmp_path,
+ env=env,
+ timeout=180,
+ )
+
+ assert add.returncode == 0, add.stderr or add.stdout
+ assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500
+ captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=120)
+ assert "Root" in captured_text
+ finally:
+ if server is not None and server.poll() is None:
+ stop_archivebox_process(server, signal.SIGTERM)
+ kill_processes_for_data_dir(tmp_path)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+
+
+@pytest.mark.timeout(300)
+def test_behavior_background_add_returns_and_server_archives_visible_url(tmp_path, initialized_archive, recursive_test_site):
+
+ port = get_free_port()
+ env = cli_env(live=True, server=True, port=port, SEARCH_BACKEND_ENGINE="ripgrep")
+ server = None
+ try:
+ server = start_archivebox_server(tmp_path, port=port, log_name="behavior-server-bg-add.log", env=env)
+ assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500
+
+ add = run_archivebox_cmd(
+ [
+ "add",
+ "--bg",
+ "--depth=0",
+ "--plugins=wget",
+ recursive_test_site["root_url"],
+ ],
+ cwd=tmp_path,
+ env=env,
+ timeout=60,
+ )
+
+ assert add.returncode == 0, add.stderr or add.stdout
+ assert "background runner will process" in add.stdout
+ assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500
+ captured_text = wait_for_snapshot_capture(tmp_path, recursive_test_site["root_url"], timeout=180)
+ assert "Root" in captured_text
+ finally:
+ if server is not None and server.poll() is None:
+ stop_archivebox_process(server, signal.SIGTERM)
+ kill_processes_for_data_dir(tmp_path)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+
+
+@pytest.mark.timeout(300)
+def test_behavior_daemonized_server_restarts_cleanly_after_forced_stop(tmp_path, initialized_archive):
+
+ port = get_free_port()
+ env = cli_env(live=True, server=True, port=port)
+ try:
+ first = start_archivebox_server(tmp_path, port=port, env=env, daemonize=True)
+ assert first.returncode == 0, first.stderr or first.stdout
+ assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500
+
+ kill_processes_for_data_dir(tmp_path)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+
+ second = start_archivebox_server(tmp_path, port=port, env=env, daemonize=True)
+ assert second.returncode == 0, second.stderr or second.stdout
+ assert wait_for_http(port, host=f"archivebox.localhost:{port}").status_code < 500
+ finally:
+ kill_processes_for_data_dir(tmp_path)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+
+
+@pytest.mark.timeout(240)
+def test_live_second_server_takes_over_existing_server_process(tmp_path, initialized_archive):
+
+ env = cli_env(live=True)
+ port = get_free_port()
+ first = None
+ second = None
+ try:
+ first = start_archivebox_server(tmp_path, port=port, log_name="server-first.log", env=env)
+ first_log = first.log_path
+ second = start_archivebox_server(tmp_path, port=port, log_name="server-second.log", env=env)
+ second_log = second.log_path
+
+ assert first.poll() is None
+ first_text = first_log.read_text(encoding="utf-8", errors="replace")
+ second_text = second_log.read_text(encoding="utf-8", errors="replace")
+ assert "A newer archivebox process took over the orchestrator, server" in first_text
+ assert "Starting orchestrator, server" in second_text
+
+ _cmd_result = run_archivebox_cmd(
+ ["status"],
+ cwd=tmp_path,
+ env=env,
+ timeout=60,
+ )
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert returncode == 0, stderr or stdout
+
+ first_resumes = first_log.read_text(encoding="utf-8", errors="replace").count("Other newer archivebox process")
+ stop_archivebox_process(second, signal.SIGTERM)
+ second = None
+ wait_for_log_count(first_log, "Other newer archivebox process", first_resumes + 1, timeout=35)
+ assert first.poll() is None
+ finally:
+ if second is not None and second.poll() is None:
+ stop_archivebox_process(second, signal.SIGTERM)
+ if first is not None and first.poll() is None:
+ stop_archivebox_process(first, signal.SIGKILL)
+ kill_processes_for_data_dir(tmp_path)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+
+
+@pytest.mark.timeout(180)
+def test_live_update_index_only_does_not_take_over_server_runtime(tmp_path, initialized_archive):
+
+ env = cli_env(live=True)
+ port = get_free_port()
+ server = None
+ try:
+ server = start_archivebox_server(tmp_path, port=port, log_name="server-update-owner.log", env=env)
+ server_log = server.log_path
+ supervisor_pid_before = supervisor_pid_from_log(server_log)
+ daphne_pid_before = worker_pid_from_log(server_log, "worker_daphne")
+
+ _cmd_result = run_archivebox_cmd(
+ ["update", "--index-only", "--before=0"],
+ cwd=tmp_path,
+ env=env,
+ timeout=90,
+ )
+ update_stdout, update_stderr, update_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+
+ assert update_returncode == 0, update_stderr or update_stdout
+ assert server.poll() is None
+ assert pid_is_alive(supervisor_pid_before)
+ assert pid_is_alive(daphne_pid_before)
+ assert supervisor_pid_from_log(server_log) == supervisor_pid_before
+ assert worker_pid_from_log(server_log, "worker_daphne") == daphne_pid_before
+ assert "A newer archivebox process took over the orchestrator, server" not in server_log.read_text(
+ encoding="utf-8",
+ errors="replace",
+ )
+ finally:
+ if server is not None and server.poll() is None:
+ stop_archivebox_process(server, signal.SIGTERM)
+ kill_processes_for_data_dir(tmp_path)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+
+
+@pytest.mark.timeout(360)
+def test_live_server_keeps_http_runtime_while_update_runs_real_sqlite_indexer(tmp_path, initialized_archive, recursive_test_site):
+
+ env = cli_env(
+ live=True,
+ SEARCH_BACKEND_ENGINE="sqlite",
+ SEARCH_BACKEND_SONIC_PORT=str(get_free_port()),
+ )
+ _archive_pages_for_sqlite_reindexing(tmp_path, env, recursive_test_site["root_url"])
+
+ port = get_free_port()
+ server = None
+ try:
+ server = start_archivebox_server(tmp_path, port=port, log_name="server-real-sqlite-update.log", env=env)
+ server_log = server.log_path
+ supervisor_pid_before = supervisor_pid_from_log(server_log)
+ daphne_pid_before = worker_pid_from_log(server_log, "worker_daphne")
+ runner_pid_before = worker_pid_from_log(server_log, "worker_runner")
+ sonic_pid_before = worker_pid_from_log(server_log, "worker_sonic")
+
+ _cmd_result = run_archivebox_cmd(
+ ["update", "--index-only", "--batch-size=1"],
+ cwd=tmp_path,
+ env=env,
+ timeout=180,
+ )
+ update_stdout, update_stderr, update_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+
+ assert update_returncode == 0, update_stderr or update_stdout
+ assert server.poll() is None
+ assert supervisor_pid_from_log(server_log) == supervisor_pid_before
+ assert pid_is_alive(daphne_pid_before)
+ assert pid_is_alive(sonic_pid_before)
+ assert worker_pid_from_log(server_log, "worker_daphne") == daphne_pid_before
+ assert worker_pid_from_log(server_log, "worker_sonic") == sonic_pid_before
+ assert "A newer archivebox process took over the orchestrator, server" not in server_log.read_text(
+ encoding="utf-8",
+ errors="replace",
+ )
+ assert "Stopping older ArchiveBox runner process" in update_stdout
+
+ deadline = time.time() + 90
+ runner_pid_after = runner_pid_before
+ while time.time() < deadline:
+ with use_archivebox_db(tmp_path):
+ rows = list(
+ Process.objects.filter(
+ process_type=Process.TypeChoices.ORCHESTRATOR,
+ worker_type="worker_runner",
+ status=Process.StatusChoices.RUNNING,
+ ).values("pid"),
+ )
+ for row in rows:
+ pid = int(row["pid"])
+ if pid != runner_pid_before and pid_is_alive(pid):
+ runner_pid_after = pid
+ break
+ if runner_pid_after != runner_pid_before:
+ break
+ time.sleep(0.25)
+ assert runner_pid_after != runner_pid_before
+
+ deadline = time.time() + 180
+ indexed_results: list[str] = []
+ while time.time() < deadline:
+ with use_archivebox_db(tmp_path):
+ indexed_results = list(
+ ArchiveResult.objects.filter(plugin="search_backend_sqlite").values_list("status", flat=True),
+ )
+ if indexed_results and all(status in ArchiveResult.FINAL_STATES for status in indexed_results):
+ break
+ time.sleep(0.25)
+ assert indexed_results
+ assert all(status in ArchiveResult.FINAL_STATES for status in indexed_results)
+
+ stop_archivebox_process(server, signal.SIGTERM)
+ server = None
+ wait_for_pid_to_disappear(daphne_pid_before, timeout=20)
+ wait_for_pid_to_disappear(sonic_pid_before, timeout=20)
+ wait_for_pid_to_disappear(runner_pid_after, timeout=20)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+ finally:
+ if server is not None and server.poll() is None:
+ stop_archivebox_process(server, signal.SIGTERM)
+ kill_processes_for_data_dir(tmp_path)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+
+
+@pytest.mark.timeout(420)
+def test_live_update_yields_to_server_then_reclaims_real_sqlite_indexing(tmp_path, initialized_archive, recursive_test_site):
+
+ env = cli_env(
+ live=True,
+ SEARCH_BACKEND_ENGINE="sqlite",
+ SEARCH_BACKEND_SONIC_PORT=str(get_free_port()),
+ )
+ _archive_pages_for_sqlite_reindexing(tmp_path, env, recursive_test_site["root_url"])
+
+ port = get_free_port()
+ update_proc = None
+ server = None
+ try:
+ update_log = tmp_path / "update-real-sqlite-owner.log"
+ update_log_handle = update_log.open("w", encoding="utf-8")
+ update_proc = run_archivebox_cmd(
+ ["update", "--index-only", "--batch-size=1"],
+ cwd=tmp_path,
+ env=env,
+ stdout=update_log_handle,
+ stderr=subprocess.STDOUT,
+ start_new_session=True,
+ wait=False,
+ )
+ update_log_handle.close()
+ wait_for_log(update_log, "[*] Reindexing", timeout=90)
+ update_supervisor_match = wait_for_log_pattern(update_log, r"Supervisord connected \(pid=(\d+)\)", timeout=90)
+ update_supervisor_pid_before = int(update_supervisor_match.group(1))
+ update_sonic_pid_before = wait_for_worker_pid_from_log(update_log, "worker_sonic", timeout=90)
+ update_runner_pid_before = wait_for_worker_pid_from_log(update_log, f"worker_runner_update_{update_proc.pid}", timeout=90)
+ assert pid_is_alive(update_supervisor_pid_before)
+ assert pid_is_alive(update_sonic_pid_before)
+ assert pid_is_alive(update_runner_pid_before)
+ assert "worker_daphne" not in update_log.read_text(encoding="utf-8", errors="replace")
+
+ server = start_archivebox_server(tmp_path, port=port, log_name="server-takes-real-sqlite-update.log", env=env)
+ server_log = server.log_path
+ wait_for_log(update_log, "A newer archivebox process took over the orchestrator, sonic", timeout=90)
+ assert update_proc.poll() is None
+ assert server.poll() is None
+ server_text = server_log.read_text(encoding="utf-8", errors="replace")
+ assert "Taking over orchestrator, sonic from older existing archivebox process" in server_text
+ assert "Starting orchestrator, server, sonic" in server_text
+ server_daphne_pid = worker_pid_from_log(server_log, "worker_daphne")
+ server_runner_pid = worker_pid_from_log(server_log, "worker_runner")
+ server_sonic_pid = worker_pid_from_log(server_log, "worker_sonic")
+ assert pid_is_alive(server_daphne_pid)
+ assert pid_is_alive(server_runner_pid)
+ assert pid_is_alive(server_sonic_pid)
+ wait_for_pid_to_disappear(update_supervisor_pid_before, timeout=30)
+ wait_for_pid_to_disappear(update_sonic_pid_before, timeout=30)
+ wait_for_pid_to_disappear(update_runner_pid_before, timeout=30)
+
+ stop_archivebox_process(server, signal.SIGTERM)
+ server = None
+ update_proc.wait(timeout=180)
+ update_text = update_log.read_text(encoding="utf-8", errors="replace")
+ assert update_proc.returncode == 0, update_text
+ wait_for_pid_to_disappear(server_daphne_pid, timeout=20)
+ wait_for_pid_to_disappear(server_runner_pid, timeout=20)
+ wait_for_pid_to_disappear(server_sonic_pid, timeout=20)
+
+ deadline = time.time() + 30
+ indexed_results: list[str] = []
+ while time.time() < deadline:
+ with use_archivebox_db(tmp_path):
+ indexed_results = list(
+ ArchiveResult.objects.filter(plugin="search_backend_sqlite").values_list("status", flat=True),
+ )
+ if indexed_results and all(status in ArchiveResult.FINAL_STATES for status in indexed_results):
+ break
+ time.sleep(0.25)
+ assert indexed_results
+ assert all(status in ArchiveResult.FINAL_STATES for status in indexed_results)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+ finally:
+ if update_proc is not None and update_proc.poll() is None:
+ stop_archivebox_process(update_proc, signal.SIGTERM)
+ if server is not None and server.poll() is None:
+ stop_archivebox_process(server, signal.SIGTERM)
+ kill_processes_for_data_dir(tmp_path)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+
+
+@pytest.mark.timeout(420)
+def test_live_repeated_server_startups_take_over_cleanly(tmp_path, initialized_archive):
+
+ env = cli_env(live=True)
+ port = get_free_port()
+ servers: list[subprocess.Popen[str]] = []
+ server_pids: list[int] = []
+ daphne_pids: list[int] = []
+ runner_pids: list[int] = []
+ try:
+ for index in range(5):
+ server = start_archivebox_server(tmp_path, port=port, log_name=f"server-chaos-{index}.log", env=env)
+ log_path = server.log_path
+ servers.append(server)
+ server_pids.append(server.pid)
+ daphne_pids.append(worker_pid_from_log(log_path, "worker_daphne"))
+ runner_pids.append(worker_pid_from_log(log_path, "worker_runner"))
+
+ if index > 0:
+ previous_server = servers[index - 1]
+ previous_log = (tmp_path / f"server-chaos-{index - 1}.log").read_text(encoding="utf-8", errors="replace")
+ current_log = log_path.read_text(encoding="utf-8", errors="replace")
+ assert previous_server.poll() is None
+ assert pid_is_alive(server_pids[index - 1])
+ assert "A newer archivebox process took over the orchestrator, server" in previous_log
+ assert "Starting orchestrator, server" in current_log
+ wait_for_pid_to_disappear(daphne_pids[index - 1], timeout=15)
+ wait_for_pid_to_disappear(runner_pids[index - 1], timeout=15)
+
+ _cmd_result = run_archivebox_cmd(
+ ["status"],
+ cwd=tmp_path,
+ env=env,
+ timeout=60,
+ )
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert returncode == 0, stderr or stdout
+ time.sleep(5)
+
+ assert servers[-1].poll() is None
+ assert all(server.poll() is None for server in servers)
+ listener = subprocess.run(
+ ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN"],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ assert listener.returncode == 0, listener.stderr or listener.stdout
+ assert listener.stdout.count(f":{port} (LISTEN)") == 1
+
+ previous_log_path = tmp_path / "server-chaos-3.log"
+ previous_takeovers = previous_log_path.read_text(encoding="utf-8", errors="replace").count(
+ "Other newer archivebox process",
+ )
+ stop_archivebox_process(servers[-1], signal.SIGTERM)
+ wait_for_log_count(previous_log_path, "Other newer archivebox process", previous_takeovers + 1, timeout=35)
+ assert servers[3].poll() is None
+ finally:
+ for server in reversed(servers):
+ if server.poll() is None:
+ stop_archivebox_process(server, signal.SIGTERM)
+ kill_processes_for_data_dir(tmp_path)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+
+
+@pytest.mark.timeout(420)
+def test_live_add_update_jobs_survive_server_and_cli_owner_exits(tmp_path, initialized_archive):
+ plugins_root = tmp_path / "runtime_plugins"
+ marker_dir = tmp_path / "slow-plugin-markers"
+ plugin_dir = plugins_root / "slow_exit"
+ plugin_dir.mkdir(parents=True, exist_ok=True)
+ counter_hook = plugin_dir / "on_Snapshot__08_counter.sh"
+ counter_hook.write_text(
+ "\n".join(
+ [
+ "#!/usr/bin/env bash",
+ "set -euo pipefail",
+ f"marker_dir={str(marker_dir)!r}",
+ 'mkdir -p "$marker_dir/counter-seen"',
+ 'snapshot_key="${SNAPSHOT_ID:-$(basename "${SNAP_DIR:-unknown}")}"',
+ 'seen_file="$marker_dir/counter-seen/$snapshot_key"',
+ 'if [[ -e "$seen_file" ]]; then',
+ ' echo "$snapshot_key" >> "$marker_dir/counter-duplicates.txt"',
+ " exit 42",
+ "fi",
+ 'touch "$seen_file"',
+ 'echo "$snapshot_key" >> "$marker_dir/counter-runs.txt"',
+ "",
+ ],
+ ),
+ encoding="utf-8",
+ )
+ counter_hook.chmod(0o755)
+ hook = plugin_dir / "on_Snapshot__09_slow_exit.sh"
+ hook.write_text(
+ "\n".join(
+ [
+ "#!/usr/bin/env bash",
+ "set -euo pipefail",
+ f"marker_dir={str(marker_dir)!r}",
+ 'mkdir -p "$marker_dir"',
+ 'echo $$ >> "$marker_dir/hook-pids.txt"',
+ 'touch "$marker_dir/hook-started"',
+ "trap 'touch \"$marker_dir/hook-stopped\"; exit 143' TERM INT HUP",
+ 'while [[ ! -f "$marker_dir/allow-finish" ]]; do sleep 0.1; done',
+ 'touch "$marker_dir/hook-finished"',
+ "",
+ ],
+ ),
+ encoding="utf-8",
+ )
+ hook.chmod(0o755)
+
+ env = cli_env(live=True, plugins_root=plugins_root)
+ port = get_free_port()
+ server = None
+ server2 = None
+ server3 = None
+ add_proc = None
+ add_proc2 = None
+ try:
+ server = start_archivebox_server(tmp_path, port=port, log_name="server-add-owner-1.log", env=env)
+ server_log = server.log_path
+ supervisor_pid_before = supervisor_pid_from_log(server_log)
+
+ _cmd_result = run_archivebox_cmd(
+ ["update", "--index-only", "--batch-size=10"],
+ cwd=tmp_path,
+ env=env,
+ timeout=90,
+ )
+ update_stdout, update_stderr, update_returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
+ assert update_returncode == 0, update_stderr or update_stdout
+ assert server.poll() is None
+ assert pid_is_alive(supervisor_pid_before)
+ assert supervisor_pid_from_log(server_log) == supervisor_pid_before
+
+ add_log = tmp_path / "archivebox-add-1.log"
+ add_log_handle = add_log.open("w", encoding="utf-8")
+ add_proc = run_archivebox_cmd(
+ [
+ "add",
+ "--depth=1",
+ "--max-urls=2",
+ "--crawl-max-size=50mb",
+ "--plugins=wget,parse_html_urls,slow_exit",
+ "https://example.com",
+ "https://blog.sweeting.me",
+ ],
+ cwd=tmp_path,
+ env=env,
+ stdout=add_log_handle,
+ stderr=subprocess.STDOUT,
+ start_new_session=True,
+ wait=False,
+ )
+ add_log_handle.close()
+
+ pid_file = marker_dir / "hook-pids.txt"
+ deadline = time.time() + 45
+ hook_pids: list[int] = []
+ while time.time() < deadline:
+ if pid_file.exists():
+ hook_pids = [int(line.strip()) for line in pid_file.read_text().splitlines() if line.strip()]
+ if len(hook_pids) >= 1:
+ break
+ time.sleep(0.25)
+ assert len(hook_pids) >= 1
+
+ deadline = time.time() + 30
+ snapshot_started = False
+ while time.time() < deadline:
+ with use_archivebox_db(tmp_path):
+ snapshot_started = Snapshot.objects.filter(status=Snapshot.StatusChoices.STARTED).exists()
+ if snapshot_started:
+ break
+ time.sleep(0.25)
+ assert snapshot_started
+
+ os.kill(server.pid, signal.SIGTERM)
+ server.wait(timeout=20)
+ assert add_proc.poll() is None, "foreground add should keep owning its crawl after the server exits"
+ assert "Got SIGTERM" in server_log.read_text(encoding="utf-8", errors="replace")
+
+ server2 = start_archivebox_server(tmp_path, port=port, log_name="server-add-owner-2.log", env=env)
+ _server2_log = server2.log_path
+ (marker_dir / "allow-finish").touch()
+ stop_archivebox_process(add_proc, signal.SIGTERM, timeout=30)
+ add_output = add_log.read_text(encoding="utf-8", errors="replace")
+ assert "Runner error" not in add_output
+
+ deadline = time.time() + 90
+ crawls = []
+ snapshots = []
+ bad_results = []
+ while time.time() < deadline:
+ with use_archivebox_db(tmp_path):
+ crawls = list(Crawl.objects.order_by("created_at").values_list("status", "retry_at"))
+ snapshots = list(Snapshot.objects.order_by("created_at").values_list("url", "status", "retry_at"))
+ bad_results = list(
+ ArchiveResult.objects.filter(
+ status__in=[
+ ArchiveResult.StatusChoices.FAILED,
+ ArchiveResult.StatusChoices.SKIPPED,
+ ],
+ ).values_list("plugin", "status", "output_str"),
+ )
+ if (
+ crawls
+ and snapshots
+ and all(status == Crawl.StatusChoices.SEALED for status, _retry_at in crawls)
+ and all(status == Snapshot.StatusChoices.SEALED for _url, status, _retry_at in snapshots)
+ and not bad_results
+ ):
+ break
+ time.sleep(0.25)
+
+ os.kill(server2.pid, signal.SIGTERM)
+ server2.wait(timeout=20)
+ with use_archivebox_db(tmp_path):
+ crawls = list(Crawl.objects.order_by("created_at").values_list("status", "retry_at"))
+ snapshots = list(Snapshot.objects.order_by("created_at").values_list("url", "status", "retry_at"))
+ bad_results = list(
+ ArchiveResult.objects.filter(
+ status__in=[
+ ArchiveResult.StatusChoices.FAILED,
+ ArchiveResult.StatusChoices.SKIPPED,
+ ],
+ ).values_list("plugin", "status", "output_str"),
+ )
+ assert crawls
+ assert snapshots
+ assert all(status == Crawl.StatusChoices.SEALED for status, _retry_at in crawls)
+ assert all(status == Snapshot.StatusChoices.SEALED for _url, status, _retry_at in snapshots)
+ counter_runs = (marker_dir / "counter-runs.txt").read_text(encoding="utf-8").splitlines()
+ assert counter_runs
+ assert len(counter_runs) == len(set(counter_runs))
+ assert not (marker_dir / "counter-duplicates.txt").exists()
+
+ # TODO: improve abx-dl's ability to explicitly resume from a given plugin / hook and skip ones before that
+ # current behavior: on retry, earlier sealed results are left untouched; the interrupted result is marked skipped
+ # and may have partial output saved to fs
+ # assertions that enforce the current behavior (uncommented): previous results are not run twice + interrupted
+ # result is marked skipped
+ assert bad_results == [("slow_exit", ArchiveResult.StatusChoices.SKIPPED, "")]
+
+ # desired future behavior: earlier sealed results are left untouched, interrupted result is retried and cleanly
+ # overwrites on top of any previous partial output
+ # assert not bad_results
+ # assert (marker_dir / "hook-finished").exists()
+ finally:
+ for proc in (add_proc, add_proc2, server, server2, server3):
+ if proc is not None and proc.poll() is None:
+ stop_archivebox_process(proc, signal.SIGTERM, timeout=10)
+ kill_processes_for_data_dir(tmp_path)
+ assert_no_processes_for_data_dir(tmp_path, timeout=12)
+
+
+# Utility-level takeover selection tests.
def test_runtime_stack_owner_prefers_newer_server_over_older_update(tmp_path):
@@ -53,6 +806,141 @@ def test_runtime_stack_owner_prefers_newer_server_over_older_update(tmp_path):
proc.wait(timeout=5)
+def test_runtime_stack_owner_keeps_server_over_newer_update(tmp_path):
+ from archivebox.machine.models import Machine, Process
+ from archivebox.core.takeover_util import runtime_stack_owner
+
+ procs: list[subprocess.Popen[str]] = []
+ try:
+ for process_type in (Process.TypeChoices.SERVER, Process.TypeChoices.UPDATE):
+ proc = subprocess.Popen(
+ [sys.executable, "-c", "import time; time.sleep(60)"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ start_new_session=True,
+ )
+ procs.append(proc)
+ Process.objects.create(
+ machine=Machine.current(),
+ process_type=process_type,
+ worker_type=process_type,
+ pwd=str(tmp_path),
+ cmd=[],
+ pid=proc.pid,
+ status=Process.StatusChoices.RUNNING,
+ )
+ time.sleep(0.05)
+
+ owner = runtime_stack_owner(data_dir=tmp_path)
+
+ assert owner is not None
+ assert owner.process_type == Process.TypeChoices.SERVER
+ assert owner.pid == procs[0].pid
+ finally:
+ for proc in procs:
+ if proc.poll() is None:
+ os.killpg(proc.pid, signal.SIGTERM)
+ for proc in procs:
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ os.killpg(proc.pid, signal.SIGKILL)
+ proc.wait(timeout=5)
+
+
+def test_foreground_runner_owner_prefers_newer_update_over_server(tmp_path):
+ from archivebox.machine.models import Machine, Process
+ from archivebox.core.takeover_util import foreground_runner_owner, runtime_stack_owner
+
+ procs: list[subprocess.Popen[str]] = []
+ try:
+ for process_type in (Process.TypeChoices.SERVER, Process.TypeChoices.UPDATE):
+ proc = subprocess.Popen(
+ [sys.executable, "-c", "import time; time.sleep(60)"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ start_new_session=True,
+ )
+ procs.append(proc)
+ Process.objects.create(
+ machine=Machine.current(),
+ process_type=process_type,
+ worker_type=process_type,
+ pwd=str(tmp_path),
+ cmd=[],
+ pid=proc.pid,
+ status=Process.StatusChoices.RUNNING,
+ )
+ time.sleep(0.05)
+
+ runtime_owner = runtime_stack_owner(data_dir=tmp_path)
+ runner_owner = foreground_runner_owner(data_dir=tmp_path)
+
+ assert runtime_owner is not None
+ assert runtime_owner.process_type == Process.TypeChoices.SERVER
+ assert runner_owner is not None
+ assert runner_owner.process_type == Process.TypeChoices.UPDATE
+ assert runner_owner.pid == procs[-1].pid
+ finally:
+ for proc in procs:
+ if proc.poll() is None:
+ os.killpg(proc.pid, signal.SIGTERM)
+ for proc in procs:
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ os.killpg(proc.pid, signal.SIGKILL)
+ proc.wait(timeout=5)
+
+
+def test_foreground_runner_owner_prefers_newer_server_over_update(tmp_path):
+ from archivebox.machine.models import Machine, Process
+ from archivebox.core.takeover_util import foreground_runner_owner, runtime_stack_owner
+
+ procs: list[subprocess.Popen[str]] = []
+ try:
+ for process_type in (Process.TypeChoices.UPDATE, Process.TypeChoices.SERVER):
+ proc = subprocess.Popen(
+ [sys.executable, "-c", "import time; time.sleep(60)"],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ text=True,
+ start_new_session=True,
+ )
+ procs.append(proc)
+ Process.objects.create(
+ machine=Machine.current(),
+ process_type=process_type,
+ worker_type=process_type,
+ pwd=str(tmp_path),
+ cmd=[],
+ pid=proc.pid,
+ status=Process.StatusChoices.RUNNING,
+ )
+ time.sleep(0.05)
+
+ runtime_owner = runtime_stack_owner(data_dir=tmp_path)
+ runner_owner = foreground_runner_owner(data_dir=tmp_path)
+
+ assert runtime_owner is not None
+ assert runtime_owner.process_type == Process.TypeChoices.SERVER
+ assert runner_owner is not None
+ assert runner_owner.process_type == Process.TypeChoices.SERVER
+ assert runner_owner.pid == procs[-1].pid
+ finally:
+ for proc in procs:
+ if proc.poll() is None:
+ os.killpg(proc.pid, signal.SIGTERM)
+ for proc in procs:
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ os.killpg(proc.pid, signal.SIGKILL)
+ proc.wait(timeout=5)
+
+
def test_runtime_stack_owner_keeps_server_over_newer_supervised_runner(tmp_path):
from archivebox.machine.models import Machine, Process
from archivebox.core.takeover_util import RUNNER_ACTIVE_WORKER_TYPE, runtime_stack_owner
@@ -99,7 +987,7 @@ def test_runtime_stack_owner_keeps_server_over_newer_supervised_runner(tmp_path)
proc.wait(timeout=5)
-def test_runtime_stack_owner_reaps_dead_newer_parent_and_promotes_next_live_command(tmp_path):
+def test_runtime_stack_owner_reaps_dead_server_without_promoting_update(tmp_path):
from archivebox.machine.models import Machine, Process
from archivebox.core.takeover_util import runtime_stack_owner
@@ -139,11 +1027,11 @@ def test_runtime_stack_owner_reaps_dead_newer_parent_and_promotes_next_live_comm
owner = runtime_stack_owner(data_dir=tmp_path)
- assert owner is not None
- assert owner.id == older_row.id
- assert owner.pid == procs[0].pid
+ assert owner is None
newer_row.refresh_from_db()
assert newer_row.status == Process.StatusChoices.EXITED
+ older_row.refresh_from_db()
+ assert older_row.status == Process.StatusChoices.RUNNING
finally:
for proc in procs:
if proc.poll() is None:
diff --git a/archivebox/tests/test_test_harness.py b/archivebox/tests/test_test_harness.py
index d7bdae64..f4030c48 100644
--- a/archivebox/tests/test_test_harness.py
+++ b/archivebox/tests/test_test_harness.py
@@ -33,10 +33,7 @@ def test_in_process_archivebox_config_uses_temp_data_dir():
def test_cli_helpers_reject_repo_root_runtime_paths():
with pytest.raises(AssertionError, match="repo root"):
- test_harness.run_archivebox_cmd(["version"], data_dir=test_harness.REPO_ROOT)
-
- with pytest.raises(AssertionError, match="repo root"):
- test_harness.run_archivebox_cmd_cwd(["version"], cwd=test_harness.REPO_ROOT)
+ test_harness.run_archivebox_cmd(["version"], cwd=test_harness.REPO_ROOT)
with pytest.raises(AssertionError, match="repo root"):
test_harness.run_python_cwd("print('hello')", cwd=test_harness.REPO_ROOT)
diff --git a/archivebox/tests/test_ui_add_view_runtime.py b/archivebox/tests/test_ui_add_view_runtime.py
index 1bec66a8..b1cde087 100644
--- a/archivebox/tests/test_ui_add_view_runtime.py
+++ b/archivebox/tests/test_ui_add_view_runtime.py
@@ -1,4 +1,3 @@
-import os
import re
import time
@@ -9,13 +8,13 @@ from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl, CrawlSchedule
from archivebox.tests.test_orm_helpers import use_archivebox_db
from .conftest import (
- build_test_env,
+ cli_env,
create_admin_and_token,
get_depth_counts,
get_free_port,
init_archive,
- run_archivebox_cmd_cwd,
- start_server,
+ run_archivebox_cmd,
+ start_archivebox_server,
stop_server,
wait_for_http,
)
@@ -25,11 +24,10 @@ pytestmark = pytest.mark.django_db(transaction=True)
@pytest.mark.timeout(180)
def test_add_view_restarts_stopped_supervisord_runner(tmp_path, recursive_test_site):
- os.chdir(tmp_path)
init_archive(tmp_path)
port = get_free_port()
- env = build_test_env(
+ env = cli_env(
port,
PLUGINS="wget",
PUBLIC_ADD_VIEW="True",
@@ -38,7 +36,7 @@ def test_add_view_restarts_stopped_supervisord_runner(tmp_path, recursive_test_s
create_admin_and_token(tmp_path)
try:
- start_server(tmp_path, env=env, port=port)
+ start_archivebox_server(tmp_path, env=env, port=port)
_wait_for_worker_state(tmp_path, "worker_runner", "RUNNING")
_stop_worker(tmp_path, "worker_runner")
assert _worker_state(tmp_path, "worker_runner") != "RUNNING"
@@ -119,7 +117,8 @@ supervisor = get_existing_supervisord_process()
worker = get_worker(supervisor, {worker_name!r}) if supervisor else None
print(json.dumps(worker))
"""
- stdout, stderr, returncode = run_archivebox_cmd_cwd(["manage", "shell", "-c", script], cwd=cwd, timeout=60)
+ _cmd_result = run_archivebox_cmd(["manage", "shell", "-c", script], cwd=cwd, timeout=60)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, stderr or stdout
import json
@@ -135,7 +134,8 @@ assert supervisor is not None
stop_worker(supervisor, {worker_name!r})
print("stopped")
"""
- stdout, stderr, returncode = run_archivebox_cmd_cwd(["manage", "shell", "-c", script], cwd=cwd, timeout=60)
+ _cmd_result = run_archivebox_cmd(["manage", "shell", "-c", script], cwd=cwd, timeout=60)
+ stdout, stderr, returncode = _cmd_result.stdout, _cmd_result.stderr, _cmd_result.returncode
assert returncode == 0, stderr or stdout
@@ -152,15 +152,14 @@ def _wait_for_worker_state(cwd, worker_name: str, statename: str, timeout: int =
@pytest.mark.timeout(180)
def test_add_view_post_creates_schedule_over_server(tmp_path, recursive_test_site):
- os.chdir(tmp_path)
init_archive(tmp_path)
port = get_free_port()
- env = build_test_env(port, PUBLIC_ADD_VIEW="True")
+ env = cli_env(port=port, server=True, PUBLIC_ADD_VIEW="True")
create_admin_and_token(tmp_path)
try:
- start_server(tmp_path, env=env, port=port)
+ start_archivebox_server(tmp_path, env=env, port=port)
session = requests.Session()
wait_for_http(port, host=f"admin.archivebox.localhost:{port}", path="/admin/login/")
login_page = session.get(
@@ -219,11 +218,10 @@ def test_add_view_post_creates_schedule_over_server(tmp_path, recursive_test_sit
@pytest.mark.timeout(240)
def test_add_view_depth_two_crawl_renders_outputs_over_server(tmp_path, recursive_test_site):
- os.chdir(tmp_path)
init_archive(tmp_path)
port = get_free_port()
- env = build_test_env(
+ env = cli_env(
port,
PLUGINS="wget,parse_html_urls",
PUBLIC_INDEX="True",
@@ -232,7 +230,7 @@ def test_add_view_depth_two_crawl_renders_outputs_over_server(tmp_path, recursiv
create_admin_and_token(tmp_path)
try:
- start_server(tmp_path, env=env, port=port)
+ start_archivebox_server(tmp_path, env=env, port=port)
session = requests.Session()
wait_for_http(port, host=f"admin.archivebox.localhost:{port}", path="/admin/login/")
login_page = session.get(
diff --git a/archivebox/tests/test_ui_admin_apitoken.py b/archivebox/tests/test_ui_admin_apitoken.py
new file mode 100644
index 00000000..8d38e5a6
--- /dev/null
+++ b/archivebox/tests/test_ui_admin_apitoken.py
@@ -0,0 +1,17 @@
+"""API token admin UI tests."""
+
+import pytest
+from django.urls import reverse
+
+from archivebox.tests.conftest import ADMIN_TEST_HOST
+
+pytestmark = pytest.mark.django_db
+
+
+class TestAPITokenAdmin:
+ def test_api_token_admin_list_view_renders(self, client, admin_user):
+ client.force_login(admin_user)
+ response = client.get(reverse("admin:api_apitoken_changelist"), HTTP_HOST=ADMIN_TEST_HOST)
+
+ assert response.status_code == 200
+ assert b"API Keys" in response.content
diff --git a/archivebox/tests/test_ui_admin_archiveresult.py b/archivebox/tests/test_ui_admin_archiveresult.py
new file mode 100644
index 00000000..f878f70e
--- /dev/null
+++ b/archivebox/tests/test_ui_admin_archiveresult.py
@@ -0,0 +1,35 @@
+"""ArchiveResult admin UI tests."""
+
+import pytest
+from django.urls import reverse
+
+from archivebox.tests.conftest import ADMIN_TEST_HOST
+
+pytestmark = pytest.mark.django_db
+
+
+class TestArchiveResultAdminListView:
+ def test_list_view_renders_readonly_tags_and_noresults_status(self, client, admin_user, snapshot):
+ from archivebox.core.models import ArchiveResult, Tag
+
+ tag = Tag.objects.create(name="Alpha Research")
+ snapshot.tags.add(tag)
+ ArchiveResult.objects.create(
+ snapshot=snapshot,
+ plugin="title",
+ status=ArchiveResult.StatusChoices.NORESULTS,
+ output_str="No title found",
+ )
+
+ client.force_login(admin_user)
+ response = client.get(reverse("admin:core_archiveresult_changelist"), HTTP_HOST=ADMIN_TEST_HOST)
+
+ assert response.status_code == 200
+ assert b"Alpha Research" in response.content
+ assert b"tag-editor-inline readonly" in response.content
+ assert b"No Results" in response.content
+
+ def test_archiveresult_model_has_retry_at_field(self):
+ from archivebox.core.models import ArchiveResult
+
+ assert "retry_at" in {field.name for field in ArchiveResult._meta.fields}
diff --git a/archivebox/tests/test_crawl_admin.py b/archivebox/tests/test_ui_admin_crawl.py
similarity index 60%
rename from archivebox/tests/test_crawl_admin.py
rename to archivebox/tests/test_ui_admin_crawl.py
index 9e0bb05f..0b357cf1 100644
--- a/archivebox/tests/test_crawl_admin.py
+++ b/archivebox/tests/test_ui_admin_crawl.py
@@ -1,47 +1,88 @@
+"""Crawl model and admin UI tests."""
+
import re
-from typing import cast
import pytest
-from django.contrib.auth import get_user_model
-from django.contrib.auth.models import UserManager
from django.urls import reverse
from archivebox.crawls.admin import CrawlAdminForm
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
+from archivebox.tests.conftest import ADMIN_TEST_HOST
pytestmark = pytest.mark.django_db
-User = get_user_model()
-ADMIN_HOST = "admin.archivebox.localhost:8000"
+class TestCrawlScheduleAdmin:
+ def test_crawlschedule_change_view_renders_and_saves(self, client, admin_user, crawl):
+ from archivebox.crawls.models import CrawlSchedule
+
+ schedule = CrawlSchedule.objects.create(
+ label="Nightly crawl",
+ notes="",
+ schedule="0 0 * * *",
+ template=crawl,
+ created_by=admin_user,
+ )
+ client.force_login(admin_user)
+
+ change_url = reverse("admin:crawls_crawlschedule_change", args=[schedule.pk])
+ get_response = client.get(change_url, HTTP_HOST=ADMIN_TEST_HOST)
+
+ assert get_response.status_code == 200
+ assert b"Schedule Info" in get_response.content
+ assert b"No Crawls yet..." not in get_response.content
+ assert b"No Snapshots yet..." not in get_response.content
+
+ post_response = client.post(
+ change_url,
+ {
+ "label": "Morning crawl",
+ "notes": "updated",
+ "schedule": "0 8 * * *",
+ "template": str(crawl.pk),
+ "created_by": str(admin_user.pk),
+ "_save": "Save",
+ },
+ HTTP_HOST=ADMIN_TEST_HOST,
+ )
+
+ assert post_response.status_code == 302
+ schedule.refresh_from_db()
+ assert schedule.label == "Morning crawl"
+ assert schedule.notes == "updated"
+ assert schedule.schedule == "0 8 * * *"
+ assert schedule.template_id == crawl.pk
+ assert schedule.created_by_id == admin_user.pk
+
+ def test_crawlschedule_changelist_renders_snapshot_counts(self, client, admin_user, crawl, snapshot):
+ from archivebox.crawls.models import CrawlSchedule
+
+ schedule = CrawlSchedule.objects.create(
+ label="Daily crawl",
+ notes="",
+ schedule="0 0 * * *",
+ template=crawl,
+ created_by=admin_user,
+ )
+ crawl.schedule = schedule
+ crawl.save(update_fields=["schedule"])
+ snapshot.crawl = crawl
+ snapshot.save(update_fields=["crawl"])
+
+ client.force_login(admin_user)
+ url = reverse("admin:crawls_crawlschedule_changelist")
+ response = client.get(url, HTTP_HOST=ADMIN_TEST_HOST)
+
+ assert response.status_code == 200
+ assert b"Daily crawl" in response.content
-@pytest.fixture
-def admin_user(db):
- return cast(UserManager, User.objects).create_superuser(
- username="crawladmin",
- email="crawladmin@test.com",
- password="testpassword",
- )
-
-
-@pytest.fixture
-def crawl(admin_user):
- return Crawl.objects.create(
- urls="https://example.com\nhttps://example.org",
- tags_str="alpha,beta",
- created_by=admin_user,
- )
-
-
-def test_crawl_admin_change_view_renders_tag_editor_widget(client, admin_user, crawl):
- client.login(username="crawladmin", password="testpassword")
-
- response = client.get(
+def test_crawl_admin_change_view_renders_tag_editor_widget(admin_client, crawl):
+ response = admin_client.get(
reverse("admin:crawls_crawl_change", args=[crawl.pk]),
- HTTP_HOST=ADMIN_HOST,
+ HTTP_HOST=ADMIN_TEST_HOST,
)
assert response.status_code == 200
@@ -51,12 +92,28 @@ def test_crawl_admin_change_view_renders_tag_editor_widget(client, admin_user, c
assert b"beta" in response.content
-def test_crawl_admin_add_view_renders_url_filter_alias_fields(client, admin_user):
- client.login(username="crawladmin", password="testpassword")
+def test_crawl_admin_recrawl_object_action_is_post_only(admin_client, crawl):
+ action_url = reverse("admin:crawls_crawl_actions", kwargs={"pk": crawl.pk, "tool": "recrawl"})
- response = client.get(
+ change_response = admin_client.get(reverse("admin:crawls_crawl_change", args=[crawl.pk]), HTTP_HOST=ADMIN_TEST_HOST)
+ assert change_response.status_code == 200
+ assert b'