mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +01:00
Match renderer link routing and keep browser callbacks self-contained
Prefix only parsed issue references, preserve ordinary root-relative anchors, and avoid tsx name helpers inside the serialized DOM callback. Verify with the exact QA Node24/tsx path and positive/negative browser cases. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7aa94ff745
commit
6eba7fffb4
6 files changed
+35
-15
No files matched your search
@@ -65,7 +65,7 @@ describe("neutral decline evidence", () => {
|
|||||||
it("checks both the visible explanation and preserved native Markdown when links become chips", () => {
|
it("checks both the visible explanation and preserved native Markdown when links become chips", () => {
|
||||||
const input = native();
|
const input = native();
|
||||||
const body = "Contacts are unavailable. See [Connection outcome](/QA/issues/QA-123#document-outcome).";
|
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",
|
Object.assign(input.nativeFinalReply, { sourceBody: body, body: "Contacts are unavailable. See QA-123.", origin: "https://fixture.test", pageUrl: "https://fixture.test/QA/issues/QA-123",
|
||||||
content: [{ text: "Contacts are unavailable. See " }, { href: "/QA/issues/QA-123#document-outcome", label: "QA-123", decorated: true }, { 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;
|
input.nativeFinalReply.finalEvent.body = body;
|
||||||
expect(passes(input)).toBe(true);
|
expect(passes(input)).toBe(true);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import { readBoundTaskFinalReply } from "./native-task-reply.js";
|
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 markdown = "I **couldn't retrieve** the pages. See [Connection outcome](/QA/issues/QA-123#document-outcome).";
|
||||||
@@ -7,6 +9,17 @@ const run = { id: "final-run", companyId: "company", agentId: "agent", status: "
|
|||||||
resultJson: { presentationDecision: { commentId: "selected" } } };
|
resultJson: { presentationDecision: { commentId: "selected" } } };
|
||||||
const comment = { id: "selected", authorAgentId: "agent", createdByRunId: run.id, body: markdown };
|
const comment = { id: "selected", authorAgentId: "agent", createdByRunId: run.id, body: markdown };
|
||||||
|
|
||||||
|
test("the QA tsx loader produces a self-contained browser callback", async ({ page }) => {
|
||||||
|
const moduleUrl = new URL("./native-task-reply.ts", import.meta.url).href;
|
||||||
|
const loader = fileURLToPath(new URL("../../cli/node_modules/tsx/dist/loader.mjs", import.meta.url));
|
||||||
|
const serialized = JSON.parse(execFileSync(process.execPath, ["--import", loader, "--input-type=module", "-e",
|
||||||
|
`import { observeTaskReplyElements } from ${JSON.stringify(moduleUrl)}; console.log(JSON.stringify(observeTaskReplyElements.toString()));`], { encoding: "utf8" }));
|
||||||
|
await page.setContent(`<div data-testid='reply'>${rendered}</div>`);
|
||||||
|
const observations: any = await page.evaluate(`(${serialized})(Array.from(document.querySelectorAll('[data-testid="reply"]')))`);
|
||||||
|
expect(observations[0].body).toBe("I couldn't retrieve the pages. See QA-123.");
|
||||||
|
expect(observations[0].content.some((part: any) => part.label === "QA-123" && part.decorated)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
for (const [name, content, passes] of [
|
for (const [name, content, passes] of [
|
||||||
["decorated document link and bold text", rendered, true],
|
["decorated document link and bold text", rendered, true],
|
||||||
["ordinary link with its source label", rendered.replace(" data-mention-kind='issue'", "").replace(">QA-123</a>", ">Connection outcome</a>"), true],
|
["ordinary link with its source label", rendered.replace(" data-mention-kind='issue'", "").replace(">QA-123</a>", ">Connection outcome</a>"), true],
|
||||||
@@ -16,9 +29,9 @@ for (const [name, content, passes] of [
|
|||||||
["extra visible text", rendered + " Extra.", false],
|
["extra visible text", rendered + " Extra.", false],
|
||||||
["hidden source text", rendered.replace("couldn't retrieve", "<span hidden>couldn't retrieve</span>"), false],
|
["hidden source text", rendered.replace("couldn't retrieve", "<span hidden>couldn't retrieve</span>"), false],
|
||||||
] as const) test(`reloaded task reply: ${name}`, async ({ page }) => {
|
] as const) test(`reloaded task reply: ${name}`, async ({ page }) => {
|
||||||
await page.route("https://fixture.test/task", route => route.fulfill({ contentType: "text/html", body:
|
await page.route("https://fixture.test/QA/issues/QA-123", route => route.fulfill({ contentType: "text/html", body:
|
||||||
`<div data-testid='task-chat-thread'><div data-thread-anchor='selected'><div data-testid='task-chat-agent-bubble'>${content}</div></div></div>` }));
|
`<div data-testid='task-chat-thread'><div data-thread-anchor='selected'><div data-testid='task-chat-agent-bubble'>${content}</div></div></div>` }));
|
||||||
await page.goto("https://fixture.test/task");
|
await page.goto("https://fixture.test/QA/issues/QA-123");
|
||||||
const result = readBoundTaskFinalReply({ page, runId: run.id, agentId: "agent", deadlineAt: Date.now() + (passes ? 3000 : 300),
|
const result = readBoundTaskFinalReply({ page, runId: run.id, agentId: "agent", deadlineAt: Date.now() + (passes ? 3000 : 300),
|
||||||
load: async () => ({ run, comments: [comment], events: [] }) });
|
load: async () => ({ run, comments: [comment], events: [] }) });
|
||||||
if (passes) {
|
if (passes) {
|
||||||
@@ -35,6 +48,8 @@ for (const [sourceHref, renderedHref, passes] of [
|
|||||||
["#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],
|
["/issues/QA-123#document-outcome", "/OTHER/issues/QA-123#document-outcome", false],
|
||||||
["#document-outcome", "/QA/issues/QA-999#document-outcome", false],
|
["#document-outcome", "/QA/issues/QA-999#document-outcome", false],
|
||||||
|
["/projects/example", "/projects/example", true],
|
||||||
|
["/projects/example", "/QA/projects/example", false],
|
||||||
] as const) test(`reloaded link ${sourceHref} to ${renderedHref}`, async ({ page }) => {
|
] as const) test(`reloaded link ${sourceHref} to ${renderedHref}`, async ({ page }) => {
|
||||||
const url = "https://fixture.test/QA/issues/QA-123";
|
const url = "https://fixture.test/QA/issues/QA-123";
|
||||||
await page.route(url, route => route.fulfill({ contentType: "text/html", body:
|
await page.route(url, route => route.fulfill({ contentType: "text/html", body:
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ it("binds complete Markdown content and the exact link destination despite a dec
|
|||||||
const body = "I **couldn't retrieve** the pages. See [Connection outcome](/QA/issues/QA-123#document-outcome).";
|
const body = "I **couldn't retrieve** the pages. See [Connection outcome](/QA/issues/QA-123#document-outcome).";
|
||||||
const comment = { id: "selected", authorAgentId: "owned-agent", createdByRunId: "final-run", body };
|
const comment = { id: "selected", authorAgentId: "owned-agent", createdByRunId: "final-run", body };
|
||||||
const published = { ...run, resultJson: { presentationDecision: { commentId: "selected" } } };
|
const published = { ...run, resultJson: { presentationDecision: { commentId: "selected" } } };
|
||||||
const bubble: TaskReplyObservation = { ...observed, anchor: "selected", origin: "https://fixture.test",
|
const bubble: TaskReplyObservation = { ...observed, anchor: "selected", origin: "https://fixture.test", pageUrl: "https://fixture.test/QA/issues/QA-123",
|
||||||
body: "I couldn't retrieve the pages. See QA-123.",
|
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", decorated: true }, { 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);
|
expect(taskReplyBelongsToRun(bubble, published, "owned-agent", [comment])).toBe(true);
|
||||||
|
|||||||
@@ -55,21 +55,22 @@ export function uniqueBoundTaskReply(all: TaskReplyObservation[], run: Row, agen
|
|||||||
export function observeTaskReplyElements(elements: Array<HTMLElement | SVGElement>): TaskReplyObservation[] {
|
export function observeTaskReplyElements(elements: Array<HTMLElement | SVGElement>): TaskReplyObservation[] {
|
||||||
return elements.map(element => {
|
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 }> = [];
|
||||||
const walk = (node: Node) => {
|
const nodes: Array<Node | string> = [element];
|
||||||
if (node.nodeType === Node.TEXT_NODE) { content.push({ text: node.textContent ?? "" }); return; }
|
while (nodes.length) {
|
||||||
|
const node = nodes.pop()!;
|
||||||
|
if (typeof node === "string") { content.push({ text: node }); continue; }
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) { content.push({ text: node.textContent ?? "" }); continue; }
|
||||||
if (!(node instanceof HTMLElement) || node.getAttribute("aria-hidden") === "true"
|
if (!(node instanceof HTMLElement) || node.getAttribute("aria-hidden") === "true"
|
||||||
|| getComputedStyle(node).display === "none" || getComputedStyle(node).visibility === "hidden") return;
|
|| getComputedStyle(node).display === "none" || getComputedStyle(node).visibility === "hidden") continue;
|
||||||
if (node instanceof HTMLAnchorElement) {
|
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]') });
|
decorated: node.matches('[data-mention-kind="issue"], [data-sidebar-document-link]') });
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
const block = ["P", "DIV", "BR", "LI", "PRE", "BLOCKQUOTE", "H1", "H2", "H3", "H4", "H5", "H6"].includes(node.tagName);
|
const block = ["P", "DIV", "BR", "LI", "PRE", "BLOCKQUOTE", "H1", "H2", "H3", "H4", "H5", "H6"].includes(node.tagName);
|
||||||
if (block) content.push({ text: " " });
|
if (block) { content.push({ text: " " }); nodes.push(" "); }
|
||||||
node.childNodes.forEach(walk);
|
for (let index = node.childNodes.length - 1; index >= 0; index--) nodes.push(node.childNodes[index]!);
|
||||||
if (block) content.push({ text: " " });
|
}
|
||||||
};
|
|
||||||
walk(element);
|
|
||||||
return {
|
return {
|
||||||
body: (element as HTMLElement).innerText.trim(),
|
body: (element as HTMLElement).innerText.trim(),
|
||||||
content, origin: location.origin, pageUrl: location.href,
|
content, origin: location.origin, pageUrl: location.href,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ it("uses the production company prefix and current task URL without accepting a
|
|||||||
content: [{ text: "Read " }, { href: "/QA/issues/QA-123#document-outcome", label: "Outcome" }] };
|
content: [{ text: "Read " }, { href: "/QA/issues/QA-123#document-outcome", label: "Outcome" }] };
|
||||||
for (const href of ["/issues/QA-123#document-outcome", "#document-outcome"])
|
for (const href of ["/issues/QA-123#document-outcome", "#document-outcome"])
|
||||||
expect(taskReplyContentMatches(observation, `Read [Outcome](${href})`)).toBe(true);
|
expect(taskReplyContentMatches(observation, `Read [Outcome](${href})`)).toBe(true);
|
||||||
for (const href of ["/OTHER/issues/QA-123#document-outcome", "/QA/issues/QA-999#document-outcome", "#document-other"])
|
for (const href of ["/QA/issues/QA-999#document-outcome", "#document-other"])
|
||||||
expect(taskReplyContentMatches(observation, `Read [Outcome](${href})`)).toBe(false);
|
expect(taskReplyContentMatches(observation, `Read [Outcome](${href})`)).toBe(false);
|
||||||
|
expect(taskReplyContentMatches({ ...observation, content: [{ text: "Read " },
|
||||||
|
{ href: "/OTHER/issues/QA-123#document-outcome", label: "Outcome" }] }, "Read [Outcome](/issues/QA-123#document-outcome)")).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||||
import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "../../ui/src/lib/company-routes.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 };
|
||||||
type MarkdownNode = { type: string; value?: string; url?: string; children?: MarkdownNode[] };
|
type MarkdownNode = { type: string; value?: string; url?: string; children?: MarkdownNode[] };
|
||||||
@@ -9,7 +10,8 @@ function normalized(parts: ReplyContentPart[], pageUrl: string, source = false)
|
|||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if ("href" in part) {
|
if ("href" in part) {
|
||||||
try {
|
try {
|
||||||
const href = source ? applyCompanyPrefix(part.href, extractCompanyPrefixFromPath(new URL(pageUrl).pathname)) : part.href;
|
const issueRef = source ? parseIssueReferenceFromHref(part.href) : null;
|
||||||
|
const href = issueRef ? applyCompanyPrefix(issueRef.href, extractCompanyPrefixFromPath(new URL(pageUrl).pathname)) : part.href;
|
||||||
result.push({ href: new URL(href, pageUrl).href, label: part.label?.replace(/\s+/g, " ").trim(), decorated: part.decorated });
|
result.push({ href: new URL(href, pageUrl).href, label: part.label?.replace(/\s+/g, " ").trim(), decorated: part.decorated });
|
||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in new issue
Block a user