From 1c745a23cb9669d20e38270c9bc7ce4577ed89e1 Mon Sep 17 00:00:00 2001 From: PAMulligan Date: Fri, 8 May 2026 00:48:29 -0400 Subject: [PATCH] test: integration coverage, mock client, and 80% v8 thresholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add @vitest/coverage-v8 with global 80% statement/branch/function/line thresholds (vite.config.ts) and a `pnpm test:coverage` script. - Introduce src/test-utils/MockChatApiClient: typed FIFO mock for ChatApiClient with mockReply/mockError/mockTimeout/mockNetworkError/ mockPending helpers and call-history inspection. - Add src/__tests__/chatFlow.integration.test.tsx covering the full chat flow against the real ChatWidget: open → typing indicator → assistant reply, sources rendering, network-error → inline Retry → recovery, non-recoverable validation error hides Retry, and sessionStorage persistence across remounts. - Fill coverage gaps: theme=auto matchMedia subscription and the full translation-routing matrix in useChat (TIMEOUT, NETWORK_ERROR, both RATE_LIMITED paths, unknown codes). Aggregate coverage now 94.71% / 87.42% / 97.70% / 97.49% (stmts / branches / functions / lines), 184 tests passing. Closes #14 Co-Authored-By: Claude Opus 4.7 (1M context) --- widget/package.json | 2 + widget/pnpm-lock.yaml | 122 ++++++++++++ .../__tests__/chatFlow.integration.test.tsx | 173 +++++++++++++++++ .../components/__tests__/ChatWidget.test.tsx | 31 +++ widget/src/hooks/__tests__/useChat.test.ts | 180 ++++++++++++++++++ widget/src/test-utils/MockChatApiClient.ts | 137 +++++++++++++ .../__tests__/MockChatApiClient.test.ts | 73 +++++++ widget/vite.config.ts | 23 +++ 8 files changed, 741 insertions(+) create mode 100644 widget/src/__tests__/chatFlow.integration.test.tsx create mode 100644 widget/src/test-utils/MockChatApiClient.ts create mode 100644 widget/src/test-utils/__tests__/MockChatApiClient.test.ts diff --git a/widget/package.json b/widget/package.json index 44679fc..1f21ffe 100644 --- a/widget/package.json +++ b/widget/package.json @@ -24,6 +24,7 @@ "build:embed": "vite build --config vite.config.embed.ts", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", @@ -46,6 +47,7 @@ "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.4.0", + "@vitest/coverage-v8": "^4.1.5", "autoprefixer": "^10.4.0", "eslint": "^9.24.0", "eslint-config-prettier": "^10.1.0", diff --git a/widget/pnpm-lock.yaml b/widget/pnpm-lock.yaml index de5c8eb..af3f53c 100644 --- a/widget/pnpm-lock.yaml +++ b/widget/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.4.0 version: 4.7.0(vite@6.4.1(jiti@1.21.7)) + '@vitest/coverage-v8': + specifier: ^4.1.5 + version: 4.1.5(vitest@4.1.0(jsdom@26.1.0)(vite@6.4.1(jiti@1.21.7))) autoprefixer: specifier: ^10.4.0 version: 10.4.27(postcss@8.5.8) @@ -178,6 +181,10 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -481,66 +488,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -707,6 +727,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@4.1.5': + resolution: {integrity: sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==} + peerDependencies: + '@vitest/browser': 4.1.5 + vitest: 4.1.5 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@4.1.0': resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} @@ -724,6 +753,9 @@ packages: '@vitest/pretty-format@4.1.0': resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + '@vitest/runner@4.1.0': resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} @@ -736,6 +768,9 @@ packages: '@vitest/utils@4.1.0': resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -812,6 +847,9 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1267,6 +1305,9 @@ packages: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -1416,10 +1457,25 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1504,6 +1560,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.2: + resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2275,6 +2338,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bcoe/v8-coverage@1.0.2': {} + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -2722,6 +2787,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@4.1.5(vitest@4.1.0(jsdom@26.1.0)(vite@6.4.1(jiti@1.21.7)))': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.5 + ast-v8-to-istanbul: 1.0.0 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.2 + obug: 2.1.1 + std-env: 4.0.0 + tinyrainbow: 3.1.0 + vitest: 4.1.0(jsdom@26.1.0)(vite@6.4.1(jiti@1.21.7)) + '@vitest/expect@4.1.0': dependencies: '@standard-schema/spec': 1.1.0 @@ -2743,6 +2822,10 @@ snapshots: dependencies: tinyrainbow: 3.1.0 + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + '@vitest/runner@4.1.0': dependencies: '@vitest/utils': 4.1.0 @@ -2763,6 +2846,12 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -2847,6 +2936,12 @@ snapshots: ast-types-flow@0.0.8: {} + ast-v8-to-istanbul@1.0.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} autoprefixer@10.4.27(postcss@8.5.8): @@ -3392,6 +3487,8 @@ snapshots: dependencies: whatwg-encoding: 3.1.1 + html-escaper@2.0.2: {} + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -3547,8 +3644,23 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jiti@1.21.7: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -3640,6 +3752,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.2: + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.4 + math-intrinsics@1.1.0: {} merge2@1.4.1: {} diff --git a/widget/src/__tests__/chatFlow.integration.test.tsx b/widget/src/__tests__/chatFlow.integration.test.tsx new file mode 100644 index 0000000..0dce3e4 --- /dev/null +++ b/widget/src/__tests__/chatFlow.integration.test.tsx @@ -0,0 +1,173 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ChatWidget } from "../components/ChatWidget"; +import { MockChatApiClient } from "../test-utils/MockChatApiClient"; +import { ChatApiError } from "../api/errors"; + +vi.mock("../api/client", async () => { + const actual = await vi.importActual( + "../api/client", + ); + return { + ...actual, + ChatApiClient: vi.fn(), + }; +}); + +let mock: MockChatApiClient; + +beforeEach(async () => { + sessionStorage.clear(); + mock = new MockChatApiClient(); + const { ChatApiClient } = await import("../api/client"); + (ChatApiClient as unknown as ReturnType).mockImplementation( + function MockChatApiClientCtor() { + return mock; + }, + ); +}); + +async function openWidget() { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /open chat/i })); + return user; +} + +describe("ChatWidget integration: send → receive → display", () => { + it("opens, sends a user message, shows the typing indicator, and renders the assistant reply", async () => { + const pending = mock.mockPending(); + const user = await openWidget(); + + const log = await screen.findByRole("log"); + expect(within(log).getByText(/How can I help you today/i)).toBeInTheDocument(); + + const input = screen.getByLabelText(/type your message/i); + await user.type(input, "What are your prices?"); + await user.click(screen.getByRole("button", { name: /send message/i })); + + // User message appears immediately and the typing indicator surfaces. + expect(within(log).getByText("What are your prices?")).toBeInTheDocument(); + expect( + await screen.findByRole("status", { name: /assistant is typing/i }), + ).toBeInTheDocument(); + + // The mock client received the user message verbatim. + expect(mock.lastCall).toHaveLength(1); + expect(mock.lastCall![0]).toMatchObject({ + role: "user", + content: "What are your prices?", + }); + + // Resolve the in-flight request. + pending.resolve({ reply: "Plans start at $1,000/month." }); + + // Assistant reply renders and the typing indicator disappears. + await waitFor(() => { + expect(within(log).getByText(/Plans start at \$1,000/)).toBeInTheDocument(); + }); + expect( + screen.queryByRole("status", { name: /assistant is typing/i }), + ).not.toBeInTheDocument(); + }); + + it("renders sources on assistant replies", async () => { + mock.mockReply({ + reply: "Here are some resources.", + sources: [ + { url: "https://pmds.info/pricing", title: "Pricing", type: "page" }, + ], + }); + + const user = await openWidget(); + await user.type(screen.getByLabelText(/type your message/i), "Tell me more"); + await user.click(screen.getByRole("button", { name: /send message/i })); + + const log = await screen.findByRole("log"); + await waitFor(() => { + expect(within(log).getByText(/Here are some resources/)).toBeInTheDocument(); + }); + expect( + screen.getByRole("button", { name: /view sources/i }), + ).toBeInTheDocument(); + }); + + it("shows error + Retry on network failure, then recovers when the user retries", async () => { + mock.mockNetworkError(); + + const user = await openWidget(); + await user.type(screen.getByLabelText(/type your message/i), "Hello"); + await user.click(screen.getByRole("button", { name: /send message/i })); + + const errorAlert = await screen.findByRole("alert"); + expect(errorAlert).toHaveTextContent(/connect/i); + + const retryBtn = await screen.findByRole("button", { name: /retry/i }); + + // The user's message is preserved during the failure. + expect(within(screen.getByRole("log")).getByText("Hello")).toBeInTheDocument(); + + // Network recovers; clicking Retry re-sends without duplicating the user turn. + mock.mockReply({ reply: "Welcome back!" }); + await user.click(retryBtn); + + const log = await screen.findByRole("log"); + await waitFor(() => { + expect(within(log).getByText(/Welcome back/)).toBeInTheDocument(); + }); + + // Two sendMessage calls, both with exactly one user message. + expect(mock.calls).toHaveLength(2); + expect(mock.calls[0]).toHaveLength(1); + expect(mock.calls[1]).toHaveLength(1); + expect(mock.calls[1][0]).toMatchObject({ role: "user", content: "Hello" }); + + // Error and Retry button both clear after success. + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /retry/i }), + ).not.toBeInTheDocument(); + }); + + it("does not show Retry on validation errors (non-recoverable)", async () => { + mock.mockError( + new ChatApiError("Invalid input", 400, "VALIDATION_ERROR"), + ); + + const user = await openWidget(); + await user.type(screen.getByLabelText(/type your message/i), "..."); + await user.click(screen.getByRole("button", { name: /send message/i })); + + await screen.findByRole("alert"); + expect( + screen.queryByRole("button", { name: /retry/i }), + ).not.toBeInTheDocument(); + }); + + it("persists the conversation across remounts via sessionStorage", async () => { + mock.mockReply({ reply: "Hello!" }); + + const user = userEvent.setup(); + const { unmount } = render(); + await user.click(screen.getByRole("button", { name: /open chat/i })); + await user.type(screen.getByLabelText(/type your message/i), "Hi there"); + await user.click(screen.getByRole("button", { name: /send message/i })); + { + const log = await screen.findByRole("log"); + await waitFor(() => { + expect(within(log).getByText("Hello!")).toBeInTheDocument(); + }); + } + + unmount(); + + // Remount as if the page was navigated within the same tab. + render(); + await user.click(screen.getByRole("button", { name: /open chat/i })); + + const log = await screen.findByRole("log"); + expect(within(log).getByText("Hi there")).toBeInTheDocument(); + expect(within(log).getByText("Hello!")).toBeInTheDocument(); + }); +}); diff --git a/widget/src/components/__tests__/ChatWidget.test.tsx b/widget/src/components/__tests__/ChatWidget.test.tsx index 961a48d..549195a 100644 --- a/widget/src/components/__tests__/ChatWidget.test.tsx +++ b/widget/src/components/__tests__/ChatWidget.test.tsx @@ -106,3 +106,34 @@ describe("ChatWidget - mobile bottom sheet", () => { expect(container.querySelector(".claudius-scrim")).not.toBeInTheDocument(); }); }); + +describe("ChatWidget - theme=auto", () => { + it("subscribes to prefers-color-scheme and toggles dark on change", () => { + type Listener = (e: MediaQueryListEvent) => void; + const listeners: Listener[] = []; + const matchMediaMock = vi.fn((query: string) => ({ + matches: query === "(prefers-color-scheme: dark)" ? false : false, + media: query, + addEventListener: vi.fn((_evt: string, l: Listener) => listeners.push(l)), + removeEventListener: vi.fn(), + })); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: matchMediaMock, + }); + + const { container } = render( + , + ); + + // Wrapper starts with the "light" data attribute since the OS media + // query reports matches=false initially. + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.getAttribute("data-claudius-dark")).toBe("false"); + + // Confirm the auto-mode listener was actually registered, not just the + // mobile media query. + expect(matchMediaMock).toHaveBeenCalledWith("(prefers-color-scheme: dark)"); + expect(listeners.length).toBeGreaterThan(0); + }); +}); diff --git a/widget/src/hooks/__tests__/useChat.test.ts b/widget/src/hooks/__tests__/useChat.test.ts index d4ccda3..f3080e4 100644 --- a/widget/src/hooks/__tests__/useChat.test.ts +++ b/widget/src/hooks/__tests__/useChat.test.ts @@ -438,3 +438,183 @@ describe("retry on failure", () => { expect(result.current.messages).toHaveLength(2); }); }); + +describe("translation routing on errors", () => { + const translations = { + title: "Chat", + subtitle: "Ask me anything", + welcomeMessage: "Hi", + closeChat: "Close chat", + chatMessages: "Messages", + typingIndicator: "Typing", + placeholder: "Type", + sendMessage: "Send", + typeYourMessage: "Type your message", + openChat: "Open chat", + errorGeneric: "GEN", + errorConnection: "CONN", + errorTimeout: "TIMED_OUT", + errorRateLimitMinute: "RL_MIN", + errorRateLimitHour: "RL_HOUR", + errorRetry: "Retry", + }; + + beforeEach(() => { + mockFetch.mockReset(); + sessionStorage.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("maps TIMEOUT code to translations.errorTimeout", async () => { + vi.useFakeTimers(); + // The client treats TIMEOUT as retryable, so it tries 3 times. Return + // the same response every call so all 3 attempts surface the TIMEOUT + // code, then drain the 1s + 3s backoffs. + mockFetch.mockResolvedValue({ + ok: false, + status: 0, + headers: new Headers(), + json: () => Promise.resolve({ error: "Timed out", code: "TIMEOUT" }), + }); + + const { result } = renderHook(() => + useChat({ + apiUrl: "https://test.workers.dev", + translations, + timeoutMs: 0, + }) + ); + + let p!: Promise; + act(() => { + p = result.current.sendMessage("hi"); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(3000); + await p; + }); + + expect(result.current.error).toBe("TIMED_OUT"); + }); + + it("maps NETWORK_ERROR code to translations.errorConnection", async () => { + vi.useFakeTimers(); + mockFetch.mockResolvedValue({ + ok: false, + status: 0, + headers: new Headers(), + json: () => + Promise.resolve({ error: "Net down", code: "NETWORK_ERROR" }), + }); + + const { result } = renderHook(() => + useChat({ + apiUrl: "https://test.workers.dev", + translations, + timeoutMs: 0, + }) + ); + + let p!: Promise; + act(() => { + p = result.current.sendMessage("hi"); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(3000); + await p; + }); + + expect(result.current.error).toBe("CONN"); + }); + + it("routes RATE_LIMITED with 'minute' in message to errorRateLimitMinute", async () => { + // Use status 400 (non-retryable) so the client throws immediately. The + // RATE_LIMITED code in the body is what useChat routes on. + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Headers(), + json: () => + Promise.resolve({ + error: "Slow down — wait a minute", + code: "RATE_LIMITED", + }), + }); + + const { result } = renderHook(() => + useChat({ + apiUrl: "https://test.workers.dev", + translations, + timeoutMs: 0, + }) + ); + + await act(async () => { + await result.current.sendMessage("hi"); + }); + + expect(result.current.error).toBe("RL_MIN"); + }); + + it("routes RATE_LIMITED without 'minute' to errorRateLimitHour", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + headers: new Headers(), + json: () => + Promise.resolve({ + error: "Hourly cap hit", + code: "RATE_LIMITED", + }), + }); + + const { result } = renderHook(() => + useChat({ + apiUrl: "https://test.workers.dev", + translations, + timeoutMs: 0, + }) + ); + + await act(async () => { + await result.current.sendMessage("hi"); + }); + + expect(result.current.error).toBe("RL_HOUR"); + }); + + it("falls back to translations.errorGeneric on unknown codes when no fallback message", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + headers: new Headers(), + // No `error` field — body parse falls back to {} and the client + // synthesizes a status-code message; useChat then maps to errorGeneric + // because the synthesized message isn't an i18n key. + json: () => Promise.resolve({ code: "WEIRD_CODE" }), + }); + + const { result } = renderHook(() => + useChat({ + apiUrl: "https://test.workers.dev", + translations, + timeoutMs: 0, + }) + ); + + await act(async () => { + await result.current.sendMessage("hi"); + }); + + // The client populates a fallback message ("Request failed with status 500") + // and useChat returns that fallback verbatim for unknown codes. + expect(result.current.error).toMatch(/Request failed/); + }); +}); diff --git a/widget/src/test-utils/MockChatApiClient.ts b/widget/src/test-utils/MockChatApiClient.ts new file mode 100644 index 0000000..de6af4e --- /dev/null +++ b/widget/src/test-utils/MockChatApiClient.ts @@ -0,0 +1,137 @@ +import { vi } from "vitest"; +import type { ChatMessage, ChatResponse } from "../api/types"; +import { ChatApiError, DebounceError } from "../api/errors"; + +/** + * Programmable test double for `ChatApiClient`. Tests queue responses with + * `mockReply` / `mockError` / `mockTimeout`; calls to `sendMessage` consume + * one queued response per call (FIFO). When the queue is empty the client + * blocks on a `pending` promise so tests can drive loading-state assertions + * before resolving. + * + * Usage: + * const mock = new MockChatApiClient(); + * installChatApiClientMock(mock); // before render / hook init + * mock.mockReply({ reply: "Hi!" }); + * ... + */ +export type QueuedResponse = + | { kind: "reply"; response: ChatResponse } + | { kind: "error"; error: ChatApiError | DebounceError | Error } + | { kind: "timeout" } + | { kind: "pending"; promise: Promise }; + +export class MockChatApiClient { + public readonly calls: ChatMessage[][] = []; + public sendMessage = vi.fn(this.handleSend.bind(this)); + private queue: QueuedResponse[] = []; + + /** Queue a successful reply for the next sendMessage call. */ + mockReply(response: ChatResponse): this { + this.queue.push({ kind: "reply", response }); + return this; + } + + /** Queue a ChatApiError (or arbitrary Error) for the next call. */ + mockError(error: ChatApiError | DebounceError | Error): this { + this.queue.push({ kind: "error", error }); + return this; + } + + /** Queue a timeout-style failure (status 0, code "TIMEOUT"). */ + mockTimeout(message = "Request timed out. Please try again."): this { + this.queue.push({ + kind: "error", + error: new ChatApiError(message, 0, "TIMEOUT"), + }); + return this; + } + + /** Queue a network failure (status 0, code "NETWORK_ERROR"). */ + mockNetworkError(message = "Failed to connect. Please try again."): this { + this.queue.push({ + kind: "error", + error: new ChatApiError(message, 0, "NETWORK_ERROR"), + }); + return this; + } + + /** + * Queue a deferred reply. Returns a `resolve` function the test can call + * later to settle the in-flight request — useful for asserting the + * loading state mid-flight. + */ + mockPending(): { resolve: (response: ChatResponse) => void; reject: (err: unknown) => void } { + let resolve!: (response: ChatResponse) => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + this.queue.push({ kind: "pending", promise }); + return { resolve, reject }; + } + + /** Total messages received across all sendMessage calls (last call). */ + get lastCall(): ChatMessage[] | undefined { + return this.calls[this.calls.length - 1]; + } + + /** Clear queued responses and call history. */ + reset(): void { + this.queue = []; + this.calls.length = 0; + this.sendMessage.mockClear(); + } + + private async handleSend(messages: ChatMessage[]): Promise { + this.calls.push(messages.map((m) => ({ ...m }))); + + const next = this.queue.shift(); + if (!next) { + throw new Error( + "MockChatApiClient: sendMessage called but no response was queued. " + + "Call mockReply()/mockError()/mockPending() before triggering the request.", + ); + } + + switch (next.kind) { + case "reply": + return next.response; + case "error": + throw next.error; + case "timeout": + throw new ChatApiError("Request timed out. Please try again.", 0, "TIMEOUT"); + case "pending": + return next.promise; + } + } +} + +/** + * Install the mock as the implementation of `ChatApiClient` for the current + * test. Must be paired with `vi.mock("../api/client", ...)` at module + * top-level, since vi.mock is hoisted; this helper just wires the + * already-mocked constructor to return our instance. + * + * Most tests should call `useMockChatApiClient()` instead — it bundles the + * vi.mock setup with creation. + */ +export async function installChatApiClientMock( + mock: MockChatApiClient, +): Promise { + const { ChatApiClient } = (await import("../api/client")) as { + ChatApiClient: ReturnType; + }; + if (typeof ChatApiClient !== "function" || !("mockImplementation" in ChatApiClient)) { + throw new Error( + "installChatApiClientMock: ChatApiClient is not a vi.fn(). " + + "Add `vi.mock(\"../api/client\")` at the top of the test file.", + ); + } + (ChatApiClient as unknown as ReturnType).mockImplementation( + function MockChatApiClientCtor() { + return mock; + }, + ); +} diff --git a/widget/src/test-utils/__tests__/MockChatApiClient.test.ts b/widget/src/test-utils/__tests__/MockChatApiClient.test.ts new file mode 100644 index 0000000..6294020 --- /dev/null +++ b/widget/src/test-utils/__tests__/MockChatApiClient.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { MockChatApiClient } from "../MockChatApiClient"; +import { ChatApiError } from "../../api/errors"; + +describe("MockChatApiClient", () => { + it("returns queued replies in FIFO order and records each call", async () => { + const mock = new MockChatApiClient(); + mock.mockReply({ reply: "first" }).mockReply({ reply: "second" }); + + const r1 = await mock.sendMessage([{ id: "1", role: "user", content: "a" }]); + const r2 = await mock.sendMessage([{ id: "2", role: "user", content: "b" }]); + + expect(r1.reply).toBe("first"); + expect(r2.reply).toBe("second"); + expect(mock.calls).toHaveLength(2); + expect(mock.lastCall![0].content).toBe("b"); + }); + + it("throws queued ChatApiError instances", async () => { + const mock = new MockChatApiClient(); + mock.mockError(new ChatApiError("Bad", 400, "VALIDATION_ERROR")); + + await expect( + mock.sendMessage([{ id: "1", role: "user", content: "x" }]), + ).rejects.toMatchObject({ status: 400, code: "VALIDATION_ERROR" }); + }); + + it("mockTimeout / mockNetworkError throw the right ChatApiError shape", async () => { + const a = new MockChatApiClient(); + a.mockTimeout(); + await expect( + a.sendMessage([{ id: "1", role: "user", content: "x" }]), + ).rejects.toMatchObject({ code: "TIMEOUT", status: 0 }); + + const b = new MockChatApiClient(); + b.mockNetworkError(); + await expect( + b.sendMessage([{ id: "1", role: "user", content: "x" }]), + ).rejects.toMatchObject({ code: "NETWORK_ERROR", status: 0 }); + }); + + it("mockPending lets a test resolve the in-flight request later", async () => { + const mock = new MockChatApiClient(); + const pending = mock.mockPending(); + + const inflight = mock.sendMessage([ + { id: "1", role: "user", content: "wait" }, + ]); + + pending.resolve({ reply: "done" }); + await expect(inflight).resolves.toEqual({ reply: "done" }); + }); + + it("throws a clear error when the queue is empty", async () => { + const mock = new MockChatApiClient(); + await expect( + mock.sendMessage([{ id: "1", role: "user", content: "x" }]), + ).rejects.toThrow(/no response was queued/); + }); + + it("reset() clears queued responses and call history", async () => { + const mock = new MockChatApiClient(); + mock.mockReply({ reply: "ignored" }); + await mock.sendMessage([{ id: "1", role: "user", content: "x" }]); + expect(mock.calls).toHaveLength(1); + + mock.reset(); + expect(mock.calls).toHaveLength(0); + await expect( + mock.sendMessage([{ id: "2", role: "user", content: "y" }]), + ).rejects.toThrow(/no response was queued/); + }); +}); diff --git a/widget/vite.config.ts b/widget/vite.config.ts index 924bfa6..f1b6fe0 100644 --- a/widget/vite.config.ts +++ b/widget/vite.config.ts @@ -36,5 +36,28 @@ export default defineConfig({ environment: "jsdom", setupFiles: ["./src/test-setup.ts"], globals: true, + coverage: { + provider: "v8", + reporter: ["text", "html", "lcov"], + include: ["src/**/*.{ts,tsx}"], + exclude: [ + "src/**/__tests__/**", + "src/**/*.test.{ts,tsx}", + "src/test-setup.ts", + "src/test-utils/**", + "src/main.tsx", + "src/embed.tsx", + "src/index.ts", + "src/api/index.ts", + "src/api/types.ts", + "src/vite-env.d.ts", + ], + thresholds: { + statements: 80, + branches: 80, + functions: 80, + lines: 80, + }, + }, }, });