mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The chat eval catalog runs real agents through chat journeys and
grades the saved results
> - Full cohort 4 (OpenCode with DeepSeek v4.1 flash, master f1aef0cb58)
failed 14 cases for eval reasons, not product reasons
> - Some phase captures read links before the reply rendered. Some
graders matched keywords in free-form replies. One locator was stale.
One fixture answer looked like a placeholder. Some checks could not say
what failed
> - This pull request grades those cases on saved state, setup literals
and rendered links, and it fixes the observers and fixtures
> - The benefit is that these cases fail only when the product fails,
and a failure names the part that broke
## Linked Issues or Issue Description
**What happened?**
In full chat cohort 4, valid agent behavior failed these cases:
case-history, goals.explain-purpose and workspaces.preview-service
(phase captures read before the reply rendered); routines.pause-owned
("If you want that one stopped too, use Pause on its task page" matched
a cancellation regex); budgets-costs.spend-breakdown ("unknown, not
$0.00" matched a stated-zero regex); steering.edit-queued-message
("change ... and drag it above" missed a keyword list);
connection-setup.choose-provider (the resumed page says "Finish with
Notion"); questions-and-approvals.necessary-question (the answer "Acorn
2a4f72ec4ae2-1" was saved as "Acorn"). Some checks could not say what
failed: onboarding.new-company compared the whole members response as
JSON and saved no after-state; agents.choose-primary threw one combined
error.
**Expected behavior**
Each case grades the feature contract on saved state, setup literals and
rendered or clicked links. A valid reworded reply passes. A broken
product still fails. A failure names the clause and its values.
**Steps to reproduce**
Grade the retained cohort 4 evidence for the cases above with the
earlier oracles.
Refs #21009, #21002, #21007
## What Changed
- Phase captures (case-history first phase, goals.explain-purpose
phases, preview-service query phase, ambiguous-task clarification,
edit-queued-message guidance) now wait for the saved reply to render
(`waitForTurnReplyLinks`) before they observe. When a saved reply yields
no rendered links, the observation records thread and anchor diagnostics
(`linkDiagnostics`) and logs them.
- routines.pause-owned: remove the reply keyword clause. Saved state at
the reply proves the run kept running, and the final state proves it
completed normally.
- budgets-costs.spend-breakdown: the unresolved record is graded
structurally. The reply must identify it (rendered link, identifier or
run id), and a chat run must have read its saved accounting (cost
summary, run record or unranged company report, bound by input digest).
"$0.00" in the reply is not matched.
- steering.edit-queued-message: grade the rendered task link in the
reply, the refused agent edit and reorder (403), and the unchanged queue
at the reply. Keyword lists are removed.
- connection-setup.choose-provider: the sign-in locator also accepts
"Finish with Notion" (the resumed-draft label). The button has no test
id or other stable name.
- questions-and-approvals.necessary-question: the answer is now
"Quillmere". The case also accepts the exact answer in a saved
deliverable (a document revision or text attachment on the linked task,
saved by an agent of the company after the answer). The yaml expected
outcome records this decision, with a `changedFields` entry in the
testops manifest.
- onboarding.new-company: save the members after-state and compare
identity, role, status and grants, and name what changed. Guidance is
graded on a rendered link to `/onboarding` (the new-organization
wizard), the business literal from the query, and the saved company
count. The source company and person get realistic names for this case
and are restored at cleanup, because the CEO reasoned from the generated
names that the request was a graded test.
- agents.choose-primary: the settled-Chat check is split into four named
clauses with values, saved as evidence on failure.
- A question card the turn ended on counts as its reply (decision after
cohort 4) in cli-operations.owned-dev-instance, onboarding.new-company
and status-cards.other-authors-card. The card's Markdown-rendered fields
(question form description; confirmation prompt and details) count as
reply links. Link requirements still apply.
- Every changed case bumps its `oracleVersion`; the descriptor snapshot
and inventory digest are updated.
Why each old check was wrong and why the new one still catches a broken
product:
| Case | Old check was wrong because | A broken product still fails
because |
|---|---|---|
| phase captures | runs succeeded and the chat waited before the reply
rendered, so links were empty | the same link checks run on the rendered
reply |
| routines.pause-owned | a correct offer to pause the run read as a
cancellation | the checkpoint run must be running and the run must later
succeed |
| spend-breakdown | "unknown, not $0.00" read as "$0" | the reply must
identify the record and a chat run must read its saved accounting;
totals and per-agent amounts stay graded |
| edit-queued-message | correct wording missed a keyword list | the
reply must link the task, the agent's edit and reorder must be refused,
and the queue must be unchanged at the reply |
| choose-provider | the resumed page label was not accepted | the button
must still be visible on the selected Notion surface |
| necessary-question | the answer read as a placeholder; deliverables
did not count | the exact answer must be saved in the linked work after
the answer, by an agent of the company |
| new-company | timestamps and profiles made the JSON compare fail
without saying why | membership identity, role, status and grants must
be unchanged |
| choose-primary | one combined error | the same four conditions, each
named |
| card as reply | a card answer read as an empty reply | card text must
exist and link requirements still apply |
## Verification
- Calibration tests (valid, reworded valid, broken) for each changed
grader: `integrator-routine-pause.test.ts`,
`identity-accounting.test.ts` (includes the cohort 4 reply),
`runtime-queue-controls.test.ts` (includes the cohort 4 reply),
`external-provider-choice.test.ts`, `identity.test.ts`,
`identity-onboarding-work.test.ts`,
`runtime-primary-ready-chat.test.ts`, `integrator-status-cards.test.ts`,
`turn-response-card.test.ts`, `chat-rendered-links.test.ts`.
- The corrected spend-breakdown grader passes on the retained cohort 4
evidence (its cost-summary read binds to the unresolved task) and fails
with the reads removed.
- `npx vitest run --config tests/runner-e2e/vitest.config.ts
chat-catalog chat-rendered chat-linked`: 284 files passed.
- `node --test
tests/runner-e2e/chat-full-catalog-testops-manifest.test.mjs`: 10/10
passed.
- `node scripts/typecheck-test-projects.mjs tests/runner-e2e`: passed.
- `node tests/runner-e2e/source-sdk-public-inputs.mjs <sha> --check`:
unchanged.
- Paid run (8 cells, OpenCode + DeepSeek v4.1 flash) on this branch
combined with the guidance PR: see the PR comment for results.
## Risks
- The new-company guidance check now needs a rendered link to
`/onboarding`. A reply that only names the company switcher fails. The
companion guidance PR tells agents this route.
- spend-breakdown does not grade wording, so a reply that reads the
record and still calls it "$0" is not caught. The amounts, total and
identification are still graded.
- The new-company case renames the source company and the person for the
case and restores them at cleanup.
## Model Used
- Anthropic Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with
tool use, shell execution and code editing.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
16 KiB
TypeScript
180 lines
16 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { readFileSync } from "node:fs";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { collectConversationLinks, collectConversationLinksWithDiagnostics, conversationLinkBindings, mislabeledIssueLinks, readConversationDomLinks, readLatestConversationReplyRender, waitForLatestConversationReplyRender, waitForSavedTurnReplyRender, 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']]);
|
|
});
|
|
|
|
// Full cohort on 9296c74e8d (choose-primary, case-history, set-budget): the API saw the saved reply ~2s before
|
|
// the open conversation rendered it, so links read in between were empty although the reply linked correctly.
|
|
describe("waits for the latest saved reply to render before reading links", () => {
|
|
const page = () => ({ evaluate: async <T, A>(read: (arg: A) => T, arg: A) => read(arg) }) as unknown as Parameters<typeof collectConversationLinks>[0];
|
|
it("reads the reply's links once the reply renders after the API saw it", async () => {
|
|
document.querySelector('#comment-reply')!.remove();
|
|
setTimeout(() => document.querySelector('[data-testid="task-chat-scroller"]')!.insertAdjacentHTML("beforeend", '<div id="comment-reply"><a href="/RUN/issues/task">Task</a></div>'), 600);
|
|
const value = input(); value.projects = []; value.activity = [];
|
|
expect((await collectConversationLinks(page(), value)).map(row => row.href)).toEqual(["/RUN/issues/task"]);
|
|
});
|
|
it("does not wait when the conversation is not open, and waits at most once for a reply that never renders", async () => {
|
|
const bindings = conversationLinkBindings(input());
|
|
document.body.innerHTML = "<main>Other page</main>";
|
|
expect(readLatestConversationReplyRender({ anchors: bindings.anchors, latest: "reply" })).toBe("closed");
|
|
document.body.innerHTML = thread(); document.querySelector('#comment-reply')!.remove();
|
|
const p = page();
|
|
expect(await waitForLatestConversationReplyRender(p, bindings, 300, 50)).toBe("pending");
|
|
const started = Date.now(); expect(await waitForLatestConversationReplyRender(p, bindings, 5_000, 50)).toBe("pending"); expect(Date.now() - started).toBeLessThan(1_000);
|
|
});
|
|
// Cohort 3 on dfd2a6efca (access.other-company, teams.install-team and six more): the final evidence read
|
|
// ran on a freshly loaded conversation whose wait had expired in an earlier document, so it read instantly
|
|
// and saved no links although the reply rendered them. A new document gets its own bounded wait.
|
|
it("waits again in a newly loaded document after an earlier document's wait expired", async () => {
|
|
const bindings = conversationLinkBindings(input());
|
|
document.body.innerHTML = thread(); document.querySelector('#comment-reply')!.remove();
|
|
const p = page();
|
|
expect(await waitForLatestConversationReplyRender(p, bindings, 200, 50)).toBe("pending");
|
|
delete (window as typeof window & { __paperclipConversationLinkDocument?: string }).__paperclipConversationLinkDocument;
|
|
setTimeout(() => document.querySelector('[data-testid="task-chat-scroller"]')!.insertAdjacentHTML("beforeend", '<div id="comment-reply"><a href="/RUN/issues/task">Task</a></div>'), 300);
|
|
expect(await waitForLatestConversationReplyRender(p, bindings, 5_000, 50)).toBe("rendered");
|
|
});
|
|
});
|
|
|
|
// Full cohort 3 (fast-responses, choose-primary, set-budget): the run was recorded finished 0.2-1.3s before its
|
|
// reply comment was saved, so a link read right after the run finished saw no reply at all.
|
|
describe("waits for a finished run's reply to be saved, then rendered, before its links are read", () => {
|
|
const page = () => ({ evaluate: async <T, A>(read: (arg: A) => T, arg: A) => read(arg) }) as unknown as Parameters<typeof collectConversationLinks>[0];
|
|
it("waits for the reply saved after the run finished and for its render", async () => {
|
|
const saved = input().comments, before = saved.filter(row => row.id !== "reply");
|
|
document.querySelector('#comment-reply')!.remove();
|
|
let reads = 0; const comments = async () => (reads++ < 3 ? before : saved);
|
|
setTimeout(() => document.querySelector('[data-testid="task-chat-scroller"]')!.insertAdjacentHTML("beforeend", '<div id="comment-reply"><a href="/RUN/issues/task">Task</a></div>'), 400);
|
|
expect(await waitForSavedTurnReplyRender(page(), { comments, leadId: "ceo", runIds: ["run"] }, 3_000, 50)).toBe("rendered");
|
|
expect(reads).toBeGreaterThan(3);
|
|
});
|
|
it("reports a run that saved no reply in time, without waiting past its bound", async () => {
|
|
const before = input().comments.filter(row => row.id !== "reply");
|
|
const started = Date.now();
|
|
expect(await waitForSavedTurnReplyRender(page(), { comments: async () => before, leadId: "ceo", runIds: ["run"] }, 300, 50)).toBe("unsaved");
|
|
expect(Date.now() - started).toBeLessThan(1_500);
|
|
});
|
|
});
|
|
|
|
describe("task links whose visible identifier names another task", () => {
|
|
it("accepts links whose label names the task they open, or names no task", () => {
|
|
expect(mislabeledIssueLinks("Open [COM-1](/COM/issues/COM-1) in [Launch](/COM/projects/launch).")).toEqual([]);
|
|
expect(mislabeledIssueLinks("The [open COM-1 task](/COM/issues/com-1#document-plan) and [this one](/COM/issues/COM-2).")).toEqual([]);
|
|
expect(mislabeledIssueLinks("[COM-1 Launch open website](/COM/issues/task-uuid)", id => id === "task-uuid" ? "COM-1" : undefined)).toEqual([]);
|
|
expect(mislabeledIssueLinks("[SHA-256 notes for COM-1](/COM/issues/COM-1)")).toEqual([]);
|
|
});
|
|
it("rejects a link whose label names a different task than it opens", () => {
|
|
expect(mislabeledIssueLinks("Open tasks: [COM-2 Launch open website](/COM/issues/COM-1)... correction:")).toEqual(["[COM-2 Launch open website](/COM/issues/COM-1)"]);
|
|
expect(mislabeledIssueLinks("[COM-2](/COM/issues/task-uuid)", id => id === "task-uuid" ? "COM-1" : undefined)).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
// Full cohort 4 (case-history, goals.explain-purpose, preview-service): a phase capture that saved no links could
|
|
// not say whether the thread was closed, showed another conversation, or rendered the reply without anchors.
|
|
describe("records thread and anchor diagnostics when a saved reply yields no rendered links", () => {
|
|
const page = () => ({ evaluate: async <T, A>(read: (arg: A) => T, arg: A) => read(arg) }) as unknown as Parameters<typeof collectConversationLinks>[0];
|
|
it("reports the rendered thread, its saved comment anchors and the reply's anchor count", async () => {
|
|
const value = input(); value.projects = []; value.activity = [];
|
|
vi.spyOn(console, "info").mockImplementation(() => undefined);
|
|
const result = await collectConversationLinksWithDiagnostics(page(), value);
|
|
expect(result.links).toEqual([]);
|
|
expect(result.diagnostics).toEqual({ render: "rendered", expectedReplyIds: ["reply"], savedCommentCount: 2,
|
|
threads: [{ testId: "task-chat-thread", visible: true, renderedCommentIds: ["human", "reply"], foreignCommentIds: [], replyAnchorCounts: { reply: 0 } }] });
|
|
});
|
|
it("adds no diagnostics when links were read", async () => {
|
|
document.querySelector('#comment-reply')!.innerHTML = '<a href="/RUN/issues/task">Task</a>';
|
|
const value = input(); value.projects = []; value.activity = [];
|
|
const result = await collectConversationLinksWithDiagnostics(page(), value);
|
|
expect(result.links.map(row => row.href)).toEqual(["/RUN/issues/task"]); expect(result.diagnostics).toBeUndefined();
|
|
});
|
|
});
|