Merge pull request #680 from mvanhorn/codex/optional-html-publishing

Add optional HTML publishing
This commit is contained in:
Trevin Chow
2026-06-24 23:34:55 -07:00
committed by GitHub
8 changed files with 521 additions and 7 deletions
+2
View File
@@ -45,6 +45,8 @@ The engine's `.env` reader doesn't expand `$HOME` — only the tilde, via `Path(
- `--output <file>` - write the rendered output to an exact file path, using the format selected by `--emit`.
- `--save-suffix <name>` - distinguish runs of the same topic (e.g. per client: `--save-suffix=acme`).
- `--no-browser-cookies` - hard-disable browser-cookie extraction for this run, even when `FROM_BROWSER` is configured. MCP and folder-mode hosts use this for safe defaults.
- `--publish-html` - with `--emit=html`, publish the rendered HTML to `ht-ml.app` after local output/save-dir writes. This is explicit opt-in only; pages are public by default.
- `--publish-password <password>` - optional shared password for `--publish-html`. Prefer `LAST30DAYS_PUBLISH_PASSWORD=<password>` instead so the password is not visible in the process list or shell history. Use a unique non-personal password; never reuse the user's own password. The provider's update key is treated as secret and is not written to stdout, HTML, raw output, or `.publish.json` metadata.
- `--preflight` - print a human-readable permission preflight. It reports config source, project config trust/ignore state, browser-cookie plan, planned writes, optional commands, source availability, and endpoint overrides without reading browser cookies, writing setup/config/report files, or running research. Add `--emit=json` for machine-readable preflight JSON; use `--diagnose` when you need the full source diagnostic JSON.
The footer line `📎 Raw results saved to ${LAST30DAYS_MEMORY_DIR:-$HOME/Documents/Last30Days}/<slug>-raw.md` is the canonical pointer; if it shows backslashes on Windows update past v3.1.1.
+9 -1
View File
@@ -1771,6 +1771,13 @@ If the research output does not contain the footer block (rare, only when all so
**SELF-CHECK before displaying**: Re-read your "What I learned" section. Does it match what the research ACTUALLY says? If you catch yourself projecting your own knowledge instead of the research, rewrite it. Then verify: (a) no `##` headers in your response body, (b) no em-dashes or en-dashes anywhere, (c) the engine footer block appears verbatim between KEY PATTERNS and the invitation.
**Saved artifact access flow:** after the engine has created a file, decide how the user should get access to it based on what they asked for:
- **Normal report:** the Markdown raw artifact already appears in the engine footer (`📎 Raw results saved to ...`). The chat synthesis is the primary user-facing report, so do not open the raw Markdown file automatically and do not ask a follow-up access question. The path line is enough.
- **Markdown file requested:** if the user explicitly asked for a Markdown file/export, treat the saved Markdown path as the deliverable. Provide the path and open it locally when the host can safely open local files and the request implies viewing it now. Do not offer hosted publishing for Markdown.
- **HTML file requested:** follow `references/save-html-brief.md`. Save the local HTML first, show the absolute path, then present explicit next-step choices: open the HTML file, publish to an available/preferred HTML publishing service, or done for now.
- **Share/publish requested:** sharing means hosted HTML, not Markdown. Save the local HTML first and show the path. Then respect existing publishing preferences, show available publishing choices, and ask for public-vs-password only when the selected service requires that choice (for `ht-ml.app`, ask whether password protection should be used; if yes, ask the user to type the shared password before publishing). Never block creation of the local file on the hosting decision.
**LAST - Invitation (adapt to QUERY_TYPE):**
**CRITICAL: Every invitation MUST include 2-3 specific example suggestions based on what you ACTUALLY learned from the research.** Don't be generic - show the user you absorbed the content by referencing real things from the results.
@@ -1866,6 +1873,7 @@ Close with `I have all the links to the {N} {source list} I pulled from. Just as
- Read `references/save-html-brief.md` BEFORE proceeding to WAIT FOR USER'S RESPONSE
- Follow that file's instructions exactly - it is the canonical source for the save flow
- End with the artifact handoff defined there: saved HTML path, open the local file when the host can do so, and a concise confirmation for requests where HTML is the requested deliverable
- If the user explicitly asks for a hosted/shareable web link, follow the opt-in publishing instructions in the reference file. Never publish by default.
**You MUST NOT:**
@@ -1874,7 +1882,7 @@ Close with `I have all the links to the {N} {source list} I pulled from. Just as
- Save to a different path than the reference specifies
- Add data quality warnings, debug headers, or safety notes to the saved HTML
- Re-research the topic for the HTML render - the engine cache covers the second invocation
- Upload or publish the HTML anywhere public in this flow
- Upload or publish the HTML to a third-party host unless the user explicitly asked for hosted sharing and you have told them the link may be public/indexed unless password-protected
**Why the directive is forceful:** the reference file is the only source of truth for the save flow. Skipping it produces broken artifacts - wrong path conventions, missing synthesis content, leaked engine debug output, or warnings that don't belong in shareable docs.
@@ -81,6 +81,46 @@ fi
# completion message.
```
## Optional hosted publishing
Only publish after the local HTML file has already been saved and the user chooses a publish option. The local HTML save is always first, and its absolute path is always shown before any publish/upload step.
Respect any existing user, project, or host preference for HTML publishing first. If the user already has a preferred publisher or internal sharing workflow, include that option. If multiple publishing options are available, show each as its own choice and include `ht-ml.app` as one option; label `ht-ml.app` as supporting optional password protection. If no preference exists, use `ht-ml.app` as the fallback publishing option.
Use this decision flow:
- Save the local HTML file.
- Show the absolute saved path.
- Then proactively present next-step choices:
1. Open HTML file
2. Publish to `<preferred/configured service>`; if `ht-ml.app` is shown, say password protection is available
3. Done for now
- Do not upload until the user chooses a publishing option.
When publishing to `ht-ml.app`, ask a second question:
- **Public link** - publish without a password.
- **Password-protected link** - ask the user to type the shared password in free form, then publish with that password.
Before the `ht-ml.app` choice, tell the user that public pages may be crawled or indexed, and that password protection is available. If the user chooses password protection, use a unique shared password they provide for this report; do not use their own account password.
Agents should discover the current publishing mechanics for the selected service when needed, including by visiting the service site, rather than hard-coding detailed service-specific instructions in chat. For the built-in `ht-ml.app` path, the engine supports `--publish-html`; on the password-protected branch, pass the shared password through `LAST30DAYS_PUBLISH_PASSWORD` rather than command-line arguments.
When the user chooses the built-in `ht-ml.app` path, add `--publish-html` to the same `--emit=html` command. Use `--output "$HTML_PATH"` rather than shell redirection so the engine can write the `.publish.json` companion metadata next to the local HTML file. On the password-protected branch, set `LAST30DAYS_PUBLISH_PASSWORD` in the subprocess environment instead of passing `--publish-password` in the shell command.
```bash
LAST30DAYS_PUBLISH_PASSWORD="${PUBLISH_PASSWORD:-}" \
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" "${TOPIC}" \
--emit=html \
--synthesis-file "$SYNTHESIS_FILE" \
--output "$HTML_PATH" \
--publish-html \
"${SCOPE_FLAGS[@]}" \
>/dev/null
```
The hosted URL appears on stderr as `[last30days] Published HTML to https://...`. Confirm the result with the hosted URL. If the user chose password protection, also repeat the shared password they selected so they can send the URL and password together. The engine writes URL metadata to `<HTML_PATH>.publish.json`. The provider may return an `update_key`; treat it as secret. The engine deliberately does not write the update key to stdout, the HTML artifact, or `.publish.json` companion metadata.
## Chat handoff after saving
Use the mode that matches the request.
@@ -89,17 +129,20 @@ Use the mode that matches the request.
When HTML is the requested deliverable - whether by `--emit=html`, `--emit:html`, `--html`, or natural-language phrasing - do **not** paste the full Markdown report back into chat after saving the artifact. The user asked for an HTML deliverable; repeating the Markdown makes the run feel like a normal report with an attachment bolted on.
Respond with a concise handoff:
Respond with a concise handoff that includes the next-step choices:
```text
🌐 last30days v{VERSION} · synced {YYYY-MM-DD}
📎 Shareable brief saved to <absolute HTML path>
I saved the full HTML brief locally. It is not uploaded or published anywhere.
What do you want to do next?
1. Open HTML file
2. Publish to <available HTML publishing service> (<service-specific note, e.g. ht-ml.app supports optional password protection>)
3. Done for now
```
If the host can safely open local files for the user and doing so matches the user's request, open the HTML file after it is written, leave the saved-path line in chat, and add `Opened locally.` Let the host choose the correct OS-specific mechanism; do not print a menu of shell commands. If opening fails or the host is headless, do not treat that as a failed report; show the path and say the file is ready to open in a browser.
If the user chooses open, open the HTML file when the host can safely open local files, leave the saved-path line in chat, and add `Opened locally.` Let the host choose the correct OS-specific mechanism; do not print a menu of shell commands. If opening fails or the host is headless, do not treat that as a failed report; show the path and say the file is ready to open in a browser.
### Normal report plus HTML copy
@@ -107,9 +150,14 @@ When the user asked for a normal `/last30days` report and also asked for an HTML
```text
📎 Shareable brief saved to <absolute HTML path>
What do you want to do next?
1. Open HTML file
2. Publish to <available HTML publishing service> (<service-specific note, e.g. ht-ml.app supports optional password protection>)
3. Done for now
```
If the host can safely open local files, open it for the user when that matches the request; otherwise the saved-path line is enough. Do not offer public publishing or upload in this flow. Hosted sharing is a separate opt-in capability and must not happen automatically.
If the user chooses open, open it when the host can safely open local files; otherwise the saved-path line is enough. Do not upload in this flow unless the user chooses a publishing option.
## What ends up in the HTML file
@@ -143,7 +191,10 @@ The engine will try to reuse `~/.config/last30days/last-report.json` for that se
- Do NOT change the file path convention. `${LAST30DAYS_MEMORY_DIR}/${SLUG}-brief.html` is the canonical location.
- Do NOT silently overwrite an existing file. The `--emit=html` output is written via a shell redirect (`>| "$HTML_PATH"`), which OVERWRITES the collision-guarded path — use `>|` not `>` because `set -o noclobber` refuses plain `>` when the file already exists. The collision guard in step 2 handles same-topic re-runs: if `{slug}-brief.html` already exists it date-suffixes to `{slug}-brief-YYYY-MM-DD.html`. Always report whichever path the redirect actually used in the chat handoff.
- Do NOT include the data quality warning text in the temp file or in your final chat line. Warnings are an engine-stderr concern, not an artifact concern.
- Do NOT publish, upload, or send the HTML to a third-party service. This reference only saves and opens local files.
- Do NOT publish, upload, or send the HTML to a third-party service as part of the local save flow.
- Do NOT publish to any service merely because HTML was requested. Show the saved path and next-step choices first; publishing requires the user to choose a publish option.
- Do NOT block a local HTML export on a hosting decision unless the user explicitly asked for a hosted URL.
- Do NOT paste or store the `update_key` in chat, Markdown, HTML, raw output, or companion metadata.
## Edge cases
+70
View File
@@ -158,6 +158,45 @@ def save_rendered_output(rendered_content: str, output_file: str) -> Path:
return out_path
def _publish_metadata_path(html_path: Path) -> Path:
return html_path.with_name(f"{html_path.name}.publish.json")
def _write_publish_metadata(html_path: Path, publish_result: dict[str, object]) -> None:
payload = {
"url": publish_result.get("url"),
"site_id": publish_result.get("site_id"),
"status": publish_result.get("status"),
"published_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
_publish_metadata_path(html_path).write_text(json.dumps(payload, indent=2), encoding="utf-8")
def publish_rendered_html(
rendered: str,
*,
password: str | None = None,
companion_paths: list[Path] | None = None,
) -> dict[str, object]:
from lib import html_publish
result = html_publish.publish_html(rendered, password=password)
metadata_errors: list[str] = []
for path in companion_paths or []:
try:
_write_publish_metadata(path, result)
except OSError as exc:
metadata_errors.append(f"{path}: {exc}")
if metadata_errors:
result = dict(result)
result["_metadata_errors"] = metadata_errors
return result
def _publish_password_for_args(args: argparse.Namespace) -> str | None:
return (args.publish_password or os.environ.get("LAST30DAYS_PUBLISH_PASSWORD") or None)
def emit_output(
report: schema.Report,
emit: str,
@@ -301,6 +340,10 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--save-dir", help="Optional directory for saving the rendered output")
parser.add_argument("--output", help="Optional exact file path for saving the rendered output")
parser.add_argument("--synthesis-file", help="Markdown synthesis to embed in --emit=html output")
parser.add_argument("--publish-html", action="store_true",
help="Publish --emit=html output to ht-ml.app (explicit opt-in; public by default)")
parser.add_argument("--publish-password",
help="Optional shared password for --publish-html; prefer LAST30DAYS_PUBLISH_PASSWORD to avoid exposing secrets in process lists")
parser.add_argument("--store", action="store_true", help="Persist ranked findings to the SQLite research store")
parser.add_argument("--x-handle", help="X handle for targeted supplemental search")
parser.add_argument("--x-related", help="Comma-separated related X handles (searched with lower weight)")
@@ -754,8 +797,11 @@ def _render_save_and_print(
save_path=footer_save_path,
synthesis_md=synthesis_md,
)
publish_companion_paths: list[Path] = []
if args.output:
output_path = save_rendered_output(rendered, args.output)
if args.emit == "html":
publish_companion_paths.append(output_path)
sys.stderr.write(f"[last30days] Saved output to {output_path}\n")
sys.stderr.flush()
if args.save_dir:
@@ -769,6 +815,8 @@ def _render_save_and_print(
topic_override=comparison_topic(entity_reports) if is_comparison_html else None,
rendered_content=rendered if is_comparison_html else None,
)
if args.emit == "html":
publish_companion_paths.append(save_path)
sys.stderr.write(f"[last30days] Saved output to {save_path}\n")
comparison_peer_paths: list[Path] = []
# Competitor / vs-mode: also save a per-entity raw file for each peer.
@@ -788,6 +836,25 @@ def _render_save_and_print(
f"peers={peers_display}\n"
)
sys.stderr.flush()
if args.publish_html:
try:
publish_result = publish_rendered_html(
rendered,
password=_publish_password_for_args(args),
companion_paths=publish_companion_paths,
)
sys.stderr.write(f"[last30days] Published HTML to {publish_result['url']}\n")
for warning in publish_result.get("_metadata_errors") or []:
sys.stderr.write(f"[last30days] Publish metadata warning: {warning}\n")
if publish_result.get("update_key"):
sys.stderr.write(
"[last30days] ht-ml.app returned an update key; not writing it "
"to stdout, HTML, or publish metadata.\n"
)
sys.stderr.flush()
except Exception as exc:
sys.stderr.write(f"[last30days] HTML publish failed: {exc}\n")
sys.stderr.flush()
print(rendered)
return 0
@@ -968,6 +1035,9 @@ def main() -> int:
if not topic:
parser.print_usage(sys.stderr)
return 2
if args.publish_html and args.emit != "html":
sys.stderr.write("[last30days] --publish-html requires --emit=html\n")
return 2
synthesis_md = None
if args.synthesis_file:
@@ -0,0 +1,73 @@
"""Optional hosted publishing for rendered HTML artifacts."""
from __future__ import annotations
import json
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
DEFAULT_ENDPOINT = "https://api.ht-ml.app/v1/sites"
class HtmlPublishError(RuntimeError):
"""Raised when the hosted HTML publish endpoint rejects the artifact."""
def publish_html(
html_content: str,
*,
password: str | None = None,
endpoint: str = DEFAULT_ENDPOINT,
opener: Callable[..., Any] | None = None,
timeout: int = 30,
) -> dict[str, Any]:
"""Publish a single HTML document and return the provider response."""
if not html_content.strip():
raise HtmlPublishError("HTML content is empty")
payload: dict[str, str] = {"html_content": html_content}
if password is not None:
payload["password"] = password
request = Request(
endpoint,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
open_fn = opener or urlopen
try:
with open_fn(request, timeout=timeout) as response:
body = response.read().decode("utf-8")
except HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise HtmlPublishError(_error_message(exc.code, detail)) from exc
except URLError as exc:
raise HtmlPublishError(str(exc.reason)) from exc
except OSError as exc:
raise HtmlPublishError(str(exc)) from exc
try:
result = json.loads(body)
except json.JSONDecodeError as exc:
raise HtmlPublishError("publish endpoint returned non-JSON response") from exc
if not isinstance(result, dict):
raise HtmlPublishError("publish endpoint returned unexpected JSON response")
url = result.get("url")
if not isinstance(url, str) or not url.startswith("https://"):
raise HtmlPublishError("publish endpoint response did not include a valid url")
return result
def _error_message(status: int, detail: str) -> str:
try:
payload = json.loads(detail)
except json.JSONDecodeError:
payload = {}
message = payload.get("message") if isinstance(payload, dict) else None
if message:
return f"{status}: {message}"
return f"{status}: {detail.strip() or 'publish failed'}"
+24
View File
@@ -29,6 +29,30 @@ def test_configuration_documents_new_safety_flags():
assert "--preflight" in text
assert "--save-dir" in text
assert "--output" in text
assert "--publish-html" in flags
assert "--publish-html" in text
assert "--publish-password" in flags
assert "--publish-password" in text
assert "LAST30DAYS_PUBLISH_PASSWORD" in text
def test_html_publish_reference_prompts_for_password_choice():
text = HTML_REFERENCE.read_text(encoding="utf-8")
publish_section = text[text.index("## Optional hosted publishing"):text.index("## What ends up in the HTML file")]
assert "Respect any existing user, project, or host preference for HTML publishing first" in publish_section
assert "If multiple publishing options are available, show each as its own choice" in publish_section
assert "label `ht-ml.app` as supporting optional password protection" in publish_section
assert "Show the absolute saved path" in publish_section
assert "Open HTML file" in publish_section
assert "Done for now" in publish_section
assert "Do not upload until the user chooses a publishing option" in publish_section
assert "ask a second question" in publish_section
assert "**Public link** - publish without a password" in publish_section
assert "**Password-protected link** - ask the user to type the shared password" in publish_section
assert "repeat the shared password they selected" in publish_section
assert "LAST30DAYS_PUBLISH_PASSWORD" in publish_section
assert '--output "$HTML_PATH"' in publish_section
assert "<HTML_PATH>.publish.json" in publish_section
def test_reddit_backend_env_var_is_documented_for_users_and_runtime_skill():
+269
View File
@@ -0,0 +1,269 @@
"""Tests for optional hosted HTML publishing."""
from __future__ import annotations
import io
import json
import os
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest import mock
from urllib.error import HTTPError
import last30days as cli
from lib import html_publish, schema
def _report(topic: str = "OpenClaw") -> schema.Report:
return schema.Report(
topic=topic,
range_from="2026-05-01",
range_to="2026-05-31",
generated_at="2026-05-31T00:00:00+00:00",
provider_runtime=schema.ProviderRuntime(
reasoning_provider="local",
planner_model="mock-planner",
rerank_model="mock-rerank",
),
query_plan=schema.QueryPlan(
intent="concept",
freshness_mode="balanced_recent",
cluster_mode="none",
raw_topic=topic,
subqueries=[
schema.SubQuery(
label="primary",
search_query=topic,
ranking_query=topic,
sources=["grounding"],
)
],
source_weights={"grounding": 1.0},
),
clusters=[],
ranked_candidates=[],
items_by_source={"grounding": []},
errors_by_source={},
)
def _diag() -> dict[str, object]:
return {
"available_sources": ["grounding"],
"providers": {"google": True, "openai": False, "xai": False},
"x_backend": None,
"bird_installed": True,
"bird_authenticated": False,
"bird_username": None,
"native_web_backend": "brave",
}
class _FakeResponse:
def __init__(self, payload: object):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self) -> bytes:
return json.dumps(self.payload).encode("utf-8")
class HtmlPublishModuleTests(unittest.TestCase):
def test_publish_posts_html_and_password(self):
captured = {}
def opener(request, timeout):
captured["timeout"] = timeout
captured["url"] = request.full_url
captured["headers"] = dict(request.header_items())
captured["body"] = json.loads(request.data.decode("utf-8"))
return _FakeResponse(
{
"url": "https://example.ht-ml.app",
"site_id": "site_123",
"status": "active",
"update_key": "secret-update-key",
}
)
result = html_publish.publish_html(
"<html>ok</html>",
password="share-pass",
opener=opener,
)
self.assertEqual("https://api.ht-ml.app/v1/sites", captured["url"])
self.assertEqual({"html_content": "<html>ok</html>", "password": "share-pass"}, captured["body"])
self.assertEqual("https://example.ht-ml.app", result["url"])
self.assertEqual("secret-update-key", result["update_key"])
def test_publish_rejects_http_error_with_message(self):
def opener(_request, timeout):
raise HTTPError(
"https://api.ht-ml.app/v1/sites",
400,
"Bad Request",
{},
io.BytesIO(b'{"message":"HTML content is required"}'),
)
with self.assertRaisesRegex(html_publish.HtmlPublishError, "HTML content is required"):
html_publish.publish_html("<html></html>", opener=opener)
def test_publish_rejects_json_response_that_is_not_an_object(self):
def opener(_request, timeout):
return _FakeResponse(["https://site.ht-ml.app"])
with self.assertRaisesRegex(html_publish.HtmlPublishError, "unexpected JSON response"):
html_publish.publish_html("<html></html>", opener=opener)
class HtmlPublishCliTests(unittest.TestCase):
def test_publish_requires_html_emit(self):
with mock.patch.object(sys, "argv", [
"last30days.py",
"OpenClaw",
"--emit=md",
"--publish-html",
]), mock.patch.object(cli.env, "get_config", return_value={}):
stderr = io.StringIO()
with redirect_stderr(stderr):
rc = cli.main()
self.assertEqual(2, rc)
self.assertIn("--publish-html requires --emit=html", stderr.getvalue())
def test_publish_writes_url_metadata_without_update_key(self):
with tempfile.TemporaryDirectory() as tmp:
output_path = Path(tmp) / "brief.html"
with mock.patch.object(cli.env, "get_config", return_value={}), \
mock.patch.object(cli.pipeline, "diagnose", return_value=_diag()), \
mock.patch.object(cli.pipeline, "run", return_value=_report()), \
mock.patch.object(cli, "emit_output", return_value="<html>brief</html>"), \
mock.patch.object(cli, "publish_rendered_html", wraps=cli.publish_rendered_html) as publish_wrapper, \
mock.patch("lib.html_publish.publish_html", return_value={
"url": "https://site.ht-ml.app",
"site_id": "site_123",
"status": "active",
"update_key": "secret-update-key",
}), \
mock.patch.object(sys, "argv", [
"last30days.py",
"OpenClaw",
"--emit=html",
"--output",
str(output_path),
"--publish-html",
]), \
mock.patch.dict(os.environ, {"LAST30DAYS_SKIP_PREFLIGHT": "1"}, clear=False):
stdout = io.StringIO()
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
rc = cli.main()
self.assertEqual(0, rc)
publish_wrapper.assert_called_once()
self.assertEqual("<html>brief</html>", output_path.read_text(encoding="utf-8"))
self.assertIn("Published HTML to https://site.ht-ml.app", stderr.getvalue())
self.assertNotIn("secret-update-key", stdout.getvalue())
self.assertNotIn("secret-update-key", stderr.getvalue())
metadata = json.loads((Path(str(output_path) + ".publish.json")).read_text(encoding="utf-8"))
self.assertEqual("https://site.ht-ml.app", metadata["url"])
self.assertNotIn("update_key", metadata)
def test_publish_uses_password_from_environment(self):
with tempfile.TemporaryDirectory() as tmp:
output_path = Path(tmp) / "brief.html"
with mock.patch.object(cli.env, "get_config", return_value={}), \
mock.patch.object(cli.pipeline, "diagnose", return_value=_diag()), \
mock.patch.object(cli.pipeline, "run", return_value=_report()), \
mock.patch.object(cli, "emit_output", return_value="<html>brief</html>"), \
mock.patch("lib.html_publish.publish_html", return_value={
"url": "https://site.ht-ml.app",
}) as publish_mock, \
mock.patch.object(sys, "argv", [
"last30days.py",
"OpenClaw",
"--emit=html",
"--output",
str(output_path),
"--publish-html",
]), \
mock.patch.dict(os.environ, {
"LAST30DAYS_SKIP_PREFLIGHT": "1",
"LAST30DAYS_PUBLISH_PASSWORD": "share-pass",
}, clear=False):
stdout = io.StringIO()
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
rc = cli.main()
self.assertEqual(0, rc)
self.assertEqual("share-pass", publish_mock.call_args.kwargs["password"])
def test_publish_metadata_failure_still_reports_url(self):
with tempfile.TemporaryDirectory() as tmp:
output_path = Path(tmp) / "brief.html"
with mock.patch.object(cli.env, "get_config", return_value={}), \
mock.patch.object(cli.pipeline, "diagnose", return_value=_diag()), \
mock.patch.object(cli.pipeline, "run", return_value=_report()), \
mock.patch.object(cli, "emit_output", return_value="<html>brief</html>"), \
mock.patch("lib.html_publish.publish_html", return_value={
"url": "https://site.ht-ml.app",
"site_id": "site_123",
}), \
mock.patch.object(cli, "_write_publish_metadata", side_effect=PermissionError("denied")), \
mock.patch.object(sys, "argv", [
"last30days.py",
"OpenClaw",
"--emit=html",
"--output",
str(output_path),
"--publish-html",
]), \
mock.patch.dict(os.environ, {"LAST30DAYS_SKIP_PREFLIGHT": "1"}, clear=False):
stdout = io.StringIO()
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
rc = cli.main()
self.assertEqual(0, rc)
self.assertIn("Published HTML to https://site.ht-ml.app", stderr.getvalue())
self.assertIn("Publish metadata warning", stderr.getvalue())
def test_publish_failure_preserves_local_output(self):
with tempfile.TemporaryDirectory() as tmp:
output_path = Path(tmp) / "brief.html"
with mock.patch.object(cli.env, "get_config", return_value={}), \
mock.patch.object(cli.pipeline, "diagnose", return_value=_diag()), \
mock.patch.object(cli.pipeline, "run", return_value=_report()), \
mock.patch.object(cli, "emit_output", return_value="<html>brief</html>"), \
mock.patch("lib.html_publish.publish_html", side_effect=html_publish.HtmlPublishError("timeout")), \
mock.patch.object(sys, "argv", [
"last30days.py",
"OpenClaw",
"--emit=html",
"--output",
str(output_path),
"--publish-html",
]), \
mock.patch.dict(os.environ, {"LAST30DAYS_SKIP_PREFLIGHT": "1"}, clear=False):
stdout = io.StringIO()
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
rc = cli.main()
self.assertEqual(0, rc)
self.assertEqual("<html>brief</html>", output_path.read_text(encoding="utf-8"))
self.assertIn("HTML publish failed: timeout", stderr.getvalue())
if __name__ == "__main__":
unittest.main()
+18 -1
View File
@@ -34,6 +34,9 @@ def test_html_deliverable_is_artifact_first_not_full_markdown_repeat():
assert 'echo "📎 Shareable brief saved to $HTML_PATH"' not in text
assert "do **not** paste the full Markdown report back into chat" in text
assert "The user asked for an HTML deliverable" in text
assert "What do you want to do next?" in text
assert "1. Open HTML file" in text
assert "3. Done for now" in text
def test_html_handoff_opens_locally_without_os_command_menu():
@@ -47,8 +50,22 @@ def test_html_handoff_opens_locally_without_os_command_menu():
def test_html_save_flow_does_not_publish_or_upload():
text = SAVE_HTML.read_text(encoding="utf-8")
assert "Do not offer public publishing or upload in this flow" in text
assert "Do not upload in this flow unless the user chooses a publishing option" in text
assert "Do NOT publish, upload, or send the HTML to a third-party service" in text
assert "Do NOT block a local HTML export on a hosting decision" in text
assert "Show the saved path and next-step choices first" in text
def test_markdown_and_html_access_paths_are_separate():
text = SKILL_MD.read_text(encoding="utf-8")
start = text.index("**Saved artifact access flow:**")
section = text[start:start + 1600]
assert "**Markdown file requested:**" in section
assert "Do not offer hosted publishing for Markdown" in section
assert "**HTML file requested:**" in section
assert "show the absolute path" in section
assert "open the HTML file, publish to an available/preferred HTML publishing service, or done for now" in section
def test_follow_up_turn_preserves_html_deliverable_mode():