mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +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", () => {
|
||||
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",
|
||||
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: "." }] });
|
||||
input.nativeFinalReply.finalEvent.body = body;
|
||||
expect(passes(input)).toBe(true);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readBoundTaskFinalReply } from "./native-task-reply.js";
|
||||
|
||||
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" } } };
|
||||
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 [
|
||||
["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],
|
||||
@@ -16,9 +29,9 @@ for (const [name, content, passes] of [
|
||||
["extra visible text", rendered + " Extra.", 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 }) => {
|
||||
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>` }));
|
||||
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),
|
||||
load: async () => ({ run, comments: [comment], events: [] }) });
|
||||
if (passes) {
|
||||
@@ -35,6 +48,8 @@ for (const [sourceHref, renderedHref, passes] of [
|
||||
["#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],
|
||||
["/projects/example", "/projects/example", true],
|
||||
["/projects/example", "/QA/projects/example", 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:
|
||||
|
||||
@@ -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 comment = { id: "selected", authorAgentId: "owned-agent", createdByRunId: "final-run", body };
|
||||
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.",
|
||||
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);
|
||||
|
||||
@@ -55,21 +55,22 @@ export function uniqueBoundTaskReply(all: TaskReplyObservation[], run: Row, agen
|
||||
export function observeTaskReplyElements(elements: Array<HTMLElement | SVGElement>): TaskReplyObservation[] {
|
||||
return elements.map(element => {
|
||||
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; }
|
||||
const nodes: Array<Node | string> = [element];
|
||||
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"
|
||||
|| getComputedStyle(node).display === "none" || getComputedStyle(node).visibility === "hidden") return;
|
||||
|| getComputedStyle(node).display === "none" || getComputedStyle(node).visibility === "hidden") continue;
|
||||
if (node instanceof HTMLAnchorElement) {
|
||||
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;
|
||||
continue;
|
||||
}
|
||||
const block = ["P", "DIV", "BR", "LI", "PRE", "BLOCKQUOTE", "H1", "H2", "H3", "H4", "H5", "H6"].includes(node.tagName);
|
||||
if (block) content.push({ text: " " });
|
||||
node.childNodes.forEach(walk);
|
||||
if (block) content.push({ text: " " });
|
||||
};
|
||||
walk(element);
|
||||
if (block) { content.push({ text: " " }); nodes.push(" "); }
|
||||
for (let index = node.childNodes.length - 1; index >= 0; index--) nodes.push(node.childNodes[index]!);
|
||||
}
|
||||
return {
|
||||
body: (element as HTMLElement).innerText.trim(),
|
||||
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" }] };
|
||||
for (const href of ["/issues/QA-123#document-outcome", "#document-outcome"])
|
||||
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, 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 { 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 };
|
||||
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) {
|
||||
if ("href" in part) {
|
||||
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 });
|
||||
} catch { return null; }
|
||||
} else {
|
||||
|
||||
Reference in new issue
Block a user