Files
kandev/apps/web/components/task/chat/messages/tool-read-message.test.tsx
T
João Salavisa 97fba4ec4b fix(i18n): close four gaps no gate can see (plurals, fallback copy, orphans) (#2388)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 20:03:10 +02:00

159 lines
5.6 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { Message } from "@/lib/types/http";
const useOpenFileAtLine = vi.hoisted(() =>
vi.fn((onOpenFile: ((path: string) => void) | undefined) => (path: string) => onOpenFile?.(path)),
);
// useOpenFileAtLine pulls in Monaco; stub it with a passthrough that forwards the
// (already selector-stripped) path so we can assert what gets opened.
vi.mock("@/hooks/use-file-editors", () => ({
useOpenFileAtLine,
}));
import { ToolReadMessage } from "./tool-read-message";
function readComment(filePath: string, offset?: number, limit?: number): Message {
return {
id: "m1",
metadata: {
status: "complete",
normalized: { read_file: { file_path: filePath, offset, limit } },
},
} as unknown as Message;
}
function readCommentWithLines(lineCount?: number): Message {
return {
id: "m1",
metadata: {
status: "complete",
normalized: {
read_file: {
file_path: "apps/web/lib/read-selector.ts",
output: { line_count: lineCount },
},
},
},
} as unknown as Message;
}
describe("ToolReadMessage", () => {
afterEach(() => {
cleanup();
useOpenFileAtLine.mockClear();
});
// The header label used to build its own "s" in a template literal, which no
// i18n gate could see. It now selects _one/_other; the English must be
// unchanged, because e2e specs query this card by its accessible name.
it("uses the singular header label for a one-line read", () => {
render(<ToolReadMessage comment={readCommentWithLines(1)} />);
expect(screen.getByText("Read 1 line")).toBeTruthy();
expect(screen.queryByText("Read 1 lines")).toBeNull();
});
it("uses the plural header label for a multi-line read", () => {
render(<ToolReadMessage comment={readCommentWithLines(137)} />);
expect(screen.getByText("Read 137 lines")).toBeTruthy();
});
it("falls back to the bare 'Read' label when no line count is reported", () => {
render(<ToolReadMessage comment={readCommentWithLines(undefined)} />);
expect(screen.getByText("Read")).toBeTruthy();
});
it("forwards the message session when opening a file at its read line", () => {
const openFile = vi.fn();
render(
<ToolReadMessage
comment={readComment("apps/web/lib/utils.ts", 50)}
worktreePath="/workspace"
sessionId="requested-session"
onOpenFile={openFile}
/>,
);
expect(useOpenFileAtLine).toHaveBeenCalledWith(openFile, 50, "/workspace", "requested-session");
});
it("renders one openable link for a single-file read", () => {
const openFile = vi.fn();
render(
<ToolReadMessage comment={readComment("apps/web/lib/utils.ts", 50)} onOpenFile={openFile} />,
);
const links = screen.getAllByRole("button");
expect(links).toHaveLength(1);
fireEvent.click(links[0]);
expect(openFile).toHaveBeenCalledWith("apps/web/lib/utils.ts");
});
it("opens a single-file link whose path still carries a line-range selector", () => {
// Legacy / not-yet-normalized reads keep the selector glued to file_path
// (e.g. "scripts/au/setup-au-sentry-secrets.sh:88-137"). The card must strip
// it so the link opens the bare path rather than a non-existent file.
const openFile = vi.fn();
render(
<ToolReadMessage
comment={readComment("scripts/au/setup-au-sentry-secrets.sh:88-137")}
onOpenFile={openFile}
/>,
);
const links = screen.getAllByRole("button");
expect(links).toHaveLength(1);
expect(screen.getByText("scripts/au/setup-au-sentry-secrets.sh")).toBeTruthy();
fireEvent.click(links[0]);
expect(openFile).toHaveBeenCalledWith("scripts/au/setup-au-sentry-secrets.sh");
expect(openFile).not.toHaveBeenCalledWith("scripts/au/setup-au-sentry-secrets.sh:88-137");
});
it("splits a comma-joined multi-file read into one link per file", () => {
const openFile = vi.fn();
render(
<ToolReadMessage
comment={readComment(
"deployments/values.backupprod.yaml:1-80,deployments/values.au-backupprod.yaml:1-80",
)}
onOpenFile={openFile}
/>,
);
expect(screen.getByText("deployments/values.backupprod.yaml")).toBeTruthy();
expect(screen.getByText("deployments/values.au-backupprod.yaml")).toBeTruthy();
// Each link opens its bare path (no embedded line numbers).
fireEvent.click(screen.getByText("deployments/values.au-backupprod.yaml"));
expect(openFile).toHaveBeenCalledWith("deployments/values.au-backupprod.yaml");
expect(openFile).not.toHaveBeenCalledWith("deployments/values.au-backupprod.yaml:1-80");
});
it("splits the hyphenated multi-file example into separate openable links", () => {
// Exact shape seen in a real session: hyphenated dirs + per-file ranges.
const openFile = vi.fn();
render(
<ToolReadMessage
comment={readComment(
"deployments/tailscale-ingress-extras/values.prod.yaml:1-180,deployments/tailscale-ingress-extras/values.staging.yaml:1-180",
)}
onOpenFile={openFile}
/>,
);
const prod = screen.getByText("deployments/tailscale-ingress-extras/values.prod.yaml");
const staging = screen.getByText("deployments/tailscale-ingress-extras/values.staging.yaml");
expect(prod).toBeTruthy();
expect(staging).toBeTruthy();
fireEvent.click(staging);
expect(openFile).toHaveBeenCalledWith(
"deployments/tailscale-ingress-extras/values.staging.yaml",
);
expect(openFile).not.toHaveBeenCalledWith(
"deployments/tailscale-ingress-extras/values.staging.yaml:1-180",
);
});
});