mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-15 17:18:38 +00:00
* feat(artifacts): preview CSV and TSV files as bounded tables * chore: keep preview screenshots out of the PR file diff * fix(artifacts): detect record newlines outside quoted fields * test(auth): include project permissions in me contract expectations
203 lines
6.5 KiB
TypeScript
203 lines
6.5 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
||
|
||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||
|
||
const threadId = "00000000-0000-0000-0000-000000003141";
|
||
const filepath = "/mnt/user-data/outputs/performance.csv";
|
||
const normal =
|
||
"ID,Note\n" +
|
||
Array.from({ length: 12_000 }, (_, i) => `${i},${"x".repeat(80)}\n`).join("");
|
||
const wide = '"",'.repeat(349_000) + '""\n';
|
||
const viewerURL = (name = filepath) =>
|
||
`/artifacts/view?path=${encodeURIComponent(name)}&thread_id=${threadId}`;
|
||
|
||
type Metrics = {
|
||
started: number;
|
||
firstTable: number | null;
|
||
beats: number;
|
||
maxHeartbeatGap: number;
|
||
longTasks: number[];
|
||
};
|
||
|
||
type MeasuredWindow = Window & { __csvMetrics: Metrics };
|
||
|
||
async function setup(page: Page, body: string) {
|
||
mockLangGraphAPI(page);
|
||
await page
|
||
.context()
|
||
.route(`**/api/threads/${threadId}/artifacts/**`, async (route) => {
|
||
// Start after the viewer has requested its content. Include cold Worker and
|
||
// table chunk loading in the reported latency, excluding page navigation.
|
||
await page.evaluate(() => {
|
||
const measured = window as unknown as MeasuredWindow;
|
||
const metrics: Metrics = {
|
||
started: performance.now(),
|
||
firstTable: null,
|
||
beats: 0,
|
||
maxHeartbeatGap: 0,
|
||
longTasks: [],
|
||
};
|
||
measured.__csvMetrics = metrics;
|
||
let previous = metrics.started;
|
||
const timer = setInterval(() => {
|
||
const now = performance.now();
|
||
if (metrics.firstTable !== null) return;
|
||
metrics.beats++;
|
||
metrics.maxHeartbeatGap = Math.max(
|
||
metrics.maxHeartbeatGap,
|
||
now - previous,
|
||
);
|
||
previous = now;
|
||
}, 16);
|
||
const observer = new MutationObserver(() => {
|
||
if (
|
||
document.querySelector(
|
||
'[data-testid="artifact-table-preview"] table',
|
||
)
|
||
) {
|
||
metrics.firstTable ??= performance.now();
|
||
observer.disconnect();
|
||
}
|
||
});
|
||
observer.observe(document.documentElement, {
|
||
childList: true,
|
||
subtree: true,
|
||
});
|
||
const longTasks = new PerformanceObserver((entries) => {
|
||
for (const entry of entries.getEntries()) {
|
||
if (
|
||
entry.startTime >= metrics.started &&
|
||
(metrics.firstTable === null ||
|
||
entry.startTime <= metrics.firstTable)
|
||
)
|
||
metrics.longTasks.push(entry.duration);
|
||
}
|
||
});
|
||
longTasks.observe({ type: "longtask", buffered: false });
|
||
window.addEventListener(
|
||
"pagehide",
|
||
() => {
|
||
clearInterval(timer);
|
||
observer.disconnect();
|
||
longTasks.disconnect();
|
||
},
|
||
{ once: true },
|
||
);
|
||
});
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "text/csv",
|
||
body,
|
||
});
|
||
});
|
||
}
|
||
|
||
async function report(page: Page, name: string, bytes: number) {
|
||
const metrics = await page.evaluate(
|
||
() => (window as unknown as MeasuredWindow).__csvMetrics,
|
||
);
|
||
const report = {
|
||
sample: name,
|
||
bytes,
|
||
firstTableMs:
|
||
metrics.firstTable === null ? null : metrics.firstTable - metrics.started,
|
||
heartbeatCount: metrics.beats,
|
||
maxHeartbeatGapMs: metrics.maxHeartbeatGap,
|
||
longTaskCount: metrics.longTasks.length,
|
||
longTaskDurationsMs: metrics.longTasks,
|
||
};
|
||
console.log("CSV browser performance", JSON.stringify(report));
|
||
await test.info().attach(`${name}-performance`, {
|
||
body: JSON.stringify(report, null, 2),
|
||
contentType: "application/json",
|
||
});
|
||
return report;
|
||
}
|
||
|
||
test("measures a cold real Worker with a normal approximately 1 MiB CSV", async ({
|
||
page,
|
||
}) => {
|
||
test.setTimeout(60_000);
|
||
await setup(page, normal);
|
||
await page.goto(viewerURL());
|
||
await expect(page.getByRole("table")).toBeVisible({ timeout: 30_000 });
|
||
await expect(
|
||
page.getByText("Preview of first 200 rows", { exact: true }),
|
||
).toBeVisible();
|
||
await expect(
|
||
page.getByRole("cell", { name: "0", exact: true }),
|
||
).toBeVisible();
|
||
await expect(page.getByRole("row")).toHaveCount(51);
|
||
const metrics = await report(page, "normal-1mib", Buffer.byteLength(normal));
|
||
expect(metrics.firstTableMs).not.toBeNull();
|
||
// Report actual latency/long tasks, without asserting hardware-specific targets.
|
||
await page.getByRole("button", { name: "Next page" }).click();
|
||
await expect(page.getByText(/^51–100/)).toBeVisible();
|
||
});
|
||
|
||
test("keeps the main thread responsive during an extremely wide quoted record", async ({
|
||
page,
|
||
}) => {
|
||
test.setTimeout(60_000);
|
||
await setup(page, wide);
|
||
const workerStarted = page.waitForEvent("worker");
|
||
await page.goto(viewerURL());
|
||
await workerStarted;
|
||
// An actual DOM interaction must finish while the real parser runs in its
|
||
// Worker. This probe is deliberately independent of the chat composer.
|
||
await page.evaluate(() => {
|
||
const button = document.createElement("button");
|
||
button.textContent = "Responsiveness probe";
|
||
button.onclick = () => {
|
||
button.textContent = "Probe acknowledged";
|
||
};
|
||
document.body.prepend(button);
|
||
});
|
||
await page.getByRole("button", { name: "Responsiveness probe" }).click();
|
||
await expect(
|
||
page.getByRole("button", { name: "Probe acknowledged" }),
|
||
).toBeVisible();
|
||
await expect
|
||
.poll(async () =>
|
||
page.evaluate(
|
||
() => (window as unknown as MeasuredWindow).__csvMetrics.beats,
|
||
),
|
||
)
|
||
.toBeGreaterThan(2);
|
||
await expect(
|
||
page
|
||
.getByRole("table")
|
||
.or(page.getByText(/Unable to preview this table reliably/)),
|
||
).toBeVisible({ timeout: 15_000 });
|
||
await report(page, "wide-quoted-1mib", Buffer.byteLength(wide));
|
||
});
|
||
|
||
test("can leave a pathological parse and open another real Worker result", async ({
|
||
page,
|
||
}) => {
|
||
test.setTimeout(60_000);
|
||
await setup(page, wide);
|
||
const workerStarted = page.waitForEvent("worker");
|
||
await page.goto(viewerURL());
|
||
const worker = await workerStarted;
|
||
let closed = false;
|
||
worker.on("close", () => {
|
||
closed = true;
|
||
});
|
||
await page
|
||
.context()
|
||
.route(`**/api/threads/${threadId}/artifacts/**/replacement.csv`, (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: "text/csv",
|
||
body: "ID,Note\nreplacement,ready\n",
|
||
}),
|
||
);
|
||
await page.goto(viewerURL("/mnt/user-data/outputs/replacement.csv"));
|
||
await expect(
|
||
page.getByRole("cell", { name: "replacement", exact: true }),
|
||
).toBeVisible({ timeout: 20_000 });
|
||
await expect.poll(() => closed).toBe(true);
|
||
await expect(page.getByRole("row")).toHaveCount(2);
|
||
});
|