diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 59f5a3d..98cdcf9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,6 +16,7 @@ jobs:
cache: "npm"
- run: npm ci
- run: npm run test:coverage
+ - run: npm run test:crap
- run: npm run build
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
diff --git a/eslint.config.js b/eslint.config.js
index 79a552e..6a679b0 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -23,6 +23,8 @@ export default tseslint.config(
"warn",
{ allowConstantExport: true },
],
+ "@typescript-eslint/no-explicit-any": "error",
+ "@typescript-eslint/explicit-function-return-type": "warn",
},
},
);
diff --git a/package.json b/package.json
index 1ca0f12..4aecb02 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,8 @@
"prepare": "husky install",
"test": "vitest run",
"test:watch": "vitest",
- "test:coverage": "vitest run --coverage && tsx scripts/crap-score.ts",
+ "test:coverage": "vitest run --coverage",
+ "test:crap": "vitest run --coverage && tsx scripts/crap-score.ts",
"test:e2e": "npx bddgen && npx playwright test",
"prod-config": "ampx generate outputs --app-id dx58fjke2s86k --branch main --profile personal",
"sandbox": "ampx sandbox --profile personal --once",
diff --git a/src/App.test.tsx b/src/App.test.tsx
new file mode 100644
index 0000000..f68e21f
--- /dev/null
+++ b/src/App.test.tsx
@@ -0,0 +1,111 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+
+vi.mock("aws-amplify", () => ({
+ Amplify: { configure: vi.fn() },
+}));
+
+vi.mock("amazon-chime-sdk-component-library-react", () => ({
+ MeetingProvider: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ FeaturedVideoTileProvider: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+vi.mock("@aws-amplify/ui-react", () => ({
+ Card: ({ children }: { children: React.ReactNode }) => {children}
,
+ View: ({ children }: { children: React.ReactNode }) => {children}
,
+ useTheme: () => ({
+ tokens: { space: { medium: "1rem" } },
+ }),
+}));
+
+vi.mock("./components/Header", () => ({
+ Header: () => Header
,
+}));
+
+vi.mock("./components/LandingPage", () => ({
+ LandingPage: () => LandingPage
,
+}));
+
+vi.mock("./components/MeetingControlBar", () => ({
+ default: () => ControlBar
,
+}));
+
+vi.mock("./components/VideoMeeting", () => ({
+ default: () => VideoMeeting
,
+}));
+
+vi.mock("./components/CopyLink", () => ({
+ CopyLink: () => CopyLink
,
+}));
+
+vi.mock("./components/AttendeeList", () => ({
+ AttendeeList: () => AttendeeList
,
+}));
+
+vi.mock("./components/ChatPanel", () => ({
+ ChatPanel: () => ChatPanel
,
+}));
+
+vi.mock("./context/AttendeeNamesContext", () => ({
+ AttendeeNamesProvider: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ useAttendeeNamesContext: () => ({
+ broadcastNameChange: vi.fn(),
+ }),
+}));
+
+vi.mock("./context/ChatContext", () => ({
+ ChatProvider: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+let mockUseMeeting = {
+ joinedMeetingId: null as string | null,
+ loadingAction: null,
+ error: null,
+ handleJoinMeeting: vi.fn(),
+ handleStartMeeting: vi.fn(),
+ attendeeName: "Test User",
+ setAttendeeName: vi.fn(),
+};
+
+vi.mock("./hooks/useMeeting", () => ({
+ useMeeting: () => mockUseMeeting,
+}));
+
+import App from "./App";
+
+describe("App", () => {
+ it("renders MeetingProvider wrapper", () => {
+ render();
+ expect(screen.getByTestId("meeting-provider")).toBeInTheDocument();
+ });
+
+ it("shows LandingPage when not in a meeting", () => {
+ mockUseMeeting = { ...mockUseMeeting, joinedMeetingId: null };
+ render();
+ expect(screen.getByTestId("landing-page")).toBeInTheDocument();
+ expect(screen.queryByTestId("video-meeting")).not.toBeInTheDocument();
+ });
+
+ it("shows meeting UI when joined", () => {
+ mockUseMeeting = { ...mockUseMeeting, joinedMeetingId: "meeting-123" };
+ render();
+ expect(screen.getByTestId("video-meeting")).toBeInTheDocument();
+ expect(screen.getByTestId("attendee-list")).toBeInTheDocument();
+ expect(screen.getByTestId("copy-link")).toBeInTheDocument();
+ expect(screen.getByTestId("chat-panel")).toBeInTheDocument();
+ expect(screen.queryByTestId("landing-page")).not.toBeInTheDocument();
+ });
+
+ it("always renders Header", () => {
+ render();
+ expect(screen.getByTestId("header")).toBeInTheDocument();
+ });
+});
diff --git a/src/amplify-outputs.d.ts b/src/amplify-outputs.d.ts
new file mode 100644
index 0000000..586d676
--- /dev/null
+++ b/src/amplify-outputs.d.ts
@@ -0,0 +1,4 @@
+declare module "*/amplify_outputs.json" {
+ const value: Record;
+ export default value;
+}
diff --git a/src/components/AttendeeList.test.tsx b/src/components/AttendeeList.test.tsx
new file mode 100644
index 0000000..224f464
--- /dev/null
+++ b/src/components/AttendeeList.test.tsx
@@ -0,0 +1,50 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+
+vi.mock("amazon-chime-sdk-component-library-react", () => ({
+ MicrophoneActivity: ({ attendeeId }: { attendeeId: string }) => (
+
+ ),
+ RosterCell: ({ name, microphone }: { name: string; microphone: React.ReactNode }) => (
+ {name}{microphone}
+ ),
+ useAttendeeStatus: () => ({
+ muted: false,
+ videoEnabled: true,
+ sharingContent: false,
+ }),
+ useRosterState: () => ({
+ roster: {
+ "attendee-1": { name: "Alice" },
+ "attendee-2": { name: "Bob" },
+ },
+ }),
+}));
+
+vi.mock("../context/AttendeeNamesContext", () => ({
+ useAttendeeNamesContext: () => ({
+ getAttendeeName: (id: string) => `Display-${id}`,
+ }),
+}));
+
+import { AttendeeList } from "./AttendeeList";
+
+describe("AttendeeList", () => {
+ it("renders a row for each attendee", () => {
+ render();
+ const cells = screen.getAllByTestId("roster-cell");
+ expect(cells).toHaveLength(2);
+ });
+
+ it("displays attendee names from context", () => {
+ render();
+ expect(screen.getByText("Display-attendee-1")).toBeInTheDocument();
+ expect(screen.getByText("Display-attendee-2")).toBeInTheDocument();
+ });
+
+ it("renders microphone activity for each attendee", () => {
+ render();
+ expect(screen.getByTestId("mic-attendee-1")).toBeInTheDocument();
+ expect(screen.getByTestId("mic-attendee-2")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/ChatMessage.test.tsx b/src/components/ChatMessage.test.tsx
new file mode 100644
index 0000000..cba33e4
--- /dev/null
+++ b/src/components/ChatMessage.test.tsx
@@ -0,0 +1,87 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+
+vi.mock("react-markdown", () => ({
+ default: ({ children }: { children: string }) => {children}
,
+}));
+
+vi.mock("remark-gfm", () => ({
+ default: () => null,
+}));
+
+vi.mock("@aws-amplify/ui-react", () => ({
+ Text: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Flex: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ Button: ({
+ children,
+ onClick,
+ title,
+ }: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ title?: string;
+ }) => (
+
+ ),
+}));
+
+import { ChatMessageItem } from "./ChatMessage";
+
+describe("ChatMessageItem", () => {
+ const defaultProps = {
+ id: "msg-1",
+ senderName: "Alice",
+ text: "Hello world",
+ timestamp: new Date("2025-01-15T10:30:00").getTime(),
+ reactions: {} as Record,
+ onReaction: vi.fn(),
+ };
+
+ it("renders sender name and message text", () => {
+ render();
+ expect(screen.getByText("Alice")).toBeInTheDocument();
+ expect(screen.getByText("Hello world")).toBeInTheDocument();
+ });
+
+ it("renders existing reactions with counts", () => {
+ const props = {
+ ...defaultProps,
+ reactions: { "\u{1F44D}": ["Bob", "Charlie"] },
+ };
+ render();
+ expect(screen.getByTitle("Bob, Charlie")).toBeInTheDocument();
+ });
+
+ it("calls onReaction when clicking an existing reaction", () => {
+ const onReaction = vi.fn();
+ const props = {
+ ...defaultProps,
+ reactions: { "\u{1F44D}": ["Bob"] },
+ onReaction,
+ };
+ render();
+ fireEvent.click(screen.getByTitle("Bob"));
+ expect(onReaction).toHaveBeenCalledWith("msg-1", "\u{1F44D}");
+ });
+
+ it("shows emoji picker on + button click", () => {
+ render();
+ fireEvent.click(screen.getByText("+"));
+ expect(screen.getByText("\u{1F44D}")).toBeInTheDocument();
+ expect(screen.getByText("\u{2764}\u{FE0F}")).toBeInTheDocument();
+ });
+
+ it("calls onReaction and hides picker when selecting emoji", () => {
+ const onReaction = vi.fn();
+ render();
+ fireEvent.click(screen.getByText("+"));
+ fireEvent.click(screen.getByText("\u{1F525}"));
+ expect(onReaction).toHaveBeenCalledWith("msg-1", "\u{1F525}");
+ });
+});
diff --git a/src/components/ChatPanel.test.tsx b/src/components/ChatPanel.test.tsx
new file mode 100644
index 0000000..ade11f0
--- /dev/null
+++ b/src/components/ChatPanel.test.tsx
@@ -0,0 +1,121 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+
+const mockSendMessage = vi.fn().mockReturnValue({});
+const mockSendReaction = vi.fn();
+const mockToggleChat = vi.fn();
+
+let mockChatContext = {
+ messages: [] as { id: string; senderName: string; text: string; timestamp: number; reactions: Record }[],
+ sendMessage: mockSendMessage,
+ sendReaction: mockSendReaction,
+ isChatOpen: true,
+ toggleChat: mockToggleChat,
+ unreadCount: 0,
+ resetUnread: vi.fn(),
+};
+
+vi.mock("../context/ChatContext", () => ({
+ useChatContext: () => mockChatContext,
+}));
+
+vi.mock("./ChatMessage", () => ({
+ ChatMessageItem: ({ senderName, text }: { senderName: string; text: string }) => (
+ {senderName}: {text}
+ ),
+}));
+
+vi.mock("@aws-amplify/ui-react", () => ({
+ View: ({ children, className }: { children: React.ReactNode; className?: string }) => (
+ {children}
+ ),
+ Text: ({ children }: { children: React.ReactNode }) => {children},
+ Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
+
+ ),
+ Flex: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+import { ChatPanel } from "./ChatPanel";
+
+describe("ChatPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockChatContext = {
+ messages: [],
+ sendMessage: mockSendMessage,
+ sendReaction: mockSendReaction,
+ isChatOpen: true,
+ toggleChat: mockToggleChat,
+ unreadCount: 0,
+ resetUnread: vi.fn(),
+ };
+ });
+
+ it("renders nothing when chat is closed", () => {
+ mockChatContext.isChatOpen = false;
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("shows empty state when no messages", () => {
+ render();
+ expect(screen.getByText(/No messages yet/)).toBeInTheDocument();
+ });
+
+ it("renders messages", () => {
+ mockChatContext.messages = [
+ { id: "1", senderName: "Bob", text: "Hi!", timestamp: Date.now(), reactions: {} },
+ ];
+ render();
+ expect(screen.getByTestId("chat-message")).toBeInTheDocument();
+ expect(screen.getByText("Bob: Hi!")).toBeInTheDocument();
+ });
+
+ it("sends a message on button click", () => {
+ mockSendMessage.mockReturnValue({});
+ render();
+ const textarea = screen.getByPlaceholderText("Type a message...");
+ fireEvent.change(textarea, { target: { value: "Hello" } });
+ fireEvent.click(screen.getByText("Send"));
+ expect(mockSendMessage).toHaveBeenCalledWith("Hello");
+ });
+
+ it("sends message on Enter key", () => {
+ mockSendMessage.mockReturnValue({});
+ render();
+ const textarea = screen.getByPlaceholderText("Type a message...");
+ fireEvent.change(textarea, { target: { value: "Hello" } });
+ fireEvent.keyDown(textarea, { key: "Enter", shiftKey: false });
+ expect(mockSendMessage).toHaveBeenCalledWith("Hello");
+ });
+
+ it("does not send on Shift+Enter", () => {
+ render();
+ const textarea = screen.getByPlaceholderText("Type a message...");
+ fireEvent.change(textarea, { target: { value: "Hello" } });
+ fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true });
+ expect(mockSendMessage).not.toHaveBeenCalled();
+ });
+
+ it("does not send empty messages", () => {
+ render();
+ fireEvent.click(screen.getByText("Send"));
+ expect(mockSendMessage).not.toHaveBeenCalled();
+ });
+
+ it("shows error from sendMessage", () => {
+ mockSendMessage.mockReturnValue({ error: "Message too long" });
+ render();
+ const textarea = screen.getByPlaceholderText("Type a message...");
+ fireEvent.change(textarea, { target: { value: "Hello" } });
+ fireEvent.click(screen.getByText("Send"));
+ expect(screen.getByText("Message too long")).toBeInTheDocument();
+ });
+
+ it("calls toggleChat on close button", () => {
+ render();
+ fireEvent.click(screen.getByText("×"));
+ expect(mockToggleChat).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/ChatToggleButton.test.tsx b/src/components/ChatToggleButton.test.tsx
new file mode 100644
index 0000000..8ba0bee
--- /dev/null
+++ b/src/components/ChatToggleButton.test.tsx
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import React from "react";
+
+const mockToggleChat = vi.fn();
+
+vi.mock("amazon-chime-sdk-component-library-react", () => ({
+ Chat: () => chat-icon,
+ ControlBarButton: ({
+ label,
+ onClick,
+ }: {
+ label: string;
+ onClick: () => void;
+ }) => (
+
+ ),
+}));
+
+vi.mock("../context/ChatContext", () => ({
+ ChatContext: React.createContext(null),
+}));
+
+import { ChatContext } from "../context/ChatContext";
+import ChatToggleButton from "./ChatToggleButton";
+
+describe("ChatToggleButton", () => {
+ it("renders nothing when no context", () => {
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders Chat label when no unread", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("Chat")).toBeInTheDocument();
+ });
+
+ it("renders unread count in label", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByText("Chat (3)")).toBeInTheDocument();
+ });
+
+ it("calls toggleChat on click", () => {
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByTestId("chat-toggle"));
+ expect(mockToggleChat).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/MeetingControlBar.test.tsx b/src/components/MeetingControlBar.test.tsx
new file mode 100644
index 0000000..d3268bb
--- /dev/null
+++ b/src/components/MeetingControlBar.test.tsx
@@ -0,0 +1,122 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+
+const mockLeave = vi.fn();
+const mockToggleContentShare = vi.fn();
+const mockHandleRecord = vi.fn();
+const mockHandleDownloadRecording = vi.fn();
+
+let mockAudioVideo: object | null = {};
+
+vi.mock("amazon-chime-sdk-component-library-react", () => ({
+ useAudioVideo: () => mockAudioVideo,
+ useMeetingManager: () => ({ leave: mockLeave }),
+ ControlBar: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+ ControlBarButton: ({
+ label,
+ onClick,
+ }: {
+ label: React.ReactNode;
+ onClick: () => void;
+ }) => (
+
+ ),
+ LeaveMeeting: () => leave-icon,
+ AudioInputControl: () => ,
+ VideoInputControl: () => ,
+ AudioOutputControl: () => ,
+ useContentShareControls: () => ({
+ toggleContentShare: mockToggleContentShare,
+ }),
+ ScreenShare: () => screen-icon,
+ Record: () => record-icon,
+ Pause: () => pause-icon,
+ Play: () => play-icon,
+ Dots: () => dots-icon,
+}));
+
+vi.mock("@aws-amplify/ui-react", () => ({
+ Loader: () => loader-icon,
+ Text: ({ children }: { children: React.ReactNode }) => {children},
+}));
+
+vi.mock("../hooks/useRecording", () => ({
+ useRecording: () => ({
+ recordingLabel: "Record",
+ iconType: "record",
+ clickAction: "toggle",
+ handleRecord: mockHandleRecord,
+ handleDownloadRecording: mockHandleDownloadRecording,
+ }),
+}));
+
+vi.mock("./ChatToggleButton", () => ({
+ default: () => Chat
,
+}));
+
+vi.mock("./EditNameButton", () => ({
+ EditNameButton: ({ attendeeName }: { attendeeName: string }) => (
+ {attendeeName}
+ ),
+}));
+
+import MeetingControlBar from "./MeetingControlBar";
+
+describe("MeetingControlBar", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockAudioVideo = {};
+ });
+
+ it("renders nothing when audioVideo is null", () => {
+ mockAudioVideo = null;
+ const { container } = render();
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders control bar when audioVideo is available", () => {
+ render();
+ expect(screen.getByTestId("control-bar")).toBeInTheDocument();
+ });
+
+ it("calls leave on Leave button click", () => {
+ render();
+ fireEvent.click(screen.getByTestId("btn-Leave"));
+ expect(mockLeave).toHaveBeenCalled();
+ });
+
+ it("calls toggleContentShare on Share button click", () => {
+ render();
+ fireEvent.click(screen.getByTestId("btn-Share"));
+ expect(mockToggleContentShare).toHaveBeenCalled();
+ });
+
+ it("renders ChatToggleButton", () => {
+ render();
+ expect(screen.getByTestId("chat-toggle")).toBeInTheDocument();
+ });
+
+ it("shows more controls panel on More button click", () => {
+ render();
+ fireEvent.click(screen.getByTestId("btn-More"));
+ expect(screen.getByTestId("audio-output")).toBeInTheDocument();
+ expect(screen.getByTestId("edit-name")).toBeInTheDocument();
+ });
+
+ it("calls handleRecord from more panel", () => {
+ render();
+ fireEvent.click(screen.getByTestId("btn-More"));
+ fireEvent.click(screen.getByTestId("btn-custom"));
+ expect(mockHandleRecord).toHaveBeenCalled();
+ });
+
+ it("renders audio and video controls", () => {
+ render();
+ expect(screen.getByTestId("audio-input")).toBeInTheDocument();
+ expect(screen.getByTestId("video-input")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/VideoMeeting.test.tsx b/src/components/VideoMeeting.test.tsx
new file mode 100644
index 0000000..cee54d0
--- /dev/null
+++ b/src/components/VideoMeeting.test.tsx
@@ -0,0 +1,57 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+
+const mockUseVideoMeeting = vi.fn();
+
+vi.mock("amazon-chime-sdk-component-library-react", () => ({
+ LocalVideo: ({ nameplate }: { nameplate: string }) => (
+ {nameplate}
+ ),
+ RemoteVideo: ({ tileId, name }: { tileId: number; name: string }) => (
+ {name}
+ ),
+ useLocalVideo: () => ({ isVideoEnabled: true }),
+ useRemoteVideoTileState: () => ({
+ tiles: [1, 2],
+ tileIdToAttendeeId: { 1: "attendee-a", 2: "attendee-b" },
+ }),
+ useRosterState: () => ({
+ roster: { "attendee-a": { name: "Alice" }, "attendee-b": { name: "Bob" } },
+ }),
+}));
+
+vi.mock("../context/AttendeeNamesContext", () => ({
+ useAttendeeNamesContext: () => ({
+ getAttendeeName: (id: string) => `Name-${id}`,
+ }),
+}));
+
+vi.mock("../hooks/useVideoMeeting", () => ({
+ useVideoMeeting: () => mockUseVideoMeeting(),
+}));
+
+import VideoMeeting from "./VideoMeeting";
+
+describe("VideoMeeting", () => {
+ it("renders local video when enabled", () => {
+ render();
+ expect(screen.getByTestId("local-video")).toBeInTheDocument();
+ });
+
+ it("renders remote video tiles", () => {
+ render();
+ expect(screen.getByTestId("remote-video-1")).toBeInTheDocument();
+ expect(screen.getByTestId("remote-video-2")).toBeInTheDocument();
+ });
+
+ it("displays attendee names on remote tiles", () => {
+ render();
+ expect(screen.getByText("Name-attendee-a")).toBeInTheDocument();
+ expect(screen.getByText("Name-attendee-b")).toBeInTheDocument();
+ });
+
+ it("calls useVideoMeeting hook", () => {
+ render();
+ expect(mockUseVideoMeeting).toHaveBeenCalled();
+ });
+});
diff --git a/src/context/AttendeeNamesContext.test.tsx b/src/context/AttendeeNamesContext.test.tsx
index ea8c246..beb82e1 100644
--- a/src/context/AttendeeNamesContext.test.tsx
+++ b/src/context/AttendeeNamesContext.test.tsx
@@ -125,6 +125,34 @@ describe("AttendeeNamesContext", () => {
);
});
+ it("getAttendeeName falls back to externalUserId prefix when no name", () => {
+ mockRoster["attendee-789"] = { externalUserId: "Charlie#789" };
+ const { result } = renderHook(() => useAttendeeNamesContext(), { wrapper });
+ expect(result.current.getAttendeeName("attendee-789")).toBe("Charlie");
+ delete mockRoster["attendee-789"];
+ });
+
+ it("getAttendeeName returns Unknown when no roster entry at all", () => {
+ const { result } = renderHook(() => useAttendeeNamesContext(), { wrapper });
+ expect(result.current.getAttendeeName("no-such-id")).toBe("Unknown");
+ });
+
+ it("broadcastNameChange is no-op when attendeeId is empty", () => {
+ mockMeetingManager.meetingSessionConfiguration = {
+ credentials: { attendeeId: "" },
+ };
+ const { result } = renderHook(() => useAttendeeNamesContext(), { wrapper });
+
+ act(() => {
+ result.current.broadcastNameChange("NewName");
+ });
+
+ expect(mockAudioVideo.realtimeSendDataMessage).not.toHaveBeenCalled();
+ mockMeetingManager.meetingSessionConfiguration = {
+ credentials: { attendeeId: "attendee-123" },
+ };
+ });
+
it("throws when used outside provider", () => {
expect(() => {
renderHook(() => useAttendeeNamesContext());
diff --git a/src/hooks/useChat.test.ts b/src/hooks/useChat.test.ts
index c0ec388..45084fc 100644
--- a/src/hooks/useChat.test.ts
+++ b/src/hooks/useChat.test.ts
@@ -249,6 +249,88 @@ describe("useChat", () => {
}).not.toThrow();
});
+ it("deduplicates incoming messages with the same messageId", () => {
+ const { result } = renderHook(() => useChat());
+
+ const chatMessageCallback =
+ mockRealtimeSubscribeToReceiveDataMessage.mock.calls.find(
+ (call) => call[0] === "chat-message",
+ )?.[1];
+
+ const payload = {
+ senderName: "User",
+ message: "Hello",
+ messageId: "dup-1",
+ timestamp: 1000,
+ };
+
+ act(() => {
+ chatMessageCallback(createDataMessage("chat-message", payload));
+ });
+ act(() => {
+ chatMessageCallback(createDataMessage("chat-message", payload));
+ });
+
+ expect(result.current.messages).toHaveLength(1);
+ });
+
+ it("toggling a reaction removes it when already present", () => {
+ const { result } = renderHook(() => useChat());
+
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+
+ const messageId = result.current.messages[0].id;
+
+ act(() => {
+ result.current.sendReaction(messageId, "\u{1F44D}");
+ });
+ expect(result.current.messages[0].reactions["\u{1F44D}"]).toEqual(["Test User"]);
+
+ act(() => {
+ result.current.sendReaction(messageId, "\u{1F44D}");
+ });
+ expect(result.current.messages[0].reactions["\u{1F44D}"]).toBeUndefined();
+ });
+
+ it("incoming reaction toggle removes sender when already reacted", () => {
+ const { result } = renderHook(() => useChat());
+
+ act(() => {
+ result.current.sendMessage("Hello");
+ });
+ const messageId = result.current.messages[0].id;
+
+ const reactionCallback =
+ mockRealtimeSubscribeToReceiveDataMessage.mock.calls.find(
+ (call) => call[0] === "chat-reaction",
+ )?.[1];
+
+ const payload = { messageId, emoji: "\u{1F525}", senderName: "Bob" };
+
+ act(() => {
+ reactionCallback(createDataMessage("chat-reaction", payload));
+ });
+ expect(result.current.messages[0].reactions["\u{1F525}"]).toEqual(["Bob"]);
+
+ act(() => {
+ reactionCallback(createDataMessage("chat-reaction", payload));
+ });
+ expect(result.current.messages[0].reactions["\u{1F525}"]).toBeUndefined();
+ });
+
+ it("sendMessage does not add message when text is empty", () => {
+ const { result } = renderHook(() => useChat());
+
+ act(() => {
+ result.current.sendMessage(" ");
+ });
+
+ expect(mockRealtimeSendDataMessage).not.toHaveBeenCalled();
+ expect(result.current.messages).toHaveLength(0);
+ });
+
it("handleChatReaction drops malformed payloads without crashing", () => {
renderHook(() => useChat());
diff --git a/src/test/amplify-outputs-mock.json b/src/test/amplify-outputs-mock.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/src/test/amplify-outputs-mock.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/src/test/setup.ts b/src/test/setup.ts
index f149f27..d6780ee 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -1 +1,3 @@
import "@testing-library/jest-dom/vitest";
+
+Element.prototype.scrollIntoView = () => {};
diff --git a/vite.config.ts b/vite.config.ts
index 0fe068b..c3c7348 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,6 +1,15 @@
///
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
+import path from "path";
+import { existsSync } from "fs";
+
+const amplifyOutputsPath = path.resolve(__dirname, "amplify_outputs.json");
+const amplifyMockPath = path.resolve(
+ __dirname,
+ "src/test/amplify-outputs-mock.json",
+);
+const needsAmplifyMock = !existsSync(amplifyOutputsPath);
// https://vitejs.dev/config/
export default defineConfig({
@@ -10,7 +19,18 @@ export default defineConfig({
// necessary for chime lib to work
global: {},
},
+ resolve: {
+ alias: needsAmplifyMock
+ ? { "../amplify_outputs.json": amplifyMockPath }
+ : {},
+ },
test: {
+ alias: {
+ "../amplify_outputs.json": path.resolve(
+ __dirname,
+ "src/test/amplify-outputs-mock.json",
+ ),
+ },
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
@@ -24,19 +44,12 @@ export default defineConfig({
"src/test/**",
"src/vite-env.d.ts",
"src/main.tsx",
- "src/App.tsx",
- "src/components/MeetingControlBar.tsx",
- "src/components/VideoMeeting.tsx",
- "src/components/AttendeeList.tsx",
- "src/components/ChatPanel.tsx",
- "src/components/ChatMessage.tsx",
- "src/components/ChatToggleButton.tsx",
],
thresholds: {
- branches: 80,
- functions: 80,
- lines: 80,
- statements: 80,
+ branches: 90,
+ functions: 90,
+ lines: 90,
+ statements: 90,
},
},
},