deer-flow/frontend/tests/unit/core/artifacts/use-delimited-preview.dom.test.tsx
Ryker_Feng 9fda432ba1
feat(artifacts): preview CSV and TSV files as bounded tables (#5284)
* 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
2026-09-08 19:11:11 +08:00

154 lines
5.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
import { act, cleanup, renderHook } from "@testing-library/react";
import { useDelimitedPreview } from "@/core/artifacts/use-delimited-preview";
class FakeWorker {
static instances: FakeWorker[] = [];
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
onmessageerror: (() => void) | null = null;
postMessage = rs.fn();
terminate = rs.fn();
constructor() {
FakeWorker.instances.push(this);
}
complete() {
this.onmessage?.({ data: { result: sample } } as MessageEvent);
}
}
const sample = {
rows: [["a"]],
columnCount: 1,
limited: false,
unevenRows: false,
};
const input = {
content: "a",
delimiter: "," as const,
truncated: false,
active: true,
identity: "file-a",
};
const lastWorker = () => FakeWorker.instances.at(-1)!;
describe("useDelimitedPreview", () => {
beforeEach(() => {
FakeWorker.instances = [];
rs.stubGlobal("Worker", FakeWorker);
rs.useFakeTimers();
});
afterEach(() => {
cleanup();
rs.useRealTimers();
rs.unstubAllGlobals();
});
it("starts only while active, reuses success, and ignores unrelated renders", () => {
const { result, rerender } = renderHook(useDelimitedPreview, {
initialProps: { ...input, active: false },
});
expect(FakeWorker.instances).toHaveLength(0);
rerender(input);
expect(result.current.status).toBe("loading");
act(() => lastWorker().complete());
expect(result.current.result).toEqual(sample);
expect(lastWorker().terminate).toHaveBeenCalledTimes(1);
rerender({ ...input });
rerender({ ...input, active: false });
rerender(input);
expect(FakeWorker.instances).toHaveLength(1);
expect(result.current.status).toBe("ready");
});
it("terminates on input changes and rejects late responses", () => {
const { result, rerender } = renderHook(useDelimitedPreview, {
initialProps: input,
});
const old = lastWorker();
const late = old.onmessage!;
rerender({ ...input, identity: "file-b" });
expect(old.terminate).toHaveBeenCalled();
expect(result.current.result).toBeUndefined();
act(() => late({ data: { result: sample } } as MessageEvent));
expect(result.current.result).toBeUndefined();
act(() => lastWorker().complete());
expect(result.current.result).toEqual(sample);
rerender({ ...input, identity: "file-c" });
expect(result.current.result).toBeUndefined();
});
it("cancels pending work when hidden and on unmount", () => {
const { rerender, unmount } = renderHook(useDelimitedPreview, {
initialProps: input,
});
const first = lastWorker();
rerender({ ...input, active: false });
expect(first.terminate).toHaveBeenCalled();
rerender(input);
const second = lastWorker();
expect(second).not.toBe(first);
unmount();
expect(second.terminate).toHaveBeenCalled();
});
it("times out after 5 seconds and retries only on explicit request", () => {
const { result, rerender } = renderHook(useDelimitedPreview, {
initialProps: input,
});
act(() => {
rs.advanceTimersByTime(4999);
});
expect(result.current.status).toBe("loading");
act(() => {
rs.advanceTimersByTime(1);
});
expect(result.current.status).toBe("error");
expect(lastWorker().terminate).toHaveBeenCalled();
rerender({ ...input, active: false });
rerender(input);
expect(FakeWorker.instances).toHaveLength(1);
act(() => result.current.retry());
expect(FakeWorker.instances).toHaveLength(2);
act(() => lastWorker().complete());
expect(result.current.status).toBe("ready");
});
it("surfaces unavailable workers and worker syntax/load/message errors", () => {
const { result } = renderHook(useDelimitedPreview, { initialProps: input });
act(() => lastWorker().onerror?.());
expect(result.current.status).toBe("error");
act(() => result.current.retry());
act(() =>
lastWorker().onmessage?.({ data: { error: "syntax" } } as MessageEvent),
);
expect(result.current.status).toBe("error");
act(() => result.current.retry());
act(() => lastWorker().onmessageerror?.());
expect(result.current.status).toBe("error");
rs.stubGlobal("Worker", undefined);
act(() => result.current.retry());
expect(result.current.status).toBe("error");
});
it("invalidates a result on content, delimiter, or truncation changes", () => {
const { result, rerender } = renderHook(useDelimitedPreview, {
initialProps: { ...input, delimiter: input.delimiter as "," | "\t" },
});
act(() => lastWorker().complete());
rerender({ ...input, content: "b" });
expect(result.current.result).toBeUndefined();
act(() => lastWorker().complete());
rerender({ ...input, content: "b", delimiter: "\t" });
expect(result.current.result).toBeUndefined();
act(() => lastWorker().complete());
rerender({ ...input, content: "b", delimiter: "\t", truncated: true });
expect(result.current.result).toBeUndefined();
expect(FakeWorker.instances).toHaveLength(4);
});
it("caps input before posting without splitting a surrogate pair", () => {
renderHook(useDelimitedPreview, {
initialProps: { ...input, content: "a".repeat(1_048_575) + "😀tail" },
});
expect(lastWorker().postMessage).toHaveBeenCalledWith({
content: "a".repeat(1_048_575),
delimiter: ",",
truncated: true,
});
});
});