test(canvas/chat): add AttachmentAudio coverage (9 cases)
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 20s
CI / Detect changes (pull_request) Successful in 26s
Harness Replays / detect-changes (pull_request) Successful in 21s
E2E API Smoke Test / detect-changes (pull_request) Successful in 45s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 44s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 44s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 18s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 33s
gate-check-v3 / gate-check (pull_request) Failing after 39s
qa-review / approved (pull_request) Successful in 22s
security-review / approved (pull_request) Successful in 22s
sop-tier-check / tier-check (pull_request) Successful in 29s
CI / Python Lint & Test (pull_request) Successful in 9s
audit-force-merge / audit (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
Harness Replays / Harness Replays (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 9s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 8m40s
CI / Canvas (Next.js) (pull_request) Successful in 12m30s
CI / all-required (pull_request) Successful in 7s
CI / Canvas Deploy Reminder (pull_request) Has been skipped

Same state machine as AttachmentVideo: idle → loading → ready/error.
Loading skeleton with aria-label, tone-based border colors,
filename label, AttachmentChip fallback on error, blob URL cleanup,
external URI direct href without fetch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Molecule AI · core-uiux 2026-05-12 00:22:51 +00:00
parent 6c27ed934c
commit ddff3d282a

View File

@ -0,0 +1,275 @@
// @vitest-environment jsdom
/**
* AttachmentAudio inline native HTML5 <audio> player for chat attachments.
*
* Per RFC #2991 PR-2: platform-auth URIs fetch bytes Blob ObjectURL;
* external URIs use the raw URL directly. State machine: idle loading
* ready/error. Loading skeleton shown while fetching. Error falls back to
* AttachmentChip. Blob URL cleaned up on unmount / re-run.
*
* NOTE: No @testing-library/jest-dom import use DOM APIs for assertions.
*
* Covers:
* - Renders loading skeleton with aria-label while fetching
* - Renders <audio> element with correct src when ready
* - Error state renders AttachmentChip fallback
* - tone=user applies blue border class
* - tone=agent applies neutral border class
* - Filename label shown before audio controls
* - onDownload called when error chip is clicked
* - Cleans up blob URL on unmount
* - External URI uses direct href without fetch
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render } from "@testing-library/react";
import React from "react";
import { AttachmentAudio } from "../AttachmentAudio";
import type { ChatAttachment } from "../types";
// ─── Mocks ───────────────────────────────────────────────────────────────────
const mockResolveAttachmentHref = vi.fn<(id: string, uri: string) => string>(
(id, uri) => `https://api.moleculesai.app/attachments/${uri}`,
);
const mockIsPlatformAttachment = vi.fn<(uri: string) => boolean>(() => true);
vi.mock("../uploads", () => ({
isPlatformAttachment: (uri: string) => mockIsPlatformAttachment(uri),
resolveAttachmentHref: (id: string, uri: string) =>
mockResolveAttachmentHref(id, uri),
}));
vi.mock("@/lib/api", () => ({
platformAuthHeaders: () => ({ Authorization: "Bearer test-token" }),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeAttachment(name: string, size?: number): ChatAttachment {
return { name, uri: `workspace:/tmp/${name}`, size };
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.resetModules();
});
// ─── Fetch mock helpers ──────────────────────────────────────────────────────
function mockFetchOk(body: string, contentType = "audio/mpeg") {
const blob = new Blob([body], { type: contentType });
global.fetch = vi.fn((href: string, opts?: RequestInit) => {
void href;
void opts;
return Promise.resolve({
ok: true,
status: 200,
blob: () => Promise.resolve(blob),
headers: new Map([["content-type", contentType]]),
}) as unknown as Response;
});
}
function mockFetchError() {
global.fetch = vi.fn(() =>
Promise.resolve({ ok: false, status: 500 }) as unknown as Response,
);
}
// ─── Idle/Loading state ─────────────────────────────────────────────────────
describe("AttachmentAudio — idle/loading", () => {
beforeEach(() => {
mockFetchOk("audiodata");
});
it("renders loading skeleton with aria-label", () => {
const att = makeAttachment("podcast.mp3", 1024 * 512);
const { container } = render(
<AttachmentAudio
workspaceId="ws1"
attachment={att}
onDownload={vi.fn()}
tone="user"
/>,
);
const skeleton = container.querySelector('[aria-label]') as HTMLElement;
expect(skeleton?.getAttribute("aria-label")).toContain("podcast.mp3");
expect(skeleton?.getAttribute("aria-label")).toContain("Loading");
});
it("loading skeleton has correct dimensions", () => {
const att = makeAttachment("track.mp3");
const { container } = render(
<AttachmentAudio
workspaceId="ws1"
attachment={att}
onDownload={vi.fn()}
tone="agent"
/>,
);
const skeleton = container.querySelector('[aria-label]') as HTMLElement;
expect(skeleton?.style.width).toBe("280px");
expect(skeleton?.style.height).toBe("40px");
});
});
// ─── Ready state ─────────────────────────────────────────────────────────────
describe("AttachmentAudio — ready", () => {
beforeEach(() => {
mockFetchOk("audiodata");
});
it("renders <audio> element with correct src when ready", async () => {
const att = makeAttachment("clip.mp3", 1024 * 512);
render(
<AttachmentAudio
workspaceId="ws1"
attachment={att}
onDownload={vi.fn()}
tone="user"
/>,
);
await vi.waitFor(() => {
expect(document.querySelector("audio")).toBeTruthy();
});
const audio = document.querySelector("audio") as HTMLAudioElement;
expect(audio.src).toMatch(/^blob:/);
expect(audio.hasAttribute("controls")).toBe(true);
expect(audio.getAttribute("preload")).toBe("metadata");
});
it("tone=user applies blue border class", async () => {
mockFetchOk("data");
const att = makeAttachment("clip.mp3");
render(
<AttachmentAudio
workspaceId="ws1"
attachment={att}
onDownload={vi.fn()}
tone="user"
/>,
);
await vi.waitFor(() => {
expect(document.querySelector("audio")).toBeTruthy();
});
const audio = document.querySelector("audio");
const container = audio?.closest("div");
expect(container?.className).toContain("blue-400");
});
it("tone=agent applies neutral border class (no blue)", async () => {
mockFetchOk("data");
const att = makeAttachment("clip.mp3");
render(
<AttachmentAudio
workspaceId="ws1"
attachment={att}
onDownload={vi.fn()}
tone="agent"
/>,
);
await vi.waitFor(() => {
expect(document.querySelector("audio")).toBeTruthy();
});
const audio = document.querySelector("audio");
const container = audio?.closest("div");
expect(container?.className).not.toContain("blue-400");
});
it("filename label shown before audio controls", async () => {
mockFetchOk("data");
const att = makeAttachment("my-podcast-episode.mp3");
render(
<AttachmentAudio
workspaceId="ws1"
attachment={att}
onDownload={vi.fn()}
tone="agent"
/>,
);
await vi.waitFor(() => {
expect(document.querySelector("audio")).toBeTruthy();
});
const container = document.querySelector("audio")?.closest("div");
expect(container?.textContent ?? "").toContain("my-podcast-episode.mp3");
});
});
// ─── Error state ─────────────────────────────────────────────────────────────
describe("AttachmentAudio — error", () => {
it("renders AttachmentChip fallback when fetch fails", async () => {
mockFetchError();
const onDownload = vi.fn();
const att = makeAttachment("broken.mp3", 256);
render(
<AttachmentAudio
workspaceId="ws1"
attachment={att}
onDownload={onDownload}
tone="agent"
/>,
);
await vi.waitFor(() => {
const chip = document.querySelector("button");
expect(chip).toBeTruthy();
expect(chip?.textContent).toContain("broken.mp3");
});
const chip = document.querySelector("button") as HTMLButtonElement;
chip.click();
expect(onDownload).toHaveBeenCalledWith(att);
});
});
// ─── Cleanup ─────────────────────────────────────────────────────────────────
describe("AttachmentAudio — blob URL cleanup", () => {
it("creates blob URL on mount and cleans up on unmount", async () => {
mockFetchOk("audiodata");
const att = makeAttachment("clip.mp3");
const { unmount } = render(
<AttachmentAudio
workspaceId="ws1"
attachment={att}
onDownload={vi.fn()}
tone="user"
/>,
);
await vi.waitFor(() => {
expect(document.querySelector("audio")).toBeTruthy();
});
const audio = document.querySelector("audio") as HTMLAudioElement;
const blobUrl = audio.src;
expect(blobUrl).toMatch(/^blob:/);
unmount();
expect(document.querySelector("audio")).toBeNull();
});
});
// ─── External URI ───────────────────────────────────────────────────────────
describe("AttachmentAudio — external URI", () => {
it("uses direct href for external URIs without fetch", async () => {
mockIsPlatformAttachment.mockReturnValue(false);
const externalUri = "https://example.com/audio.mp3";
const att = makeAttachment("audio.mp3");
att.uri = externalUri;
render(
<AttachmentAudio
workspaceId="ws1"
attachment={att}
onDownload={vi.fn()}
tone="user"
/>,
);
await vi.waitFor(() => {
expect(document.querySelector("audio")).toBeTruthy();
});
const audio = document.querySelector("audio") as HTMLAudioElement;
expect(audio.src).toContain("example.com/audio.mp3");
});
});