fix(evals): collect bound conversation project creation links

This commit is contained in:
Dotta committed 2026-10-28 13:24:43 -05:00
1 parent 65fa0c6a84
commit 7ef235b063
10 files changed
+276 -24

No files matched your search

@@ -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();
@@ -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();
@@ -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();
@@ -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<ReturnType<typeof createAuthenticatedPerson>>;
@@ -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 });
@@ -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();
@@ -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<string, unknown>;
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<string, unknown>, key: string): Entity {
const value = state[key];
@@ -50,15 +51,16 @@ export async function observeInternal(ctx: ChatCatalogContext, state: Record<str
const source = ctx.issue();
const chat = source ? await api.get<Entity[]>(`/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<Entity[]>(`/api/issues/${target.id}/comments?order=asc`) : [];
const companyActivity = await api.get<Entity[]>(`${companyPath(ctx)}/activity?limit=1000`);
const taskActivity = target ? await api.get<Entity[]>(`/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<Entity[]>(`/api/issues/${target.id}/documents`)) {
@@ -69,7 +71,7 @@ export async function observeInternal(ctx: ChatCatalogContext, state: Record<str
}
return { scenarioId: ctx.scenario.id, origin: api.baseURL, companyId: fixtures.company.id, leadId: fixtures.agent.id,
state, tasks: tasks.filter(row => 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);
}
+10 -6
View File
@@ -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<Array<{ id: string; body: string; authorAgentId?: string; authorUserId?: string; issueId?: string; companyId?: string; createdByRunId?: string }>>(
`/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<ConversationLinkInput["projects"]>(`/api/companies/${input.fixtures.company.id}/projects?includeArchived=true`),
input.api.get<ConversationLinkInput["activity"]>(`/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;
@@ -0,0 +1,59 @@
{
"html": "<div data-testid=\"task-chat-thread\"><div><div data-testid=\"task-chat-history-content\"><div><div data-testid=\"task-chat-scroller\"><div><div data-thread-anchor=\"optimistic-e69226c8-8ad4-4645-b859-a3dda913f56b\" id=\"comment-edf2bd07-4cc2-4aad-835e-b69deaf22e4d\" data-thread-item-kind=\"human\"><div><div data-testid=\"task-chat-human-bubble\"><div style=\"overflow-wrap: anywhere; word-break: break-word;\"><p style=\"overflow-wrap: anywhere; word-break: break-word;\">Create a project called Launch for the new website.</p></div></div><span>2:48 AM</span></div></div><div data-thread-anchor=\"project-created:6dd3e7e2-8856-4628-a0bb-bd6c7b3511ab\" data-thread-item-kind=\"project_created\"><article aria-label=\"Project created: Launch\"><div><div><p>Project created</p><a href=\"/RUN/projects/6dd3e7e2-8856-4628-a0bb-bd6c7b3511ab\">Launch</a></div></div></article></div><div data-thread-anchor=\"35fac4e2-1156-41e1-a6f9-a3c81f65a01a\" id=\"comment-35fac4e2-1156-41e1-a6f9-a3c81f65a01a\" data-thread-item-kind=\"agent\"><div><div><div data-testid=\"task-chat-turn\"><div data-testid=\"task-chat-turn-summary\"><span data-testid=\"task-chat-agent-identity\"><span data-testid=\"task-chat-agent-avatar\"><span aria-hidden=\"true\"><img></img></span></span><span>CEO</span></span></div><div data-testid=\"task-chat-turn-timeline\"><div data-testid=\"task-chat-turn-timeline-row\" data-thread-anchor=\"06c67909-f1f3-4320-9ed6-50a65e2a767c:tool:2:phase:opening\"><section data-testid=\"task-chat-activity-phase\"><div></div></section></div></div></div></div><div data-testid=\"task-chat-agent-bubble\"><div style=\"overflow-wrap: anywhere; word-break: break-word;\"><p style=\"overflow-wrap: anywhere; word-break: break-word;\">Created the <strong>Launch</strong> 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.</p></div></div><div><span>2:48 AM</span><div data-testid=\"task-chat-bubble-actions\"></div></div></div></div></div></div></div></div></div></div>",
"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"
}
@@ -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 = '<div data-thread-item-kind="project_created" data-thread-anchor="project-created:project"><article aria-label="Project created: Launch"><div><p>Project created</p><a href="/RUN/projects/project">Launch</a></div></article></div>';
const thread = (content = card) => `<div data-testid="task-chat-thread"><div data-testid="task-chat-history-content"><div data-testid="task-chat-scroller"><div id="comment-human" data-thread-item-kind="human">Create a project called Launch for the new website.</div>${content}<div id="comment-reply" data-thread-item-kind="agent">Created the Launch project. No repositories are connected yet.</div></div></div></div>`;
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', '<div id="comment-retry">The project already exists.</div>');
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 = '<a href="/RUN/issues/task">Task</a><a href="/RUN/goals/goal">Goal</a><a href="/api/attachments/file/content">Artifact</a><a href="https://github.com/owner/repository/issues/12">External issue</a>';
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('')+`<aside>${card}</aside>`; }],
['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='<a href="/RUN/issues/task">Task</a><a hidden href="/RUN/issues/hidden">Hidden</a><a href="/OTHER/issues/task">Other company</a>';
document.body.insertAdjacentHTML('beforeend','<aside><a href="/RUN/issues/sidebar">Sidebar</a></aside>');
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='<a href="/RUN/issues/task">Task</a>';
expect(collect().map(row=>[row.threadTestId,row.source,row.href])).toEqual([['thread-root','reply','/RUN/issues/task']]);
});
+88
View File
@@ -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<typeof conversationLinkBindings>): 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)}]`);
}