Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 25 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,22 @@ jobs:
- name: Verify exe
run: |
$env:CITESEAL_PORT = "8765"
$server = Start-Process -FilePath "tools/dist/citeseal_server.exe" -PassThru
$env:CITESEAL_ROOT = (Resolve-Path "tests/fixtures/accounts/example_user/tweets/2026/2026-07/20260708_180000_1234567890").Path
$originalPath = $env:PATH
$server = $null
try {
Start-Sleep -Seconds 5
(Invoke-WebRequest -UseBasicParsing "http://127.0.0.1:8765/api/health").Content
# The frozen process and its CLI child must not discover host Python.
$env:PATH = "$env:SystemRoot\System32"
$server = Start-Process -FilePath "tools/dist/citeseal_server.exe" -PassThru
$env:PATH = $originalPath
python tools/server/smoke_test_server.py `
--root "$env:CITESEAL_ROOT" --timeout 45
if ($LASTEXITCODE -ne 0) { throw "Windows frozen smoke test failed" }
} finally {
Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue
$env:PATH = $originalPath
if ($null -ne $server) {
Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue
}
}
- name: Upload artifact
uses: actions/upload-artifact@v4
Expand All @@ -71,12 +81,19 @@ jobs:
python-version: "3.12"
- name: Build portable tarball
run: bash tools/server/build_linux.sh
- name: Smoke-test
- name: Verify and smoke-test
run: |
python tools/server/verify_linux_tarball.py tools/dist/citeseal_server-linux-x64.tar.gz
rm -rf /tmp/citeseal_server-linux-x64
tar -xzf tools/dist/citeseal_server-linux-x64.tar.gz -C /tmp
CITESEAL_ROOT=$PWD/../accounts /tmp/citeseal_server-linux-x64/bin/run.sh &
sleep 5
curl -sf http://127.0.0.1:8765/api/health
PATH=/nonexistent \
CITESEAL_HOST=127.0.0.1 CITESEAL_PORT=8765 \
CITESEAL_ROOT="$PWD/tests/fixtures/accounts/example_user/tweets/2026/2026-07/20260708_180000_1234567890" \
/tmp/citeseal_server-linux-x64/bin/citeseal_server &
server_pid=$!
trap 'kill "$server_pid" 2>/dev/null || true' EXIT
python tools/server/smoke_test_server.py \
--root "$PWD/tests/fixtures/accounts/example_user/tweets/2026/2026-07/20260708_180000_1234567890" --timeout 45
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
Expand Down
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ Maintenance release restoring cross-platform demo execution and release artifact
- Made the live demo's terminal output ASCII-safe and its JSON file I/O explicitly UTF-8, preventing `UnicodeEncodeError` failures on legacy Windows console encodings.
- Added a `cp1252` subprocess regression test so Windows console compatibility remains covered by the full test suite.
- Repaired the Windows release job by installing and invoking PyInstaller explicitly, fixing its output path, and setting the health-check port before server startup.
- Corrected the Linux tarball source layout so bundled scripts remain directly importable; added release packaging contract tests for both platforms.
- Replaced the non-relocatable Linux virtual-environment payload with a self-contained PyInstaller executable; frozen background jobs now re-execute the embedded CiteSeal CLI without searching for a host Python interpreter.
- Fixed explicit `--extract` and `--out` path handling for Markdown/PDF commands and added a ReportLab CJK fallback for minimal Linux systems without an installed CJK font.
- Hardened release verification against unsafe tar members and added real `/api/run` validation-job smoke tests on Windows and Linux; the Linux gate runs the frozen server with no usable host `PATH`.

### Added
- ADR (Architecture Decision Records): `docs/adr/0001-agent-bundle-as-agent-consumption-layer.md`, `docs/adr/0002-local-first-boundary.md`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ It is designed for builders who use social media as a research, product, or mark

![CiteSeal architecture](docs/assets/architecture.svg)

> **Status:** early-stage but functional. Playwright-based capture, structured storage with schema validation, Markdown/PDF/OCR export helpers, a FastAPI local server, GitHub Actions CI, 218 passing tests, agent bundle + provenance manifest exports, and a Flutter client skeleton for mobile/desktop review.
> **Status:** early-stage but functional. Playwright-based capture, structured storage with schema validation, Markdown/PDF/OCR export helpers, a FastAPI local server, cross-platform GitHub Actions CI, agent bundle + provenance manifest exports, and a Flutter client skeleton for mobile/desktop review.

---

Expand Down
108 changes: 108 additions & 0 deletions tests/server/test_frozen_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Contracts for the self-contained PyInstaller server runtime."""
from __future__ import annotations

import importlib
import json
import sys
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parent.parent.parent
TOOLS_DIR = ROOT / "tools"
SCRIPTS_DIR = TOOLS_DIR / "scripts"
SERVER_DIR = TOOLS_DIR / "server"
for _path in (str(SCRIPTS_DIR), str(TOOLS_DIR), str(SERVER_DIR)):
if _path not in sys.path:
sys.path.insert(0, _path)

frozen_entry = importlib.import_module("_frozen_entry")
server_app = importlib.import_module("app")
cli = importlib.import_module("citeseal")


def test_frozen_server_job_reexecutes_itself_in_cli_mode(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(server_app, "FROZEN", True, raising=False)
monkeypatch.setattr(server_app, "PYTHON", "/bundle/citeseal_server")
job = server_app.Job(
id="job1",
op="validate",
args={"root": "/data/accounts", "quiet": True},
)

assert server_app._job_command(job) == [
"/bundle/citeseal_server",
"--citeseal-cli",
"validate",
"--root",
"/data/accounts",
"--quiet",
]


def test_frozen_entry_dispatches_embedded_cli_before_server(
monkeypatch: pytest.MonkeyPatch,
) -> None:
assert hasattr(frozen_entry, "_run_embedded_cli")
assert hasattr(frozen_entry, "_run_server")

calls: list[list[str]] = []
monkeypatch.setattr(
frozen_entry,
"_run_embedded_cli",
lambda argv: calls.append(list(argv)) or 7,
)
monkeypatch.setattr(
frozen_entry,
"_run_server",
lambda: pytest.fail("server must not start in embedded CLI mode"),
)
monkeypatch.setattr(
sys,
"argv",
["citeseal_server", "--citeseal-cli", "doctor"],
)

assert frozen_entry.main() == 7
assert calls == [["doctor"]]


def test_frozen_cli_runs_bundled_script_in_process_and_restores_state(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
scripts = tmp_path / "scripts"
scripts.mkdir()
workdir = tmp_path / "work"
workdir.mkdir()
probe = scripts / "probe.py"
probe.write_text(
"import json, os, sys\n"
"from pathlib import Path\n"
"Path('probe.json').write_text(json.dumps({\n"
" 'argv': sys.argv, 'cwd': os.getcwd(), 'name': __name__\n"
"}), encoding='utf-8')\n"
"raise SystemExit(7)\n",
encoding="utf-8",
)

original_cwd = Path.cwd()
original_argv = list(sys.argv)
monkeypatch.setattr(cli, "_SCRIPTS_DIR", scripts)
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(
cli.subprocess,
"run",
lambda *args, **kwargs: pytest.fail(
"frozen CLI must not require a host Python subprocess"
),
)

assert cli._run_script("probe.py", ["alpha", "beta"], cwd=workdir) == 7

result = json.loads((workdir / "probe.json").read_text(encoding="utf-8"))
assert result["argv"] == [str(probe), "alpha", "beta"]
assert result["cwd"] == str(workdir)
assert result["name"] == "__main__"
assert Path.cwd() == original_cwd
assert sys.argv == original_argv
75 changes: 75 additions & 0 deletions tests/server/test_smoke_test_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Tests for the cross-platform frozen-server smoke verifier."""
from __future__ import annotations

import importlib.util
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parent.parent.parent
SCRIPT = ROOT / "tools" / "server" / "smoke_test_server.py"
SPEC = importlib.util.spec_from_file_location("smoke_test_server", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
smoke = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(smoke)


def test_smoke_test_waits_for_health_and_successful_validate_job(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
responses = iter(
[
{"ok": True},
{"job_id": "job-1", "status": "queued"},
{"id": "job-1", "status": "running", "returncode": None},
{"id": "job-1", "status": "done", "returncode": 0},
]
)
calls: list[tuple[str, str, object]] = []

def fake_request(url, *, method="GET", payload=None, timeout=5.0):
calls.append((url, method, payload))
return next(responses)

monkeypatch.setattr(smoke, "_request_json", fake_request)
monkeypatch.setattr(smoke.time, "sleep", lambda _seconds: None)

result = smoke.smoke_test("http://127.0.0.1:8765/", tmp_path, timeout=5)

assert result["status"] == "done"
assert calls[1] == (
"http://127.0.0.1:8765/api/run",
"POST",
{
"op": "validate",
"args": {"root": str(tmp_path.resolve()), "quiet": True},
},
)
assert calls[-1][0].endswith("/api/jobs/job-1")


def test_smoke_test_fails_closed_on_failed_job(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
responses = iter(
[
{"ok": True},
{"job_id": "job-2", "status": "queued"},
{
"id": "job-2",
"status": "failed",
"returncode": 2,
"stderr_tail": "boom",
},
]
)
monkeypatch.setattr(
smoke,
"_request_json",
lambda *args, **kwargs: next(responses),
)

with pytest.raises(RuntimeError, match="validate smoke job failed"):
smoke.smoke_test("http://127.0.0.1:8765", tmp_path, timeout=5)
41 changes: 41 additions & 0 deletions tests/test_cli_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""
from __future__ import annotations

import json
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -141,6 +142,46 @@ def test_doctor_runs_and_reports(self):
assert "Python" in r.stdout
assert "Project layout" in r.stdout

@pytest.mark.parametrize("command,suffix", [("md", ".md"), ("pdf", ".pdf")])
def test_document_commands_accept_explicit_output_paths(
self,
command: str,
suffix: str,
tmp_path: Path,
):
out = tmp_path / f"full{suffix}"
extract = tmp_path / "extract.json"
extract.write_text(
json.dumps(
{
"title": "Portable PDF test",
"author_handle": "example_user",
"url": "https://example.test/item",
"datetime_utc": "2026-07-08T18:01:00Z",
"blocks": [{"type": "p", "text": "Hello from CiteSeal."}],
}
),
encoding="utf-8",
)
r = _run(
CLI,
[
command,
"--tweet-dir",
str(GOOD_DIR),
"--extract",
str(extract),
"--out",
str(out),
"--force",
],
)
assert r.returncode == 0, r.stderr
assert out.is_file()
assert out.stat().st_size > 0
if suffix == ".pdf":
assert out.read_bytes().startswith(b"%PDF-")


# ── export-agent ────────────────────────────────────────────────────────────

Expand Down
28 changes: 28 additions & 0 deletions tests/test_pdf_font.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Tests for portable PDF font selection."""
from __future__ import annotations

import importlib
import sys
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parent.parent
SCRIPTS = ROOT / "tools" / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))

pdf_builder = importlib.import_module("make_article_pdf")


def test_cjk_font_falls_back_to_reportlab_cid_without_system_font(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("CJK_FONT_PATH", raising=False)
monkeypatch.setattr(pdf_builder.platform, "system", lambda: "Linux")
monkeypatch.setattr(pdf_builder.os.path, "exists", lambda _path: False)

font_name = pdf_builder.register_cjk_font()

assert font_name == "STSong-Light"
assert pdf_builder.pdfmetrics.getFont(font_name) is not None
Loading
Loading