mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
fix(evals): batch task search result readback across renders
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6dcf4160dd
commit
aee30ecfec
4 files changed
+102
-17
No files matched your search
@@ -70,8 +70,12 @@ Unexpected browser/API errors retain only a closed `task_search_<stage>_failed`
|
||||
code. Stage names come from the journey source; raw exception text, URLs, form
|
||||
values, and response bodies are not added to reporting. Existing owned evidence
|
||||
failures keep their specific codes and the screenshot privacy guard stays in place.
|
||||
It does not depend on row order or an otherwise empty company. This selection is
|
||||
manual until a separate target owner records a fresh pass and admits it to a daily
|
||||
It does not depend on row order or an otherwise empty company. The browser reads
|
||||
the visible links together. Per-row indexed locators can disappear
|
||||
while a search replaces the old collection; a detached snapshot is retried within
|
||||
the existing poll deadline. Duplicates, unknown destinations, and extra controls
|
||||
remain evidence failures. This does not add a journey retry or extend its timeout.
|
||||
The selection stays manual until a separate target owner records a fresh pass and admits it to a daily
|
||||
plan; adding the shared journey does not qualify any deployed target.
|
||||
|
||||
Supported acquisitions are managed self-hosted (`local`), registered existing
|
||||
|
||||
@@ -16,9 +16,9 @@ vi.mock("@playwright/test", () => ({ expect: Object.assign(
|
||||
type Fault = "ignore-search" | "ignore-status" | "duplicate-result" | "unknown-result" | "reload-query"
|
||||
| "reload-status" | "back-query" | "back-status" | "wrong-route" | "reload-content"
|
||||
| "saved-company" | "saved-description" | "target-run" | "control-run" | "missing-run-evidence"
|
||||
| "intent" | "receipt" | "missing-identifier" | "ignore-named-view";
|
||||
| "intent" | "receipt" | "missing-identifier" | "ignore-named-view" | "detached-snapshot";
|
||||
type UiNode = { key: string; role?: string; name?: string; title?: string; text?: string; href?: string;
|
||||
visible?: boolean; children?: UiNode[]; click?(): void; fill?(value: string): void; check?(): void;
|
||||
visible?: boolean; connected?: boolean; children?: UiNode[]; click?(): void; fill?(value: string): void; check?(): void;
|
||||
value?(): string; checked?(): boolean };
|
||||
type CollectionState = { query: string; backlog: boolean };
|
||||
|
||||
@@ -59,6 +59,7 @@ function fixture(fault?: Fault, shell: "coordinator" | "streamlined" | "legacy"
|
||||
};
|
||||
const links = (): UiNode[] => displayedId || !allTasks ? [] : visibleTasks().map((task, index) => ({
|
||||
key: `task-${task.id}-${index}`, role: "link", name: task.title, text: task.title,
|
||||
connected: fault !== "detached-snapshot" || task.id !== "task-2",
|
||||
href: `/QA/issues/${task.identifier}`, click: () => openTask(task),
|
||||
}));
|
||||
const nodes = (): UiNode[] => {
|
||||
@@ -107,7 +108,10 @@ function fixture(fault?: Fault, shell: "coordinator" | "streamlined" | "legacy"
|
||||
resolve,
|
||||
or(other: { resolve(): UiNode[] }) { return locator(() => [...new Map([...resolve(), ...other.resolve()].map(node => [node.key, node])).values()]); },
|
||||
first() { return locator(() => resolve().slice(0, 1)); },
|
||||
filter({ hasText }: { hasText: string }) { return locator(() => resolve().filter(node => node.text?.includes(hasText))); },
|
||||
filter({ hasText, visible }: { hasText?: string; visible?: boolean }) {
|
||||
return locator(() => resolve().filter(node => (hasText === undefined || node.text?.includes(hasText))
|
||||
&& (visible !== true || node.visible !== false)));
|
||||
},
|
||||
locator(selector: string) {
|
||||
if (selector !== "..") throw Error(`Unsupported control-relative selector: ${selector}`);
|
||||
return locator(() => {
|
||||
@@ -122,6 +126,10 @@ function fixture(fault?: Fault, shell: "coordinator" | "streamlined" | "legacy"
|
||||
return locator(() => flatten(resolve().flatMap(node => node.children ?? [])).filter(node => node.role === role && matches(node.name, name)));
|
||||
},
|
||||
async all() { return resolve().map(node => locator(() => [node])); },
|
||||
async evaluateAll<R>(read: (nodes: Array<{ isConnected: boolean; getAttribute(name: string): string | null }>) => R) {
|
||||
return read(resolve().map(node => ({ isConnected: node.connected !== false,
|
||||
getAttribute(name: string) { expect(name).toBe("href"); return node.href ?? null; } })));
|
||||
},
|
||||
async isVisible() { return resolve().length > 0; },
|
||||
async getAttribute(name: string) { expect(name).toBe("href"); return unique().href ?? null; },
|
||||
async inputValue() { const read = unique().value; if (!read) throw Error("Not an input"); return read(); },
|
||||
@@ -229,7 +237,7 @@ describe("taskSearchFilter browser journey", () => {
|
||||
expect(test.evidence.checks).toEqual([]);
|
||||
});
|
||||
it.each<Fault>(["ignore-search", "ignore-status", "duplicate-result", "unknown-result", "reload-query", "reload-status",
|
||||
"back-query", "back-status", "reload-content"])("does not report success when the UI has %s", async fault => {
|
||||
"back-query", "back-status", "reload-content", "detached-snapshot"])("does not report success when the UI has %s", async fault => {
|
||||
const test = fixture(fault);
|
||||
await expect(test.run()).rejects.toThrow();
|
||||
expect(test.evidence.checks.some(check => check.passed)).toBe(false);
|
||||
|
||||
@@ -12,20 +12,21 @@ export function taskSearchBacklogCheckbox(filters: Locator) {
|
||||
.getByRole("checkbox", { name: /^Backlog(?:\s*Backlog)?$/ });
|
||||
}
|
||||
|
||||
async function resultIds(page: Page, tasks: readonly SearchTask[]) {
|
||||
const ids: string[] = [];
|
||||
for (const link of await page.locator("main#main-content a[data-inbox-issue-link]").all()) {
|
||||
if (!await link.isVisible()) continue;
|
||||
const href = await link.getAttribute("href");
|
||||
export async function taskSearchResultIds(page: Page, tasks: readonly SearchTask[]) {
|
||||
// Read the matched nodes together. Indexed locators from all() can disappear
|
||||
// between isVisible() and getAttribute() while the search replaces its rows.
|
||||
const hrefs = await page.locator("main#main-content a[data-inbox-issue-link]").filter({ visible: true })
|
||||
.evaluateAll(links => links.some(link => !link.isConnected) ? null : links.map(link => link.getAttribute("href")));
|
||||
if (hrefs === null) return [];
|
||||
return hrefs.map(href => {
|
||||
const identifier = href ? decodeURIComponent(new URL(href, page.url()).pathname.split("/").at(-1)!) : "";
|
||||
ids.push(tasks.find(task => task.identifier === identifier || task.id === identifier)?.id ?? `unknown:${identifier}`);
|
||||
}
|
||||
return ids.sort();
|
||||
return tasks.find(task => task.identifier === identifier || task.id === identifier)?.id ?? `unknown:${identifier}`;
|
||||
}).sort();
|
||||
}
|
||||
|
||||
async function expectResults(page: Page, tasks: readonly SearchTask[], expectedIds: readonly string[]) {
|
||||
let observed: string[] = [];
|
||||
await expect.poll(async () => { observed = await resultIds(page, tasks); return observed; }, { timeout: 30_000 })
|
||||
await expect.poll(async () => { observed = await taskSearchResultIds(page, tasks); return observed; }, { timeout: 30_000 })
|
||||
.toEqual([...expectedIds].sort());
|
||||
return observed;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { taskSearchBacklogCheckbox } from "./product-journeys/task-search-filter.js";
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
||||
import { taskSearchBacklogCheckbox, taskSearchResultIds } from "./product-journeys/task-search-filter.js";
|
||||
import type { SearchTask } from "./product-journeys/task-search-evidence.js";
|
||||
|
||||
// Reproduce the observed Radix checkbox + StatusIcon + visible text label.
|
||||
// No app server, provider, browser profile, or target credentials are involved.
|
||||
@@ -31,3 +32,74 @@ for (const presentation of ["legacy", "spaced-legacy", "refreshed"] as const) {
|
||||
await expect(page.locator("#assignee-backlog")).not.toBeChecked();
|
||||
});
|
||||
}
|
||||
|
||||
const searchTasks: SearchTask[] = ["target", "status-control", "search-control"].map((name, index) => ({
|
||||
id: name, identifier: `QA-${index + 1}`, companyId: "company", title: name, description: `${name} context`,
|
||||
status: index === 1 ? "done" : "backlog", assigneeUserId: "board", assigneeAgentId: null,
|
||||
}));
|
||||
const taskLink = (task: SearchTask) => `<a data-inbox-issue-link href="https://paperclip.example.test/QA/issues/${task.identifier}">${task.title}</a>`;
|
||||
|
||||
test("task search readback survives a search rerender that removes an observed row", async ({ page }) => {
|
||||
page.setDefaultTimeout(150); // Bound only the reproduced stale-index wait.
|
||||
await page.setContent(`<main id="main-content">${searchTasks.map(taskLink).join("")}</main>`);
|
||||
let transitions = 0;
|
||||
const transition = async () => {
|
||||
if (transitions++) return;
|
||||
await page.locator("main#main-content").evaluate((main, html) => { main.innerHTML = html; }, searchTasks.slice(0, 2).map(taskLink).join(""));
|
||||
};
|
||||
// Keep all browser operations real. Place the render between the old
|
||||
// visibility/attribute reads, or immediately before the batch read.
|
||||
const withTransition = (source: Locator): Locator => new Proxy(source, {
|
||||
get(object, property, receiver) {
|
||||
if (property === "all") return async () => (await object.all()).map((row, index) => index !== 2 ? row : new Proxy(row, {
|
||||
get(element, operation, elementReceiver) {
|
||||
if (operation === "isVisible") return async (...args: Parameters<Locator["isVisible"]>) => {
|
||||
const visible = await element.isVisible(...args);
|
||||
if (visible) await transition();
|
||||
return visible;
|
||||
};
|
||||
const value = Reflect.get(element, operation, elementReceiver);
|
||||
return typeof value === "function" ? value.bind(element) : value;
|
||||
},
|
||||
}));
|
||||
if (property === "filter") return (...args: Parameters<Locator["filter"]>) => withTransition(object.filter(...args));
|
||||
if (property === "evaluateAll") return async (...args: Parameters<Locator["evaluateAll"]>) => {
|
||||
await transition();
|
||||
return object.evaluateAll(...args);
|
||||
};
|
||||
const value = Reflect.get(object, property, receiver);
|
||||
return typeof value === "function" ? value.bind(object) : value;
|
||||
},
|
||||
});
|
||||
const observedPage = new Proxy(page, {
|
||||
get(object, property, receiver) {
|
||||
if (property === "locator") return (...args: Parameters<Page["locator"]>) => withTransition(object.locator(...args));
|
||||
const value = Reflect.get(object, property, receiver);
|
||||
return typeof value === "function" ? value.bind(object) : value;
|
||||
},
|
||||
});
|
||||
await expect(taskSearchResultIds(observedPage, searchTasks)).resolves.toEqual(["status-control", "target"]);
|
||||
expect(transitions).toBe(1);
|
||||
await expect(page.locator("a[data-inbox-issue-link]")).toHaveCount(2);
|
||||
});
|
||||
|
||||
for (const fixture of [
|
||||
{ name: "extra owned control", html: searchTasks.map(taskLink).join(""), ids: ["search-control", "status-control", "target"] },
|
||||
{ name: "duplicate target", html: [...searchTasks.slice(0, 2), searchTasks[0]!].map(taskLink).join(""), ids: ["status-control", "target", "target"] },
|
||||
{ name: "unknown destination", html: searchTasks.slice(0, 2).map(taskLink).join("") + taskLink({ ...searchTasks[2]!, identifier: "QA-99" }), ids: ["status-control", "target", "unknown:QA-99"] },
|
||||
{ name: "wrong target destination", html: taskLink(searchTasks[1]!) + taskLink({ ...searchTasks[0]!, identifier: "QA-2" }), ids: ["status-control", "status-control"] },
|
||||
]) {
|
||||
test(`task search snapshot preserves invalid evidence: ${fixture.name}`, async ({ page }) => {
|
||||
await page.setContent(`<main id="main-content">${fixture.html}</main>`);
|
||||
const ids = await taskSearchResultIds(page, searchTasks);
|
||||
expect(ids).toEqual(fixture.ids);
|
||||
expect(ids).not.toEqual(["status-control", "target"]);
|
||||
});
|
||||
}
|
||||
|
||||
test("task search snapshot retains the existing visible-link scope", async ({ page }) => {
|
||||
await page.setContent(`<main id="main-content">${searchTasks.slice(0, 2).map(taskLink).join("")}
|
||||
<div hidden>${taskLink(searchTasks[2]!)}</div></main>
|
||||
${taskLink({ ...searchTasks[2]!, identifier: "QA-99" })}`);
|
||||
expect(await taskSearchResultIds(page, searchTasks)).toEqual(["status-control", "target"]);
|
||||
});
|
||||
Reference in new issue
Block a user