diff --git a/.changeset/fix-details-open-safe-output.md b/.changeset/fix-details-open-safe-output.md new file mode 100644 index 00000000000..5e9b0873789 --- /dev/null +++ b/.changeset/fix-details-open-safe-output.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Preserve `
` in safe-output bodies so expanded details blocks render correctly instead of appearing as escaped text. diff --git a/actions/setup/js/report_ast.cjs b/actions/setup/js/report_ast.cjs index 8153ddc21f1..fd6d1916c30 100644 --- a/actions/setup/js/report_ast.cjs +++ b/actions/setup/js/report_ast.cjs @@ -20,7 +20,7 @@ const { createExpirationLine } = require("./ephemerals.cjs"); * {type: "list", ordered: boolean, items: ReportBlock[][]} | * {type: "quote", children: ReportBlock[]} | * {type: "alert", kind: string, children: ReportBlock[]} | - * {type: "details", summary: string | ReportInline[], children: ReportBlock[]} | + * {type: "details", open?: boolean, summary: string | ReportInline[], children: ReportBlock[]} | * {type: "table", headers: ReportInline[][], rows: ReportInline[][][]} | * {type: "task", checked: boolean, children: ReportInline[]} | * {type: "separator"} | @@ -402,7 +402,7 @@ function normalizeBlocks(nodes, depth) { if (node.type === "details") { const summary = typeof node.summary === "string" ? normalizeReportText(node.summary).replace(/\s+/g, " ").trim() : normalizeInlines(node.summary, depth + 1); if (!summary.length) throw new TypeError("Report details must have a summary"); - result.push({ type: "details", summary, children }); + result.push({ type: "details", ...(node.open === true ? { open: true } : {}), summary, children }); } else if (node.type === "alert") result.push({ type: "alert", kind: node.kind, children }); else result.push({ type: "quote", children }); break; @@ -668,7 +668,7 @@ function renderBlocks(nodes, compactExpiration = false) { case "alert": return (node.type === "alert" ? `[!${node.kind}]\n` : "") + renderBlocks(node.children, node.type === "quote"); case "details": - return `
\n${typeof node.summary === "string" ? escapeSummaryText(node.summary) : renderInlines(node.summary, true)}\n\n${renderBlocks(node.children)}\n\n
`; + return `\n${typeof node.summary === "string" ? escapeSummaryText(node.summary) : renderInlines(node.summary, true)}\n\n${renderBlocks(node.children)}\n\n
`; case "table": { const row = cells => `| ${cells.map(cell => renderInlines(cell).replace(/(? "---").join(" | ")} |`, ...node.rows.map(row)].join("\n"); diff --git a/actions/setup/js/report_html_fidelity.test.cjs b/actions/setup/js/report_html_fidelity.test.cjs index c357493ead5..c9603b92989 100644 --- a/actions/setup/js/report_html_fidelity.test.cjs +++ b/actions/setup/js/report_html_fidelity.test.cjs @@ -166,6 +166,50 @@ describe("typed report image presentation", () => { }); describe("report HTML import boundary", () => { + it.each(["
Open", "
\nOpen"])("preserves expanded details blocks: %s", opening => { + const source = `${opening}\n\nbody\n\n
`; + const input = parse(source); + expect(input.children).toEqual([ + { + type: "details", + open: true, + agentTainted: true, + summary: "Open", + children: [{ type: "paragraph", agentTainted: true, children: [{ type: "text", value: "body", agentTainted: true }] }], + }, + ]); + expect(normalizeReport(input).children[0].open).toBe(true); + const body = renderReport(input).body; + expect(body).toBe("
\nOpen\n\nbody\n\n
\n"); + expect(renderReport(parse(body)).body).toBe(body); + expect(renderReport(parse(sanitizeContent(source))).body).toBe(body); + }); + + it.each(["
Closed", "
\nClosed"])("keeps details collapsed by default: %s", opening => { + const input = parse(`${opening}\n\nbody\n\n
`); + expect(input.children[0].type).toBe("details"); + expect(input.children[0]).not.toHaveProperty("open"); + expect(normalizeReport(input).children[0]).not.toHaveProperty("open"); + expect(renderReport(input).body).toBe("
\nClosed\n\nbody\n\n
\n"); + }); + + it("recognizes expanded details immediately after a paragraph", () => { + const input = parse("Introduction\n
\nOpen\n\nbody\n\n
"); + expect(input.children.map(node => node.type)).toEqual(["paragraph", "details"]); + expect(input.children[1].open).toBe(true); + expect(renderReport(input).body).toBe("Introduction\n\n
\nOpen\n\nbody\n\n
\n"); + }); + + it.each(['class="extra"', 'open class="extra"', 'onclick="attack()"', 'open onclick="attack()"', 'open="true"', "open open"])("keeps unsupported details attributes inert: %s", attributes => { + for (const separator of ["", "\n"]) { + const input = parse(`
${separator}Rejected\n\nbody\n\n
`); + expect(input.children.some(node => node.type === "details")).toBe(false); + const body = renderReport(input).body; + expect(body).not.toContain("', "Mascot", diff --git a/actions/setup/js/report_markdown.cjs b/actions/setup/js/report_markdown.cjs index b2857161e8f..7fb824805d3 100644 --- a/actions/setup/js/report_markdown.cjs +++ b/actions/setup/js/report_markdown.cjs @@ -398,7 +398,7 @@ function parseReportMarkdown(markdown, { context, inlineSlots, blockSlots, onBlo const isFieldLine = line => /^(?:\{\w+\}\s*)+$/.test(line.trim()) && (!inlineSlots || [...line.matchAll(/\{(\w+)\}/g)].some(field => !Object.hasOwn(inlineSlots, field[1]))); const isBlockStart = line => openingFence(line) || - /^(?:#{1,6}\s|>|
(?:\s*$|\s*)|<\/details>\s*$|[-*+]\s|\d+\.\s|---\s*$)/.test(line) || + /^(?:#{1,6}\s|>|(?:\s*$|\s*)|<\/details>\s*$|[-*+]\s|\d+\.\s|---\s*$)/.test(line) || isFieldLine(line) || isBlockSlot(line) || (allowMetadata && /^(?: