From b3d2d08305014151e1eca3b295ea588666ce0b92 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 30 Oct 2026 00:39:40 -0500 Subject: [PATCH] Preserve generated GFM checkbox states in reply evidence Recognize read-only task-list controls from the existing UI renderer and match their visible checked state. Keep raw HTML rejected and add positive and changed-state source/browser regressions. Co-Authored-By: Paperclip --- tests/runner-e2e/PRODUCT-JOURNEYS.md | 2 +- tests/runner-e2e/native-task-reply.spec.ts | 2 ++ tests/runner-e2e/native-task-reply.ts | 6 +++++- tests/runner-e2e/task-reply-content.test.ts | 6 ++++++ tests/runner-e2e/task-reply-content.ts | 12 ++++++++---- ui/src/lib/markdown-reply-content.ts | 8 ++++++-- 6 files changed, 28 insertions(+), 8 deletions(-) diff --git a/tests/runner-e2e/PRODUCT-JOURNEYS.md b/tests/runner-e2e/PRODUCT-JOURNEYS.md index daee209ef45..e95cd38c208 100644 --- a/tests/runner-e2e/PRODUCT-JOURNEYS.md +++ b/tests/runner-e2e/PRODUCT-JOURNEYS.md @@ -115,7 +115,7 @@ satisfy the check. A pending or suppressed comment can use the same proven nativ reply path. A finish summary by itself, an earlier turn, a progress bubble, or a cancelled run cannot satisfy it. Private receipts record the run, thread anchor and observed text; classic protocol comment matchers stay strict. -Markdown replies compare complete text and exact link destinations after parsing +Markdown replies compare complete text, GFM checkbox states and exact link destinations after parsing through the UI-owned projection of its existing Markdown renderer and GFM/issue-reference plugins. The observer receives the same visible-company prefix list used by the UI, so bare issue keys and URLs match their rendered links. Whitespace and inline formatting can change during rendering. A diff --git a/tests/runner-e2e/native-task-reply.spec.ts b/tests/runner-e2e/native-task-reply.spec.ts index 558df0385d5..14372a944a5 100644 --- a/tests/runner-e2e/native-task-reply.spec.ts +++ b/tests/runner-e2e/native-task-reply.spec.ts @@ -26,6 +26,8 @@ for (const [source, html, passes] of [ ["Depends on TREE-604.", "Depends on TREE-604.", true], ["Depends on QA-123.", "Depends on QA-123.", false], ["See https://example.com/", "See https://example.com/", false], + ["- [x] Finished\n- [ ] Next", "", true], + ["- [x] Finished\n- [ ] Next", "", false], ] as const) test(`reloaded auto-linked content: ${source} (${passes})`, async ({ page }) => { const url = "https://fixture.test/QA/issues/QA-1"; await page.route(url, route => route.fulfill({ contentType: "text/html", body: diff --git a/tests/runner-e2e/native-task-reply.ts b/tests/runner-e2e/native-task-reply.ts index c9c77ead298..59d11f1a178 100644 --- a/tests/runner-e2e/native-task-reply.ts +++ b/tests/runner-e2e/native-task-reply.ts @@ -54,7 +54,7 @@ export function uniqueBoundTaskReply(all: TaskReplyObservation[], run: Row, agen export function observeTaskReplyElements(elements: Array): TaskReplyObservation[] { return elements.map(element => { - const content: Array<{ text: string } | { href: string; label: string; decorated: boolean }> = []; + const content: Array<{ text: string } | { href: string; label: string; decorated: boolean } | { checked: boolean }> = []; const nodes: Array = [element]; while (nodes.length) { const node = nodes.pop()!; @@ -62,6 +62,10 @@ export function observeTaskReplyElements(elements: Array { + const observation = { body: "Finished Next", content: [{ checked: true }, { text: " Finished " }, { checked: false }, { text: " Next" }] }; + expect(taskReplyContentMatches(observation, "- [x] Finished\n- [ ] Next")).toBe(true); + expect(taskReplyContentMatches(observation, "- [ ] Finished\n- [ ] Next")).toBe(false); +}); + it("uses production GFM autolinks and issue linking with the viewer's known companies", () => { const context = { pageUrl: "https://fixture.test/QA/issues/QA-1", knownCompanyPrefixes: ["QA"] }; expect(taskReplyContentMatches({ ...context, body: "Depends on QA-123.", content: [{ text: "Depends on " }, diff --git a/tests/runner-e2e/task-reply-content.ts b/tests/runner-e2e/task-reply-content.ts index 6b35116c0f3..d3d6ce9f5e0 100644 --- a/tests/runner-e2e/task-reply-content.ts +++ b/tests/runner-e2e/task-reply-content.ts @@ -2,12 +2,15 @@ import { markdownReplyContent } from "../../ui/src/lib/markdown-reply-content.js import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "../../ui/src/lib/company-routes.js"; import { parseIssueReferenceFromHref } from "../../ui/src/lib/issue-reference.js"; -export type ReplyContentPart = { text: string } | { href: string; label?: string; decorated?: boolean }; +export type ReplyContentPart = { text: string } | { href: string; label?: string; decorated?: boolean } | { checked: boolean }; function normalized(parts: ReplyContentPart[], pageUrl: string, source = false) { const result: ReplyContentPart[] = []; for (const part of parts) { - if ("href" in part) { + if ("checked" in part) { + if (typeof part.checked !== "boolean") return null; + result.push({ checked: part.checked }); + } else if ("href" in part) { try { const issueRef = source ? parseIssueReferenceFromHref(part.href) : null; const href = issueRef ? applyCompanyPrefix(issueRef.href, extractCompanyPrefixFromPath(new URL(pageUrl).pathname)) : part.href; @@ -37,7 +40,7 @@ export function taskReplyContentMatches( // Bind the structured projection to the independently observed innerText. // A destination alone cannot attest to arbitrary visible wording. if (observation.content.some(part => "href" in part && (typeof part.label !== "string" || !part.label.trim()))) return false; - const displayed = observation.content.map(part => "text" in part ? part.text : part.label!).join("").replace(/\s+/g, " ").trim(); + const displayed = observation.content.map(part => "text" in part ? part.text : "href" in part ? part.label! : "").join("").replace(/\s+/g, " ").trim(); if (displayed !== observation.body.replace(/\s+/g, " ").trim()) return false; } const parts = markdownReplyContent(markdown, observation.knownCompanyPrefixes); @@ -48,6 +51,7 @@ export function taskReplyContentMatches( return expected !== null && actual !== null && expected.length === actual.length && expected.every((part, index) => { const displayed = actual[index]!; return "text" in part ? "text" in displayed && part.text === displayed.text - : "href" in displayed && part.href === displayed.href && (part.label === displayed.label || displayed.decorated === true); + : "href" in part ? "href" in displayed && part.href === displayed.href && (part.label === displayed.label || displayed.decorated === true) + : "checked" in displayed && part.checked === displayed.checked; }); } diff --git a/ui/src/lib/markdown-reply-content.ts b/ui/src/lib/markdown-reply-content.ts index 1855b58272a..67c70f1b338 100644 --- a/ui/src/lib/markdown-reply-content.ts +++ b/ui/src/lib/markdown-reply-content.ts @@ -3,7 +3,7 @@ import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { remarkLinkIssueReferences } from "./issue-reference.js"; -export type MarkdownReplyPart = { text: string } | { href: string; label: string }; +export type MarkdownReplyPart = { text: string } | { href: string; label: string } | { checked: boolean }; type AstNode = { type: string; children?: AstNode[] }; /** The reply observer consumes the UI's existing parser without adding a @@ -31,8 +31,12 @@ export function markdownReplyContent(markdown: string, knownPrefixes?: string[]) return true; } if (Array.isArray(node)) return node.every(child => visit(child, label)); - if (!isValidElement<{ children?: ReactNode; href?: string }>(node)) return false; + if (!isValidElement<{ children?: ReactNode; href?: string; type?: string; checked?: boolean; disabled?: boolean }>(node)) return false; if (node.type === Fragment) return visit(node.props.children, label); + if (!label && node.type === "input" && node.props.type === "checkbox" && node.props.disabled === true) { + parts.push({ checked: node.props.checked === true }); + return true; + } if (node.type === "a" && !label && node.props.href) { const words: string[] = []; if (!visit(node.props.children, words)) return false;