Complete independent projection runtime
This commit is contained in:
parent
1134c2d375
commit
f1fabaf0ca
38 changed files with 4907 additions and 87 deletions
187
tests/accessibility.spec.mjs
Normal file
187
tests/accessibility.spec.mjs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const AXE_TAGS = [
|
||||
"wcag2a",
|
||||
"wcag2aa",
|
||||
"wcag21a",
|
||||
"wcag21aa",
|
||||
"wcag22a",
|
||||
"wcag22aa",
|
||||
];
|
||||
const MANUAL_AXE_TAGS = AXE_TAGS.filter((tag) => !tag.startsWith("wcag22"));
|
||||
|
||||
let fixtureProcess;
|
||||
let surfaces;
|
||||
|
||||
function startFixture() {
|
||||
const python = process.env.DOCFORGE_PYTHON || ".venv/bin/python";
|
||||
const child = spawn(python, ["tools/accessibility_fixture.py"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
const lines = createInterface({ input: child.stdout });
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
lines.once("line", (line) => {
|
||||
settled = true;
|
||||
try {
|
||||
const payload = JSON.parse(line);
|
||||
if (
|
||||
payload.schema_version !== 1
|
||||
|| typeof payload.manual_html !== "string"
|
||||
|| typeof payload.portable_html !== "string"
|
||||
|| typeof payload.live_url !== "string"
|
||||
) {
|
||||
throw new Error("Accessibility fixture returned an invalid payload");
|
||||
}
|
||||
resolve(payload);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
lines.close();
|
||||
}
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
if (!settled) {
|
||||
reject(
|
||||
new Error(
|
||||
`Accessibility fixture exited before readiness `
|
||||
+ `(code=${code}, signal=${signal}):\n${stderr}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
return { child, ready };
|
||||
}
|
||||
|
||||
async function stopFixture(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
const exited = new Promise((resolve) => child.once("exit", resolve));
|
||||
child.stdin.end();
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Accessibility fixture did not stop")), 5_000);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function violationReport(violations) {
|
||||
return violations.map((violation) => {
|
||||
const targets = violation.nodes
|
||||
.flatMap((node) => node.target)
|
||||
.join(", ");
|
||||
return `${violation.id} (${violation.impact}): ${violation.help}\n ${targets}`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
async function expectNoAxeViolations(page, tags = AXE_TAGS) {
|
||||
const results = await new AxeBuilder({ page }).withTags(tags).analyze();
|
||||
expect(results.violations, violationReport(results.violations)).toEqual([]);
|
||||
}
|
||||
|
||||
async function tabTo(page, selector, maximumTabs = 40) {
|
||||
for (let count = 0; count < maximumTabs; count += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
if (await page.evaluate((target) => document.activeElement?.matches(target), selector)) {
|
||||
return page.locator(selector).filter({ visible: true }).first();
|
||||
}
|
||||
}
|
||||
throw new Error(`Keyboard focus did not reach ${selector}`);
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const fixture = startFixture();
|
||||
fixtureProcess = fixture.child;
|
||||
surfaces = await fixture.ready;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stopFixture(fixtureProcess);
|
||||
});
|
||||
|
||||
test("generated manual has no axe violations and its navigation works by keyboard", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setContent(surfaces.manual_html, { waitUntil: "load" });
|
||||
await expect(page.locator("main section")).not.toHaveCount(0);
|
||||
// The generic renderer owns structure, while this frozen project template owns target sizing.
|
||||
await expectNoAxeViolations(page, MANUAL_AXE_TAGS);
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
const firstNavigationLink = page.locator("nav[aria-label='Documentation'] a").first();
|
||||
await expect(firstNavigationLink).toBeFocused();
|
||||
const target = await firstNavigationLink.getAttribute("href");
|
||||
expect(target).toMatch(/^#[A-Za-z0-9_.-]+$/);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(() => page.evaluate(() => window.location.hash)).toBe(target);
|
||||
});
|
||||
|
||||
test("portable graph supports skip, filter, view, and dialog keyboard flows", async ({ page }) => {
|
||||
await page.goto("about:blank");
|
||||
await page.setContent(surfaces.portable_html, { waitUntil: "load" });
|
||||
await expect(page.locator("#status")).toContainText("nodes and");
|
||||
await expectNoAxeViolations(page);
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator("a.skip")).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("main#main")).toBeFocused();
|
||||
|
||||
await page.goto("about:blank");
|
||||
await page.setContent(surfaces.portable_html, { waitUntil: "load" });
|
||||
await page.keyboard.press("Tab");
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator("#filter")).toBeFocused();
|
||||
await page.keyboard.type("guide.workflow");
|
||||
await expect(page.locator("#status")).toContainText("1 nodes and");
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(page.locator("#mode")).toBeFocused();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(page.locator("main#main")).toHaveAttribute("data-mode", "flow");
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(page.locator("main#main")).toHaveAttribute("data-mode", "nodes");
|
||||
|
||||
await page.keyboard.press("Tab");
|
||||
const nodeButton = page.locator("#node-list button").first();
|
||||
await expect(nodeButton).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#node-dialog")).toHaveAttribute("open", "");
|
||||
await expect(page.locator("#close-dialog")).toBeFocused();
|
||||
await expectNoAxeViolations(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("#node-dialog")).not.toHaveAttribute("open", "");
|
||||
await expect(nodeButton).toBeFocused();
|
||||
});
|
||||
|
||||
test("live viewer passes axe and exposes keyboard graph and resize controls", async ({ page }) => {
|
||||
await page.goto(surfaces.live_url);
|
||||
await expect(page.locator("#status")).toContainText("nodes ·");
|
||||
await expect(page.locator("#graph g.node[role='button']").first()).toBeVisible();
|
||||
await expectNoAxeViolations(page);
|
||||
|
||||
const resizer = await tabTo(page, "#left-resizer");
|
||||
const originalWidth = Number(await resizer.getAttribute("aria-valuenow"));
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(resizer).toHaveAttribute("aria-valuenow", String(originalWidth + 16));
|
||||
|
||||
await tabTo(page, "#graph g.node[role='button']");
|
||||
await page.keyboard.press("Shift+Enter");
|
||||
await expect(page.locator("#node-dialog")).toHaveAttribute("open", "");
|
||||
await expect(page.locator("#close-node-dialog")).toBeFocused();
|
||||
await expectNoAxeViolations(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("#node-dialog")).not.toHaveAttribute("open", "");
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
|
@ -18,6 +19,7 @@ from jsonschema import Draft202012Validator
|
|||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
from docforge.changeset_contract import document_hash
|
||||
from docforge.cli import _parser, _run, main
|
||||
from docforge.client_config import (
|
||||
_read_existing,
|
||||
|
|
@ -39,6 +41,19 @@ CONFIGURATION_SCHEMA = json.loads(
|
|||
DOCTOR_SCHEMA = json.loads((SCHEMAS / "doctor-result.schema.json").read_text(encoding="utf-8"))
|
||||
POLICY_SCHEMA = json.loads((SCHEMAS / "policy.schema.json").read_text(encoding="utf-8"))
|
||||
|
||||
GRAPH_RENDER_CONFIG = """
|
||||
|
||||
[graph_render]
|
||||
output_root = ".docforge/portable-graph"
|
||||
|
||||
[[graph_render.views]]
|
||||
id = "architecture"
|
||||
renderer = "portable_graph_html"
|
||||
output = "architecture.html"
|
||||
title = "Alpha architecture"
|
||||
root = "guide.workflow"
|
||||
"""
|
||||
|
||||
|
||||
class ClientIntegrationTests(unittest.TestCase):
|
||||
def copy_fixture(self, destination: Path) -> Path:
|
||||
|
|
@ -156,6 +171,256 @@ class ClientIntegrationTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual("invalid_capability_binding", escalated.exception.code)
|
||||
|
||||
def test_nondefault_projection_policy_selectors_serialize_and_validate(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture(Path(directory))
|
||||
descriptor = root / ".docforge" / "project.toml"
|
||||
descriptor.write_text(
|
||||
descriptor.read_text(encoding="utf-8") + GRAPH_RENDER_CONFIG,
|
||||
encoding="utf-8",
|
||||
)
|
||||
project = Project.open(root)
|
||||
expected_arguments = [
|
||||
"-I",
|
||||
"-m",
|
||||
"docforge.mcp_server",
|
||||
"--project-root",
|
||||
str(root),
|
||||
"--capability-mode",
|
||||
"read",
|
||||
"--manual-render-policy",
|
||||
"disabled",
|
||||
"--portable-graph-policy",
|
||||
"disabled",
|
||||
"--live-viewer-policy",
|
||||
"disabled",
|
||||
]
|
||||
for client in ("codex", "claude", "openclaw"):
|
||||
with self.subTest(client=client):
|
||||
result = generate_client_configuration(
|
||||
project,
|
||||
client,
|
||||
manual_render_policy="disabled",
|
||||
portable_graph_policy="disabled",
|
||||
live_viewer_policy="disabled",
|
||||
)
|
||||
Draft202012Validator(CONFIGURATION_SCHEMA).validate(result)
|
||||
_validate_configuration_result(result)
|
||||
self.assertEqual(
|
||||
{
|
||||
"schema_version": 2,
|
||||
"manual": "disabled",
|
||||
"portable_graph": "disabled",
|
||||
"live_viewer": "disabled",
|
||||
},
|
||||
result["projection_policy"],
|
||||
)
|
||||
self.assertEqual(expected_arguments, result["binding"]["args"])
|
||||
content = result["artifact"]["content"]
|
||||
if client == "codex":
|
||||
document = tomllib.loads(content)
|
||||
serialized = document["mcp_servers"][result["server_name"]]["args"]
|
||||
elif client == "claude":
|
||||
document = json.loads(content)
|
||||
serialized = document["mcpServers"][result["server_name"]]["args"]
|
||||
else:
|
||||
document = json.loads(content)
|
||||
serialized = document["mcp"]["servers"][result["server_name"]]["args"]
|
||||
self.assertEqual(expected_arguments, serialized)
|
||||
canonical = json.dumps(
|
||||
result["projection_policy"],
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
self.assertEqual(
|
||||
hashlib.sha256(canonical).hexdigest(),
|
||||
result["projection_policy_hash"],
|
||||
)
|
||||
|
||||
def test_projection_policy_schema_and_configuration_validation_reject_drift(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
project = Project.open(self.copy_fixture(Path(directory)))
|
||||
result = generate_client_configuration(
|
||||
project,
|
||||
"codex",
|
||||
manual_render_policy="disabled",
|
||||
live_viewer_policy="disabled",
|
||||
)
|
||||
validator = Draft202012Validator(CONFIGURATION_SCHEMA)
|
||||
validator.validate(result)
|
||||
_validate_configuration_result(result)
|
||||
|
||||
for field, value in (
|
||||
("schema_version", 1),
|
||||
("manual", "on-demand"),
|
||||
("portable_graph", "auto"),
|
||||
("live_viewer", "explicit"),
|
||||
):
|
||||
with self.subTest(field=field):
|
||||
drifted = json.loads(json.dumps(result))
|
||||
drifted["projection_policy"][field] = value
|
||||
self.assertTrue(list(validator.iter_errors(drifted)))
|
||||
|
||||
missing = json.loads(json.dumps(result))
|
||||
missing.pop("projection_policy")
|
||||
self.assertTrue(list(validator.iter_errors(missing)))
|
||||
extra = json.loads(json.dumps(result))
|
||||
extra["projection_policy"]["project_path"] = "/private/project"
|
||||
self.assertTrue(list(validator.iter_errors(extra)))
|
||||
|
||||
mismatched = json.loads(json.dumps(result))
|
||||
mismatched["projection_policy"]["manual"] = "explicit"
|
||||
canonical = json.dumps(
|
||||
mismatched["projection_policy"],
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
mismatched["projection_policy_hash"] = hashlib.sha256(canonical).hexdigest()
|
||||
with self.assertRaises(AssertionError):
|
||||
_validate_configuration_result(mismatched)
|
||||
|
||||
bad_hash = json.loads(json.dumps(result))
|
||||
bad_hash["projection_policy_hash"] = "0" * 64
|
||||
with self.assertRaises(AssertionError):
|
||||
_validate_configuration_result(bad_hash)
|
||||
|
||||
defaulted = generate_client_configuration(project, "codex")
|
||||
self.assertNotIn(
|
||||
"--manual-render-policy",
|
||||
defaulted["binding"]["args"],
|
||||
)
|
||||
defaulted["projection_policy"]["manual"] = "disabled"
|
||||
canonical = json.dumps(
|
||||
defaulted["projection_policy"],
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
defaulted["projection_policy_hash"] = hashlib.sha256(canonical).hexdigest()
|
||||
with self.assertRaises(AssertionError):
|
||||
_validate_configuration_result(defaulted)
|
||||
|
||||
unavailable = json.loads(json.dumps(result))
|
||||
unavailable["projection_availability"]["manual_configured"] = False
|
||||
with self.assertRaises(AssertionError):
|
||||
_validate_configuration_result(unavailable)
|
||||
|
||||
descriptor = project.descriptor.descriptor_path
|
||||
descriptor.write_text(
|
||||
descriptor.read_text(encoding="utf-8") + GRAPH_RENDER_CONFIG,
|
||||
encoding="utf-8",
|
||||
)
|
||||
graph_project = Project.open(project.descriptor.root)
|
||||
graph_defaulted = generate_client_configuration(graph_project, "codex")
|
||||
self.assertNotIn(
|
||||
"--portable-graph-policy",
|
||||
graph_defaulted["binding"]["args"],
|
||||
)
|
||||
coordinated_graph_drift = json.loads(json.dumps(graph_defaulted))
|
||||
coordinated_graph_drift["projection_policy"]["portable_graph"] = "disabled"
|
||||
coordinated_graph_drift["projection_availability"]["portable_graph_configured"] = False
|
||||
canonical = json.dumps(
|
||||
coordinated_graph_drift["projection_policy"],
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
coordinated_graph_drift["projection_policy_hash"] = hashlib.sha256(
|
||||
canonical
|
||||
).hexdigest()
|
||||
coordinated_graph_drift["configuration_hash"] = document_hash(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"client": coordinated_graph_drift["client"],
|
||||
"server_name": coordinated_graph_drift["server_name"],
|
||||
"project": coordinated_graph_drift["project"],
|
||||
"binding": coordinated_graph_drift["binding"],
|
||||
"effective_policy": coordinated_graph_drift["effective_policy"],
|
||||
"projection_policy": coordinated_graph_drift["projection_policy"],
|
||||
"projection_policy_hash": coordinated_graph_drift["projection_policy_hash"],
|
||||
"projection_availability": coordinated_graph_drift["projection_availability"],
|
||||
"artifact_format": coordinated_graph_drift["artifact"]["format"],
|
||||
"artifact_content_sha256": coordinated_graph_drift["artifact"][
|
||||
"content_sha256"
|
||||
],
|
||||
}
|
||||
)
|
||||
with self.assertRaises(AssertionError):
|
||||
_validate_configuration_result(coordinated_graph_drift)
|
||||
|
||||
invalid_selections = (
|
||||
{"manual_render_policy": "sometimes"},
|
||||
{"portable_graph_policy": "auto"},
|
||||
{"live_viewer_policy": "always"},
|
||||
)
|
||||
for selection in invalid_selections:
|
||||
with self.subTest(selection=selection):
|
||||
with self.assertRaises(DocForgeError) as raised:
|
||||
generate_client_configuration(graph_project, "codex", **selection)
|
||||
self.assertEqual("invalid_projection_policy", raised.exception.code)
|
||||
|
||||
def test_doctor_round_trips_projection_selectors_and_rejects_invalid_modes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent = Path(directory)
|
||||
project = Project.open(self.copy_fixture(parent))
|
||||
ProjectIndex(project).build()
|
||||
config = parent / "openclaw.json"
|
||||
generated = generate_client_configuration(
|
||||
project,
|
||||
"openclaw",
|
||||
manual_render_policy="disabled",
|
||||
live_viewer_policy="disabled",
|
||||
output=config,
|
||||
)
|
||||
healthy = run_doctor(
|
||||
project,
|
||||
"openclaw",
|
||||
config_path=config,
|
||||
server_name=generated["server_name"],
|
||||
)
|
||||
Draft202012Validator(DOCTOR_SCHEMA).validate(healthy)
|
||||
self.assertEqual("healthy", healthy["doctor_state"])
|
||||
self.assertIn(
|
||||
"effective_policy_valid",
|
||||
[check["code"] for check in healthy["checks"]],
|
||||
)
|
||||
|
||||
document = json.loads(config.read_text(encoding="utf-8"))
|
||||
entry = document["mcp"]["servers"][generated["server_name"]]
|
||||
arguments = entry["args"]
|
||||
manual_position = arguments.index("--manual-render-policy") + 1
|
||||
arguments[manual_position] = "sometimes"
|
||||
config.write_text(json.dumps(document, sort_keys=True), encoding="utf-8")
|
||||
invalid = run_doctor(
|
||||
project,
|
||||
"openclaw",
|
||||
config_path=config,
|
||||
server_name=generated["server_name"],
|
||||
)
|
||||
Draft202012Validator(DOCTOR_SCHEMA).validate(invalid)
|
||||
self.assertEqual("unhealthy", invalid["doctor_state"])
|
||||
policy_check = next(
|
||||
check for check in invalid["checks"] if check["check_id"] == "policy.effective"
|
||||
)
|
||||
self.assertEqual("invalid_projection_policy", policy_check["code"])
|
||||
|
||||
arguments[manual_position] = "auto"
|
||||
config.write_text(json.dumps(document, sort_keys=True), encoding="utf-8")
|
||||
unavailable = run_doctor(
|
||||
project,
|
||||
"openclaw",
|
||||
config_path=config,
|
||||
server_name=generated["server_name"],
|
||||
)
|
||||
Draft202012Validator(DOCTOR_SCHEMA).validate(unavailable)
|
||||
self.assertEqual("unhealthy", unavailable["doctor_state"])
|
||||
policy_check = next(
|
||||
check for check in unavailable["checks"] if check["check_id"] == "policy.effective"
|
||||
)
|
||||
self.assertEqual("projection_policy_unavailable", policy_check["code"])
|
||||
|
||||
def test_explicit_fragment_write_is_atomic_conflict_aware_and_private(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent = Path(directory)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from docforge.cli import _parser, _run
|
||||
from docforge.errors import DocForgeError
|
||||
|
|
@ -10,6 +11,28 @@ from docforge.project import Project
|
|||
|
||||
|
||||
class DocForgeOnboardingTests(unittest.TestCase):
|
||||
def test_scaffold_honors_disabled_manual_projection_policy(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "disabled-render"
|
||||
root.mkdir()
|
||||
arguments = _parser().parse_args(
|
||||
[
|
||||
"--project-root",
|
||||
str(root),
|
||||
"--manual-render-policy",
|
||||
"disabled",
|
||||
"onboard",
|
||||
"--scaffold",
|
||||
]
|
||||
)
|
||||
with mock.patch(
|
||||
"docforge.cli.RenderService.render",
|
||||
side_effect=AssertionError("disabled onboarding must not render"),
|
||||
):
|
||||
result = _run(arguments)
|
||||
self.assertEqual("skipped", result["render"]["state"])
|
||||
self.assertFalse((root / ".docforge/rendered/manual.html").exists())
|
||||
|
||||
def test_assessment_detects_multiple_languages_without_writing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory) / "polyglot"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ from docforge.projection_contract import (
|
|||
canonical_projection_bytes,
|
||||
projection_hash,
|
||||
)
|
||||
from docforge.projection_fragments import FRAGMENT_CACHE_DIRECTORY, FragmentRecord
|
||||
from docforge.projection_worker import render_projection_in_worker
|
||||
from docforge.render_contract import GenericHtmlRenderer
|
||||
from docforge_renderers.manual import ManualHtmlRenderer
|
||||
|
||||
|
|
@ -412,6 +414,171 @@ class ProjectionContractTests(unittest.TestCase):
|
|||
prepared.output,
|
||||
)
|
||||
|
||||
def test_manual_fragments_are_incremental_and_full_worker_output_is_the_oracle(
|
||||
self,
|
||||
) -> None:
|
||||
renderer = GenericHtmlRenderer()
|
||||
with mock.patch(
|
||||
"docforge.render_contract.render_projection_in_worker",
|
||||
wraps=render_projection_in_worker,
|
||||
) as detached:
|
||||
cold = renderer.prepare(
|
||||
self.snapshot,
|
||||
self.view,
|
||||
self.template,
|
||||
changeset_hash=None,
|
||||
)
|
||||
self.assertEqual(2, detached.call_count)
|
||||
fragment_root = self.snapshot.descriptor.cache_root / FRAGMENT_CACHE_DIRECTORY
|
||||
self.assertEqual(
|
||||
len(self.plan.document["pages"]),
|
||||
len(list(fragment_root.glob("*.json"))),
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"docforge.render_contract.render_projection_in_worker",
|
||||
wraps=render_projection_in_worker,
|
||||
) as detached:
|
||||
warm = renderer.prepare(
|
||||
self.snapshot,
|
||||
self.view,
|
||||
self.template,
|
||||
changeset_hash=None,
|
||||
)
|
||||
self.assertEqual(1, detached.call_count)
|
||||
self.assertEqual(cold.output, warm.output)
|
||||
|
||||
with mock.patch(
|
||||
"docforge.render_contract.render_projection_in_worker",
|
||||
wraps=render_projection_in_worker,
|
||||
) as detached:
|
||||
full = GenericHtmlRenderer(incremental=False).prepare(
|
||||
self.snapshot,
|
||||
self.view,
|
||||
self.template,
|
||||
changeset_hash=None,
|
||||
)
|
||||
self.assertEqual(1, detached.call_count)
|
||||
self.assertEqual(full.output, warm.output)
|
||||
|
||||
first_fragment = sorted(fragment_root.glob("*.json"))[0]
|
||||
first_fragment.write_bytes(b"{corrupt")
|
||||
with mock.patch(
|
||||
"docforge.render_contract.render_projection_in_worker",
|
||||
wraps=render_projection_in_worker,
|
||||
) as detached:
|
||||
recovered = renderer.prepare(
|
||||
self.snapshot,
|
||||
self.view,
|
||||
self.template,
|
||||
changeset_hash=None,
|
||||
)
|
||||
self.assertEqual(2, detached.call_count)
|
||||
self.assertEqual(full.output, recovered.output)
|
||||
|
||||
original_record = FragmentRecord.from_bytes(first_fragment.read_bytes())
|
||||
first_fragment.write_bytes(
|
||||
FragmentRecord.create(
|
||||
original_record.key,
|
||||
b"<script>forged fragment</script>",
|
||||
).to_bytes()
|
||||
)
|
||||
with mock.patch(
|
||||
"docforge.render_contract.render_projection_in_worker",
|
||||
wraps=render_projection_in_worker,
|
||||
) as detached:
|
||||
forged = renderer.prepare(
|
||||
self.snapshot,
|
||||
self.view,
|
||||
self.template,
|
||||
changeset_hash=None,
|
||||
)
|
||||
self.assertEqual(2, detached.call_count)
|
||||
self.assertEqual(full.output, forged.output)
|
||||
self.assertNotIn(b"forged fragment", forged.output)
|
||||
|
||||
def test_oversized_manual_fragment_bypasses_cache_and_uses_full_worker(self) -> None:
|
||||
content = "x" * 4_100_000
|
||||
node = replace(
|
||||
self.snapshot.nodes[0],
|
||||
content=content,
|
||||
content_hash=hashlib.sha256(content.encode()).hexdigest(),
|
||||
)
|
||||
snapshot = replace(
|
||||
self.snapshot,
|
||||
descriptor=replace(
|
||||
self.snapshot.descriptor,
|
||||
limits=replace(
|
||||
self.snapshot.descriptor.limits,
|
||||
max_render_bytes=20_000_000,
|
||||
),
|
||||
),
|
||||
nodes=(node,),
|
||||
edges=(),
|
||||
source_hash=hashlib.sha256(b"oversized-fragment").hexdigest(),
|
||||
)
|
||||
with mock.patch(
|
||||
"docforge.render_contract.render_projection_in_worker",
|
||||
wraps=render_projection_in_worker,
|
||||
) as detached:
|
||||
prepared = GenericHtmlRenderer().prepare(
|
||||
snapshot,
|
||||
self.view,
|
||||
self.template,
|
||||
changeset_hash=None,
|
||||
)
|
||||
self.assertEqual(1, detached.call_count)
|
||||
full = GenericHtmlRenderer(incremental=False).prepare(
|
||||
snapshot,
|
||||
self.view,
|
||||
self.template,
|
||||
changeset_hash=None,
|
||||
)
|
||||
self.assertEqual(full.output, prepared.output)
|
||||
|
||||
def test_fragment_package_overflow_falls_back_to_full_worker(self) -> None:
|
||||
content = "x" * 2_700_000
|
||||
base = self.snapshot.nodes[0]
|
||||
nodes = tuple(
|
||||
replace(
|
||||
base,
|
||||
node_id=f"guide.large-{index}",
|
||||
title=f"Large {index}",
|
||||
content=content,
|
||||
source_path=f"docs/content/large-{index}.md",
|
||||
content_hash=hashlib.sha256(f"{index}:{content}".encode()).hexdigest(),
|
||||
)
|
||||
for index in range(4)
|
||||
)
|
||||
descriptor = replace(
|
||||
self.snapshot.descriptor,
|
||||
limits=replace(
|
||||
self.snapshot.descriptor.limits,
|
||||
max_render_bytes=20_000_000,
|
||||
),
|
||||
)
|
||||
snapshot = replace(
|
||||
self.snapshot,
|
||||
descriptor=descriptor,
|
||||
nodes=nodes,
|
||||
edges=(),
|
||||
source_hash=hashlib.sha256(b"large-fragment-fixture").hexdigest(),
|
||||
)
|
||||
incremental = GenericHtmlRenderer().prepare(
|
||||
snapshot,
|
||||
self.view,
|
||||
self.template,
|
||||
changeset_hash=None,
|
||||
)
|
||||
full = GenericHtmlRenderer(incremental=False).prepare(
|
||||
snapshot,
|
||||
self.view,
|
||||
self.template,
|
||||
changeset_hash=None,
|
||||
)
|
||||
self.assertGreater(len(full.output), 10_000_000)
|
||||
self.assertEqual(full.output, incremental.output)
|
||||
|
||||
def test_manual_renderer_rejects_project_provided_active_content(self) -> None:
|
||||
for active in (
|
||||
"<script>alert(1)</script>{{ docforge_content }}",
|
||||
|
|
|
|||
205
tests/test_projection_fragments.py
Normal file
205
tests/test_projection_fragments.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from docforge.projection_fragments import (
|
||||
FRAGMENT_CACHE_DIRECTORY,
|
||||
FRAGMENT_KEY_CONTRACT,
|
||||
FRAGMENT_RECORD_CONTRACT,
|
||||
FRAGMENT_SCHEMA_VERSION,
|
||||
FragmentKey,
|
||||
FragmentRecord,
|
||||
ProjectionFragmentCache,
|
||||
fragment_semantic_hash,
|
||||
)
|
||||
|
||||
|
||||
class ProjectionFragmentCacheTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name) / "project"
|
||||
self.root.mkdir()
|
||||
self.cache_root = self.root / ".docforge" / "cache"
|
||||
self.cache = ProjectionFragmentCache(
|
||||
self.root,
|
||||
self.cache_root,
|
||||
maximum_content_bytes=1_000,
|
||||
)
|
||||
self.key = self.make_key({"node": "guide.alpha", "summary": "Alpha"})
|
||||
|
||||
@staticmethod
|
||||
def make_key(
|
||||
semantics: object,
|
||||
*,
|
||||
renderer_version: str = "1",
|
||||
component_version: str = "graph.node@1",
|
||||
) -> FragmentKey:
|
||||
return FragmentKey.create(
|
||||
projection_kind="graph",
|
||||
renderer_id="portable_graph_html",
|
||||
renderer_version=renderer_version,
|
||||
component_version=component_version,
|
||||
semantic_input_hash=fragment_semantic_hash(semantics),
|
||||
)
|
||||
|
||||
@property
|
||||
def entry_path(self) -> Path:
|
||||
return self.cache_root / FRAGMENT_CACHE_DIRECTORY / f"{self.key.key_id}.json"
|
||||
|
||||
def test_miss_hit_and_unchanged_reuse_are_exact(self) -> None:
|
||||
self.assertIsNone(self.cache.get(self.key))
|
||||
with mock.patch(
|
||||
"docforge.projection_fragments.atomic_replace_bytes_at",
|
||||
wraps=__import__(
|
||||
"docforge.projection_fragments",
|
||||
fromlist=["atomic_replace_bytes_at"],
|
||||
).atomic_replace_bytes_at,
|
||||
) as atomic:
|
||||
stored = self.cache.put(self.key, b"<li>Alpha</li>")
|
||||
self.assertIsNotNone(stored)
|
||||
self.assertEqual(1, atomic.call_count)
|
||||
before = self.entry_path.stat()
|
||||
repeated = self.cache.put(self.key, b"<li>Alpha</li>")
|
||||
self.assertEqual(1, atomic.call_count)
|
||||
self.assertEqual(stored, repeated)
|
||||
self.assertEqual(stored, self.cache.get(self.key))
|
||||
after = self.entry_path.stat()
|
||||
self.assertEqual((before.st_dev, before.st_ino), (after.st_dev, after.st_ino))
|
||||
|
||||
def test_key_versions_and_complete_semantics_invalidate_independently(self) -> None:
|
||||
self.assertIsNotNone(self.cache.put(self.key, b"alpha"))
|
||||
variants = (
|
||||
self.make_key(
|
||||
{"node": "guide.alpha", "summary": "Alpha"},
|
||||
renderer_version="2",
|
||||
),
|
||||
self.make_key(
|
||||
{"node": "guide.alpha", "summary": "Alpha"},
|
||||
component_version="graph.node@2",
|
||||
),
|
||||
self.make_key({"node": "guide.alpha", "summary": "Changed"}),
|
||||
)
|
||||
for variant in variants:
|
||||
with self.subTest(key=variant):
|
||||
self.assertNotEqual(self.key.key_id, variant.key_id)
|
||||
self.assertIsNone(self.cache.get(variant))
|
||||
|
||||
def test_add_delete_and_reorder_change_sequence_semantics(self) -> None:
|
||||
base = ["a", "b"]
|
||||
added = ["a", "b", "c"]
|
||||
deleted = ["a"]
|
||||
reordered = ["b", "a"]
|
||||
keys = [self.make_key(value) for value in (base, added, deleted, reordered)]
|
||||
self.assertEqual(4, len({key.key_id for key in keys}))
|
||||
self.assertEqual(
|
||||
fragment_semantic_hash({"a": 1, "b": 2}),
|
||||
fragment_semantic_hash({"b": 2, "a": 1}),
|
||||
)
|
||||
|
||||
def test_corrupt_oversized_incompatible_and_foreign_entries_are_misses(self) -> None:
|
||||
self.assertIsNotNone(self.cache.put(self.key, b"alpha"))
|
||||
self.entry_path.write_bytes(b"{bad-json")
|
||||
self.assertIsNone(self.cache.get(self.key))
|
||||
self.assertIsNotNone(self.cache.put(self.key, b"repaired"))
|
||||
repaired = self.cache.get(self.key)
|
||||
self.assertIsNotNone(repaired)
|
||||
assert repaired is not None
|
||||
self.assertEqual(b"repaired", repaired.content)
|
||||
|
||||
self.entry_path.write_bytes(b"x" * (self.cache.maximum_record_bytes + 1))
|
||||
self.assertIsNone(self.cache.get(self.key))
|
||||
|
||||
incompatible = FragmentRecord.create(self.key, b"alpha").as_dict()
|
||||
incompatible["schema_version"] = FRAGMENT_SCHEMA_VERSION + 1
|
||||
self.entry_path.write_bytes(
|
||||
json.dumps(incompatible, sort_keys=True, separators=(",", ":")).encode()
|
||||
)
|
||||
self.assertIsNone(self.cache.get(self.key))
|
||||
|
||||
foreign_key = self.make_key({"node": "foreign"})
|
||||
foreign = FragmentRecord.create(foreign_key, b"foreign").to_bytes()
|
||||
self.entry_path.write_bytes(foreign)
|
||||
self.assertIsNone(self.cache.get(self.key))
|
||||
|
||||
def test_symlinked_entry_and_cache_root_fail_closed_without_outside_writes(self) -> None:
|
||||
self.entry_path.parent.mkdir(parents=True)
|
||||
outside = Path(self.temporary.name) / "outside.json"
|
||||
outside.write_bytes(FragmentRecord.create(self.key, b"outside").to_bytes())
|
||||
self.entry_path.symlink_to(outside)
|
||||
self.assertIsNone(self.cache.get(self.key))
|
||||
self.assertIsNone(self.cache.put(self.key, b"replacement"))
|
||||
self.assertEqual(b"outside", FragmentRecord.from_bytes(outside.read_bytes()).content)
|
||||
|
||||
outside_cache = Path(self.temporary.name) / "outside-cache"
|
||||
escaped = ProjectionFragmentCache(self.root, outside_cache)
|
||||
self.assertIsNone(escaped.put(self.key, b"escaped"))
|
||||
self.assertFalse(outside_cache.exists())
|
||||
|
||||
def test_keys_and_records_have_deterministic_path_free_serialization(self) -> None:
|
||||
same_key = self.make_key({"summary": "Alpha", "node": "guide.alpha"})
|
||||
self.assertEqual(self.key, same_key)
|
||||
first = FragmentRecord.create(self.key, b"\x00fragment\xff")
|
||||
second = FragmentRecord.create(same_key, b"\x00fragment\xff")
|
||||
self.assertEqual(first, second)
|
||||
self.assertEqual(first.to_bytes(), second.to_bytes())
|
||||
self.assertEqual(first, FragmentRecord.from_bytes(first.to_bytes()))
|
||||
document = json.loads(first.to_bytes())
|
||||
self.assertEqual(FRAGMENT_SCHEMA_VERSION, document["schema_version"])
|
||||
self.assertEqual(FRAGMENT_RECORD_CONTRACT, document["contract"])
|
||||
self.assertEqual(FRAGMENT_KEY_CONTRACT, document["key"]["contract"])
|
||||
self.assertNotIn(str(self.root), first.to_bytes().decode("utf-8"))
|
||||
self.assertEqual(first.byte_count, len(first.content))
|
||||
self.assertEqual(64, len(first.content_sha256))
|
||||
|
||||
def test_cache_write_failure_is_a_miss_and_does_not_mutate_canonical_files(self) -> None:
|
||||
canonical = self.root / "canonical.md"
|
||||
canonical.write_text("canonical", encoding="utf-8")
|
||||
with mock.patch(
|
||||
"docforge.projection_fragments.atomic_replace_bytes_at",
|
||||
side_effect=OSError("synthetic cache failure"),
|
||||
):
|
||||
self.assertIsNone(self.cache.put(self.key, b"fragment"))
|
||||
self.assertIsNone(self.cache.get(self.key))
|
||||
self.assertEqual("canonical", canonical.read_text(encoding="utf-8"))
|
||||
|
||||
def test_record_rejects_noncanonical_serialization_and_invalid_evidence(self) -> None:
|
||||
record = FragmentRecord.create(self.key, b"alpha")
|
||||
pretty = json.dumps(record.as_dict(), sort_keys=True, indent=2).encode()
|
||||
with self.assertRaisesRegex(Exception, "canonically serialized"):
|
||||
FragmentRecord.from_bytes(pretty)
|
||||
tampered = record.as_dict()
|
||||
tampered["byte_count"] = record.byte_count + 1
|
||||
canonical = json.dumps(tampered, sort_keys=True, separators=(",", ":")).encode()
|
||||
with self.assertRaisesRegex(Exception, "byte evidence"):
|
||||
FragmentRecord.from_bytes(canonical)
|
||||
|
||||
def test_prune_retains_only_the_exact_bounded_active_inventory(self) -> None:
|
||||
retained = self.key
|
||||
stale = self.make_key({"node": "stale"})
|
||||
self.assertIsNotNone(self.cache.put(retained, b"retained"))
|
||||
self.assertIsNotNone(self.cache.put(stale, b"stale"))
|
||||
foreign = self.entry_path.parent / "foreign.tmp"
|
||||
foreign.write_bytes(b"foreign")
|
||||
|
||||
self.assertTrue(self.cache.prune((retained,)))
|
||||
retained_record = self.cache.get(retained)
|
||||
self.assertIsNotNone(retained_record)
|
||||
assert retained_record is not None
|
||||
self.assertEqual(b"retained", retained_record.content)
|
||||
self.assertIsNone(self.cache.get(stale))
|
||||
self.assertFalse(foreign.exists())
|
||||
|
||||
with mock.patch(
|
||||
"docforge.projection_fragments.MAX_FRAGMENT_CACHE_BYTES",
|
||||
1,
|
||||
):
|
||||
self.assertFalse(self.cache.prune((retained,)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
266
tests/test_projection_policy.py
Normal file
266
tests/test_projection_policy.py
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import unittest
|
||||
from dataclasses import FrozenInstanceError
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from docforge.errors import DocForgeError
|
||||
from docforge.projection_policy import (
|
||||
ProjectionPolicyV2,
|
||||
compose_projection_policy,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
POLICY_SCHEMA = json.loads(
|
||||
(ROOT / "schemas" / "projection-policy.schema.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
class ProjectionPolicyV2Tests(unittest.TestCase):
|
||||
def test_compatible_defaults_are_independent_and_schema_valid(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
{
|
||||
"manual_configured": False,
|
||||
"portable_graph_configured": False,
|
||||
"application_enabled": False,
|
||||
},
|
||||
("disabled", "disabled", "on-demand"),
|
||||
),
|
||||
(
|
||||
{
|
||||
"manual_configured": True,
|
||||
"portable_graph_configured": False,
|
||||
"application_enabled": False,
|
||||
},
|
||||
("explicit", "disabled", "on-demand"),
|
||||
),
|
||||
(
|
||||
{
|
||||
"manual_configured": True,
|
||||
"portable_graph_configured": True,
|
||||
"application_enabled": True,
|
||||
},
|
||||
("auto", "explicit", "on-demand"),
|
||||
),
|
||||
(
|
||||
{
|
||||
"manual_configured": True,
|
||||
"portable_graph_configured": True,
|
||||
"application_enabled": False,
|
||||
"live_viewer_available": False,
|
||||
},
|
||||
("explicit", "explicit", "disabled"),
|
||||
),
|
||||
)
|
||||
validator = Draft202012Validator(POLICY_SCHEMA)
|
||||
for arguments, expected in cases:
|
||||
with self.subTest(arguments=arguments):
|
||||
policy = compose_projection_policy(**arguments)
|
||||
self.assertEqual(
|
||||
expected, (policy.manual, policy.portable_graph, policy.live_viewer)
|
||||
)
|
||||
validator.validate(policy.as_dict())
|
||||
|
||||
def test_explicit_selections_can_narrow_each_consumer_independently(self) -> None:
|
||||
policy = compose_projection_policy(
|
||||
manual="explicit",
|
||||
portable_graph="disabled",
|
||||
live_viewer="disabled",
|
||||
manual_configured=True,
|
||||
portable_graph_configured=True,
|
||||
application_enabled=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"schema_version": 2,
|
||||
"manual": "explicit",
|
||||
"portable_graph": "disabled",
|
||||
"live_viewer": "disabled",
|
||||
},
|
||||
policy.as_dict(),
|
||||
)
|
||||
|
||||
fully_disabled = compose_projection_policy(
|
||||
manual="disabled",
|
||||
portable_graph="disabled",
|
||||
live_viewer="disabled",
|
||||
manual_configured=False,
|
||||
portable_graph_configured=False,
|
||||
application_enabled=False,
|
||||
live_viewer_available=False,
|
||||
)
|
||||
self.assertEqual("disabled", fully_disabled.manual)
|
||||
self.assertEqual("disabled", fully_disabled.portable_graph)
|
||||
self.assertEqual("disabled", fully_disabled.live_viewer)
|
||||
|
||||
def test_policy_is_frozen_and_hashes_exact_canonical_payload(self) -> None:
|
||||
first = compose_projection_policy(
|
||||
manual_configured=True,
|
||||
portable_graph_configured=True,
|
||||
application_enabled=False,
|
||||
)
|
||||
second = compose_projection_policy(
|
||||
manual_configured=True,
|
||||
portable_graph_configured=True,
|
||||
application_enabled=False,
|
||||
)
|
||||
canonical = json.dumps(
|
||||
first.as_dict(),
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
self.assertEqual(first.as_dict(), second.as_dict())
|
||||
self.assertEqual(hashlib.sha256(canonical).hexdigest(), first.policy_hash)
|
||||
self.assertEqual(first.policy_hash, second.policy_hash)
|
||||
|
||||
changed = compose_projection_policy(
|
||||
manual="disabled",
|
||||
manual_configured=True,
|
||||
portable_graph_configured=True,
|
||||
application_enabled=False,
|
||||
)
|
||||
self.assertNotEqual(first.policy_hash, changed.policy_hash)
|
||||
with self.assertRaises(FrozenInstanceError):
|
||||
first.manual = "disabled" # type: ignore[misc]
|
||||
|
||||
def test_invalid_modes_and_direct_construction_fail_closed(self) -> None:
|
||||
cases = (
|
||||
("manual", {"manual": "sometimes"}),
|
||||
("portable_graph", {"portable_graph": "auto"}),
|
||||
("live_viewer", {"live_viewer": "always"}),
|
||||
("manual", {"manual": 1}),
|
||||
)
|
||||
for expected_projection, selection in cases:
|
||||
with self.subTest(selection=selection):
|
||||
with self.assertRaises(DocForgeError) as raised:
|
||||
compose_projection_policy(
|
||||
**selection,
|
||||
manual_configured=True,
|
||||
portable_graph_configured=True,
|
||||
application_enabled=True,
|
||||
)
|
||||
self.assertEqual("invalid_projection_policy", raised.exception.code)
|
||||
self.assertEqual(
|
||||
expected_projection,
|
||||
raised.exception.details["projection"],
|
||||
)
|
||||
|
||||
with self.assertRaises(DocForgeError) as direct:
|
||||
ProjectionPolicyV2(
|
||||
manual="automatic", # type: ignore[arg-type]
|
||||
portable_graph="explicit",
|
||||
live_viewer="on-demand",
|
||||
)
|
||||
self.assertEqual("invalid_projection_policy", direct.exception.code)
|
||||
|
||||
def test_resource_and_capability_unavailability_are_distinct(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
{"manual": "explicit"},
|
||||
{
|
||||
"manual_configured": False,
|
||||
"portable_graph_configured": False,
|
||||
"application_enabled": False,
|
||||
},
|
||||
"manual",
|
||||
"manual_render_config",
|
||||
),
|
||||
(
|
||||
{"manual": "auto"},
|
||||
{
|
||||
"manual_configured": True,
|
||||
"portable_graph_configured": False,
|
||||
"application_enabled": False,
|
||||
},
|
||||
"manual",
|
||||
"canonical_application",
|
||||
),
|
||||
(
|
||||
{"portable_graph": "explicit"},
|
||||
{
|
||||
"manual_configured": False,
|
||||
"portable_graph_configured": False,
|
||||
"application_enabled": False,
|
||||
},
|
||||
"portable_graph",
|
||||
"portable_graph_render_config",
|
||||
),
|
||||
(
|
||||
{"live_viewer": "on-demand"},
|
||||
{
|
||||
"manual_configured": False,
|
||||
"portable_graph_configured": False,
|
||||
"application_enabled": False,
|
||||
"live_viewer_available": False,
|
||||
},
|
||||
"live_viewer",
|
||||
"live_viewer_runtime",
|
||||
),
|
||||
)
|
||||
for selection, availability, projection, required in cases:
|
||||
with self.subTest(selection=selection):
|
||||
with self.assertRaises(DocForgeError) as raised:
|
||||
compose_projection_policy(**selection, **availability)
|
||||
self.assertEqual("projection_policy_unavailable", raised.exception.code)
|
||||
self.assertEqual(projection, raised.exception.details["projection"])
|
||||
self.assertEqual(required, raised.exception.details["required"])
|
||||
|
||||
def test_availability_inputs_must_be_real_booleans(self) -> None:
|
||||
cases = (
|
||||
{"manual_configured": 1},
|
||||
{"portable_graph_configured": 0},
|
||||
{"application_enabled": "yes"},
|
||||
{"live_viewer_available": None},
|
||||
)
|
||||
for replacement in cases:
|
||||
arguments = {
|
||||
"manual_configured": False,
|
||||
"portable_graph_configured": False,
|
||||
"application_enabled": False,
|
||||
"live_viewer_available": True,
|
||||
**replacement,
|
||||
}
|
||||
with self.subTest(replacement=replacement):
|
||||
with self.assertRaises(DocForgeError) as raised:
|
||||
compose_projection_policy(**arguments)
|
||||
self.assertEqual("invalid_projection_policy", raised.exception.code)
|
||||
|
||||
def test_schema_rejects_every_runtime_contract_drift(self) -> None:
|
||||
validator = Draft202012Validator(POLICY_SCHEMA)
|
||||
valid = compose_projection_policy(
|
||||
manual_configured=True,
|
||||
portable_graph_configured=True,
|
||||
application_enabled=True,
|
||||
).as_dict()
|
||||
invalid_documents: list[dict[str, object]] = []
|
||||
for field in ("schema_version", "manual", "portable_graph", "live_viewer"):
|
||||
missing = copy.deepcopy(valid)
|
||||
missing.pop(field)
|
||||
invalid_documents.append(missing)
|
||||
for field, value in (
|
||||
("schema_version", 1),
|
||||
("manual", "on-demand"),
|
||||
("portable_graph", "auto"),
|
||||
("live_viewer", "explicit"),
|
||||
):
|
||||
changed = copy.deepcopy(valid)
|
||||
changed[field] = value
|
||||
invalid_documents.append(changed)
|
||||
extra = copy.deepcopy(valid)
|
||||
extra["project_path"] = "/private/project"
|
||||
invalid_documents.append(extra)
|
||||
|
||||
for document in invalid_documents:
|
||||
with self.subTest(document=document):
|
||||
self.assertFalse(validator.is_valid(document))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
239
tests/test_projection_policy_integration.py
Normal file
239
tests/test_projection_policy_integration.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from docforge.application import CanonicalApplicationService
|
||||
from docforge.errors import DocForgeError
|
||||
from docforge.graph_rendering import GraphRenderService
|
||||
from docforge.index import ProjectIndex
|
||||
from docforge.mcp_server import DocForgeService
|
||||
from docforge.project import Project
|
||||
from docforge.rendering import RenderService
|
||||
from docforge.viewer_manager import ViewerManagerClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
|
||||
GRAPH_CONFIG = """
|
||||
|
||||
[graph_render]
|
||||
output_root = ".docforge/portable-graph"
|
||||
|
||||
[[graph_render.views]]
|
||||
id = "architecture"
|
||||
renderer = "portable_graph_html"
|
||||
output = "architecture.html"
|
||||
title = "Alpha architecture"
|
||||
root = "guide.workflow"
|
||||
initial_mode = "nodes"
|
||||
depth = 2
|
||||
max_nodes = 20
|
||||
max_edges = 40
|
||||
max_work = 1000
|
||||
include_logic = false
|
||||
"""
|
||||
|
||||
|
||||
class ProjectionPolicyIntegrationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name) / "alpha"
|
||||
shutil.copytree(FIXTURES / "alpha", self.root)
|
||||
|
||||
def project(self, *, graph: bool = False) -> Project:
|
||||
if graph:
|
||||
descriptor = self.root / ".docforge/project.toml"
|
||||
descriptor.write_text(
|
||||
descriptor.read_text(encoding="utf-8") + GRAPH_CONFIG,
|
||||
encoding="utf-8",
|
||||
)
|
||||
return Project.open(self.root)
|
||||
|
||||
def test_disabled_manual_blocks_work_before_load_but_status_remains_receipt_only(self) -> None:
|
||||
project = self.project()
|
||||
service = RenderService(project, manual_policy="disabled")
|
||||
with mock.patch.object(
|
||||
project,
|
||||
"load",
|
||||
side_effect=AssertionError("disabled manual policy must fail before loading"),
|
||||
):
|
||||
status = service.status("manual")
|
||||
self.assertEqual("stale", status["state"])
|
||||
for operation in (
|
||||
lambda: service.render("manual"),
|
||||
lambda: service.deep_status("manual"),
|
||||
lambda: service.preview("proposal", "manual"),
|
||||
):
|
||||
with self.subTest(operation=operation), self.assertRaises(DocForgeError) as raised:
|
||||
operation()
|
||||
self.assertEqual(
|
||||
"projection_policy_forbids_operation",
|
||||
raised.exception.code,
|
||||
)
|
||||
|
||||
def test_disabled_graph_blocks_plan_and_render_but_status_remains_available(self) -> None:
|
||||
project = self.project(graph=True)
|
||||
service = GraphRenderService(project, portable_graph_policy="disabled")
|
||||
with mock.patch.object(
|
||||
project,
|
||||
"load",
|
||||
side_effect=AssertionError("disabled graph policy must fail before loading"),
|
||||
):
|
||||
status = service.status("architecture")
|
||||
self.assertEqual("stale", status["state"])
|
||||
for operation in (
|
||||
lambda: service.plan("architecture"),
|
||||
lambda: service.render("architecture"),
|
||||
):
|
||||
with self.subTest(operation=operation), self.assertRaises(DocForgeError) as raised:
|
||||
operation()
|
||||
self.assertEqual(
|
||||
"projection_policy_forbids_operation",
|
||||
raised.exception.code,
|
||||
)
|
||||
|
||||
def test_live_viewer_disabled_blocks_start_before_index_work_but_allows_lifecycle(self) -> None:
|
||||
project = self.project()
|
||||
index = ProjectIndex(project)
|
||||
client = ViewerManagerClient(index, live_viewer_policy="disabled")
|
||||
with (
|
||||
mock.patch.object(
|
||||
index,
|
||||
"check",
|
||||
side_effect=AssertionError("disabled viewer must fail before index work"),
|
||||
),
|
||||
self.assertRaises(DocForgeError) as raised,
|
||||
):
|
||||
client.start(node_id="guide.workflow")
|
||||
self.assertEqual("projection_policy_forbids_operation", raised.exception.code)
|
||||
|
||||
with mock.patch.object(
|
||||
client,
|
||||
"_lifecycle_request",
|
||||
side_effect=(
|
||||
{"status": "ok", "state": "not_running"},
|
||||
{"status": "ok", "state": "stopped"},
|
||||
),
|
||||
):
|
||||
self.assertEqual("not_running", client.status()["state"])
|
||||
self.assertEqual("stopped", client.stop()["state"])
|
||||
|
||||
def test_application_auto_renders_and_explicit_or_disabled_skip_cleanly(self) -> None:
|
||||
project = self.project()
|
||||
for mode, expected_action, expected_calls in (
|
||||
("auto", "rendered", 1),
|
||||
("explicit", "skipped_explicit", 0),
|
||||
("disabled", "skipped_disabled", 0),
|
||||
):
|
||||
with self.subTest(mode=mode):
|
||||
service = CanonicalApplicationService(
|
||||
project,
|
||||
applier_id="alpha-editor",
|
||||
applier=mock.Mock(),
|
||||
manual_policy=mode, # type: ignore[arg-type]
|
||||
)
|
||||
with (
|
||||
mock.patch.object(
|
||||
service.changesets,
|
||||
"apply",
|
||||
return_value={"status": "ok", "applied": True},
|
||||
),
|
||||
mock.patch.object(
|
||||
service.index,
|
||||
"build",
|
||||
return_value={"status": "ok"},
|
||||
),
|
||||
mock.patch.object(
|
||||
service.index,
|
||||
"check",
|
||||
return_value={"status": "ok"},
|
||||
),
|
||||
mock.patch.object(
|
||||
service.rendering,
|
||||
"render",
|
||||
return_value={"status": "ok", "state": "current"},
|
||||
) as rendered,
|
||||
):
|
||||
result = service.apply("policy-application", "a" * 64)
|
||||
refresh = result["derived_refresh"]
|
||||
assert isinstance(refresh, dict)
|
||||
self.assertEqual(
|
||||
{"mode": mode, "action": expected_action},
|
||||
refresh["render_policy"],
|
||||
)
|
||||
self.assertEqual(expected_calls, rendered.call_count)
|
||||
self.assertEqual("ok", refresh["status"])
|
||||
|
||||
def test_mcp_exposes_v2_without_changing_v1_policy_or_legacy_render_projection(self) -> None:
|
||||
project = self.project(graph=True)
|
||||
baseline = DocForgeService(project, capability_mode_name="read")
|
||||
narrowed = DocForgeService(
|
||||
project,
|
||||
capability_mode_name="read",
|
||||
manual_projection_policy="disabled",
|
||||
portable_graph_policy="disabled",
|
||||
live_viewer_policy="disabled",
|
||||
)
|
||||
self.assertEqual(baseline.policy.as_dict(), narrowed.policy.as_dict())
|
||||
result = narrowed.bootstrap()
|
||||
self.assertEqual(narrowed.policy.as_dict(), result["effective_policy"])
|
||||
self.assertEqual(
|
||||
narrowed.projection_policy.as_dict(),
|
||||
result["projection_policy"],
|
||||
)
|
||||
self.assertEqual(
|
||||
narrowed.projection_policy.policy_hash,
|
||||
result["projection_policy_hash"],
|
||||
)
|
||||
session = result["session_contract"]
|
||||
assert isinstance(session, dict)
|
||||
self.assertEqual(
|
||||
{
|
||||
"manual": narrowed.policy.manual_render,
|
||||
"graph": narrowed.policy.graph_render,
|
||||
"live_viewer": narrowed.policy.live_viewer,
|
||||
},
|
||||
session["render_policies"],
|
||||
)
|
||||
self.assertEqual(
|
||||
"stale",
|
||||
narrowed.graph_render_status("architecture")["state"],
|
||||
)
|
||||
blocked_plan = narrowed.graph_plan("architecture")
|
||||
self.assertEqual(
|
||||
"projection_policy_forbids_operation",
|
||||
blocked_plan["error"]["code"],
|
||||
)
|
||||
|
||||
def test_direct_service_policy_values_are_runtime_validated(self) -> None:
|
||||
project = self.project(graph=True)
|
||||
factories = (
|
||||
lambda: RenderService(project, manual_policy="bogus"), # type: ignore[arg-type]
|
||||
lambda: GraphRenderService(
|
||||
project,
|
||||
portable_graph_policy="bogus", # type: ignore[arg-type]
|
||||
),
|
||||
lambda: ViewerManagerClient(
|
||||
ProjectIndex(project),
|
||||
live_viewer_policy="bogus", # type: ignore[arg-type]
|
||||
),
|
||||
lambda: CanonicalApplicationService(
|
||||
project,
|
||||
applier_id=None,
|
||||
applier=None,
|
||||
manual_policy="bogus", # type: ignore[arg-type]
|
||||
),
|
||||
)
|
||||
for factory in factories:
|
||||
with self.subTest(factory=factory), self.assertRaises(DocForgeError) as raised:
|
||||
factory()
|
||||
self.assertEqual("invalid_projection_policy", raised.exception.code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
387
tests/test_projection_worker.py
Normal file
387
tests/test_projection_worker.py
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from docforge.errors import DocForgeError
|
||||
from docforge.graph_projection import (
|
||||
GraphViewRequestV1,
|
||||
build_graph_projection_package,
|
||||
build_graph_view_plan,
|
||||
)
|
||||
from docforge.manual_projection import (
|
||||
build_manual_projection_package,
|
||||
build_manual_render_plan,
|
||||
)
|
||||
from docforge.project import Project
|
||||
from docforge.projection_contract import (
|
||||
ProjectionPackageV1,
|
||||
ProjectionReceiptV1,
|
||||
canonical_projection_bytes,
|
||||
)
|
||||
from docforge.projection_worker import (
|
||||
MAX_WORKER_ARTIFACT_BYTES,
|
||||
MAX_WORKER_RESPONSE_BYTES,
|
||||
_child_response,
|
||||
render_projection_in_worker,
|
||||
)
|
||||
from docforge.render_contract import GenericHtmlRenderer
|
||||
from docforge_renderers.graph import PortableGraphHtmlRenderer
|
||||
from docforge_renderers.manual import ManualHtmlRenderer
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
|
||||
|
||||
class ProjectionWorkerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name) / "alpha"
|
||||
shutil.copytree(FIXTURES / "alpha", self.root)
|
||||
self.project = Project.open(self.root)
|
||||
self.snapshot = self.project.load()
|
||||
render = self.snapshot.descriptor.render
|
||||
assert render is not None
|
||||
self.manual_view = render.views[0]
|
||||
self.manual_version = GenericHtmlRenderer().renderer_version
|
||||
self.manual_plan = build_manual_render_plan(
|
||||
self.snapshot,
|
||||
self.manual_view,
|
||||
changeset_hash=None,
|
||||
)
|
||||
self.manual_package = build_manual_projection_package(
|
||||
self.manual_plan,
|
||||
self.manual_view.template_path.read_bytes(),
|
||||
renderer_id="generic_html",
|
||||
renderer_version=self.manual_version,
|
||||
max_output_bytes=self.snapshot.descriptor.limits.max_render_bytes,
|
||||
)
|
||||
self.graph_plan = build_graph_view_plan(
|
||||
self.snapshot,
|
||||
GraphViewRequestV1(
|
||||
view_id="architecture",
|
||||
title="Alpha architecture",
|
||||
root_node_id="guide.workflow",
|
||||
depth=2,
|
||||
max_nodes=20,
|
||||
max_edges=40,
|
||||
max_work=1_000,
|
||||
),
|
||||
False,
|
||||
)
|
||||
self.graph_package = build_graph_projection_package(
|
||||
self.graph_plan,
|
||||
renderer_id=PortableGraphHtmlRenderer.renderer_id,
|
||||
renderer_version=PortableGraphHtmlRenderer.renderer_version,
|
||||
max_output_bytes=self.snapshot.descriptor.limits.max_render_bytes,
|
||||
)
|
||||
|
||||
def test_manual_and_graph_workers_match_the_in_process_renderers(self) -> None:
|
||||
cases = (
|
||||
(
|
||||
self.manual_package,
|
||||
ManualHtmlRenderer(self.manual_version).render(self.manual_package),
|
||||
),
|
||||
(
|
||||
self.graph_package,
|
||||
PortableGraphHtmlRenderer().render(self.graph_package),
|
||||
),
|
||||
)
|
||||
for package, expected in cases:
|
||||
with self.subTest(kind=package.kind):
|
||||
result = render_projection_in_worker(package)
|
||||
self.assertEqual(expected.artifacts, result.artifacts)
|
||||
receipt = result.receipt.as_dict()
|
||||
self.assertEqual(package.package_id, receipt["package_id"])
|
||||
self.assertEqual(package.document["plan_id"], receipt["plan_id"])
|
||||
self.assertIs(type(receipt["peak_memory_bytes"]), int)
|
||||
self.assertGreater(receipt["peak_memory_bytes"], 0)
|
||||
|
||||
def test_parent_launch_is_fixed_and_request_contains_no_runtime_authority(self) -> None:
|
||||
original = subprocess.run
|
||||
with mock.patch(
|
||||
"docforge.projection_worker.subprocess.run",
|
||||
wraps=original,
|
||||
) as launched:
|
||||
render_projection_in_worker(self.manual_package)
|
||||
command = launched.call_args.args[0]
|
||||
options = launched.call_args.kwargs
|
||||
self.assertEqual(
|
||||
[sys.executable, "-I", "-m", "docforge.projection_worker"],
|
||||
command,
|
||||
)
|
||||
self.assertIs(options["shell"], False)
|
||||
self.assertEqual(sys.prefix, options["cwd"])
|
||||
self.assertEqual(
|
||||
{"PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"},
|
||||
options["env"],
|
||||
)
|
||||
request = options["input"]
|
||||
self.assertEqual(
|
||||
canonical_projection_bytes(self.manual_package.as_dict()) + b"\n",
|
||||
request,
|
||||
)
|
||||
for forbidden in (
|
||||
str(self.root).encode(),
|
||||
b"project_path",
|
||||
b"index_path",
|
||||
b"database_path",
|
||||
b'"command"',
|
||||
b'"module"',
|
||||
b'"shell"',
|
||||
b'"sql"',
|
||||
):
|
||||
self.assertNotIn(forbidden, request)
|
||||
|
||||
def test_worker_ignores_hostile_cwd_and_pythonpath_import_shadows(self) -> None:
|
||||
shadow_root = Path(self.temporary.name) / "shadow"
|
||||
shadow_package = shadow_root / "docforge"
|
||||
shadow_package.mkdir(parents=True)
|
||||
sentinel = shadow_root / "executed"
|
||||
shadow_package.joinpath("__init__.py").write_text(
|
||||
"from pathlib import Path\n"
|
||||
f"Path({str(sentinel)!r}).write_text('executed', encoding='utf-8')\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
contextlib.chdir(shadow_root),
|
||||
mock.patch.dict(
|
||||
os.environ,
|
||||
{"PYTHONPATH": str(shadow_root)},
|
||||
clear=False,
|
||||
),
|
||||
):
|
||||
result = render_projection_in_worker(self.manual_package)
|
||||
self.assertEqual("manual", result.receipt.document["kind"])
|
||||
self.assertFalse(sentinel.exists())
|
||||
|
||||
def test_parent_accepts_only_valid_packages_and_closed_renderer_versions(self) -> None:
|
||||
with self.assertRaises(TypeError):
|
||||
render_projection_in_worker({}) # type: ignore[arg-type]
|
||||
|
||||
unsupported = ProjectionPackageV1.create(
|
||||
kind="manual",
|
||||
plan=self.manual_plan,
|
||||
renderer={"renderer_id": "generic_html", "renderer_version": "other"},
|
||||
components=[{"component_id": "manual.document@1"}],
|
||||
assets=copy.deepcopy(self.manual_package.document["assets"]),
|
||||
output_policy=copy.deepcopy(self.manual_package.document["output_policy"]),
|
||||
)
|
||||
with (
|
||||
mock.patch("docforge.projection_worker._invoke_worker") as invoke,
|
||||
self.assertRaises(DocForgeError) as raised,
|
||||
):
|
||||
render_projection_in_worker(unsupported)
|
||||
self.assertEqual("unsupported_renderer", raised.exception.code)
|
||||
invoke.assert_not_called()
|
||||
|
||||
permissive_allowance = ProjectionPackageV1.create(
|
||||
kind="manual",
|
||||
plan=self.manual_plan,
|
||||
renderer={
|
||||
"renderer_id": "generic_html",
|
||||
"renderer_version": self.manual_version,
|
||||
},
|
||||
components=[{"component_id": "manual.document@1"}],
|
||||
assets=copy.deepcopy(self.manual_package.document["assets"]),
|
||||
output_policy={
|
||||
"artifact_ids": ["manual.html"],
|
||||
"max_total_bytes": MAX_WORKER_ARTIFACT_BYTES + 1,
|
||||
},
|
||||
)
|
||||
rendered = render_projection_in_worker(permissive_allowance)
|
||||
self.assertEqual(
|
||||
ManualHtmlRenderer(self.manual_version)
|
||||
.render(permissive_allowance)
|
||||
.artifacts[0]
|
||||
.content,
|
||||
rendered.artifacts[0].content,
|
||||
)
|
||||
|
||||
def test_process_timeout_exit_and_signal_fail_closed(self) -> None:
|
||||
failures = (
|
||||
(
|
||||
subprocess.TimeoutExpired(["worker"], 30),
|
||||
"projection_worker_timeout",
|
||||
),
|
||||
(
|
||||
subprocess.CompletedProcess(["worker"], 2, stdout=b""),
|
||||
"projection_worker_failure",
|
||||
),
|
||||
(
|
||||
subprocess.CompletedProcess(["worker"], -9, stdout=b""),
|
||||
"projection_worker_failure",
|
||||
),
|
||||
)
|
||||
for outcome, code in failures:
|
||||
with self.subTest(outcome=type(outcome).__name__):
|
||||
patch = (
|
||||
mock.patch(
|
||||
"docforge.projection_worker._invoke_worker",
|
||||
side_effect=outcome,
|
||||
)
|
||||
if isinstance(outcome, BaseException)
|
||||
else mock.patch(
|
||||
"docforge.projection_worker._invoke_worker",
|
||||
return_value=outcome,
|
||||
)
|
||||
)
|
||||
with patch, self.assertRaises(DocForgeError) as raised:
|
||||
render_projection_in_worker(self.manual_package)
|
||||
self.assertEqual(code, raised.exception.code)
|
||||
|
||||
def test_malformed_trailing_and_oversized_responses_fail_closed(self) -> None:
|
||||
valid = _child_response(self.manual_package)
|
||||
malformed = (
|
||||
b"",
|
||||
b"{}",
|
||||
b"not-json\n",
|
||||
b'{ "schema_version": 1 }\n',
|
||||
valid + b"{}\n",
|
||||
b"x" * (MAX_WORKER_RESPONSE_BYTES + 1),
|
||||
)
|
||||
for response in malformed:
|
||||
with (
|
||||
self.subTest(length=len(response)),
|
||||
mock.patch(
|
||||
"docforge.projection_worker._invoke_worker",
|
||||
return_value=subprocess.CompletedProcess(
|
||||
["worker"],
|
||||
0,
|
||||
stdout=response,
|
||||
),
|
||||
),
|
||||
self.assertRaises(DocForgeError) as raised,
|
||||
):
|
||||
render_projection_in_worker(self.manual_package)
|
||||
self.assertEqual("projection_worker_failure", raised.exception.code)
|
||||
|
||||
def test_wrong_artifact_and_receipt_evidence_fail_closed(self) -> None:
|
||||
valid = json.loads(_child_response(self.manual_package))
|
||||
cases: dict[str, dict[str, object]] = {}
|
||||
|
||||
wrong_content = copy.deepcopy(valid)
|
||||
wrong_content["artifacts"][0]["content_base64"] = base64.b64encode(b"changed").decode()
|
||||
cases["content_hash"] = wrong_content
|
||||
|
||||
wrong_id = copy.deepcopy(valid)
|
||||
wrong_id["artifacts"][0]["artifact_id"] = "other.html"
|
||||
cases["artifact_id"] = wrong_id
|
||||
|
||||
missing_artifact = copy.deepcopy(valid)
|
||||
missing_artifact["artifacts"] = []
|
||||
cases["artifact_count"] = missing_artifact
|
||||
|
||||
wrong_media_type = copy.deepcopy(valid)
|
||||
wrong_media_type["artifacts"][0]["media_type"] = "application/octet-stream"
|
||||
cases["media_type"] = wrong_media_type
|
||||
|
||||
bad_base64 = copy.deepcopy(valid)
|
||||
bad_base64["artifacts"][0]["content_base64"] = "***"
|
||||
cases["base64"] = bad_base64
|
||||
|
||||
wrong_package = copy.deepcopy(valid)
|
||||
receipt = wrong_package["receipt"]
|
||||
replacement = ProjectionReceiptV1.create(
|
||||
kind="manual",
|
||||
package_id="0" * 64,
|
||||
plan_id=receipt["plan_id"],
|
||||
renderer=receipt["renderer"],
|
||||
artifacts=receipt["artifacts"],
|
||||
diagnostics=receipt["diagnostics"],
|
||||
timing=receipt["timing"],
|
||||
peak_memory_bytes=receipt["peak_memory_bytes"],
|
||||
)
|
||||
wrong_package["receipt"] = replacement.as_dict()
|
||||
cases["package_id"] = wrong_package
|
||||
|
||||
zero_peak = copy.deepcopy(valid)
|
||||
receipt = zero_peak["receipt"]
|
||||
replacement = ProjectionReceiptV1.create(
|
||||
kind="manual",
|
||||
package_id=receipt["package_id"],
|
||||
plan_id=receipt["plan_id"],
|
||||
renderer=receipt["renderer"],
|
||||
artifacts=receipt["artifacts"],
|
||||
diagnostics=receipt["diagnostics"],
|
||||
timing=receipt["timing"],
|
||||
peak_memory_bytes=0,
|
||||
)
|
||||
zero_peak["receipt"] = replacement.as_dict()
|
||||
cases["peak_memory"] = zero_peak
|
||||
|
||||
wrong_size = copy.deepcopy(valid)
|
||||
receipt = wrong_size["receipt"]
|
||||
artifacts = copy.deepcopy(receipt["artifacts"])
|
||||
artifacts[0]["bytes"] += 1
|
||||
replacement = ProjectionReceiptV1.create(
|
||||
kind="manual",
|
||||
package_id=receipt["package_id"],
|
||||
plan_id=receipt["plan_id"],
|
||||
renderer=receipt["renderer"],
|
||||
artifacts=artifacts,
|
||||
diagnostics=receipt["diagnostics"],
|
||||
timing=receipt["timing"],
|
||||
peak_memory_bytes=receipt["peak_memory_bytes"],
|
||||
)
|
||||
wrong_size["receipt"] = replacement.as_dict()
|
||||
cases["artifact_size"] = wrong_size
|
||||
|
||||
configured_oversize = copy.deepcopy(valid)
|
||||
maximum = self.manual_package.document["output_policy"]["max_total_bytes"]
|
||||
configured_oversize["artifacts"][0]["content_base64"] = base64.b64encode(
|
||||
b"x" * (maximum + 1)
|
||||
).decode()
|
||||
cases["configured_aggregate"] = configured_oversize
|
||||
|
||||
for name, document in cases.items():
|
||||
response = canonical_projection_bytes(document) + b"\n"
|
||||
with (
|
||||
self.subTest(name=name),
|
||||
mock.patch(
|
||||
"docforge.projection_worker._invoke_worker",
|
||||
return_value=subprocess.CompletedProcess(
|
||||
["worker"],
|
||||
0,
|
||||
stdout=response,
|
||||
),
|
||||
),
|
||||
self.assertRaises(DocForgeError) as raised,
|
||||
):
|
||||
render_projection_in_worker(self.manual_package)
|
||||
self.assertEqual("projection_worker_failure", raised.exception.code)
|
||||
|
||||
def test_child_rejects_noncanonical_invalid_and_trailing_requests(self) -> None:
|
||||
valid = canonical_projection_bytes(self.manual_package.as_dict()) + b"\n"
|
||||
requests = (
|
||||
b"{}\n",
|
||||
b'{ "schema_version": 1 }\n',
|
||||
valid + b"{}\n",
|
||||
)
|
||||
for request in requests:
|
||||
with self.subTest(length=len(request)):
|
||||
completed = subprocess.run(
|
||||
[sys.executable, "-m", "docforge.projection_worker"],
|
||||
cwd=ROOT,
|
||||
input=request,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
self.assertEqual(2, completed.returncode)
|
||||
self.assertEqual(b"", completed.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -94,6 +94,19 @@ PUBLIC_IMPORTS = {
|
|||
"capability_mode",
|
||||
"compose_effective_policy",
|
||||
),
|
||||
"docforge.projection_policy": (
|
||||
"ProjectionPolicyV2",
|
||||
"compose_projection_policy",
|
||||
"validate_live_viewer_projection_mode",
|
||||
"validate_manual_projection_mode",
|
||||
"validate_portable_graph_projection_mode",
|
||||
),
|
||||
"docforge.projection_fragments": (
|
||||
"FragmentKey",
|
||||
"FragmentRecord",
|
||||
"ProjectionFragmentCache",
|
||||
"fragment_semantic_hash",
|
||||
),
|
||||
"docforge.projection_contract": (
|
||||
"GraphViewPlanV1",
|
||||
"ManualRenderPlanV1",
|
||||
|
|
@ -104,6 +117,7 @@ PUBLIC_IMPORTS = {
|
|||
"canonical_projection_bytes",
|
||||
"projection_hash",
|
||||
),
|
||||
"docforge.projection_worker": ("render_projection_in_worker",),
|
||||
"docforge.retrieval": (
|
||||
"ContextCapsuleV1",
|
||||
"RetrievalPlanV1",
|
||||
|
|
@ -186,6 +200,8 @@ EXPECTED_MCP_TOOLS = {
|
|||
"docforge_rebase_changeset",
|
||||
"docforge_register_changes",
|
||||
"docforge_render_status",
|
||||
"docforge_graph_plan",
|
||||
"docforge_graph_render_status",
|
||||
"docforge_search",
|
||||
"docforge_stop_visualization",
|
||||
"docforge_sync",
|
||||
|
|
@ -242,6 +258,9 @@ class PublicContractTests(unittest.TestCase):
|
|||
)
|
||||
self.assertIn("--project-root", completed.stdout)
|
||||
self.assertIn("--no-ast", completed.stdout)
|
||||
self.assertIn("--manual-render-policy", completed.stdout)
|
||||
self.assertIn("--portable-graph-policy", completed.stdout)
|
||||
self.assertIn("--live-viewer-policy", completed.stdout)
|
||||
|
||||
def test_published_schemas_validate_their_current_contract_examples(self) -> None:
|
||||
for path in sorted(SCHEMAS.glob("*.json")):
|
||||
|
|
|
|||
|
|
@ -55,6 +55,14 @@ class DocForgeRenderingTests(unittest.TestCase):
|
|||
self.assertEqual("missing", missing["outputs"][0]["state"])
|
||||
|
||||
first = service.render("manual")
|
||||
projection_receipt = first["output"]["projection_receipt"]
|
||||
self.assertIsInstance(projection_receipt, dict)
|
||||
assert isinstance(projection_receipt, dict)
|
||||
self.assertEqual("manual", projection_receipt["kind"])
|
||||
self.assertEqual(
|
||||
first["output"]["actual_output_hash"],
|
||||
projection_receipt["artifacts"][0]["sha256"],
|
||||
)
|
||||
output = root / ".docforge/rendered/manual.html"
|
||||
first_bytes = output.read_bytes()
|
||||
second = service.render("manual")
|
||||
|
|
@ -191,7 +199,12 @@ class DocForgeRenderingTests(unittest.TestCase):
|
|||
self.assertEqual("receipt_corrupt", corrupt["outputs"][0]["reason"])
|
||||
|
||||
def test_render_receipt_schema_and_renderer_version_fail_closed(self) -> None:
|
||||
for mutation in ("missing_hash", "renderer_version", "file_identity"):
|
||||
for mutation in (
|
||||
"missing_hash",
|
||||
"renderer_version",
|
||||
"file_identity",
|
||||
"projection_artifact",
|
||||
):
|
||||
with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
service = RenderService(Project.open(root))
|
||||
|
|
@ -202,6 +215,8 @@ class DocForgeRenderingTests(unittest.TestCase):
|
|||
receipt.pop("output_hash")
|
||||
elif mutation == "renderer_version":
|
||||
receipt["renderer_version"] = "obsolete"
|
||||
elif mutation == "projection_artifact":
|
||||
receipt["projection_receipt"]["artifacts"][0]["sha256"] = "0" * 64
|
||||
else:
|
||||
receipt["output_file"].pop("ctime_ns")
|
||||
receipt_path.write_text(
|
||||
|
|
@ -401,6 +416,19 @@ class DocForgeRenderingTests(unittest.TestCase):
|
|||
with self.assertRaisesRegex(DocForgeError, "configured limit") as limit_error:
|
||||
limit_service.render("manual")
|
||||
self.assertEqual("render_too_large", limit_error.exception.code)
|
||||
|
||||
excessive = self.copy_fixture("alpha", Path(directory) / "excessive")
|
||||
excessive_descriptor = excessive / ".docforge/project.toml"
|
||||
excessive_descriptor.write_text(
|
||||
excessive_descriptor.read_text(encoding="utf-8").replace(
|
||||
"max_changeset_bytes = 100000",
|
||||
"max_changeset_bytes = 100000\nmax_render_bytes = 20000001",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
permissive_limit = RenderService(Project.open(excessive)).render("manual")
|
||||
self.assertEqual("current", permissive_limit["receipt"]["state"])
|
||||
self.assertTrue((excessive / ".docforge/rendered/manual.html").is_file())
|
||||
self.assertFalse((limit_root / ".docforge/rendered/manual.html").exists())
|
||||
|
||||
template_limit_root = self.copy_fixture("alpha", parent / "template-limit")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue