241 lines
14 KiB
TypeScript
241 lines
14 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
test.setTimeout(90_000);
|
|
|
|
test.use({ video: "on", trace: "retain-on-failure", viewport: { width: 1440, height: 900 } });
|
|
|
|
let prefix: string;
|
|
let issue: { id: string; identifier: string };
|
|
let other: { id: string; identifier: string };
|
|
let oldestCommentId: string;
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
const company = await (await request.post("/api/companies", { data: { name: `Layout acceptance ${Date.now()}` } })).json();
|
|
prefix = company.issuePrefix;
|
|
const createIssue = async (title: string) => {
|
|
const response = await request.post(`/api/companies/${company.id}/issues`, {
|
|
data: { title, status: "backlog", description: "A rich task with a long conversation.\n\n".repeat(15) },
|
|
});
|
|
expect(response.ok(), await response.text()).toBeTruthy();
|
|
return response.json();
|
|
};
|
|
issue = await createIssue("Stable conversation acceptance");
|
|
other = await createIssue("Rapid navigation destination");
|
|
for (let i = 0; i < 160; i++) {
|
|
const response = await request.post(`/api/issues/${issue.id}/comments`, {
|
|
data: { body: `## Historical message ${i}\n\n${"Long Markdown with **emphasis**, detail, and enough text to wrap across several lines. ".repeat(8)}\n\n\`\`\`js\nconsole.log(${i});\n\`\`\`\n\n| State | Result |\n|---|---|\n| Complete | Stable |${i === 159 ? `\n\n[Read the oldest message](#comment-${oldestCommentId})` : ""}` },
|
|
});
|
|
expect(response.ok()).toBeTruthy();
|
|
if (i === 0) oldestCommentId = (await response.json()).id;
|
|
}
|
|
const interaction = await (await request.post(`/api/issues/${issue.id}/interactions`, {
|
|
data: { kind: "request_confirmation", title: "Geometry review", payload: { version: 1, prompt: "Inspect the conversation." }, continuationPolicy: "none" },
|
|
})).json();
|
|
await request.post(`/api/issues/${issue.id}/interactions/${interaction.id}/accept`, { data: {} });
|
|
});
|
|
|
|
async function ready(page: Page) {
|
|
await expect(page.getByTestId("task-chat-thread").locator('[aria-busy="false"]')).toBeVisible({ timeout: 90_000 });
|
|
}
|
|
|
|
async function installMeasurements(page: Page) {
|
|
await page.addInitScript(() => {
|
|
const evidence = { frames: [] as unknown[], shifts: [] as unknown[] };
|
|
Object.assign(window, { taskLayoutEvidence: evidence });
|
|
new PerformanceObserver((list) => {
|
|
evidence.shifts.push(...list.getEntries().map((e) => e.toJSON()));
|
|
}).observe({ type: "layout-shift", buffered: true });
|
|
const sample = () => {
|
|
const scroller = document.querySelector('[data-testid="task-chat-scroller"]');
|
|
const busy = document.querySelector('[data-testid="task-chat-thread"] [aria-busy]');
|
|
if (scroller) {
|
|
const viewport = scroller.getBoundingClientRect();
|
|
const row = [...scroller.querySelectorAll<HTMLElement>("[data-thread-anchor]")].find((el) => el.getBoundingClientRect().bottom > viewport.top);
|
|
evidence.frames.push({ time: performance.now(), busy: busy?.getAttribute("aria-busy"), top: scroller.scrollTop, width: viewport.width, fonts: document.fonts.status, rows: scroller.querySelectorAll("[data-thread-anchor]").length, height: scroller.scrollHeight, anchor: row?.dataset.threadAnchor, offset: row ? row.getBoundingClientRect().top - viewport.top : null });
|
|
}
|
|
requestAnimationFrame(sample);
|
|
};
|
|
requestAnimationFrame(sample);
|
|
});
|
|
}
|
|
|
|
test.afterEach(async ({ page }, info) => {
|
|
const evidence = await page.evaluate(() => (window as unknown as { taskLayoutEvidence?: unknown }).taskLayoutEvidence).catch(() => null);
|
|
if (evidence) {
|
|
const file = info.outputPath("layout.json");
|
|
await fs.writeFile(file, JSON.stringify(evidence));
|
|
await info.attach("layout-frames-and-shifts", { path: file, contentType: "application/json" });
|
|
}
|
|
});
|
|
|
|
test("Inbox click reveals once after reordered initial responses; refresh retains rows", async ({ page }) => {
|
|
await installMeasurements(page);
|
|
await page.goto(`/${prefix}/inbox/all`);
|
|
const link = page.getByRole("link", { name: new RegExp(`Open ${issue.identifier}:`) });
|
|
await expect(link).toBeVisible({ timeout: 90_000 });
|
|
let release!: () => void;
|
|
const held = new Promise<void>((resolve) => { release = resolve; });
|
|
await page.route("**/api/issues/*/comments?*", async (route) => { await held; await route.continue(); });
|
|
await link.click();
|
|
await expect(page.getByTestId("task-chat-thread").locator('[aria-busy="true"]')).toBeVisible();
|
|
await expect(page.getByText("Historical message 159", { exact: true })).not.toBeVisible();
|
|
release();
|
|
await ready(page);
|
|
const scroller = page.getByTestId("task-chat-scroller");
|
|
await expect.poll(() => scroller.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(2);
|
|
await page.waitForTimeout(1200);
|
|
const frames = await page.evaluate(() => (window as unknown as { taskLayoutEvidence: { frames: { busy: string; top: number; anchor: string; offset: number }[] } }).taskLayoutEvidence.frames);
|
|
const visibleFrames = frames.filter((frame) => frame.busy === "false");
|
|
expect(visibleFrames.length).toBeGreaterThan(2);
|
|
expect(new Set(visibleFrames.map((f) => f.anchor)).size).toBe(1);
|
|
expect(Math.max(...visibleFrames.map((f) => f.offset)) - Math.min(...visibleFrames.map((f) => f.offset))).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
test("reading anchor survives late media, composer resizing, older history and browser Back", async ({ page }) => {
|
|
await installMeasurements(page);
|
|
await page.goto(`/${prefix}/issues/${issue.identifier}`);
|
|
await ready(page);
|
|
const scroller = page.getByTestId("task-chat-scroller");
|
|
await expect(page.getByText("Historical message 10", { exact: true })).toBeAttached();
|
|
await scroller.evaluate((el) => { el.scrollTop = el.scrollHeight / 2; });
|
|
await page.waitForTimeout(100);
|
|
const anchor = await scroller.evaluate((el) => {
|
|
const top = el.getBoundingClientRect().top;
|
|
const row = [...el.querySelectorAll<HTMLElement>("[data-thread-anchor]")].find((r) => r.getBoundingClientRect().bottom > top)!;
|
|
return { id: row.dataset.threadAnchor!, top: row.getBoundingClientRect().top - top };
|
|
});
|
|
// Deterministic late media sizing above the reader, after the initial data
|
|
// has arrived. This exercises the actual route's ResizeObserver owner.
|
|
await scroller.evaluate((el) => {
|
|
const media = document.createElement("div");
|
|
media.style.height = "360px";
|
|
el.querySelector("[data-thread-anchor]")!.append(media);
|
|
});
|
|
const offset = () => scroller.evaluate((el, id) => {
|
|
const row = [...el.querySelectorAll<HTMLElement>("[data-thread-anchor]")].find((r) => r.dataset.threadAnchor === id)!;
|
|
return row.getBoundingClientRect().top - el.getBoundingClientRect().top;
|
|
}, anchor.id);
|
|
await expect.poll(async () => Math.abs(await offset() - anchor.top)).toBeLessThanOrEqual(2);
|
|
await page.getByRole("button", { name: "Hide properties", exact: true }).click();
|
|
await page.waitForTimeout(300);
|
|
await expect.poll(async () => Math.abs(await offset() - anchor.top)).toBeLessThanOrEqual(2);
|
|
await page.getByRole("button", { name: "Show properties", exact: true }).click();
|
|
await page.waitForTimeout(300);
|
|
await expect.poll(async () => Math.abs(await offset() - anchor.top)).toBeLessThanOrEqual(2);
|
|
await page.locator('[contenteditable="true"]').last().fill("A multiline draft\n".repeat(8));
|
|
await expect.poll(async () => Math.abs(await offset() - anchor.top)).toBeLessThanOrEqual(2);
|
|
// Invoke the real older-history control without scrolling it into view.
|
|
await page.getByRole("button", { name: /Load earlier/ }).evaluate((el) => (el as HTMLButtonElement).click());
|
|
await expect(page.getByText("Historical message 0", { exact: true })).toBeAttached();
|
|
await expect.poll(async () => Math.abs(await offset() - anchor.top)).toBeLessThanOrEqual(2);
|
|
|
|
await page.getByRole("link", { name: "Tasks", exact: true }).first().click();
|
|
await page.goBack();
|
|
await ready(page);
|
|
await expect.poll(async () => Math.abs(await offset() - anchor.top)).toBeLessThanOrEqual(2);
|
|
await page.getByRole("button", { name: "Scroll to latest" }).click();
|
|
await page.locator('[contenteditable="true"]').last().fill("Change the composer during the glide\n".repeat(5));
|
|
await expect.poll(() => scroller.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(2);
|
|
});
|
|
|
|
test("explicit comment links resolve older pages before revealing at the target", async ({ page }) => {
|
|
await page.goto(`/${prefix}/issues/${issue.identifier}#comment-${oldestCommentId}`);
|
|
await ready(page);
|
|
const target = page.locator(`[id="comment-${oldestCommentId}"]`);
|
|
await expect(target).toBeVisible();
|
|
await expect.poll(async () => {
|
|
const row = await target.boundingBox();
|
|
const viewport = await page.getByTestId("task-chat-scroller").boundingBox();
|
|
return Math.abs(row!.y - viewport!.y);
|
|
}).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
test("failed initial comments expose retry, then recover without an indefinite skeleton", async ({ page }) => {
|
|
await page.route("**/api/issues/*/comments?*", (route) => route.fulfill({ status: 503, contentType: "application/json", body: '{"error":"Temporarily unavailable"}' }));
|
|
await page.goto(`/${prefix}/issues/${issue.identifier}`);
|
|
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible({ timeout: 30_000 });
|
|
await page.unroute("**/api/issues/*/comments?*");
|
|
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
|
await ready(page);
|
|
await expect(page.getByText("Historical message 159", { exact: true })).toBeAttached();
|
|
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeHidden();
|
|
});
|
|
|
|
test("mobile reduced-motion cold open and rapid task switching", async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await page.goto(`/${prefix}/issues/${issue.identifier}`);
|
|
await ready(page);
|
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollHeight - window.scrollY - window.innerHeight)).toBeLessThan(2);
|
|
await page.evaluate(() => window.scrollTo(0, 1200));
|
|
await page.waitForTimeout(100);
|
|
const saved = await page.evaluate(() => window.scrollY);
|
|
await page.reload();
|
|
await ready(page);
|
|
await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(1200);
|
|
// A fresh navigation must show the new task, never retain the previous feed.
|
|
await page.goto(`/${prefix}/issues/${other.identifier}`);
|
|
await ready(page);
|
|
await expect(page.getByText("Historical message 159", { exact: true })).not.toBeAttached();
|
|
expect(saved).toBe(1200);
|
|
});
|
|
|
|
for (const mobile of [false, true]) {
|
|
test(`same-task comment links and Back restore the mounted ${mobile ? "mobile" : "desktop"} conversation`, async ({ page }) => {
|
|
if (mobile) await page.setViewportSize({ width: 390, height: 844 });
|
|
await page.goto(`/${prefix}/issues/${issue.identifier}`);
|
|
await ready(page);
|
|
await page.getByText("Historical message 10", { exact: true }).waitFor({ state: "attached" });
|
|
await expect(page.locator(`[id="comment-${oldestCommentId}"]`)).toHaveCount(0);
|
|
const originalThread = await page.getByTestId("task-chat-thread").elementHandle();
|
|
const before = await page.evaluate((mobile) => {
|
|
const root = document.querySelector(mobile ? '[data-testid="task-chat-thread"]' : '[data-testid="task-chat-scroller"]')!;
|
|
const top = mobile ? 0 : root.getBoundingClientRect().top;
|
|
const row = [...root.querySelectorAll<HTMLElement>("[data-thread-anchor]")].find((el) => el.getBoundingClientRect().bottom > top)!;
|
|
return { id: row.dataset.threadAnchor!, offset: row.getBoundingClientRect().top - top };
|
|
}, mobile);
|
|
await page.getByRole("link", { name: "Read the oldest message", exact: true }).click();
|
|
const target = page.locator(`[id="comment-${oldestCommentId}"]`);
|
|
await expect(target).toBeAttached();
|
|
const targetOffset = () => target.evaluate((el, mobile) => el.getBoundingClientRect().top - (mobile ? 0 : document.querySelector('[data-testid="task-chat-scroller"]')!.getBoundingClientRect().top), mobile);
|
|
await expect.poll(async () => Math.abs(await targetOffset())).toBeLessThanOrEqual(2);
|
|
expect(await originalThread!.evaluate((el) => el === document.querySelector('[data-testid="task-chat-thread"]'))).toBe(true);
|
|
await page.goBack();
|
|
await expect.poll(async () => Math.abs(await page.evaluate(({ mobile, before }) => {
|
|
const root = document.querySelector(mobile ? '[data-testid="task-chat-thread"]' : '[data-testid="task-chat-scroller"]')!;
|
|
const row = [...root.querySelectorAll<HTMLElement>("[data-thread-anchor]")].find((el) => el.dataset.threadAnchor === before.id)!;
|
|
return row.getBoundingClientRect().top - (mobile ? 0 : root.getBoundingClientRect().top) - before.offset;
|
|
}, { mobile, before }))).toBeLessThanOrEqual(2);
|
|
});
|
|
}
|
|
|
|
test("stalled native and log history reveal loaded content with Retry after the request deadline", async ({ page }) => {
|
|
const runId = "20000000-0000-4000-8000-000000000001";
|
|
const at = new Date().toISOString();
|
|
await page.route("**/api/issues/*/runs", (route) => route.fulfill({ json: [{ runId, runtimeMode: "native", status: "succeeded", agentId: "20000000-0000-4000-8000-000000000002", adapterType: "paperclip_runner", createdAt: at, startedAt: at, finishedAt: at }] }));
|
|
let stalled = true;
|
|
let release!: () => void;
|
|
const held = new Promise<void>((resolve) => { release = resolve; });
|
|
await page.route(`**/api/heartbeat-runs/${runId}/events?*`, async (route) => {
|
|
if (stalled) await held;
|
|
await route.fulfill({ json: [] }).catch(() => {});
|
|
});
|
|
await page.route(`**/api/heartbeat-runs/${runId}/log?*`, async (route) => {
|
|
if (stalled) await held;
|
|
await route.fulfill({ json: { runId, content: "", nextOffset: 0 } }).catch(() => {});
|
|
});
|
|
try {
|
|
await page.goto(`/${prefix}/issues/${issue.identifier}`);
|
|
await expect(page.getByTestId("task-chat-history-loading")).toBeVisible();
|
|
await expect(page.getByText("Some task history could not be loaded.")).toBeVisible({ timeout: 25_000 });
|
|
await ready(page);
|
|
await expect(page.getByText("Historical message 159", { exact: true })).toBeVisible();
|
|
stalled = false;
|
|
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
|
await expect(page.getByText("Some task history could not be loaded.")).not.toBeVisible();
|
|
} finally {
|
|
release();
|
|
}
|
|
});
|