diff --git a/tests/runner-e2e/PRODUCT-JOURNEYS.md b/tests/runner-e2e/PRODUCT-JOURNEYS.md index 549c08cdd2a..2a23d7dbbb7 100644 --- a/tests/runner-e2e/PRODUCT-JOURNEYS.md +++ b/tests/runner-e2e/PRODUCT-JOURNEYS.md @@ -118,6 +118,8 @@ thread anchor and observed text; classic protocol comment matchers stay strict. Markdown replies compare complete text and exact link destinations after parsing CommonMark. Whitespace and inline formatting can change during rendering. A document or issue chip can replace a link label, but must retain its destination. +Ordinary links retain their source label. Destination resolution uses the existing +production company-route helper and the current task URL for fragments. Receipts retain both source Markdown and observed content. Unsupported constructs such as images, raw HTML and GFM-only formatting fail closed. Exact marker checks remain exact, and decline checks inspect both visible explanations and source diff --git a/tests/runner-e2e/connection-guidance.test.ts b/tests/runner-e2e/connection-guidance.test.ts index ef3254ec770..8044605ed54 100644 --- a/tests/runner-e2e/connection-guidance.test.ts +++ b/tests/runner-e2e/connection-guidance.test.ts @@ -66,7 +66,7 @@ describe("neutral decline evidence", () => { const input = native(); const body = "Contacts are unavailable. See [Connection outcome](/QA/issues/QA-123#document-outcome)."; Object.assign(input.nativeFinalReply, { sourceBody: body, body: "Contacts are unavailable. See QA-123.", origin: "https://fixture.test", - content: [{ text: "Contacts are unavailable. See " }, { href: "/QA/issues/QA-123#document-outcome", label: "QA-123" }, { text: "." }] }); + content: [{ text: "Contacts are unavailable. See " }, { href: "/QA/issues/QA-123#document-outcome", label: "QA-123", decorated: true }, { text: "." }] }); input.nativeFinalReply.finalEvent.body = body; expect(passes(input)).toBe(true); Object.assign(input.nativeFinalReply, { sourceBody: body.replace("Connection outcome", "PRIVATE_MARKER") }); diff --git a/tests/runner-e2e/native-task-reply.spec.ts b/tests/runner-e2e/native-task-reply.spec.ts index 19392fa938f..c255db6ba49 100644 --- a/tests/runner-e2e/native-task-reply.spec.ts +++ b/tests/runner-e2e/native-task-reply.spec.ts @@ -2,13 +2,15 @@ import { expect, test } from "@playwright/test"; import { readBoundTaskFinalReply } from "./native-task-reply.js"; const markdown = "I **couldn't retrieve** the pages. See [Connection outcome](/QA/issues/QA-123#document-outcome)."; -const rendered = "I couldn't retrieve the pages. See QA-123."; +const rendered = "I couldn't retrieve the pages. See QA-123."; const run = { id: "final-run", companyId: "company", agentId: "agent", status: "succeeded", runtimeMode: "native", resultJson: { presentationDecision: { commentId: "selected" } } }; const comment = { id: "selected", authorAgentId: "agent", createdByRunId: run.id, body: markdown }; for (const [name, content, passes] of [ ["decorated document link and bold text", rendered, true], + ["ordinary link with its source label", rendered.replace(" data-mention-kind='issue'", "").replace(">QA-123", ">Connection outcome"), true], + ["renamed ordinary link", rendered.replace(" data-mention-kind='issue'", ""), false], ["wrong destination", rendered.replace("#document-outcome", "#document-other"), false], ["missing link", rendered.replace(//, "QA-123"), false], ["extra visible text", rendered + " Extra.", false], @@ -23,7 +25,23 @@ for (const [name, content, passes] of [ const receipt = await result; expect(receipt.source).toBe("posted-comment"); expect(receipt.sourceBody).toBe(markdown); - expect(receipt.body).toBe("I couldn't retrieve the pages. See QA-123."); + expect(receipt.body).toBe(content.includes(">Connection outcome") ? "I couldn't retrieve the pages. See Connection outcome." : "I couldn't retrieve the pages. See QA-123."); expect(receipt.observedAfterReload).toBe(true); } else await expect(result).rejects.toThrow(); }); + +for (const [sourceHref, renderedHref, passes] of [ + ["/issues/QA-123#document-outcome", "/QA/issues/QA-123#document-outcome", true], + ["#document-outcome", "/QA/issues/QA-123#document-outcome", true], + ["/issues/QA-123#document-outcome", "/OTHER/issues/QA-123#document-outcome", false], + ["#document-outcome", "/QA/issues/QA-999#document-outcome", false], +] as const) test(`reloaded link ${sourceHref} to ${renderedHref}`, async ({ page }) => { + const url = "https://fixture.test/QA/issues/QA-123"; + await page.route(url, route => route.fulfill({ contentType: "text/html", body: + `
Read Outcome
` })); + await page.goto(url); + const result = readBoundTaskFinalReply({ page, runId: run.id, agentId: "agent", deadlineAt: Date.now() + (passes ? 3000 : 300), + load: async () => ({ run, comments: [{ ...comment, body: `Read [Outcome](${sourceHref})` }], events: [] }) }); + if (passes) expect((await result).pageUrl).toBe(url); + else await expect(result).rejects.toThrow(); +}); diff --git a/tests/runner-e2e/native-task-reply.test.ts b/tests/runner-e2e/native-task-reply.test.ts index 086806702bc..870a377c650 100644 --- a/tests/runner-e2e/native-task-reply.test.ts +++ b/tests/runner-e2e/native-task-reply.test.ts @@ -80,7 +80,7 @@ it("binds complete Markdown content and the exact link destination despite a dec const published = { ...run, resultJson: { presentationDecision: { commentId: "selected" } } }; const bubble: TaskReplyObservation = { ...observed, anchor: "selected", origin: "https://fixture.test", body: "I couldn't retrieve the pages. See QA-123.", - content: [{ text: "I couldn't retrieve the pages. See " }, { href: "https://fixture.test/QA/issues/QA-123#document-outcome", label: "QA-123" }, { text: "." }] }; + content: [{ text: "I couldn't retrieve the pages. See " }, { href: "https://fixture.test/QA/issues/QA-123#document-outcome", label: "QA-123", decorated: true }, { text: "." }] }; expect(taskReplyBelongsToRun(bubble, published, "owned-agent", [comment])).toBe(true); const copy = events(); copy[0]!.payload.prpEvent.payload.text = body; expect(taskReplyBelongsToRun({ ...bubble, anchor: "final-run:turn" }, run, "owned-agent", [], copy)).toBe(true); diff --git a/tests/runner-e2e/native-task-reply.ts b/tests/runner-e2e/native-task-reply.ts index b7bb80831fc..f668577299b 100644 --- a/tests/runner-e2e/native-task-reply.ts +++ b/tests/runner-e2e/native-task-reply.ts @@ -5,7 +5,7 @@ import { taskReplyContentMatches, type ReplyContentPart } from "./task-reply-con type Row = Record; export interface TaskReplyObservation { body: string; anchor: string | null; settled: boolean; nativeFinal: boolean; visible: boolean; - content?: ReplyContentPart[]; origin?: string; + content?: ReplyContentPart[]; origin?: string; pageUrl?: string; } export type BoundTaskFinalReply = Omit>, "sourceBody"> & { sourceBody?: string }; @@ -54,13 +54,14 @@ 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 }> = []; + const content: Array<{ text: string } | { href: string; label: string; decorated: boolean }> = []; const walk = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { content.push({ text: node.textContent ?? "" }); return; } if (!(node instanceof HTMLElement) || node.getAttribute("aria-hidden") === "true" || getComputedStyle(node).display === "none" || getComputedStyle(node).visibility === "hidden") return; if (node instanceof HTMLAnchorElement) { - if (node.getClientRects().length && node.innerText.trim()) content.push({ href: node.href, label: node.innerText }); + if (node.getClientRects().length && node.innerText.trim()) content.push({ href: node.href, label: node.innerText, + decorated: node.matches('[data-mention-kind="issue"], [data-sidebar-document-link]') }); return; } const block = ["P", "DIV", "BR", "LI", "PRE", "BLOCKQUOTE", "H1", "H2", "H3", "H4", "H5", "H6"].includes(node.tagName); @@ -71,7 +72,7 @@ export function observeTaskReplyElements(elements: Array