Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ Output: `dist/claudius.iife.js` and `dist/claudius.css`

Host these files on your site or a CDN, then add the embed snippet to your HTML.

## Testing

```bash
cd widget
pnpm test # Unit + integration (Vitest)
pnpm test:coverage # Coverage report (target: 80%+)
pnpm e2e:install # One-time: download Chromium for Playwright
pnpm e2e # End-to-end (Playwright, against `pnpm dev`)
pnpm e2e:ui # Playwright UI mode
```

The E2E suite mocks `**/api/chat` via `page.route()` so the worker doesn't need to be running, and builds the embed bundle once in `globalSetup` to exercise it via `<script src>` and `<claudius-chat>` web component.

## Tech Stack

- **Widget:** React 18, TypeScript, Tailwind CSS, Vite
Expand Down
91 changes: 91 additions & 0 deletions widget/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { test, expect } from "@playwright/test";
import { mockChatApi } from "./helpers";

test.describe("desktop chat flow", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});

test("toggle button opens and closes the chat window", async ({ page }) => {
const toggle = page.getByRole("button", { name: /open chat/i });
await expect(toggle).toBeVisible();
await expect(page.getByRole("dialog")).toHaveCount(0);

await toggle.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();

// Header close button (first in DOM order).
await dialog.getByRole("button", { name: /close chat/i }).click();
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(toggle).toBeFocused();
});

test("send a message and render the assistant reply", async ({ page }) => {
const api = await mockChatApi(page);
api.enqueueReply("Plans start at $1,000/month.");

await page.getByRole("button", { name: /open chat/i }).click();

const input = page.getByLabel(/type your message/i);
await input.fill("What are your prices?");
await input.press("Enter");

const log = page.getByRole("log");
await expect(log.getByText("What are your prices?")).toBeVisible();
await expect(log.getByText(/Plans start at \$1,000/)).toBeVisible();
expect(api.callCount()).toBe(1);

// Typing indicator must clear after the reply lands.
await expect(
page.getByRole("status", { name: /assistant is typing/i }),
).toHaveCount(0);
});

test("Escape closes the chat and returns focus to the toggle", async ({ page }) => {
const toggle = page.getByRole("button", { name: /open chat/i });
await toggle.click();
await expect(page.getByRole("dialog")).toBeVisible();

await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
await expect(toggle).toBeFocused();
});

test("focus traps inside the dialog when tabbing", async ({ page }) => {
await page.getByRole("button", { name: /open chat/i }).click();

// After several Tab presses the active element must remain inside the dialog.
for (let i = 0; i < 8; i++) {
await page.keyboard.press("Tab");
}

const dialogContainsActive = await page.evaluate(() => {
const dlg = document.querySelector('[role="dialog"]');
return !!dlg && dlg.contains(document.activeElement);
});
expect(dialogContainsActive).toBe(true);
});

test("Retry button recovers from a network failure", async ({ page }) => {
const api = await mockChatApi(page);
api.enqueueError(500, { error: "Server error", code: "SERVICE_ERROR" });

await page.getByRole("button", { name: /open chat/i }).click();
await page.getByLabel(/type your message/i).fill("Hello");
await page.getByLabel(/type your message/i).press("Enter");

const alert = page.getByRole("alert");
await expect(alert).toBeVisible();
const retryBtn = page.getByRole("button", { name: /retry/i });
await expect(retryBtn).toBeVisible();

api.enqueueReply("Welcome back!");
await retryBtn.click();

const log = page.getByRole("log");
await expect(log.getByText("Welcome back!")).toBeVisible();
await expect(page.getByRole("alert")).toHaveCount(0);
expect(api.callCount()).toBe(2);
});
});
94 changes: 94 additions & 0 deletions widget/e2e/embed.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { test, expect } from "@playwright/test";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { mockChatApi } from "./helpers";

const __dirname = dirname(fileURLToPath(import.meta.url));
const IIFE_PATH = resolve(__dirname, "..", "dist", "claudius-embed.iife.js");
const CSS_PATH = resolve(__dirname, "..", "dist", "claudius-embed.css");

const FAKE_API = "https://test.example/api";

async function loadEmbed(
page: import("@playwright/test").Page,
init: () => void,
): Promise<void> {
// Use the dev origin so cross-origin fetch + route() works cleanly. The
// dev app mounts its own widget at #root — strip it so only the embed
// bundle's widget exists for assertions.
await page.goto("/");
await page.evaluate(() => {
document.getElementById("root")?.remove();
});
await page.evaluate(init);
await page.addStyleTag({ path: CSS_PATH });
await page.addScriptTag({ path: IIFE_PATH });
}

test.describe("embed script (IIFE bundle)", () => {
test("auto-initialises from window.ClaudiusConfig", async ({ page }) => {
const api = await mockChatApi(page);
api.enqueueReply("Hello from the embed bundle!");

await loadEmbed(page, () => {
(window as unknown as { ClaudiusConfig: unknown }).ClaudiusConfig = {
apiUrl: "https://test.example",
title: "Embed Test",
};
});

// The auto-init path appends a #claudius-chat-widget container with the toggle.
await expect(page.locator("#claudius-chat-widget")).toBeAttached();
await expect(
page.getByRole("button", { name: /open chat/i }),
).toBeVisible();

// End-to-end: open, send, receive — all driven by the bundled code.
await page.getByRole("button", { name: /open chat/i }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByText("Embed Test")).toBeVisible();

const input = page.getByLabel(/type your message/i);
await input.fill("Ping");
await input.press("Enter");

await expect(
page.getByRole("log").getByText("Hello from the embed bundle!"),
).toBeVisible();
expect(api.callCount()).toBe(1);
});

test("registers <claudius-chat> as a working web component", async ({ page }) => {
const api = await mockChatApi(page);
api.enqueueReply("Web component reply");

await loadEmbed(page, () => {
// No ClaudiusConfig — auto-init runs but logs an error and exits.
// The web-component path should still work.
});

// Inject the custom element after the script registers it.
await page.evaluate((apiUrl) => {
const el = document.createElement("claudius-chat");
el.setAttribute("api-url", apiUrl);
el.setAttribute("title", "WC Test");
document.body.appendChild(el);
}, FAKE_API.replace(/\/api$/, ""));

await expect(
page.getByRole("button", { name: /open chat/i }),
).toBeVisible();

await page.getByRole("button", { name: /open chat/i }).click();
await expect(page.getByText("WC Test")).toBeVisible();

const input = page.getByLabel(/type your message/i);
await input.fill("Hi WC");
await input.press("Enter");

await expect(
page.getByRole("log").getByText("Web component reply"),
).toBeVisible();
expect(api.callCount()).toBe(1);
});
});
32 changes: 32 additions & 0 deletions widget/e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));

/**
* Build the IIFE embed bundle once before any test runs. The embed.spec.ts
* suite injects this file via `page.addScriptTag({ path })`, so it must
* exist on disk before tests start.
*/
export default async function globalSetup(): Promise<void> {
const iifePath = resolve(__dirname, "..", "dist", "claudius-embed.iife.js");

if (process.env.E2E_SKIP_BUILD === "1" && existsSync(iifePath)) {
return;
}

// eslint-disable-next-line no-console
console.log("[e2e] Building embed bundle (pnpm build:embed)...");
execSync("pnpm build:embed", {
cwd: resolve(__dirname, ".."),
stdio: "inherit",
});

if (!existsSync(iifePath)) {
throw new Error(
`[e2e] Build completed but ${iifePath} is missing — check vite.config.embed.ts.`,
);
}
}
68 changes: 68 additions & 0 deletions widget/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { Page, Route } from "@playwright/test";

/**
* Intercept POST /api/chat with a queue of replies. The dev app points the
* widget at http://localhost:8787, so we route any URL ending in /api/chat
* regardless of host. Each call consumes the next response in the queue.
*
* Because requests are cross-origin (page on :5173, widget points at :8787)
* the mock includes CORS headers and answers the OPTIONS preflight.
*/
export interface ApiMockHandle {
enqueueReply: (reply: string, sources?: unknown) => void;
enqueueError: (status: number, body: { error: string; code?: string }) => void;
callCount: () => number;
}

const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Retry-After",
"Access-Control-Expose-Headers": "Retry-After",
};

export async function mockChatApi(page: Page): Promise<ApiMockHandle> {
const queue: Array<(route: Route) => Promise<void>> = [];
let calls = 0;

await page.route("**/api/chat", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({ status: 204, headers: CORS_HEADERS });
return;
}

calls += 1;
const handler = queue.shift();
if (!handler) {
await route.fulfill({
status: 500,
headers: { ...CORS_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ error: "no mock queued", code: "TEST_ERROR" }),
});
return;
}
await handler(route);
});

return {
enqueueReply(reply, sources) {
queue.push(async (route) => {
await route.fulfill({
status: 200,
headers: { ...CORS_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify({ reply, sources }),
});
});
},
enqueueError(status, body) {
queue.push(async (route) => {
await route.fulfill({
status,
headers: { ...CORS_HEADERS, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
});
},
callCount: () => calls,
};
}
48 changes: 48 additions & 0 deletions widget/e2e/mobile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { test, expect } from "@playwright/test";
import { mockChatApi } from "./helpers";

test.describe("mobile responsive layout", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
});

test("opens as a bottom sheet with a scrim and a drag handle", async ({ page }) => {
await page.getByRole("button", { name: /open chat/i }).click();

const dialog = page.getByRole("dialog");
await expect(dialog).toHaveClass(/claudius-bottom-sheet/);
await expect(dialog).toHaveAttribute("aria-modal", "true");

// Drag handle: the small rounded bar inside the aria-hidden wrapper.
const dragHandle = dialog.locator('[aria-hidden="true"] .rounded-full').first();
await expect(dragHandle).toBeVisible();

// Scrim is rendered as a sibling of the dialog with the dedicated class.
await expect(page.locator(".claudius-scrim")).toBeVisible();
});

test("tapping the scrim closes the bottom sheet", async ({ page }) => {
await page.getByRole("button", { name: /open chat/i }).click();
const scrim = page.locator(".claudius-scrim");
await expect(scrim).toBeVisible();

// The bottom sheet occupies the lower 90vh — click the scrim near the top
// so the click doesn't land on the dialog underneath.
await scrim.click({ position: { x: 50, y: 20 } });
await expect(page.getByRole("dialog")).toHaveCount(0);
});

test("send a message and receive a reply on mobile", async ({ page }) => {
const api = await mockChatApi(page);
api.enqueueReply("Hello from mobile!");

await page.getByRole("button", { name: /open chat/i }).click();
const input = page.getByLabel(/type your message/i);
await input.fill("Hi");
await input.press("Enter");

const log = page.getByRole("log");
await expect(log.getByText("Hi")).toBeVisible();
await expect(log.getByText("Hello from mobile!")).toBeVisible();
});
});
4 changes: 4 additions & 0 deletions widget/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:install": "playwright install chromium",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
Expand All @@ -41,6 +44,7 @@
},
"devDependencies": {
"@eslint/js": "^9.24.0",
"@playwright/test": "^1.59.1",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.0",
Expand Down
Loading
Loading