From 7ef235b063c3348c9399340b837c1b62d3dcdc18 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 28 Oct 2026 13:24:43 -0500 Subject: [PATCH] fix(evals): collect bound conversation project creation links --- .../internal-comment-reference.ts | 4 +- .../chat-catalog-domains/internal-goals.ts | 6 +- .../internal-inbox-search.ts | 4 +- .../chat-catalog-domains/internal-privacy.ts | 4 +- .../internal-repositories.ts | 4 +- .../chat-catalog-domains/internal-support.ts | 22 +++-- tests/runner-e2e/chat-catalog-flow.ts | 16 ++-- .../chat-rendered-links.retained-project.json | 59 ++++++++++++ tests/runner-e2e/chat-rendered-links.test.ts | 93 +++++++++++++++++++ tests/runner-e2e/chat-rendered-links.ts | 88 ++++++++++++++++++ 10 files changed, 276 insertions(+), 24 deletions(-) create mode 100644 tests/runner-e2e/chat-rendered-links.retained-project.json create mode 100644 tests/runner-e2e/chat-rendered-links.test.ts create mode 100644 tests/runner-e2e/chat-rendered-links.ts diff --git a/tests/runner-e2e/chat-catalog-domains/internal-comment-reference.ts b/tests/runner-e2e/chat-catalog-domains/internal-comment-reference.ts index 097a45a97e2..0c262d22e9c 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-comment-reference.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-comment-reference.ts @@ -3,7 +3,7 @@ import { expect } from "@playwright/test"; import { parseIssueReferenceFromHref } from "../../../ui/src/lib/issue-reference.js"; import type { ChatCatalogCase } from "../chat-catalog-contract.js"; import { pollUntil } from "../api.js"; -import { entity, hasLink, observeInternal, type Entity, type InternalObservation } from "./internal-support.js"; +import { internalResultLink, entity, hasLink, observeInternal, type Entity, type InternalObservation } from "./internal-support.js"; const digest = (body: string) => createHash("sha256").update(body).digest("hex"); export function savedDesignInstruction(o: InternalObservation, body: string, design: Entity) { @@ -63,7 +63,7 @@ export function withRenderedCommentReference(base: ChatCatalogCase): ChatCatalog if (!taskHref) return; const page = ctx.input.page, source = page.url(); try { - const link = page.locator(`[id^="comment-"] a[href=${JSON.stringify(taskHref)}]`).first(); + const link = internalResultLink(ctx, o, taskHref).first(); await expect(link).toBeVisible(); await link.click(); await page.waitForURL(url => hasLink({ ...o, links: [url.href] }, "issues", target)); await expect(page.getByRole("heading", { name: target.title!, exact: true }).first()).toBeVisible(); diff --git a/tests/runner-e2e/chat-catalog-domains/internal-goals.ts b/tests/runner-e2e/chat-catalog-domains/internal-goals.ts index 479cff6e21b..720d741367b 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-goals.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-goals.ts @@ -1,5 +1,5 @@ import type { ChatCatalogCase } from "../chat-catalog-contract.js"; -import { project, task, goal, putDocument, entity, observation, checks, hasLink, sameTask, type Entity, type InternalObservation } from "./internal-support.js"; +import { internalResultLink, project, task, goal, putDocument, entity, observation, checks, hasLink, sameTask, type Entity, type InternalObservation } from "./internal-support.js"; import { makeDefinition, retryDefinition, fixtureWorker } from "./internal-definition.js"; export function grade_goals(value: unknown) { @@ -119,7 +119,7 @@ function crossCompanyAlignment(): ChatCatalogCase { const saved = (kind === "issues" ? o.tasks : o.projects).find(item => item.id === row.id); if (!saved) throw new Error("Aligned result is missing from public readback"); const href = o.links.find(link => hasLink({ ...o, links: [link] }, kind, saved)); - if (href) await ctx.input.page.locator(`[id^="comment-"] a[href=${JSON.stringify(href)}]`).first().click(); + if (href) await internalResultLink(ctx, o, href).first().click(); else await ctx.input.page.goto(new URL(`/${ctx.input.fixtures.company.issuePrefix}/${kind}/${saved.identifier ?? saved.urlKey ?? saved.id}`, o.origin).href, { waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByRole("heading", { name: saved.title ?? saved.name!, exact: true }).first()).toBeVisible(); await ctx.input.evidence(`chat-catalog-alignment-opened-${kind}.json`, { id: saved.id, goalId: growth.id, url: ctx.input.page.url(), viaReturnedLink: !!href }); @@ -232,7 +232,7 @@ function createGoalTree(): ChatCatalogCase { try { const url = new URL(link, o.origin); return url.origin === new URL(o.origin).origin && (hasLink({ ...o, links: [link] }, "goals", root) || /\/goals\/?$/.test(url.pathname)); } catch { return false; } })!; - await ctx.input.page.locator(`[id^="comment-"] a[href=${JSON.stringify(href)}]`).first().click(); + await internalResultLink(ctx, o, href).first().click(); if (/\/goals\/?$/.test(new URL(href, o.origin).pathname)) { const rootLink = ctx.input.page.locator(`a[href*=${JSON.stringify(`/goals/${root.id}`)}]`).first(); await expect(rootLink).toBeVisible(); await rootLink.click(); diff --git a/tests/runner-e2e/chat-catalog-domains/internal-inbox-search.ts b/tests/runner-e2e/chat-catalog-domains/internal-inbox-search.ts index 5c5406494df..83e4672a58b 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-inbox-search.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-inbox-search.ts @@ -1,7 +1,7 @@ import { expect } from "@playwright/test"; import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js"; import { makeDefinition } from "./internal-definition.js"; -import { checks, entity, hasLink, observation, observeInternal, project, sameTask, task, type Entity, type InternalObservation } from "./internal-support.js"; +import { internalResultLink, checks, entity, hasLink, observation, observeInternal, project, sameTask, task, type Entity, type InternalObservation } from "./internal-support.js"; interface Probe { status: number; body: unknown } interface InboxObservation extends InternalObservation { @@ -137,7 +137,7 @@ function inboxBoundary(scenarioId: string): ChatCatalogCase { } if (!href && o.scenarioId !== "inbox-search.archive-without-grant") throw new Error("Missing rendered Mine link"); const source = ctx.input.page.url(); - if (href) await ctx.input.page.locator(`[id^="comment-"] a[href=${JSON.stringify(href)}]`).first().click(); + if (href) await internalResultLink(ctx, o, href).first().click(); else await ctx.input.page.getByRole("link", { name: "Inbox", exact: true }).first().click(); const mine = ctx.input.page.getByRole("button", { name: "Mine", exact: true }).or(ctx.input.page.getByRole("link", { name: "Mine", exact: true })) .or(ctx.input.page.getByRole("tab", { name: "Mine", exact: true })).first(); diff --git a/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts b/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts index b9f1a761d3c..ee6515b31bb 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-privacy.ts @@ -2,7 +2,7 @@ import { expect } from "@playwright/test"; import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contract.js"; import { createAuthenticatedPerson, enrollPrerequisitePerson } from "./identity-auth.js"; import { makeDefinition, fixtureWorker } from "./internal-definition.js"; -import { project, task, entity, observation, checks, observeInternal, hasLink, sameTask, type Entity, type InternalObservation } from "./internal-support.js"; +import { internalResultLink, project, task, entity, observation, checks, observeInternal, hasLink, sameTask, type Entity, type InternalObservation } from "./internal-support.js"; import { pollUntil } from "../api.js"; type Person = Awaited>; @@ -196,7 +196,7 @@ function shareProject(): ChatCatalogCase { const href = first.links.find(link => hasLink({ ...first, links: [link] }, "projects", p)); if (!href) throw new Error("Owner handoff omitted the actual project link"); const sourceUrl = ctx.input.page.url(); - await ctx.input.page.locator(`[id^="comment-"] a[href=${JSON.stringify(href)}]`).first().click(); + await internalResultLink(ctx, first, href).first().click(); await ctx.input.page.getByText("Configuration", { exact: true }).last().click(); await ctx.input.page.getByRole("button", { name: "Manage access", exact: true }).click(); const dialog = ctx.input.page.getByRole("dialog", { name: "Private project access", exact: true }); diff --git a/tests/runner-e2e/chat-catalog-domains/internal-repositories.ts b/tests/runner-e2e/chat-catalog-domains/internal-repositories.ts index 07bc5f71b69..9bfb7706267 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-repositories.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-repositories.ts @@ -3,7 +3,7 @@ import type { ChatCatalogCase, ChatCatalogContext } from "../chat-catalog-contra import { pollUntil } from "../api.js"; import { makeDefinition } from "./internal-definition.js"; import { externalRequirements, selectedExternalBinding, selectedExternalCredential } from "./external-fixtures.js"; -import { checks, entity, hasLink, observation, observeInternal, project, sameTask, task, type Entity, type InternalObservation } from "./internal-support.js"; +import { internalResultLink, checks, entity, hasLink, observation, observeInternal, project, sameTask, task, type Entity, type InternalObservation } from "./internal-support.js"; interface Repository { id: string; fullName: string; url: string; connectionIds?: string[] } interface Workspace extends Entity { repoUrl?: string | null; cwd?: string | null; metadata?: { githubRepositoryId?: string } } @@ -123,7 +123,7 @@ export function gradeRepositories(value: unknown) { async function openProject(ctx: ChatCatalogContext, o: RepositoryObservation) { const p = o.repositoryProject; if (!p) throw new Error("No saved repository project"); const href = o.links.find(link => hasLink({ ...o, links: [link] }, "projects", p)); - if (href) await ctx.input.page.locator(`[id^="comment-"] a[href=${JSON.stringify(href)}]`).first().click(); + if (href) await internalResultLink(ctx, o, href).first().click(); else await ctx.input.page.goto(new URL(`/${ctx.input.fixtures.company.issuePrefix}/projects/${p.urlKey ?? p.id}`, o.origin).href, { waitUntil: "domcontentloaded" }); await expect(ctx.input.page.getByRole("heading", { name: p.name!, exact: true }).first()).toBeVisible(); await ctx.input.page.getByText("Configuration", { exact: true }).last().click(); diff --git a/tests/runner-e2e/chat-catalog-domains/internal-support.ts b/tests/runner-e2e/chat-catalog-domains/internal-support.ts index c6b682521a7..e03c2686394 100644 --- a/tests/runner-e2e/chat-catalog-domains/internal-support.ts +++ b/tests/runner-e2e/chat-catalog-domains/internal-support.ts @@ -1,3 +1,4 @@ +import { collectConversationLinks, conversationLinkLocator, type RenderedConversationLink } from "../chat-rendered-links.js"; import { expect } from "@playwright/test"; import type { ChatCatalogContext } from "../chat-catalog-contract.js"; import type { ChatRun } from "../chat-flow.js"; @@ -16,7 +17,7 @@ export interface Entity { export interface InternalObservation { scenarioId: string; origin: string; companyId: string; leadId: string; state: Record; tasks: Entity[]; projects: Entity[]; goals: Entity[]; comments: Entity[]; - activity: Entity[]; annotations?: Entity[]; agents?: Entity[]; documents: Entity[]; revisions: Entity[]; reply: string; links: string[]; humanQueries: string[]; runs: ChatRun[]; + renderedLinkEvidence?: RenderedConversationLink[]; activity: Entity[]; annotations?: Entity[]; agents?: Entity[]; documents: Entity[]; revisions: Entity[]; reply: string; links: string[]; humanQueries: string[]; runs: ChatRun[]; } export function entity(state: Record, key: string): Entity { const value = state[key]; @@ -50,15 +51,16 @@ export async function observeInternal(ctx: ChatCatalogContext, state: Record(`/api/issues/${source.id}/comments?order=asc`) : []; const replies = chat.filter(row => row.authorAgentId === fixtures.agent.id && runs.some(run => run.id === row.createdByRunId)); - const links: string[] = []; - for (const reply of replies) { - links.push(...await ctx.input.page.locator(`[id=${JSON.stringify(`comment-${reply.id}`)}] a[href]`).evaluateAll(elements => elements.map(element => element.getAttribute("href")!))); - } const target = state.task ? entity(state, "task") : undefined; const comments = target ? await api.get(`/api/issues/${target.id}/comments?order=asc`) : []; const companyActivity = await api.get(`${companyPath(ctx)}/activity?limit=1000`); const taskActivity = target ? await api.get(`/api/issues/${target.id}/activity`) : []; const activity = [...new Map([...companyActivity, ...taskActivity].map(row => [row.id, row])).values()]; + const renderedLinkEvidence = source ? await collectConversationLinks(ctx.input.page, { + origin: api.baseURL, companyId: fixtures.company.id, companyPrefix: fixtures.company.issuePrefix, + sourceId: source.id, leadId: fixtures.agent.id, comments: chat, runs, projects, activity, + }) : []; + const links = [...new Set(renderedLinkEvidence.map(link => link.href))]; const documents: Entity[] = []; const revisions: Entity[] = []; if (target) { for (const summary of await api.get(`/api/issues/${target.id}/documents`)) { @@ -69,7 +71,7 @@ export async function observeInternal(ctx: ChatCatalogContext, state: Record row.id !== source?.id), projects, goals, runs, comments, activity, documents, revisions, - humanQueries: chat.filter(row => !row.authorAgentId).map(row => row.body ?? ""), reply: replies.map(row => row.body ?? "").join("\n"), links }; + renderedLinkEvidence, humanQueries: chat.filter(row => !row.authorAgentId).map(row => row.body ?? ""), reply: replies.map(row => row.body ?? "").join("\n"), links }; } export function hasLink(o: InternalObservation, kind: "issues" | "projects" | "goals", row: Entity): boolean { return o.links.some(href => { @@ -112,7 +114,7 @@ export async function verifyInternalResult(ctx: ChatCatalogContext, _state: Reco if (!candidates.length) throw new Error("No independently matched result link was rendered"); for (const { kind, row } of candidates) { const href = o.links.find(link => hasLink({ ...o, links: [link] }, kind, row))!; - const link = ctx.input.page.locator(`[id^="comment-"] a[href=${JSON.stringify(href)}]`).first(); + const link = internalResultLink(ctx, o, href).first(); await expect(link).toBeVisible(options()); await link.click(options()); await ctx.input.page.waitForURL(url => { @@ -126,3 +128,9 @@ export async function verifyInternalResult(ctx: ChatCatalogContext, _state: Reco remainingBudget?.(); } } + +export function internalResultLink(ctx: ChatCatalogContext, o: InternalObservation, href: string) { + const receipt = o.renderedLinkEvidence?.find(link => link.href === href); + if (!receipt) throw new Error("Result navigation requires current-conversation rendered-link custody"); + return conversationLinkLocator(ctx.input.page, receipt); +} diff --git a/tests/runner-e2e/chat-catalog-flow.ts b/tests/runner-e2e/chat-catalog-flow.ts index 8da77da5341..6093fb76ad2 100644 --- a/tests/runner-e2e/chat-catalog-flow.ts +++ b/tests/runner-e2e/chat-catalog-flow.ts @@ -1,3 +1,4 @@ +import { collectConversationLinks, type ConversationLinkInput } from "./chat-rendered-links.js"; import { chatCatalogAgentIsReady, chatCatalogNativePhaseIsTimely, chatCatalogNativePhaseRemainingBudget } from "./chat-catalog-contract.js"; import { chatNativeTimeoutEvidence } from "./chat-native-timeout-policy.js"; import { completeNativeSlackFileConditional, dispatchNativeSlackIngress, prepareNativeSlackOtherThread, slackOtherThreadBoundaryMatches, slackNativeRunCheck } from "./chat-slack-ingress.js"; @@ -41,12 +42,15 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) { const comments = issue ? await input.api.get>( `/api/issues/${issue.id}/comments?order=asc`, ) : []; - const renderedLinks = []; - for (const comment of comments) { - const reply = input.page.locator(`[id=${JSON.stringify(`comment-${comment.id}`)}]`); - for (const href of await reply.locator("a[href]").evaluateAll(elements => elements.map(element => element.getAttribute("href")))) - renderedLinks.push({ commentId: comment.id, href }); - } + const hasCreationCard = await input.page.locator('[data-testid="task-chat-thread"] article[aria-label^="Project created: "]').count() > 0; + const [projects, activity] = hasCreationCard ? await Promise.all([ + input.api.get(`/api/companies/${input.fixtures.company.id}/projects?includeArchived=true`), + input.api.get(`/api/companies/${input.fixtures.company.id}/activity?limit=1000`), + ]) : [[], []]; + const renderedLinks = issue ? await collectConversationLinks(input.page, { + origin: input.api.baseURL, companyId: input.fixtures.company.id, companyPrefix: input.fixtures.company.issuePrefix, + sourceId: issue.id, leadId: input.fixtures.agent.id, comments, runs, projects, activity, + }) : []; const evidence = { issue, runs, comments, renderedLinks }; await input.evidence("chat-catalog-conversation.json", evidence); return evidence; diff --git a/tests/runner-e2e/chat-rendered-links.retained-project.json b/tests/runner-e2e/chat-rendered-links.retained-project.json new file mode 100644 index 00000000000..7d60c440918 --- /dev/null +++ b/tests/runner-e2e/chat-rendered-links.retained-project.json @@ -0,0 +1,59 @@ +{ + "html": "

Create a project called Launch for the new website.

2:48 AM
CEO

Created the Launch project for the new website (status: in_progress, target date 2026-11-30). No repositories are connected yet, so it uses the managed default workspace. Let me know if you want repositories attached or tasks created under it.

2:48 AM
", + "input": { + "origin": "https://fixture.test", + "companyId": "698b3845-438b-496a-b1e4-22655b229808", + "companyPrefix": "RUN", + "sourceId": "30f0a30d-7f32-494e-b85e-dccc8e082dad", + "leadId": "dac24d01-43ab-4bcb-b0f8-fbec367baf26", + "comments": [ + { + "id": "edf2bd07-4cc2-4aad-835e-b69deaf22e4d", + "companyId": "698b3845-438b-496a-b1e4-22655b229808", + "issueId": "30f0a30d-7f32-494e-b85e-dccc8e082dad", + "authorAgentId": null, + "createdByRunId": null + }, + { + "id": "35fac4e2-1156-41e1-a6f9-a3c81f65a01a", + "companyId": "698b3845-438b-496a-b1e4-22655b229808", + "issueId": "30f0a30d-7f32-494e-b85e-dccc8e082dad", + "authorAgentId": "dac24d01-43ab-4bcb-b0f8-fbec367baf26", + "createdByRunId": "06c67909-f1f3-4320-9ed6-50a65e2a767c" + } + ], + "runs": [ + { + "id": "06c67909-f1f3-4320-9ed6-50a65e2a767c", + "companyId": "698b3845-438b-496a-b1e4-22655b229808", + "agentId": "dac24d01-43ab-4bcb-b0f8-fbec367baf26", + "status": "succeeded", + "contextSnapshot": { + "issueId": "30f0a30d-7f32-494e-b85e-dccc8e082dad" + }, + "nativeIssueId": "30f0a30d-7f32-494e-b85e-dccc8e082dad" + } + ], + "projects": [ + { + "id": "6dd3e7e2-8856-4628-a0bb-bd6c7b3511ab", + "companyId": "698b3845-438b-496a-b1e4-22655b229808", + "name": "Launch" + } + ], + "activity": [ + { + "id": "30c35fa8-dfef-46ad-970d-5b890cb14937", + "companyId": "698b3845-438b-496a-b1e4-22655b229808", + "action": "project.created", + "entityType": "project", + "entityId": "6dd3e7e2-8856-4628-a0bb-bd6c7b3511ab", + "actorType": "agent", + "actorId": "dac24d01-43ab-4bcb-b0f8-fbec367baf26", + "agentId": "dac24d01-43ab-4bcb-b0f8-fbec367baf26", + "runId": "06c67909-f1f3-4320-9ed6-50a65e2a767c" + } + ] + }, + "expectedHref": "/RUN/projects/6dd3e7e2-8856-4628-a0bb-bd6c7b3511ab" +} diff --git a/tests/runner-e2e/chat-rendered-links.test.ts b/tests/runner-e2e/chat-rendered-links.test.ts new file mode 100644 index 00000000000..f24d8ce52ed --- /dev/null +++ b/tests/runner-e2e/chat-rendered-links.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { conversationLinkBindings, readConversationDomLinks, type ConversationLinkInput } from "./chat-rendered-links.js"; + +const card = '
'; +const thread = (content = card) => `
Create a project called Launch for the new website.
${content}
Created the Launch project. No repositories are connected yet.
`; +function input(): ConversationLinkInput { + return { origin: "https://fixture.test", companyId: "company", companyPrefix: "RUN", sourceId: "source", leadId: "ceo", + comments: [{ id: "human", issueId: "source", companyId: "company" }, { id: "reply", issueId: "source", companyId: "company", authorAgentId: "ceo", createdByRunId: "run" }], + runs: [{ id: "run", companyId: "company", agentId: "ceo", status: "succeeded", contextSnapshot: { issueId: "source" } }], + projects: [{ id: "project", companyId: "company", name: "Launch" }], + activity: [{ id: "created", companyId: "company", action: "project.created", entityType: "project", entityId: "project", actorType: "agent", actorId: "ceo", agentId: "ceo", runId: "run" }] }; +} +const collect = (value = input()) => readConversationDomLinks(conversationLinkBindings(value)); +beforeEach(() => { document.body.innerHTML = thread(); vi.spyOn(Element.prototype, "getClientRects").mockReturnValue([{ width: 20, height: 20 }] as unknown as DOMRectList); }); +afterEach(() => vi.restoreAllMocks()); +it("collects the real supported sibling project-card structure when reply-only collection has no link", () => { + expect(document.querySelectorAll('#comment-reply a[href]')).toHaveLength(0); + expect(collect()).toEqual([{ threadTestId: "task-chat-thread", href: "/RUN/projects/project", source: "project-created-card", conversationId: "source", anchorCommentId: "human", resourceId: "project", cardLabel: "Project created: Launch", createdByRunId: "run" }]); +}); +it("keeps the original creation card usable on an idempotent retry without requiring another card", () => { + const value = input(); value.comments.push({ id: "retry", issueId: "source", companyId: "company", authorAgentId: "ceo", createdByRunId: "retry-run" }); + value.runs.push({ id: "retry-run", companyId: "company", agentId: "ceo", status: "succeeded", contextSnapshot: { issueId: "source" } }); + document.querySelector('[data-testid="task-chat-scroller"]')!.insertAdjacentHTML('beforeend', '
The project already exists.
'); + expect(collect(value).map(row => row.href)).toEqual(["/RUN/projects/project"]); +}); +it("retains visible ordinary reply links including task, goal and artifact links", () => { + document.querySelector('#comment-reply')!.innerHTML = 'TaskGoalArtifactExternal issue'; + expect(collect().filter(row => row.source === 'reply').map(row => row.href)).toEqual(['/RUN/issues/task','/RUN/goals/goal','/api/attachments/file/content','https://github.com/owner/repository/issues/12']); +}); +it.each([ + ['wrong saved company', (v:ConversationLinkInput) => { v.projects[0]!.companyId='other'; }], + ['wrong saved resource', (v:ConversationLinkInput) => { v.projects[0]!.id='other'; }], + ['wrong saved name', (v:ConversationLinkInput) => { v.projects[0]!.name='Other'; }], + ['missing creation audit', (v:ConversationLinkInput) => { v.activity=[]; }], + ['wrong audit company', (v:ConversationLinkInput) => { v.activity[0]!.companyId='other'; }], + ['wrong audit actor type', (v:ConversationLinkInput) => { v.activity[0]!.actorType='user'; }], + ['wrong audit actor', (v:ConversationLinkInput) => { v.activity[0]!.actorId='other'; }], + ['wrong audit agent', (v:ConversationLinkInput) => { v.activity[0]!.agentId='other'; }], + ['wrong audit entity type', (v:ConversationLinkInput) => { v.activity[0]!.entityType='issue'; }], + ['wrong audit resource', (v:ConversationLinkInput) => { v.activity[0]!.entityId='other'; }], + ['unrelated historical run', (v:ConversationLinkInput) => { v.activity[0]!.runId='old'; }], + ['run in another company', (v:ConversationLinkInput) => { v.runs[0]!.companyId='other'; }], + ['run for another conversation', (v:ConversationLinkInput) => { v.runs[0]!.contextSnapshot={issueId:'other'}; }], + ['wrong reply author', (v:ConversationLinkInput) => { v.comments[1]!.authorAgentId='other'; }], + ['reply from another conversation', (v:ConversationLinkInput) => { v.comments[1]!.issueId='other'; }], + ['comments from another company', (v:ConversationLinkInput) => { v.comments.forEach(row=>row.companyId='other'); }], +] as const)('rejects %s instead of treating any matching project link as evidence', (_label, mutate) => { const value=input(); mutate(value); expect(collect(value)).toEqual([]); }); +it.each([ + ['sidebar only', () => { document.body.innerHTML=thread('')+``; }], + ['unrelated conversation', () => { document.body.innerHTML=thread('')+thread().replaceAll('comment-human','comment-other-human').replaceAll('comment-reply','comment-other-reply'); }], + ['hidden sibling', () => { document.querySelector('article')!.setAttribute('hidden',''); }], + ['hidden ancestor', () => { document.querySelector('[data-testid="task-chat-history-content"]')!.setAttribute('style','visibility:hidden'); }], + ['inert ancestor', () => { document.querySelector('[data-testid="task-chat-history-content"]')!.setAttribute('inert',''); }], + ['aria-hidden ancestor', () => { document.querySelector('[data-testid="task-chat-history-content"]')!.setAttribute('aria-hidden','true'); }], + ['display:none', () => { document.querySelector('article')!.setAttribute('style','display:none'); }], + ['transparent card', () => { document.querySelector('article')!.setAttribute('style','opacity:0'); }], + ['missing layout box', () => { vi.mocked(Element.prototype.getClientRects).mockReturnValue([] as unknown as DOMRectList); }], + ['missing supported card wrapper', () => { document.querySelector('[data-thread-item-kind="project_created"]')!.removeAttribute('data-thread-item-kind'); }], + ['wrong item resource', () => { document.querySelector('[data-thread-item-kind="project_created"]')!.setAttribute('data-thread-anchor','project-created:other'); }], + ['wrong card kind', () => { document.querySelector('article')!.setAttribute('aria-label','Goal created: Launch'); }], + ['wrong card resource', () => { document.querySelector('article a')!.setAttribute('href','/RUN/projects/other'); }], + ['wrong company route', () => { document.querySelector('article a')!.setAttribute('href','/OTHER/projects/project'); }], + ['other origin', () => { document.querySelector('article a')!.setAttribute('href','https://other.test/RUN/projects/project'); }], + ['wrong resource kind', () => { document.querySelector('article a')!.setAttribute('href','/RUN/goals/project'); }], + ['extra destination path', () => { document.querySelector('article a')!.setAttribute('href','/RUN/projects/project/other'); }], + ['duplicate visible source conversation', () => { document.body.insertAdjacentHTML('beforeend',thread()); }], +] as const)('rejects %s in actual DOM scope', (_label, mutate) => { mutate(); expect(collect()).toEqual([]); }); +it("excludes hidden, wrong-company and unrelated inline anchors while keeping the current reply", () => { + document.querySelector('#comment-reply')!.innerHTML='TaskOther company'; + document.body.insertAdjacentHTML('beforeend',''); + expect(collect().filter(row=>row.source==='reply').map(row=>row.href)).toEqual(['/RUN/issues/task']); +}); + +it("treats a link inside an authenticated reply as an ordinary inline link, not a sibling creation receipt", () => { + document.querySelector('#comment-reply')!.append(document.querySelector('article')!); + expect(collect().map(row=>row.source)).toEqual(['reply']); +}); + +it("recognizes the minimized authentic retained DOM and server identities without changing the original measurement", () => { + const fixture = JSON.parse(readFileSync(`${import.meta.dirname}/chat-rendered-links.retained-project.json`, "utf8")); + document.body.innerHTML = fixture.html; + const reply = fixture.input.comments.find((row: { authorAgentId?: string }) => row.authorAgentId); + expect(document.getElementById(`comment-${reply.id}`)!.querySelectorAll("a[href]")).toHaveLength(0); + expect(collect(fixture.input).map(row => row.href)).toEqual([fixture.expectedHref]); +}); + +it("retains ordinary inline links in the supported classic issue-chat root", () => { + document.body.innerHTML=thread('').replace('task-chat-thread','thread-root'); + document.querySelector('#comment-reply')!.innerHTML='Task'; + expect(collect().map(row=>[row.threadTestId,row.source,row.href])).toEqual([['thread-root','reply','/RUN/issues/task']]); +}); diff --git a/tests/runner-e2e/chat-rendered-links.ts b/tests/runner-e2e/chat-rendered-links.ts new file mode 100644 index 00000000000..dadca64ce19 --- /dev/null +++ b/tests/runner-e2e/chat-rendered-links.ts @@ -0,0 +1,88 @@ +import type { Locator, Page } from "@playwright/test"; +import type { ChatRun } from "./chat-flow.js"; + +interface Row { id: string; companyId?: string; issueId?: string; authorAgentId?: string | null; createdByRunId?: string | null; name?: string; action?: string; entityType?: string; entityId?: string; agentId?: string | null; actorType?: string; actorId?: string; runId?: string | null } +export interface RenderedConversationLink { + threadTestId: "task-chat-thread" | "thread-root"; href: string; source: "reply" | "project-created-card"; conversationId: string; anchorCommentId: string; + commentId?: string; resourceId?: string; cardLabel?: string; createdByRunId?: string; +} +export interface ConversationLinkInput { + origin: string; companyId: string; companyPrefix?: string | null; sourceId: string; leadId: string; + comments: Row[]; runs: ChatRun[]; projects: Row[]; activity: Row[]; +} +export function conversationLinkBindings(input: ConversationLinkInput) { + const comments = input.comments.filter(row => row.companyId === input.companyId && row.issueId === input.sourceId); + const replies = comments.filter(row => row.authorAgentId === input.leadId && input.runs.some(run => + run.id === row.createdByRunId && run.companyId === input.companyId && run.agentId === input.leadId + && ((run as ChatRun & { nativeIssueId?: string }).nativeIssueId ?? run.contextSnapshot?.issueId) === input.sourceId)); + const projects = input.projects.flatMap(project => { + if (project.companyId !== input.companyId || !project.name) return []; + const creation = input.activity.find(row => row.companyId === input.companyId && row.action === "project.created" + && row.entityType === "project" && row.entityId === project.id && row.actorType === "agent" + && row.actorId === input.leadId && row.agentId === input.leadId + && replies.some(reply => reply.createdByRunId === row.runId)); + return creation?.runId ? [{ id: project.id, name: project.name, runId: creation.runId }] : []; + }); + return { origin: input.origin, companyPrefix: input.companyPrefix, sourceId: input.sourceId, + anchors: comments.map(row => row.id), replies: replies.map(row => row.id), projects }; +} +export function readConversationDomLinks(input: ReturnType): RenderedConversationLink[] { + const visible = (element: Element) => { + for (let node: Element | null = element; node; node = node.parentElement) { + if (node.hasAttribute("hidden") || node.hasAttribute("inert") || node.getAttribute("aria-hidden") === "true") return false; + const style = window.getComputedStyle(node); + if (style.display === "none" || ["hidden", "collapse"].includes(style.visibility) || style.opacity === "0") return false; + } + return element.getClientRects().length > 0; + }; + const threadSelector = '[data-testid="task-chat-thread"], [data-testid="thread-root"]'; + const roots = Array.from(document.querySelectorAll(threadSelector)).filter(root => visible(root) + && input.anchors.some(id => Array.from(root.querySelectorAll("[id]")).some(node => node.id === `comment-${id}` && node.closest(threadSelector) === root))); + if (roots.length !== 1) return []; + const root = roots[0]!; + const threadTestId = root.getAttribute("data-testid") as RenderedConversationLink["threadTestId"]; + const anchor = input.anchors.find(id => Array.from(root.querySelectorAll("[id]")).some(node => node.id === `comment-${id}`))!; + const rows: RenderedConversationLink[] = []; + const sameConversation = (node: Element) => node.closest(threadSelector) === root; + const companyResource = (href: string) => { + try { + const url = new URL(href, input.origin), parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent); + const resourceAt = parts.findIndex(part => ["projects", "issues", "goals"].includes(part)); + if (url.origin !== new URL(input.origin).origin) return !(resourceAt === 1 && parts.length === 3); + return resourceAt < 0 || resourceAt === 1 && parts[0] === input.companyPrefix; + } catch { return false; } + }; + for (const id of input.replies) { + const reply = Array.from(root.querySelectorAll("[id]")).find(node => node.id === `comment-${id}` && sameConversation(node)); + for (const node of reply?.querySelectorAll("a[href]") ?? []) { + const href = node.getAttribute("href"); + if (href && sameConversation(node) && visible(node) && companyResource(href)) rows.push({ threadTestId, href, source: "reply", conversationId: input.sourceId, anchorCommentId: id, commentId: id }); + } + } + for (const project of threadTestId === "task-chat-thread" ? input.projects : []) { + const label = `Project created: ${project.name}`; + for (const card of root.querySelectorAll("article[aria-label]")) { + const item = card.closest('[data-thread-item-kind="project_created"]'); + if (item?.getAttribute("data-thread-anchor") !== `project-created:${project.id}` || card.getAttribute("aria-label") !== label || card.closest('[id^="comment-"]') || !sameConversation(card) || !visible(card)) continue; + for (const node of card.querySelectorAll("a[href]")) { + const href = node.getAttribute("href"); + if (!href || !visible(node) || !sameConversation(node)) continue; + try { + const url = new URL(href, input.origin), parts = url.pathname.split("/").filter(Boolean).map(decodeURIComponent); + if (url.origin !== new URL(input.origin).origin || parts.length !== 3 || parts[0] !== input.companyPrefix || parts[1] !== "projects" || parts[2] !== project.id) continue; + rows.push({ threadTestId, href, source: "project-created-card", conversationId: input.sourceId, anchorCommentId: anchor, resourceId: project.id, cardLabel: label, createdByRunId: project.runId }); + } catch { continue; } + } + } + } + return rows; +} +export async function collectConversationLinks(page: Page, input: ConversationLinkInput) { + return page.evaluate(readConversationDomLinks, conversationLinkBindings(input)); +} +export function conversationLinkLocator(page: Page, link: RenderedConversationLink): Locator { + const thread = page.getByTestId(link.threadTestId).filter({ has: page.locator(`[id=${JSON.stringify(`comment-${link.anchorCommentId}`)}]`) }); + return link.source === "reply" + ? thread.locator(`[id=${JSON.stringify(`comment-${link.commentId}`)}] a[href=${JSON.stringify(link.href)}]`) + : thread.getByRole("article", { name: link.cardLabel!, exact: true }).locator(`a[href=${JSON.stringify(link.href)}]`); +}