diff --git a/cmd/engineering-dashboard/main.go b/cmd/engineering-dashboard/main.go new file mode 100644 index 00000000..43edb2a3 --- /dev/null +++ b/cmd/engineering-dashboard/main.go @@ -0,0 +1,115 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "chat-agent-system/internal/engineeringdashboard" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + cmd := os.Args[1] + args := os.Args[2:] + switch cmd { + case "serve": + if err := runServe(args); err != nil { + fmt.Fprintf(os.Stderr, "serve: %v\n", err) + os.Exit(1) + } + case "export": + if err := runExport(args); err != nil { + fmt.Fprintf(os.Stderr, "export: %v\n", err) + os.Exit(1) + } + case "help", "-h", "--help": + usage() + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n", cmd) + usage() + os.Exit(2) + } +} + +func usage() { + fmt.Fprintf(os.Stderr, `engineering-dashboard — local read-only personal engineering control center + +Usage: + engineering-dashboard serve [--repo PATH] [--addr 127.0.0.1:PORT] + engineering-dashboard export [--repo PATH] [--output FILE] + +Does not write docs, operate git/docker, deploy, or control Herdr. +`) +} + +func parseFlags(args []string) (repo, addr, output string) { + addr = "127.0.0.1:8787" + output = "engineering-dashboard-snapshot.html" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--repo": + i++ + if i < len(args) { + repo = args[i] + } + case "--addr": + i++ + if i < len(args) { + addr = args[i] + } + case "--output": + i++ + if i < len(args) { + output = args[i] + } + } + } + return repo, addr, output +} + +func runServe(args []string) error { + repo, addr, _ := parseFlags(args) + root, err := engineeringdashboard.ResolveRepoRoot(repo) + if err != nil { + return err + } + if !strings.HasPrefix(addr, "127.0.0.1:") && !strings.HasPrefix(addr, "localhost:") && !strings.HasPrefix(addr, "[::1]:") { + return fmt.Errorf("addr must be loopback (got %s)", addr) + } + c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{}) + ctx := context.Background() + if _, err := c.Refresh(ctx); err != nil { + fmt.Fprintf(os.Stderr, "warning: initial refresh: %v\n", err) + } + srv, err := engineeringdashboard.NewServer(c) + if err != nil { + return err + } + fmt.Printf("engineering-dashboard listening on http://%s/?token=%s\n", addr, srv.Token) + fmt.Printf("repo=%s\n", root) + return engineeringdashboard.ListenAndServe(addr, srv.Handler()) +} + +func runExport(args []string) error { + repo, _, output := parseFlags(args) + root, err := engineeringdashboard.ResolveRepoRoot(repo) + if err != nil { + return err + } + if !filepath.IsAbs(output) { + wd, _ := os.Getwd() + output = filepath.Join(wd, output) + } + c := engineeringdashboard.NewCollector(root, engineeringdashboard.ExecRunner{}) + snap, err := c.Refresh(context.Background()) + if err != nil { + return err + } + return engineeringdashboard.ExportHTML(*snap, output) +} diff --git a/internal/engineeringdashboard/assets/app.css b/internal/engineeringdashboard/assets/app.css new file mode 100644 index 00000000..36febf7d --- /dev/null +++ b/internal/engineeringdashboard/assets/app.css @@ -0,0 +1,95 @@ +:root { + --bg: #0f1419; + --panel: #1a222c; + --text: #e7ecf1; + --muted: #9aa7b5; + --accent: #3d8bfd; + --warn: #d4a017; + --bad: #d64545; + --ok: #3d9a6a; + --border: #2b3642; + --font: "IBM Plex Sans", "Noto Sans SC", sans-serif; + --mono: "IBM Plex Mono", ui-monospace, monospace; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: var(--font); + background: radial-gradient(1200px 600px at 10% -10%, #1b2735, var(--bg)); + color: var(--text); + min-height: 100vh; +} +.skip { + position: absolute; left: -9999px; +} +.skip:focus { left: 1rem; top: 1rem; background: #fff; color: #000; padding: .5rem; z-index: 9; } +.top { + display: flex; flex-wrap: wrap; justify-content: space-between; gap: 1rem; + padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); + background: rgba(26,34,44,.85); backdrop-filter: blur(6px); +} +.top h1 { margin: 0; font-size: 1.35rem; letter-spacing: .02em; } +.meta { margin: .35rem 0 0; color: var(--muted); font-size: .85rem; font-family: var(--mono); } +.banner { margin: 0; color: var(--warn); font-size: .85rem; max-width: 28rem; } +.layout { display: grid; grid-template-columns: 16rem 1fr; min-height: calc(100vh - 5rem); } +.nav-toggle { display: none; margin: .75rem; } +nav { + border-right: 1px solid var(--border); padding: 1rem; background: var(--panel); +} +nav ul { list-style: none; margin: 0; padding: 0; } +nav li { margin: 0 0 .35rem; } +.nav-btn { + width: 100%; text-align: left; background: transparent; color: var(--text); + border: 1px solid transparent; padding: .5rem .65rem; border-radius: .35rem; cursor: pointer; +} +.nav-btn.active, .nav-btn:hover, .nav-btn:focus-visible { + border-color: var(--accent); background: #243041; +} +.search-label { display: block; margin-top: 1rem; font-size: .8rem; color: var(--muted); } +#q { + width: 100%; margin-top: .35rem; padding: .45rem .55rem; border-radius: .35rem; + border: 1px solid var(--border); background: #10161d; color: var(--text); +} +.source-strip { margin-top: 1rem; font-size: .75rem; color: var(--muted); } +.source-strip .chip { + display: inline-block; margin: .15rem .2rem 0 0; padding: .15rem .4rem; + border: 1px solid var(--border); border-radius: .25rem; +} +.chip.ok { border-color: var(--ok); } +.chip.stale, .chip.degraded { border-color: var(--warn); color: var(--warn); } +.chip.error { border-color: var(--bad); color: var(--bad); } +main { padding: 1rem 1.25rem 2rem; overflow: auto; } +.view-status { min-height: 1.25rem; color: var(--muted); font-size: .85rem; margin-bottom: .75rem; } +.panel { + background: rgba(26,34,44,.7); border: 1px solid var(--border); border-radius: .5rem; + padding: 1rem; margin-bottom: 1rem; +} +.panel h2 { margin: 0 0 .75rem; font-size: 1.05rem; } +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th, td { text-align: left; padding: .45rem .5rem; border-bottom: 1px solid var(--border); vertical-align: top; } +th { color: var(--muted); font-weight: 600; } +.badge { + display: inline-block; padding: .1rem .4rem; border-radius: .25rem; + border: 1px solid var(--border); font-size: .75rem; margin-right: .25rem; +} +.badge.warn { border-color: var(--warn); color: var(--warn); } +.badge.bad { border-color: var(--bad); color: var(--bad); } +.pre { + white-space: pre-wrap; word-break: break-word; font-family: var(--mono); + font-size: .82rem; background: #0c1117; padding: .75rem; border-radius: .35rem; + border: 1px solid var(--border); max-height: 28rem; overflow: auto; +} +.axes { display: grid; grid-template-columns: repeat(4, minmax(8rem, 1fr)); gap: .5rem; } +.axis { border: 1px solid var(--border); border-radius: .35rem; padding: .5rem; font-size: .8rem; } +.axis strong { display: block; color: var(--muted); margin-bottom: .25rem; } +a, button.linkish { + color: var(--accent); background: none; border: none; cursor: pointer; padding: 0; font: inherit; +} +@media (max-width: 860px) { + .layout { grid-template-columns: 1fr; } + .nav-toggle { display: inline-block; } + nav { display: none; border-right: 0; border-bottom: 1px solid var(--border); } + nav.open { display: block; } + .axes { grid-template-columns: 1fr 1fr; } +} diff --git a/internal/engineeringdashboard/assets/app.js b/internal/engineeringdashboard/assets/app.js new file mode 100644 index 00000000..eecb4d03 --- /dev/null +++ b/internal/engineeringdashboard/assets/app.js @@ -0,0 +1,300 @@ +(function () { + const token = window.__DASHBOARD_TOKEN__; + const state = { + snap: null, + view: "overview", + q: "", + selectedDoc: "", + scrollY: 0, + }; + + const el = { + content: document.getElementById("content"), + meta: document.getElementById("repo-meta"), + sources: document.getElementById("source-strip"), + status: document.getElementById("view-status"), + q: document.getElementById("q"), + navToggle: document.getElementById("nav-toggle"), + nav: document.getElementById("nav"), + }; + + function qs() { + const u = new URL(window.location.href); + return u.searchParams; + } + + function syncURL() { + const u = new URL(window.location.href); + u.searchParams.set("token", token); + u.searchParams.set("view", state.view); + if (state.q) u.searchParams.set("q", state.q); else u.searchParams.delete("q"); + if (state.selectedDoc) u.searchParams.set("doc", state.selectedDoc); else u.searchParams.delete("doc"); + history.replaceState(null, "", u.toString()); + } + + function loadURL() { + const p = qs(); + state.view = p.get("view") || "overview"; + state.q = p.get("q") || ""; + state.selectedDoc = p.get("doc") || ""; + el.q.value = state.q; + document.querySelectorAll(".nav-btn").forEach((b) => { + b.classList.toggle("active", b.dataset.view === state.view); + }); + } + + async function fetchSnapshot(force) { + el.status.textContent = state.snap ? "刷新中…" : "首次加载中…"; + const url = "/api/v1/snapshot?token=" + encodeURIComponent(token) + (force ? "&refresh=1" : ""); + const headers = {}; + if (state.snap && state.snap.revision_id) headers["If-None-Match"] = '"' + state.snap.revision_id + '"'; + const res = await fetch(url, { headers, credentials: "same-origin" }); + if (res.status === 304) { + el.status.textContent = "已是最新 revision " + state.snap.revision_id; + return false; + } + if (!res.ok) { + el.status.textContent = "加载失败 HTTP " + res.status; + if (!state.snap) renderHardError("无法获取快照"); + return false; + } + state.snap = await res.json(); + el.status.textContent = "generation " + state.snap.generation_id + " · revision " + state.snap.revision_id; + return true; + } + + function escapeText(s) { + const d = document.createElement("div"); + d.textContent = s == null ? "" : String(s); + return d.innerHTML; + } + + function badge(text, cls) { + return '' + escapeText(text) + ""; + } + + function renderMeta() { + const s = state.snap; + if (!s) return; + el.meta.textContent = [ + s.repository.root, + "branch=" + s.repository.branch, + "HEAD=" + (s.repository.head || "").slice(0, 12), + s.repository.dirty ? "dirty" : "clean", + "at " + s.generated_at, + ].join(" · "); + el.sources.innerHTML = (s.sources || []).map((src) => { + const cls = src.freshness === "stale" || src.status === "degraded" ? "stale" : + src.status === "error" ? "error" : "ok"; + return '' + escapeText(src.name + ":" + src.status + "/" + src.freshness) + ""; + }).join(""); + } + + function matchQuery(text) { + if (!state.q) return true; + return String(text || "").toLowerCase().includes(state.q.toLowerCase()); + } + + function renderOverview() { + const s = state.snap; + const tasks = (s.tasks || []).filter((t) => ["planned", "in_progress", "blocked"].includes(t.doc_status) || matchQuery(t.title + t.path)); + const conflicts = s.conflicts || []; + let html = '

焦点任务

'; + tasks.slice(0, 40).forEach((t) => { + html += ""; + html += ""; + html += ""; + html += ""; + const dep = t.deployment || {}; + html += ""; + }); + html += "
任务计划Git验证实装
" + escapeText(t.title) + "
" + escapeText(t.path) + "
" + badge(t.doc_status) + (t.claim && t.claim.claimed ? badge("claimed") : badge("unclaimed", "warn")) + "" + escapeText((t.git && t.git.branch) || "—") + " " + escapeText((t.git && t.git.cleanliness) || "") + "" + escapeText((t.verification && t.verification.result) || "unknown") + "" + escapeText(Object.keys(dep).map((k) => k + ":" + (dep[k].app_readiness || "unknown")).join(" ")) + "
"; + html += '

漂移 / 冲突

'; + if (!conflicts.length) html += "

无冲突

"; + else { + html += ""; + } + html += "
"; + el.content.innerHTML = html; + } + + function renderModules() { + const rows = (state.snap.modules || []).filter((m) => matchQuery(m.name + (m.mutex || ""))); + let html = '

模块

'; + rows.forEach((m) => { + html += ""; + }); + html += "
模块状态mutex
" + escapeText(m.name) + "" + escapeText(m.status) + "" + escapeText(m.mutex) + "
"; + el.content.innerHTML = html; + } + + function renderTasks() { + const rows = (state.snap.tasks || []).filter((t) => matchQuery(t.title + t.path + (t.claim && t.claim.owner || ""))); + let html = ""; + rows.forEach((t) => { + html += '

' + escapeText(t.title) + "

"; + html += '
'; + html += '
计划' + escapeText(t.doc_status) + "
" + escapeText((t.claim && t.claim.owner) || "unassigned") + " · " + escapeText((t.claim && t.claim.agent_kind) || "") + " · activity=" + escapeText((t.claim && t.claim.activity) || "unknown") + "
"; + html += '
Git 实施' + escapeText(JSON.stringify(t.git || {})) + "
"; + html += '
验证' + escapeText(JSON.stringify(t.verification || {})) + "
"; + html += '
实装' + escapeText(JSON.stringify(t.deployment || {})) + "
"; + html += "
"; + if (t.evidence_chain && t.evidence_chain.length) { + html += "

证据链

"; + } + html += "
"; + }); + el.content.innerHTML = html || "

无匹配任务

"; + } + + function renderGit() { + const rows = (state.snap.git || []).filter((g) => matchQuery(g.name + (g.worktree || ""))); + let html = '

分支 / worktree

远端状态基于本地 refs,未联网刷新

'; + rows.forEach((g) => { + html += ""; + }); + html += "
分支ahead/behindworktree备注
" + escapeText(g.name) + (g.is_current ? " *" : "") + "" + escapeText(g.ahead + "/" + g.behind) + "" + escapeText(g.worktree || "") + "" + escapeText(g.remote_note || "") + "
"; + el.content.innerHTML = html; + } + + function renderDeploy() { + const rows = state.snap.environments || []; + let html = ""; + rows.forEach((e) => { + html += '

环境 ' + escapeText(e.name) + "

"; + html += "

" + badge("container:" + (e.facets && e.facets.container_status)) + badge("ready:" + (e.facets && e.facets.app_readiness)) + badge("services:" + (e.facets && e.facets.services_ok), e.facets && e.facets.services_ok === "partial" ? "warn" : "") + "

"; + if (e.drift_notes && e.drift_notes.length) { + html += ""; + } + if (e.containers && e.containers.length) { + html += '
'; + e.containers.forEach((c) => { + html += ""; + }); + html += "
容器状态healthimage
" + escapeText(c.name) + "" + escapeText(c.status) + "" + escapeText(c.health) + "" + escapeText(c.image) + "
"; + } + html += "
"; + }); + el.content.innerHTML = html; + } + + function renderDocs() { + const rows = (state.snap.documents || []).filter((d) => { + if (!state.q) return true; + return matchQuery(d.title + d.path + (d.body || "")); + }); + rows.sort((a, b) => { + const score = (d) => { + let s = 0; + if ((d.status || "") === "in_progress" || (d.status || "") === "blocked") s += 2; + if ((d.path || "").includes("short-term")) s += 1; + return -s; + }; + const d = score(a) - score(b); + return d !== 0 ? d : String(a.path).localeCompare(String(b.path)); + }); + let html = '

文档

'; + rows.slice(0, 200).forEach((d) => { + html += '"; + }); + html += "
标题路径状态
" + escapeText(d.path) + "" + escapeText(d.status || "") + "
"; + if (state.selectedDoc) { + const doc = (state.snap.documents || []).find((d) => d.id === state.selectedDoc || d.path === state.selectedDoc); + html += '

正文(纯文本)

'; + if (!doc) html += "

未找到文档

"; + else { + html += "

" + escapeText(doc.path) + (doc.body_truncated ? " · truncated" : "") + "

"; + html += '
' + escapeText(doc.body || "") + "
"; + } + html += "
"; + } + el.content.innerHTML = html; + } + + function renderEvidence() { + const tasks = (state.snap.tasks || []).filter((t) => (t.evidence_chain || []).length && matchQuery(t.title)); + let html = '

证据链导航

'; + tasks.forEach((t) => { + html += "

" + escapeText(t.title) + "

    " + (t.evidence_chain || []).map((p) => '
  1. ").join("") + "
"; + }); + html += "
"; + el.content.innerHTML = html; + } + + function renderHardError(msg) { + el.content.innerHTML = '

错误

' + escapeText(msg) + "

无可用缓存快照。

"; + } + + function render() { + if (!state.snap) return; + renderMeta(); + const y = window.scrollY; + switch (state.view) { + case "modules": renderModules(); break; + case "tasks": renderTasks(); break; + case "git": renderGit(); break; + case "deploy": renderDeploy(); break; + case "docs": renderDocs(); break; + case "evidence": renderEvidence(); break; + default: renderOverview(); + } + window.scrollTo(0, state.scrollY || y); + syncURL(); + } + + document.querySelectorAll(".nav-btn").forEach((btn) => { + btn.addEventListener("click", () => { + state.view = btn.dataset.view; + state.scrollY = 0; + document.querySelectorAll(".nav-btn").forEach((b) => b.classList.toggle("active", b === btn)); + if (el.nav) el.nav.classList.remove("open"); + if (el.navToggle) el.navToggle.setAttribute("aria-expanded", "false"); + render(); + }); + }); + + el.content.addEventListener("click", (ev) => { + const t = ev.target.closest("[data-doc]"); + if (!t) return; + state.selectedDoc = t.getAttribute("data-doc"); + state.view = "docs"; + document.querySelectorAll(".nav-btn").forEach((b) => b.classList.toggle("active", b.dataset.view === "docs")); + render(); + }); + + el.q.addEventListener("input", () => { + state.q = el.q.value.trim(); + render(); + }); + + if (el.navToggle) { + el.navToggle.addEventListener("click", () => { + const open = el.nav.classList.toggle("open"); + el.navToggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + } + + window.addEventListener("popstate", () => { + loadURL(); + render(); + }); + + async function loop() { + loadURL(); + try { + const changed = await fetchSnapshot(true); + if (changed || state.snap) render(); + } catch (e) { + el.status.textContent = "网络错误"; + if (!state.snap) renderHardError(String(e)); + } + setInterval(async () => { + try { + const changed = await fetchSnapshot(false); + if (changed) render(); + } catch (_) { /* keep last snapshot */ } + }, 4000); + } + + loop(); +})(); diff --git a/internal/engineeringdashboard/assets/index.html b/internal/engineeringdashboard/assets/index.html new file mode 100644 index 00000000..88c76f46 --- /dev/null +++ b/internal/engineeringdashboard/assets/index.html @@ -0,0 +1,43 @@ + + + + + + 个人工程中控台 + + + + +
+
+

个人工程中控台

+

加载中…

+
+ +
+
+ + +
+
+
+
+
+ + + + diff --git a/internal/engineeringdashboard/assets_embed.go b/internal/engineeringdashboard/assets_embed.go new file mode 100644 index 00000000..21e971a7 --- /dev/null +++ b/internal/engineeringdashboard/assets_embed.go @@ -0,0 +1,10 @@ +package engineeringdashboard + +import "embed" + +//go:embed assets/index.html assets/app.js assets/app.css +var assetFS embed.FS + +func readAsset(name string) ([]byte, error) { + return assetFS.ReadFile(name) +} diff --git a/internal/engineeringdashboard/export.go b/internal/engineeringdashboard/export.go new file mode 100644 index 00000000..db7ede24 --- /dev/null +++ b/internal/engineeringdashboard/export.go @@ -0,0 +1,156 @@ +package engineeringdashboard + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ExportManifest describes what was embedded in an offline HTML. +type ExportManifest struct { + Included []string `json:"included"` + Truncated []string `json:"truncated"` + MetadataOnly []string `json:"metadata_only"` + SensitiveWarn []string `json:"sensitive_warnings,omitempty"` +} + +// ExportHTML writes a self-contained offline snapshot (mode 0600). +func ExportHTML(snap Snapshot, outputPath string) error { + css, err := readAsset("assets/app.css") + if err != nil { + return err + } + + manifest := ExportManifest{} + docs := make([]DocumentNode, 0, len(snap.Documents)) + for _, d := range snap.Documents { + cp := d + if IsAssetsPath(cp.Path) { + cp.Body = "" + manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path) + } else if cp.BodyTruncated { + manifest.Truncated = append(manifest.Truncated, cp.Path) + manifest.Included = append(manifest.Included, cp.Path) + } else if cp.Body != "" { + manifest.Included = append(manifest.Included, cp.Path) + } else { + manifest.MetadataOnly = append(manifest.MetadataOnly, cp.Path) + } + // strip any accidental absolute secret-looking paths from body display only — keep relative + if strings.Contains(strings.ToLower(cp.Body), "/.secrets/") { + manifest.SensitiveWarn = append(manifest.SensitiveWarn, cp.Path+": contains .secrets path mention") + } + docs = append(docs, cp) + } + exportSnap := snap + exportSnap.Documents = docs + + payload, err := MarshalSnapshotJSON(exportSnap) + if err != nil { + return err + } + // HTML-safe JSON: break sequences + safeJSON := strings.ReplaceAll(string(payload), "<", "\\u003c") + safeJSON = strings.ReplaceAll(safeJSON, ">", "\\u003e") + safeJSON = strings.ReplaceAll(safeJSON, "&", "\\u0026") + safeJSON = strings.ReplaceAll(safeJSON, " + + + + +工程中控台快照 %s + + + + +
+
+

个人工程中控台(离线快照)

+

repo=%s · HEAD=%s · generation=%s · revision=%s · generated_at=%s

+
+ +
+
+
+

覆盖清单

+

+  
+
+
+ + + + + +`, snap.RevisionID, string(css), snap.Repository.Root, snap.Repository.Head, snap.GenerationID, snap.RevisionID, snap.GeneratedAt.Format(timeRFC3339), safeJSON, manSafe) + + dir := filepath.Dir(outputPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".engdash-*.html") + if err != nil { + return err + } + tmpName := tmp.Name() + if _, err := tmp.Write([]byte(html)); err != nil { + tmp.Close() + _ = os.Remove(tmpName) + return err + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + _ = os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpName) + return err + } + if err := os.Rename(tmpName, outputPath); err != nil { + _ = os.Remove(tmpName) + return err + } + return nil +} + +const timeRFC3339 = "2006-01-02T15:04:05Z07:00" diff --git a/internal/engineeringdashboard/export_test.go b/internal/engineeringdashboard/export_test.go new file mode 100644 index 00000000..f6785f8d --- /dev/null +++ b/internal/engineeringdashboard/export_test.go @@ -0,0 +1,56 @@ +package engineeringdashboard + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestExport_OfflineReadableAndSafe(t *testing.T) { + dir := t.TempDir() + out := filepath.Join(dir, "snap.html") + snap := Snapshot{ + SchemaVersion: SchemaVersion, + GenerationID: "gen_export", + RevisionID: "rev_export", + GeneratedAt: time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC), + Repository: RepositoryIdentity{Root: "/repo", Branch: "dev", Head: "deadbeef"}, + Tasks: []TaskState{SampleRDevTaskFixture()}, + Conflicts: []Conflict{NewConflict("env:main", "health", ConflictDeclaredVsObserved, []string{"a", "b"}, "no aggregate green")}, + Documents: []DocumentNode{{ + ID: ".doc/ops/note.md", Path: ".doc/ops/note.md", Title: "Note", + Body: "hello and more", + }}, + Environments: []EnvironmentState{SampleMainEventsForkFixture()}, + Git: []GitBranchView{SampleDevAheadGitFixture()}, + } + if err := ExportHTML(snap, out); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(out) + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0o600 { + t.Fatalf("perm %o", fi.Mode().Perm()) + } + raw, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + html := string(raw) + if !strings.Contains(html, "gen_export") || !strings.Contains(html, "rev_export") { + t.Fatal("missing snapshot identity") + } + if strings.Contains(html, "