test(EmptyState, AttachmentAudio): add 6-case and 11-case vitest suites
- EmptyState: renders icon/title/body/CTA, onAddFirst fires, aria-hidden, exactly-one-button guard - AttachmentAudio: loading skeleton, ready <audio controls>, blob URL src, filename label, fetch-404/5xx/error chip fallback, tone=user blue border, tone=agent no blue border, onDownload not called in non-error states - AttachmentViews.test.tsx: resolve merge conflict during rebase onto main (accept upstream new File([content]) approach over Object.defineProperty) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ac5d2ccb7b
commit
7e0969dccf
55
canvas/src/components/settings/__tests__/EmptyState.test.tsx
Normal file
55
canvas/src/components/settings/__tests__/EmptyState.test.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for EmptyState — the first-run CTA shown when no secrets exist.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders emoji, title, body, CTA button
|
||||
* - CTA button is a <button> with correct text
|
||||
* - CTA button calls onAddFirst when clicked
|
||||
* - Renders exactly one button (no stray click targets)
|
||||
* - Key icon span has aria-hidden
|
||||
* - No crashes when onAddFirst is not provided (noop)
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { EmptyState } from "../EmptyState";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("EmptyState", () => {
|
||||
it("renders emoji icon span with aria-hidden", () => {
|
||||
render(<EmptyState onAddFirst={vi.fn()} />);
|
||||
const icon = screen.getByText("🔑");
|
||||
expect(icon.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders title heading", () => {
|
||||
render(<EmptyState onAddFirst={vi.fn()} />);
|
||||
expect(screen.getByText("No API keys yet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders body text", () => {
|
||||
render(<EmptyState onAddFirst={vi.fn()} />);
|
||||
expect(screen.getByText(/Add your API keys to let agents connect/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders CTA button with correct text", () => {
|
||||
render(<EmptyState onAddFirst={vi.fn()} />);
|
||||
expect(screen.getByText("+ Add your first API key")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders exactly one button", () => {
|
||||
render(<EmptyState onAddFirst={vi.fn()} />);
|
||||
expect(screen.getAllByRole("button")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("calls onAddFirst when CTA button is clicked", () => {
|
||||
const onAddFirst = vi.fn();
|
||||
render(<EmptyState onAddFirst={onAddFirst} />);
|
||||
screen.getByRole("button").click();
|
||||
expect(onAddFirst).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,257 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for AttachmentAudio — inline native <audio controls> player.
|
||||
*
|
||||
* Per RFC #2991 PR-2. Dispatches from AttachmentPreview so most paths
|
||||
* are pinned there. These tests cover AttachmentAudio as a standalone
|
||||
* renderer: loading skeleton, ready <audio>, chip-error fallback, and
|
||||
* tone=user vs tone=agent styling.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use textContent / className /
|
||||
* getAttribute checks.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { AttachmentAudio } from "../AttachmentAudio";
|
||||
import type { ChatAttachment } from "../types";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Stub env token so platformAuthHeaders() is callable without a real env.
|
||||
vi.stubEnv("NEXT_PUBLIC_ADMIN_TOKEN", "test-token");
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
global.URL.createObjectURL = vi.fn(() => "blob:audio-test");
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAtt(name = "recording.mp3"): ChatAttachment {
|
||||
return { name, uri: "workspace:/workspace/tmp/" + name, mimeType: "audio/mpeg" };
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentAudio", () => {
|
||||
it("renders loading skeleton (idle) before fetch resolves", () => {
|
||||
// Never-resolving fetch → component stays in loading/idle state.
|
||||
fetchMock.mockReturnValue(new Promise(() => {}));
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws-1"
|
||||
attachment={makeAtt()}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
const skeleton = screen.getByLabelText(/Loading recording\.mp3/i);
|
||||
expect(skeleton).toBeTruthy();
|
||||
expect(skeleton.className).toContain("animate-pulse");
|
||||
});
|
||||
|
||||
it("renders loading skeleton during loading state", async () => {
|
||||
fetchMock.mockReturnValue(
|
||||
new Promise<Response>(() => {}), // hangs forever
|
||||
);
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws-1"
|
||||
attachment={makeAtt("song.wav")}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Loading song\.wav/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders <audio controls> when fetch succeeds", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["fake-mp3-bytes"], { type: "audio/mpeg" }),
|
||||
});
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws-1"
|
||||
attachment={makeAtt("podcast.mp3")}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
const audio = document.querySelector("audio");
|
||||
expect(audio).not.toBeNull();
|
||||
expect(audio?.hasAttribute("controls")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("audio src is the blob URL minted from response", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["bytes"], { type: "audio/mp3" }),
|
||||
});
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws-1"
|
||||
attachment={makeAtt("track.mp3")}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
const audio = document.querySelector("audio") as HTMLAudioElement;
|
||||
expect(audio?.src).toBe("blob:audio-test");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders filename label above the audio element", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["data"], { type: "audio/mpeg" }),
|
||||
});
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws-1"
|
||||
attachment={makeAtt("voice-note.mp3")}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
// Wait for the ready state (audio element present), then verify the
|
||||
// filename label <span> is in the DOM.
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector("audio")).not.toBeNull();
|
||||
});
|
||||
const labelSpan = document.querySelector(
|
||||
`span[title="voice-note.mp3"]`,
|
||||
);
|
||||
expect(labelSpan).not.toBeNull();
|
||||
expect(labelSpan?.textContent).toBe("voice-note.mp3");
|
||||
});
|
||||
|
||||
it("fetch 404 → renders AttachmentChip (chip error fallback)", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404 });
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws-1"
|
||||
attachment={makeAtt("missing.mp3")}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download missing\.mp3/i)).toBeTruthy();
|
||||
});
|
||||
// <audio> must NOT appear when chip is shown.
|
||||
expect(document.querySelector("audio")).toBeNull();
|
||||
});
|
||||
|
||||
it("fetch network error → chip error fallback", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("network down"));
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws-1"
|
||||
attachment={makeAtt("offline.mp3")}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download offline\.mp3/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("tone=user applies blue border class on ready-state container", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["data"], { type: "audio/mpeg" }),
|
||||
});
|
||||
const { container } = render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws-1"
|
||||
attachment={makeAtt("blue.mp3")}
|
||||
onDownload={vi.fn()}
|
||||
tone="user"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector("audio")).not.toBeNull();
|
||||
});
|
||||
// The outer ready-state <div> must contain blue-400 class when tone=user.
|
||||
const readyDivs = Array.from(container.querySelectorAll("div")).filter(
|
||||
(d) => d.className.includes("blue-400"),
|
||||
);
|
||||
expect(readyDivs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("tone=agent does not apply blue border class", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["data"], { type: "audio/mpeg" }),
|
||||
});
|
||||
const { container } = render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws-1"
|
||||
attachment={makeAtt("gray.mp3")}
|
||||
onDownload={vi.fn()}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector("audio")).not.toBeNull();
|
||||
});
|
||||
const blueDivs = Array.from(container.querySelectorAll("div")).filter(
|
||||
(d) => d.className.includes("blue-400"),
|
||||
);
|
||||
expect(blueDivs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("onDownload is NOT called during loading or ready states", async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => new Blob(["data"], { type: "audio/mpeg" }),
|
||||
});
|
||||
const onDownload = vi.fn();
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws-1"
|
||||
attachment={makeAtt("quiet.mp3")}
|
||||
onDownload={onDownload}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
// Wait for ready state — onDownload must not have been called.
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector("audio")).not.toBeNull();
|
||||
});
|
||||
expect(onDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onDownload when chip fallback is rendered (error state)", async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 500 });
|
||||
const onDownload = vi.fn();
|
||||
render(
|
||||
<AttachmentAudio
|
||||
workspaceId="ws-1"
|
||||
attachment={makeAtt("fail.mp3")}
|
||||
onDownload={onDownload}
|
||||
tone="agent"
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle(/Download fail\.mp3/i)).toBeTruthy();
|
||||
});
|
||||
// Click the chip's download button.
|
||||
screen.getByTitle(/Download fail\.mp3/i).click();
|
||||
expect(onDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "fail.mp3" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -104,7 +104,6 @@ describe("PendingAttachmentPill", () => {
|
||||
);
|
||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AttachmentChip ───────────────────────────────────────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user