Parcourir la source

ui: heredoc completion reports — markdown summary card + timeline first-line snippets

clanker il y a 1 mois
Parent
commit
478be0a6fb

+ 22 - 2
src/Services/TimelineService.vala

@@ -88,8 +88,12 @@ namespace ClankerSaddle.Services {
                 if (task.completed_at != null) {
                     bool done = task.status == STATUS_DONE;
                     string text = done ? "Task completed" : "Task failed";
-                    if ((task.summary ?? "").length > 0) {
-                        text += ": " + snippet(task.summary);
+                    // Heredoc markdown summaries open with their
+                    // headline: the event snippet carries the first
+                    // line only (fallback: the bare verdict text).
+                    string? lead = first_line(task.summary);
+                    if (lead != null) {
+                        text += ": " + snippet((!)lead);
                     }
                     events.add(new TimelineEvent() {
                         kind = done ? "completed" : "failed",
@@ -186,5 +190,21 @@ namespace ClankerSaddle.Services {
             string flat = body.replace("\n", " ");
             return flat.length > SNIPPET_MAX ? flat.substring(0, SNIPPET_MAX) + "…" : flat;
         }
+
+        /**
+         * The first non-empty line of a summary (a markdown report's
+         * headline), or null when the summary holds nothing usable.
+         */
+        private static string? first_line(string? body) {
+            if (body == null) {
+                return null;
+            }
+            string trimmed = ((!)body).strip();
+            if (trimmed.length == 0) {
+                return null;
+            }
+            int cut = trimmed.index_of("\n");
+            return cut >= 0 ? trimmed.substring(0, cut).strip() : trimmed;
+        }
     }
 }

+ 14 - 0
src/entrypoints/TaskDetailEntrypoint.vala

@@ -135,6 +135,12 @@ namespace ClankerSaddle {
         public string created_display { get; set; default = ""; }
         public string started_display { get; set; default = ""; }
         public string completed_display { get; set; default = ""; }
+        /**
+         * The completion-report card heading — "Completion report" for
+         * done, "Failure summary" for failed; empty when the card is
+         * absent (non-terminal task or no stored summary).
+         */
+        public string report_title { get; set; default = ""; }
         public Statum.Model.ActionDto? update { get; set; }
         public Statum.Model.ActionDto? comment { get; set; }
         public Statum.Model.ActionDto? save_plan { get; set; }
@@ -218,6 +224,14 @@ namespace ClankerSaddle {
             pub.editable_all = pub.draft;
             pub.editable_priority = !pub.terminal;
             pub.editable_capabilities = pub.draft || task.status == STATUS_QUEUED;
+            // The heredoc completion report (`cat << 'EOF' | saddle
+            // done` / the failure path's stored summary): terminal
+            // tasks render tasks.summary as markdown in the
+            // completion-report card below the status/description
+            // grid; an empty summary renders no card at all.
+            if (pub.terminal && pub.summary.length > 0) {
+                pub.report_title = pub.failed ? "Failure summary" : "Completion report";
+            }
 
             var sealed = new TaskActionPrivate() { task_id = task.id };
             pub.update = actions.author_private<UpdateTaskAction, TaskActionPrivate>(sealed);

+ 13 - 1
src/pages/task_detail.html

@@ -314,10 +314,22 @@
                     <h3>Description</h3>
                     <div stm-if="task.description.length > 0" class="md" stm-markdown="task.description">description</div>
                     <p class="muted" stm-if="task.description.length == 0">No description.</p>
-                    <p stm-if="task.summary.length > 0"><strong>Summary:</strong> <span stm-text="task.summary">summary</span></p>
                 </section>
             </div>
 
+            <!-- The heredoc completion report (`cat << 'EOF' | saddle
+                 done`): terminal tasks render tasks.summary as
+                 markdown (XSS-safe via stm-markdown) in this
+                 full-width card directly below the status/description
+                 grid — "Completion report" when done, "Failure
+                 summary" when failed; an empty summary renders no
+                 card. -->
+            <section class="card completion-report"
+                     stm-if="task.terminal && task.summary.length > 0">
+                <h3 stm-text="task.report_title">Completion report</h3>
+                <div class="md" stm-markdown="task.summary">summary</div>
+            </section>
+
             <!-- The edit expander sits UNDER the "Show history" expander
                  (plan 11 task D): draft tasks edit every field except
                  status; non-draft non-terminal tasks edit priority

+ 136 - 0
test/web_stack_test.py

@@ -96,6 +96,15 @@ Plan 11 task D+E (UI + notifications), the `push` shard:
   decryption + ES256 JWT signature) because the harness owns the
   browser-side keys
 
+Heredoc completion reports (pages shard):
+
+- terminal tasks render tasks.summary (set by done/fail) as a
+  markdown completion-report card below the status/description grid —
+  "Completion report" when done, "Failure summary" when failed, no
+  card when empty; the summary renders through the shipped
+  stm-markdown renderer (headings/code, no syntax leaks, HTML
+  escaped) and the dashboard timeline snippets its first line
+
 Speed pass (plan 11, task 0):
 
 - SHARDING: the scenario groups are partitioned into seven independent
@@ -1556,6 +1565,133 @@ def bundle_pages(ctx):
     check(history_page.index("Show history") < history_page.index("edit-expander"),
           "the edit expander sits under the Show history expander")
 
+    print("== heredoc completion reports: markdown card + timeline snippet ==")
+    # The daemon's `cat << 'EOF' | saddle done` stores a markdown
+    # summary (the failure path stores the same field); the task page
+    # renders it as a full-width completion-report card directly below
+    # the status/description grid — markdown via stm-markdown
+    # (XSS-safe), "Completion report" when done, "Failure summary"
+    # when failed, no card when the summary is empty. The dashboard
+    # timeline snippets the summary's FIRST line.
+    report_md = ("## Flange wired and tested\n\n"
+                 "Ran `meson test` over the full matrix.\n\n"
+                 "```sh\nmeson test -C builddir\n```\n\n"
+                 "<b>not html</b> & escaped\n")
+    conn = db()
+    conn.execute("insert into tasks (project_id, title, status, priority,"
+                 " summary, started_at, completed_at, created_at)"
+                 " values (1, 'Report done task', 'done', 5, ?,"
+                 " strftime('%s','now'), strftime('%s','now'),"
+                 " strftime('%s','now'))", (report_md,))
+    report_done = conn.execute("select max(id) from tasks").fetchone()[0]
+    conn.execute("insert into tasks (project_id, title, status, priority,"
+                 " summary, started_at, completed_at, created_at)"
+                 " values (1, 'Report failed task', 'failed', 5,"
+                 " 'Boom: tests failed\n\nsecond line carries the detail',"
+                 " strftime('%s','now'), strftime('%s','now'),"
+                 " strftime('%s','now'))")
+    report_failed = conn.execute("select max(id) from tasks").fetchone()[0]
+    conn.execute("insert into tasks (project_id, title, status, priority,"
+                 " started_at, completed_at, created_at)"
+                 " values (1, 'Report empty task', 'done', 5,"
+                 " strftime('%s','now'), strftime('%s','now'),"
+                 " strftime('%s','now'))")
+    report_empty = conn.execute("select max(id) from tasks").fetchone()[0]
+    conn.commit()
+    conn.close()
+
+    done_state = task_state(auth, report_done)
+    check(done_state["report_title"] == "Completion report"
+          and "## Flange wired" in (done_state.get("summary") or ""),
+          "a done task's summary rides the report card state")
+    check(task_state(auth, report_failed)["report_title"] == "Failure summary",
+          "a failed task's card heading reads Failure summary")
+    empty_state = task_state(auth, report_empty)
+    check(empty_state["report_title"] == "" and not (empty_state.get("summary") or ""),
+          "an empty summary renders no report card (no heading)")
+    check(task_state(auth, layout_task)["report_title"] == "",
+          "non-terminal tasks never show the report card")
+
+    status, page = http("GET", "/tasks/%d" % report_done, timeout=15)
+    check(status == 200 and "completion-report" in page
+          and page.index("grid-2") < page.index("completion-report")
+          < page.index("edit-expander"),
+          "the completion-report card sits below the status/description grid, full width")
+    card = page[page.index("completion-report"):page.index("edit-expander")]
+    check('stm-markdown="task.summary"' in card
+          and 'stm-text="task.report_title"' in card,
+          "the card renders the summary as markdown under its state-driven heading")
+    check("<strong>Summary:</strong>" not in page,
+          "the old plain-text summary line is gone (the card owns the summary)")
+
+    # The markdown itself renders client-side: drive the SHIPPED
+    # stm-markdown renderer (statum.js) on the fixture summary via
+    # node — headings and code come out as HTML, no markdown syntax
+    # leaks, raw HTML stays escaped.
+    status, statum_js = http("GET", "/_statum/resource/statum.js", timeout=15)
+    skip = None if status == 200 else "statum.js not served"
+    parts = []
+    if skip is None:
+        def extract_md(name, source):
+            start = source.index("function %s(" % name)
+            depth = 0
+            index = source.index("{", start)
+            end = index
+            while True:
+                if source[end] == "{":
+                    depth += 1
+                elif source[end] == "}":
+                    depth -= 1
+                    if depth == 0:
+                        break
+                end += 1
+            return source[start:end + 1]
+        try:
+            parts = [extract_md(name, statum_js)
+                     for name in ("safeMarkdownUrl", "mdLinks", "mdInline", "renderMarkdown")]
+        except ValueError:
+            skip = "renderer functions missing"
+    rendered = ""
+    if skip is None:
+        try:
+            result = subprocess.run(
+                ["node", "-e", "%s\n%s\n%s\n%s\nconsole.log(renderMarkdown(process.argv[1]));"
+                 % tuple(parts), report_md],
+                capture_output=True, text=True, timeout=20)
+            rendered = result.stdout
+        except (OSError, subprocess.SubprocessError):
+            skip = "node unavailable"
+    if skip is not None:
+        check(True, "stm-markdown renderer present (%s, deep check skipped)" % skip)
+    else:
+        check("<h2>Flange wired and tested</h2>" in rendered
+              and "<pre><code" in rendered and "<code>meson test</code>" in rendered,
+              "the report summary renders headings and code through stm-markdown")
+        check("## Flange" not in rendered and "```sh" not in rendered
+              and "`meson test`" not in rendered,
+              "no raw markdown syntax leaks into the rendered report")
+        check("<b>not html</b>" not in rendered and "&lt;b&gt;not html&lt;/b&gt;" in rendered
+              and "&amp; escaped" in rendered,
+              "raw HTML in the summary stays escaped (XSS-safe)")
+
+    # The dashboard timeline: completion/failure events snippet the
+    # summary's first line, falling back to the bare verdict text.
+    dash = slot_public(auth, "/", "dashboard")
+    done_event = next((e for e in dash.get("activity", [])
+                       if e["kind"] == "completed" and int(e["task_id"]) == report_done), None)
+    check(done_event is not None
+          and done_event["text"] == "Task completed: ## Flange wired and tested",
+          "the timeline completion event snippets the summary's first line")
+    failed_event = next((e for e in dash.get("activity", [])
+                         if e["kind"] == "failed" and int(e["task_id"]) == report_failed), None)
+    check(failed_event is not None
+          and failed_event["text"] == "Task failed: Boom: tests failed",
+          "the timeline failure event snippets the summary's first line")
+    empty_event = next((e for e in dash.get("activity", [])
+                        if e["kind"] == "completed" and int(e["task_id"]) == report_empty), None)
+    check(empty_event is not None and empty_event["text"] == "Task completed",
+          "a missing summary falls back to the bare timeline text")
+
     print("== plan 11 task D: block split + queue-screen layout ==")
     # One open and one answered question: the open block renders in
     # full, the answered one folds behind the history expander.