diff --git a/README.md b/README.md index de4c12b..417b56d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,161 @@ -# webagent -Embeddable GenAI-powered web chat widget with client-side agent loop, tool calling, WebMCP support, and Cloudflare Workers backend +# @vibetechnologies/webagent + +Embeddable GenAI-powered web chat widget with client-side agent loop, tool calling, WebMCP support, and Cloudflare Workers backend. + +[![CI](https://github.com/VibeTechnologies/webagent/actions/workflows/ci.yml/badge.svg)](https://github.com/VibeTechnologies/webagent/actions/workflows/ci.yml) +[![npm](https://img.shields.io/npm/v/@vibetechnologies/webagent)](https://www.npmjs.com/package/@vibetechnologies/webagent) + +## Features + +- ๐Ÿค– Client-side AI agent loop (Vercel AI SDK) +- ๐Ÿ”ง 5 built-in tools: web_fetch, skill, todo, send_email, escalate_to_human +- ๐Ÿ“š TF-IDF knowledge base search +- ๐Ÿ’พ Session persistence (IndexedDB / localStorage) +- ๐ŸŒ WebMCP support (Chrome 146+) โ€” provider + consumer +- ๐ŸŽจ Shadow DOM isolation โ€” works on any website +- โ˜๏ธ Cloudflare Workers backend (D1, KV, R2, Queues) +- ๐Ÿ”‘ Hybrid API keys: BYOK + managed + +## Quick Start + +### CDN (Recommended) + +```html + + +``` + +### npm + +```bash +npm install @vibetechnologies/webagent +``` + +```typescript +import { WebAgent } from '@vibetechnologies/webagent'; + +const agent = WebAgent.init({ + apiBase: 'https://your-worker.workers.dev', + apiKey: 'wa_your_managed_key', + welcomeMessage: 'Hi! How can I help?', + theme: 'dark', + position: 'bottom-right', + persistence: 'indexeddb', + sessionTtlDays: 7, +}); + +// Later: agent.destroy(); +``` + +## Configuration + +| Option | Type | Default | Description | +|---|---|---|---| +| `apiBase` | `string` | *required* | Backend worker URL | +| `apiKey` | `string` | `undefined` | API key (BYOK `sk-...` or managed `wa_...`) | +| `systemPrompt` | `string` | `'You are a helpful support agent.'` | System prompt | +| `skills` | `Skill[]` | `[]` | API skills for tool calling | +| `knowledgeBaseUrl` | `string` | `undefined` | URL to knowledge base JSON | +| `theme` | `'light' \| 'dark' \| 'auto' \| ThemeConfig` | `'light'` | Theme | +| `position` | `'bottom-right' \| 'bottom-left'` | `'bottom-right'` | FAB position | +| `welcomeMessage` | `string` | `undefined` | Initial greeting | +| `persistence` | `'indexeddb' \| 'localStorage' \| 'none'` | `'indexeddb'` | Session storage | +| `maxSteps` | `number` | `10` | Max tool-calling steps | +| `model` | `string` | `'gpt-4o-mini'` | LLM model name | +| `supportEmail` | `string` | `'support@example.com'` | Escalation recipient | +| `sessionTtlDays` | `number` | `7` | Session TTL in days | +| `onEscalation` | `(ticket) => void` | `undefined` | Escalation callback | +| `onMessage` | `(message) => void` | `undefined` | Message callback | + +## Skills + +See the [Skill Authoring Guide](docs/skills.md) for full documentation on creating and registering API skills. + +## Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Host Website โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Shadow DOM โ”‚ โ”‚ +โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ Chat Widget UI โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ (Preact) โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ Agent Loop โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ (Vercel AI SDK) โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ Tools โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ€ข web_fetch โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ€ข skill โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ€ข todo โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ€ข send_email โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ€ข escalate โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ HTTPS +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Cloudflare Workers โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ LLM โ”‚ โ”‚ Fetch โ”‚ โ”‚ +โ”‚ โ”‚ Proxy โ”‚ โ”‚ Proxy โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Email โ”‚ โ”‚ Escalate โ”‚ โ”‚ +โ”‚ โ”‚ Queue โ”‚ โ”‚ Tickets โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ KB (R2) โ”‚ โ”‚ Admin โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ D1 ยท KV ยท R2 ยท Queues โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## WebMCP Support + +The widget supports the [WebMCP](https://developer.chrome.com/blog/webmcp-epp) standard (Chrome 146+): + +### As Provider +Skills registered with the widget are automatically exposed via `navigator.modelContext.registerTool()`, making them available to browser-level AI agents. + +### As Consumer +The widget can discover and use tools registered by the host page or other extensions via the WebMCP API. + +### Declarative Forms +```html +
+ + +
+``` + +## Packages + +| Package | Description | +|---|---| +| `@vibetechnologies/webagent` | Client-side chat widget | +| `@vibetechnologies/webagent-backend` | Cloudflare Workers backend | + +## Development + +```bash +pnpm install +pnpm typecheck # Type-check all packages +pnpm build # Build all packages +pnpm test # Run tests +pnpm dev # Dev mode (all packages) +``` + +## License + +MIT diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..0388c33 --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,183 @@ +# Deployment Guide + +## Prerequisites + +- [Node.js 22+](https://nodejs.org/) +- [pnpm 9+](https://pnpm.io/) +- [Cloudflare account](https://dash.cloudflare.com/) +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/) + +## Backend Deployment + +### 1. Create Cloudflare Resources + +```bash +# Login to Cloudflare +npx wrangler login + +# Create D1 database +npx wrangler d1 create webagent-db + +# Create KV namespace +npx wrangler kv namespace create KV + +# Create R2 bucket +npx wrangler r2 bucket create webagent-kb + +# Create Queue +npx wrangler queues create webagent-emails +``` + +### 2. Update wrangler.toml + +Update `packages/backend/wrangler.toml` with the IDs from step 1: + +```toml +[[d1_databases]] +binding = "DB" +database_name = "webagent-db" +database_id = "YOUR_D1_DATABASE_ID" + +[[kv_namespaces]] +binding = "KV" +id = "YOUR_KV_NAMESPACE_ID" +``` + +### 3. Set Secrets + +```bash +cd packages/backend + +# Admin secret for API key management +npx wrangler secret put ADMIN_SECRET + +# Resend API key for email delivery +npx wrangler secret put RESEND_API_KEY +``` + +### 4. Run Migrations + +```bash +npx wrangler d1 migrations apply webagent-db +``` + +### 5. Deploy + +```bash +pnpm --filter @vibetechnologies/webagent-backend deploy +``` + +## Widget Publishing + +### npm + +```bash +cd packages/widget +pnpm build +npm publish --access public +``` + +### CDN + +After publishing to npm, the widget is available via: +- unpkg: `https://unpkg.com/@vibetechnologies/webagent/dist/webagent.min.js` +- jsDelivr: `https://cdn.jsdelivr.net/npm/@vibetechnologies/webagent/dist/webagent.min.js` + +## Customer API Key Setup + +### Create a managed key + +```bash +curl -X POST https://your-worker.workers.dev/api/admin/keys \ + -H "Content-Type: application/json" \ + -H "X-Admin-Secret: YOUR_ADMIN_SECRET" \ + -d '{ + "customerId": "customer-123", + "provider": "openai", + "providerApiKey": "sk-..." + }' +``` + +Response: `{ "success": true, "customerKey": "wa_abc123...", "customerId": "customer-123" }` + +### BYOK (Bring Your Own Key) + +Customers can pass their own OpenAI/Anthropic key directly: + +```html + +``` + +## Knowledge Base Setup + +### Upload knowledge base chunks + +```bash +curl -X PUT https://your-worker.workers.dev/api/kb/my-docs \ + -H "Content-Type: application/json" \ + -H "X-Admin-Secret: YOUR_ADMIN_SECRET" \ + -d '[ + { + "id": "getting-started", + "title": "Getting Started", + "content": "Welcome to our product...", + "keywords": ["setup", "install", "start"] + } + ]' +``` + +### Configure widget to use KB + +```html + +``` + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `ADMIN_SECRET` | Yes | Secret for admin API endpoints | +| `RESEND_API_KEY` | Yes | [Resend](https://resend.com) API key for email | + +## Cloudflare Free Tier Limits + +| Resource | Limit | +|---|---| +| Workers requests | 100,000/day | +| D1 reads | 5,000,000/day | +| D1 writes | 100,000/day | +| D1 storage | 5 GB | +| KV reads | 100,000/day | +| KV writes | 1,000/day | +| R2 storage | 10 GB | +| R2 reads | 10,000,000/month | +| Queues messages | 1,000,000/month | + +## CI/CD + +The repository includes GitHub Actions workflows: + +- **CI** (`.github/workflows/ci.yml`): Runs on PRs โ€” typecheck, lint, test, build +- **Publish** (`.github/workflows/publish.yml`): Publishes to npm on GitHub Release +- **Deploy** (`.github/workflows/deploy.yml`): Deploys backend on push to main + +### Setting up CI/CD secrets + +In your GitHub repo settings โ†’ Secrets: + +| Secret | Purpose | +|---|---| +| `NPM_TOKEN` | npm publish token | +| `CLOUDFLARE_API_TOKEN` | Wrangler deploy token | +| `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account ID | diff --git a/docs/skills.md b/docs/skills.md new file mode 100644 index 0000000..2fb8395 --- /dev/null +++ b/docs/skills.md @@ -0,0 +1,106 @@ +# Skill Authoring Guide + +Skills are pre-configured API integrations that the WebAgent can execute on behalf of users. They enable the agent to perform actions like searching products, placing orders, or looking up account information. + +## Skill Definition + +```typescript +interface Skill { + name: string; // Unique identifier (e.g., 'search-products') + title: string; // Human-readable name + description: string; // LLM-facing description + parameters: object; // JSON Schema for tool parameters + endpoint: string; // API endpoint URL (supports {param} templates) + method: string; // HTTP method + headers?: object; // Custom headers + bodyTemplate?: object; // Request body template with {{param}} placeholders + readOnly?: boolean; // Hint for WebMCP annotations +} +``` + +## Example: Product Search Skill + +```typescript +const searchProducts = { + name: 'search-products', + title: 'Search Products', + description: 'Search the product catalog by keyword, category, or price range', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search keywords' }, + category: { type: 'string', description: 'Product category' }, + maxPrice: { type: 'number', description: 'Maximum price' }, + }, + required: ['query'], + }, + endpoint: 'https://api.store.com/products/search', + method: 'POST', + headers: { + 'X-Store-ID': 'my-store', + }, + bodyTemplate: { + q: '{{query}}', + category: '{{category}}', + price_max: '{{maxPrice}}', + }, +}; +``` + +## Example: Order Lookup Skill + +```typescript +const lookupOrder = { + name: 'lookup-order', + title: 'Look Up Order', + description: 'Look up an order by order ID or email address', + parameters: { + type: 'object', + properties: { + orderId: { type: 'string', description: 'Order ID' }, + email: { type: 'string', description: 'Customer email' }, + }, + }, + endpoint: 'https://api.store.com/orders/{orderId}', + method: 'GET', + readOnly: true, +}; +``` + +## URL Template Parameters + +Parameters in `{braces}` in the endpoint URL are substituted from the tool call args: +- `https://api.example.com/users/{userId}` โ†’ `https://api.example.com/users/123` + +## Body Template Parameters + +Parameters in `{{double braces}}` in the bodyTemplate are substituted: +- `{ "q": "{{query}}" }` โ†’ `{ "q": "wireless headphones" }` + +## Registering Skills + +```typescript +WebAgent.init({ + apiBase: 'https://your-worker.workers.dev', + apiKey: 'wa_xxx', + skills: [searchProducts, lookupOrder], +}); +``` + +## WebMCP Integration + +When skills are registered, they are automatically exposed via the WebMCP API (`navigator.modelContext.registerTool()`). This means: + +1. Browser-level AI agents can discover your skills +2. The `readOnly` flag maps to WebMCP's `annotations.readOnlyHint` +3. Skills follow the OpenAI function-calling parameter schema + +## Best Practices + +1. **Write clear descriptions** โ€” The LLM uses these to decide when to call the skill +2. **Use JSON Schema properly** โ€” Include `description` on each parameter +3. **Mark read-only skills** โ€” Set `readOnly: true` for GET/lookup operations +4. **Use URL templates for path params** โ€” `{param}` in endpoint URL +5. **Use body templates for POST data** โ€” `{{param}}` in bodyTemplate +6. **Handle errors gracefully** โ€” The agent will receive error responses and can retry or inform the user +7. **Keep skills focused** โ€” One skill = one API action diff --git a/packages/backend/src/__tests__/admin.test.ts b/packages/backend/src/__tests__/admin.test.ts new file mode 100644 index 0000000..e4828c1 --- /dev/null +++ b/packages/backend/src/__tests__/admin.test.ts @@ -0,0 +1,73 @@ +import { Hono } from 'hono'; +import { adminRoute } from '../routes/admin'; +import { createMockBindings } from './test-helpers'; + +describe('admin route', () => { + it('returns 401 without admin secret', async () => { + const app = new Hono(); + app.route('/api/admin', adminRoute); + + const { bindings } = createMockBindings(); + const res = await app.request('/api/admin/keys', { method: 'GET' }, bindings as any); + + expect(res.status).toBe(401); + }); + + it('returns 401 for wrong admin secret', async () => { + const app = new Hono(); + app.route('/api/admin', adminRoute); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/admin/keys', + { method: 'GET', headers: { 'X-Admin-Secret': 'wrong-secret' } }, + bindings as any + ); + + expect(res.status).toBe(401); + }); + + it('creates a managed key and persists customer for list endpoint', async () => { + const app = new Hono(); + app.route('/api/admin', adminRoute); + + const { bindings, stores } = createMockBindings(); + + const createRes = await app.request( + '/api/admin/keys', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Admin-Secret': 'admin-secret', + }, + body: JSON.stringify({ + customerId: 'customer-1', + provider: 'openai', + providerApiKey: 'sk-provider', + monthlyBudgetCents: 2500, + }), + }, + bindings as any + ); + + expect(createRes.status).toBe(200); + const createBody = await createRes.json() as any; + expect(createBody.customerKey).toMatch(/^wa_/); + + const kvValue = stores.kv.get(`key:${createBody.customerKey}`); + expect(kvValue).toBeTruthy(); + + const listRes = await app.request( + '/api/admin/keys', + { method: 'GET', headers: { 'X-Admin-Secret': 'admin-secret' } }, + bindings as any + ); + + expect(listRes.status).toBe(200); + const listBody = await listRes.json() as any; + expect(listBody.customers).toHaveLength(1); + expect(listBody.customers[0].id).toBe('customer-1'); + expect(listBody.customers[0].customer_key).toBe(createBody.customerKey); + }); +}); diff --git a/packages/backend/src/__tests__/auth.test.ts b/packages/backend/src/__tests__/auth.test.ts new file mode 100644 index 0000000..18173b2 --- /dev/null +++ b/packages/backend/src/__tests__/auth.test.ts @@ -0,0 +1,102 @@ +import { Hono } from 'hono'; +import { authMiddleware } from '../middleware/auth'; +import { createMockBindings } from './test-helpers'; + +const createApp = () => { + type Variables = { + customerId?: string; + provider?: string; + providerApiKey?: string; + isByok: boolean; + }; + const app = new Hono<{ Variables: Variables }>(); + app.use('/secure/*', authMiddleware as any); + app.get('/secure/check', (c) => + c.json({ + customerId: c.get('customerId'), + provider: c.get('provider'), + providerApiKey: c.get('providerApiKey'), + isByok: c.get('isByok'), + }) + ); + return app; +}; + +describe('auth middleware', () => { + it('returns 401 without Authorization header', async () => { + const app = createApp(); + const { bindings } = createMockBindings(); + + const res = await app.request('/secure/check', { method: 'GET' }, bindings as any); + + expect(res.status).toBe(401); + }); + + it('returns 401 for empty Bearer token', async () => { + const app = createApp(); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/secure/check', + { method: 'GET', headers: { Authorization: 'Bearer ' } }, + bindings as any + ); + + expect(res.status).toBe(401); + }); + + it('passes BYOK token and marks isByok=true', async () => { + const app = createApp(); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/secure/check', + { method: 'GET', headers: { Authorization: 'Bearer sk-byok-key' } }, + bindings as any + ); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.isByok).toBe(true); + expect(body.providerApiKey).toBe('sk-byok-key'); + }); + + it('returns 401 for managed key missing in KV', async () => { + const app = createApp(); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/secure/check', + { method: 'GET', headers: { Authorization: 'Bearer wa_missing' } }, + bindings as any + ); + + expect(res.status).toBe(401); + }); + + it('passes valid managed key from KV', async () => { + const app = createApp(); + const { bindings, stores } = createMockBindings(); + stores.kv.set( + 'key:wa_valid', + JSON.stringify({ + customerId: 'cust_1', + provider: 'openai', + providerApiKey: 'sk-managed', + }) + ); + + const res = await app.request( + '/secure/check', + { method: 'GET', headers: { Authorization: 'Bearer wa_valid' } }, + bindings as any + ); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.customerId).toBe('cust_1'); + expect(body.provider).toBe('openai'); + expect(body.providerApiKey).toBe('sk-managed'); + expect(body.isByok).toBe(false); + }); +}); diff --git a/packages/backend/src/__tests__/email.test.ts b/packages/backend/src/__tests__/email.test.ts new file mode 100644 index 0000000..deb6b59 --- /dev/null +++ b/packages/backend/src/__tests__/email.test.ts @@ -0,0 +1,68 @@ +import { Hono } from 'hono'; +import { emailRoute } from '../routes/email'; +import { createMockBindings } from './test-helpers'; + +describe('email route', () => { + it('queues message for valid payload', async () => { + const app = new Hono(); + app.route('/api/email', emailRoute); + + const { bindings, stores } = createMockBindings(); + const res = await app.request( + '/api/email', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + to: 'user@example.com', + subject: 'Hello', + body: 'Message body', + }), + }, + bindings as any + ); + + expect(res.status).toBe(200); + expect(stores.queue).toHaveLength(1); + }); + + it('returns 400 for invalid email', async () => { + const app = new Hono(); + app.route('/api/email', emailRoute); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/email', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + to: 'not-an-email', + subject: 'Hello', + body: 'Message body', + }), + }, + bindings as any + ); + + expect(res.status).toBe(400); + }); + + it('returns 400 for missing required fields', async () => { + const app = new Hono(); + app.route('/api/email', emailRoute); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/email', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }, + bindings as any + ); + + expect(res.status).toBe(400); + }); +}); diff --git a/packages/backend/src/__tests__/escalate.test.ts b/packages/backend/src/__tests__/escalate.test.ts new file mode 100644 index 0000000..3d94af3 --- /dev/null +++ b/packages/backend/src/__tests__/escalate.test.ts @@ -0,0 +1,77 @@ +import { Hono } from 'hono'; +import { escalateRoute } from '../routes/escalate'; +import { createMockBindings } from './test-helpers'; + +describe('escalate route', () => { + it('accepts valid payload and queues escalation message', async () => { + const app = new Hono(); + app.route('/api/escalate', escalateRoute); + + const { bindings, stores } = createMockBindings(); + const res = await app.request( + '/api/escalate', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userEmail: 'user@example.com', + userName: 'User', + transcript: [ + { role: 'user', content: 'Need help' }, + { role: 'assistant', content: 'Sure, I can help.' }, + ], + supportEmail: 'support@example.com', + }), + }, + bindings as any + ); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.ticketId).toBeTruthy(); + expect(stores.queue).toHaveLength(1); + }); + + it('returns 400 when transcript is missing', async () => { + const app = new Hono(); + app.route('/api/escalate', escalateRoute); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/escalate', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userEmail: 'user@example.com', + supportEmail: 'support@example.com', + }), + }, + bindings as any + ); + + expect(res.status).toBe(400); + }); + + it('returns 400 for invalid email', async () => { + const app = new Hono(); + app.route('/api/escalate', escalateRoute); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/escalate', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userEmail: 'invalid-email', + transcript: [{ role: 'user', content: 'Need help' }], + supportEmail: 'support@example.com', + }), + }, + bindings as any + ); + + expect(res.status).toBe(400); + }); +}); diff --git a/packages/backend/src/__tests__/fetch.test.ts b/packages/backend/src/__tests__/fetch.test.ts new file mode 100644 index 0000000..a8f7195 --- /dev/null +++ b/packages/backend/src/__tests__/fetch.test.ts @@ -0,0 +1,142 @@ +import { Hono } from 'hono'; +import { afterEach } from 'vitest'; +import { authMiddleware } from '../middleware/auth'; +import { fetchRoute } from '../routes/fetch'; +import { createMockBindings } from './test-helpers'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('fetch route', () => { + it('returns proxied result for valid URL', async () => { + const app = new Hono(); + app.route('/api/fetch', fetchRoute); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('proxied body', { + status: 201, + statusText: 'Created', + headers: { 'content-type': 'text/plain' }, + }) + ); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com', method: 'GET' }), + }, + bindings as any + ); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.status).toBe(201); + expect(body.body).toBe('proxied body'); + expect(body.contentType).toBe('text/plain'); + }); + + it('blocks localhost URL', async () => { + const app = new Hono(); + app.route('/api/fetch', fetchRoute); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'http://localhost/test' }), + }, + bindings as any + ); + + expect(res.status).toBe(403); + }); + + it('blocks 127.0.0.1 URL', async () => { + const app = new Hono(); + app.route('/api/fetch', fetchRoute); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'http://127.0.0.1/test' }), + }, + bindings as any + ); + + expect(res.status).toBe(403); + }); + + it('blocks private IP ranges', async () => { + const app = new Hono(); + app.route('/api/fetch', fetchRoute); + const { bindings } = createMockBindings(); + + const res10 = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'http://10.1.2.3/test' }), + }, + bindings as any + ); + const res192 = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'http://192.168.1.20/test' }), + }, + bindings as any + ); + + expect(res10.status).toBe(403); + expect(res192.status).toBe(403); + }); + + it('returns 400 for invalid body', async () => { + const app = new Hono(); + app.route('/api/fetch', fetchRoute); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'GET' }), + }, + bindings as any + ); + + expect(res.status).toBe(400); + }); + + it('returns 401 when auth middleware is used and auth is missing', async () => { + const app = new Hono(); + app.use('/api/*', authMiddleware as any); + app.route('/api/fetch', fetchRoute); + const { bindings } = createMockBindings(); + + const res = await app.request( + '/api/fetch', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com' }), + }, + bindings as any + ); + + expect(res.status).toBe(401); + }); +}); diff --git a/packages/backend/src/__tests__/health.test.ts b/packages/backend/src/__tests__/health.test.ts new file mode 100644 index 0000000..dd060c2 --- /dev/null +++ b/packages/backend/src/__tests__/health.test.ts @@ -0,0 +1,17 @@ +import { Hono } from 'hono'; +import { healthRoute } from '../routes/health'; +import { createMockBindings } from './test-helpers'; + +describe('health route', () => { + it('GET /api/health returns ok', async () => { + const app = new Hono(); + app.route('/api/health', healthRoute); + + const { bindings } = createMockBindings(); + const res = await app.request('/api/health', { method: 'GET' }, bindings as any); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body.status).toBe('ok'); + }); +}); diff --git a/packages/backend/src/__tests__/kb.test.ts b/packages/backend/src/__tests__/kb.test.ts new file mode 100644 index 0000000..74ba9a3 --- /dev/null +++ b/packages/backend/src/__tests__/kb.test.ts @@ -0,0 +1,74 @@ +import { Hono } from 'hono'; +import { kbRoute } from '../routes/kb'; +import { createMockBindings } from './test-helpers'; + +describe('kb route', () => { + it('returns existing KB entry', async () => { + const app = new Hono(); + app.route('/api/kb', kbRoute); + + const { bindings, stores } = createMockBindings(); + stores.kb.set('kb/existing.json', JSON.stringify({ id: 'existing', text: 'hello' })); + + const res = await app.request('/api/kb/existing', { method: 'GET' }, bindings as any); + + expect(res.status).toBe(200); + const body = await res.json() as any; + expect(body).toEqual({ id: 'existing', text: 'hello' }); + }); + + it('returns 404 for missing KB entry', async () => { + const app = new Hono(); + app.route('/api/kb', kbRoute); + + const { bindings } = createMockBindings(); + const res = await app.request('/api/kb/missing', { method: 'GET' }, bindings as any); + + expect(res.status).toBe(404); + }); + + it('returns 401 on PUT without admin secret', async () => { + const app = new Hono(); + app.route('/api/kb', kbRoute); + + const { bindings } = createMockBindings(); + const res = await app.request( + '/api/kb/doc-1', + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: 'value' }), + }, + bindings as any + ); + + expect(res.status).toBe(401); + }); + + it('uploads with admin secret and GET returns uploaded content', async () => { + const app = new Hono(); + app.route('/api/kb', kbRoute); + + const { bindings } = createMockBindings(); + + const putRes = await app.request( + '/api/kb/doc-2', + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Admin-Secret': 'admin-secret', + }, + body: JSON.stringify({ id: 'doc-2', text: 'uploaded' }), + }, + bindings as any + ); + + expect(putRes.status).toBe(200); + + const getRes = await app.request('/api/kb/doc-2', { method: 'GET' }, bindings as any); + expect(getRes.status).toBe(200); + const body = await getRes.json() as any; + expect(body).toEqual({ id: 'doc-2', text: 'uploaded' }); + }); +}); diff --git a/packages/backend/src/__tests__/test-helpers.ts b/packages/backend/src/__tests__/test-helpers.ts new file mode 100644 index 0000000..259c5f1 --- /dev/null +++ b/packages/backend/src/__tests__/test-helpers.ts @@ -0,0 +1,158 @@ +type CustomerRecord = { + id: string; + customer_key: string; + provider: string; + monthly_budget_cents: number | null; + created_at: string; +}; + +type MockStores = { + kv: Map; + customers: Map; + queue: any[]; + kb: Map; + escalations: any[]; +}; + +type MockBindings = { + KV: KVNamespace; + DB: D1Database; + EMAIL_QUEUE: Queue; + KB_BUCKET: R2Bucket; + ADMIN_SECRET: string; +}; + +export const createMockBindings = (overrides: Partial = {}) => { + const stores: MockStores = { + kv: new Map(), + customers: new Map(), + queue: [], + kb: new Map(), + escalations: [], + }; + + let customerInsertCounter = 0; + + const KV: KVNamespace = { + get: async (key: string, type?: 'text' | 'json' | 'arrayBuffer' | 'stream') => { + const value = stores.kv.get(key); + if (value == null) return null; + if (type === 'json') return JSON.parse(value); + return value; + }, + put: async (key: string, value: string | ArrayBuffer | ArrayBufferView | ReadableStream | null) => { + stores.kv.set(key, typeof value === 'string' ? value : String(value)); + }, + delete: async (key: string) => { + stores.kv.delete(key); + }, + list: async () => ({ + keys: Array.from(stores.kv.keys()).map((name) => ({ name })) as any, + list_complete: true, + cursor: '', + }), + } as unknown as KVNamespace; + + class MockPreparedStatement { + private values: any[] = []; + + constructor( + private readonly sql: string, + private readonly state: MockStores + ) {} + + bind(...values: any[]) { + this.values = values; + return this; + } + + async run() { + if (this.sql.includes('INSERT OR REPLACE INTO customers')) { + const [id, customerKey, provider, monthlyBudget] = this.values; + customerInsertCounter += 1; + this.state.customers.set(id, { + id, + customer_key: customerKey, + provider, + monthly_budget_cents: monthlyBudget ?? null, + created_at: `2025-01-01T00:00:${String(customerInsertCounter).padStart(2, '0')}Z`, + }); + } + + if (this.sql.includes('INSERT INTO escalation_tickets')) { + const [ticketId, userEmail, userName, context, transcript, supportEmail] = this.values; + this.state.escalations.push({ + ticketId, + userEmail, + userName, + context, + transcript, + supportEmail, + }); + } + + if (this.sql.includes('DELETE FROM customers')) { + const [id] = this.values; + this.state.customers.delete(id); + } + + return { success: true, meta: {} } as any; + } + + async all() { + if (this.sql.includes('SELECT id, customer_key, provider, monthly_budget_cents, created_at FROM customers')) { + const results = Array.from(this.state.customers.values()).sort((a, b) => + a.created_at < b.created_at ? 1 : -1 + ); + return { results } as any; + } + return { results: [] } as any; + } + + async first() { + if (this.sql.includes('SELECT customer_key FROM customers WHERE id = ?')) { + const [id] = this.values; + const customer = this.state.customers.get(id); + if (!customer) return null; + return { customer_key: customer.customer_key } as any; + } + return null; + } + } + + const DB: D1Database = { + prepare: (sql: string) => new MockPreparedStatement(sql, stores) as any, + dump: async () => new ArrayBuffer(0), + batch: async () => [], + exec: async () => ({ count: 0, duration: 0 }), + } as unknown as D1Database; + + const EMAIL_QUEUE: Queue = { + send: async (message: any) => { + stores.queue.push(message); + }, + } as unknown as Queue; + + const KB_BUCKET: R2Bucket = { + get: async (key: string) => { + const value = stores.kb.get(key); + if (value == null) return null; + return { body: value } as any; + }, + put: async (key: string, value: string | ArrayBuffer | ArrayBufferView | ReadableStream | Blob) => { + stores.kb.set(key, typeof value === 'string' ? value : String(value)); + return { key } as any; + }, + } as unknown as R2Bucket; + + const bindings: MockBindings = { + KV, + DB, + EMAIL_QUEUE, + KB_BUCKET, + ADMIN_SECRET: 'admin-secret', + ...overrides, + }; + + return { bindings, stores }; +}; diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 3eb30a2..c0ffc56 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "types": ["@cloudflare/workers-types"], + "types": ["@cloudflare/workers-types", "vitest/globals"], "jsx": "react-jsx", "jsxImportSource": "hono/jsx", "outDir": "dist", diff --git a/packages/backend/vitest.config.ts b/packages/backend/vitest.config.ts new file mode 100644 index 0000000..014f97e --- /dev/null +++ b/packages/backend/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + }, +}); diff --git a/packages/widget/package.json b/packages/widget/package.json index 5dc6983..5031f1c 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -6,7 +6,9 @@ "main": "dist/webagent.min.js", "module": "dist/webagent.esm.js", "types": "dist/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "dev": "node build.ts --watch", "build": "node build.ts", @@ -14,18 +16,24 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "preact": "^10.25.0", - "ai": "^4.3.0", "@ai-sdk/openai": "^1.3.0", + "ai": "^4.3.0", + "preact": "^10.25.0", "zod": "^3.24.0" }, "devDependencies": { "esbuild": "^0.25.0", - "vitest": "^3.1.0", - "typescript": "^5.7.0" + "happy-dom": "^20.9.0", + "typescript": "^5.7.0", + "vitest": "^3.1.0" }, - "peerDependencies": {}, - "keywords": ["webagent", "chat-widget", "ai", "genai", "support"], + "keywords": [ + "webagent", + "chat-widget", + "ai", + "genai", + "support" + ], "author": "VibeTechnologies", "license": "MIT", "repository": { diff --git a/packages/widget/src/__tests__/knowledge.test.ts b/packages/widget/src/__tests__/knowledge.test.ts new file mode 100644 index 0000000..0be702b --- /dev/null +++ b/packages/widget/src/__tests__/knowledge.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearKnowledgeCache, + loadKnowledge, + searchKnowledge, +} from '../agent/knowledge'; + +type KnowledgeChunk = { + id: string; + title: string; + content: string; + url?: string; + keywords?: string[]; + metadata?: Record; +}; + +describe('knowledge', () => { + beforeEach(() => { + clearKnowledgeCache(); + vi.restoreAllMocks(); + }); + + it('searchKnowledge returns empty array for empty chunks or empty query', () => { + const chunks: KnowledgeChunk[] = [ + { id: '1', title: 'Refunds', content: 'Refund policy details' }, + ]; + + expect(searchKnowledge([], 'refund')).toEqual([]); + expect(searchKnowledge(chunks, '')).toEqual([]); + expect(searchKnowledge(chunks, ' ')).toEqual([]); + }); + + it('searchKnowledge ranks title matches higher than content-only matches', () => { + const chunks: KnowledgeChunk[] = [ + { id: 'title', title: 'Refund Policy', content: 'General billing docs' }, + { id: 'content', title: 'Billing', content: 'Our refund policy is here' }, + ]; + + const results = searchKnowledge(chunks, 'refund policy', 2); + + expect(results.length).toBeGreaterThan(0); + expect(results[0]?.id).toBe('title'); + }); + + it('searchKnowledge respects maxResults', () => { + const chunks: KnowledgeChunk[] = [ + { id: '1', title: 'Refund A', content: 'Refund details A' }, + { id: '2', title: 'Refund B', content: 'Refund details B' }, + { id: '3', title: 'Refund C', content: 'Refund details C' }, + ]; + + const results = searchKnowledge(chunks, 'refund', 2); + expect(results).toHaveLength(2); + }); + + it('searchKnowledge keyword bonus works', () => { + const chunks: KnowledgeChunk[] = [ + { + id: 'keyword', + title: 'Billing docs', + content: 'Process information', + keywords: ['refund'], + }, + { + id: 'no-keyword', + title: 'Billing docs', + content: 'Process information refund', + }, + ]; + + const results = searchKnowledge(chunks, 'refund', 2); + expect(results[0]?.id).toBe('keyword'); + }); + + it('loadKnowledge caches results (second call does not fetch again)', async () => { + const data: KnowledgeChunk[] = [{ id: '1', title: 'A', content: 'B' }]; + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, json: async () => data }); + vi.stubGlobal('fetch', fetchMock); + + const first = await loadKnowledge('https://example.com/kb.json'); + const second = await loadKnowledge('https://example.com/kb.json'); + + expect(first).toEqual(data); + expect(second).toEqual(data); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('loadKnowledge returns empty array on fetch error', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); + + const result = await loadKnowledge('https://example.com/kb.json'); + + expect(result).toEqual([]); + expect(warnSpy).toHaveBeenCalled(); + }); + + it('clearKnowledgeCache resets cache', async () => { + const firstData: KnowledgeChunk[] = [{ id: '1', title: 'A', content: 'B' }]; + const secondData: KnowledgeChunk[] = [{ id: '2', title: 'C', content: 'D' }]; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => firstData }) + .mockResolvedValueOnce({ ok: true, json: async () => secondData }); + vi.stubGlobal('fetch', fetchMock); + + const first = await loadKnowledge('https://example.com/kb.json'); + clearKnowledgeCache(); + const second = await loadKnowledge('https://example.com/kb.json'); + + expect(first).toEqual(firstData); + expect(second).toEqual(secondData); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/widget/src/__tests__/session.test.ts b/packages/widget/src/__tests__/session.test.ts new file mode 100644 index 0000000..be74623 --- /dev/null +++ b/packages/widget/src/__tests__/session.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SessionStore } from '../store/session'; +import type { SessionData } from '../store/types'; + +function makeSessionData(id: string, updatedAt = Date.now()): SessionData { + return { + id, + messages: [], + todos: [], + createdAt: updatedAt, + updatedAt, + config: { test: true }, + }; +} + +describe('session store adapters', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + vi.restoreAllMocks(); + }); + + it('LocalStorageAdapter get/set/delete/listKeys/clear via SessionStore adapter', async () => { + vi.spyOn(sessionStorage, 'getItem').mockReturnValue('sess_local_test'); + vi.spyOn(sessionStorage, 'setItem').mockImplementation(() => undefined); + + const store = new SessionStore('localStorage'); + const adapter = (store as any).adapter; + + await adapter.set('a', makeSessionData('a')); + await adapter.set('b', makeSessionData('b')); + + expect(await adapter.get('a')).toMatchObject({ id: 'a' }); + expect((await adapter.listKeys()).sort()).toEqual(['a', 'b']); + + await adapter.delete('a'); + expect(await adapter.get('a')).toBeNull(); + + await adapter.clear(); + expect(await adapter.listKeys()).toEqual([]); + }); + + it('NoopAdapter always returns null/empty via none store', async () => { + vi.spyOn(sessionStorage, 'getItem').mockReturnValue('sess_none_test'); + vi.spyOn(sessionStorage, 'setItem').mockImplementation(() => undefined); + + const store = new SessionStore('none'); + const adapter = (store as any).adapter; + + await adapter.set('x', makeSessionData('x')); + expect(await adapter.get('x')).toBeNull(); + expect(await adapter.listKeys()).toEqual([]); + await adapter.delete('x'); + await adapter.clear(); + expect(await store.load()).toBeNull(); + }); +}); + +describe('SessionStore', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + vi.restoreAllMocks(); + }); + + it('localStorage type supports load/save/clear/getSessionId', async () => { + const getItemSpy = vi.spyOn(sessionStorage, 'getItem').mockReturnValue('sess_123'); + const setItemSpy = vi.spyOn(sessionStorage, 'setItem').mockImplementation(() => undefined); + + const store = new SessionStore('localStorage'); + + expect(store.getSessionId()).toBe('sess_123'); + expect(getItemSpy).toHaveBeenCalledWith('webagent_current_session'); + expect(setItemSpy).not.toHaveBeenCalled(); + + const now = Date.now(); + await store.save({ + messages: [], + todos: [], + createdAt: now, + updatedAt: now, + config: { mode: 'test' }, + }); + + const loaded = await store.load(); + expect(loaded).not.toBeNull(); + expect(loaded?.id).toBe('sess_123'); + + await store.clear(); + expect(await store.load()).toBeNull(); + }); + + it('none type always returns null', async () => { + vi.spyOn(sessionStorage, 'getItem').mockReturnValue('sess_none'); + vi.spyOn(sessionStorage, 'setItem').mockImplementation(() => undefined); + + const store = new SessionStore('none'); + + await store.save({ + messages: [], + todos: [], + createdAt: Date.now(), + updatedAt: Date.now(), + config: {}, + }); + + expect(await store.load()).toBeNull(); + expect(await store.export()).toBeNull(); + }); + + it('TTL expiry returns null for stale sessions', async () => { + vi.spyOn(sessionStorage, 'getItem').mockReturnValue('sess_ttl'); + vi.spyOn(sessionStorage, 'setItem').mockImplementation(() => undefined); + + const store = new SessionStore('localStorage', 1); + const sessionId = store.getSessionId(); + const adapter = (store as any).adapter; + + const staleTime = Date.now() - 2 * 24 * 60 * 60 * 1000; + await adapter.set(sessionId, makeSessionData(sessionId, staleTime)); + + expect(await store.load()).toBeNull(); + expect(await adapter.get(sessionId)).toBeNull(); + }); + + it('creates a session id when sessionStorage is empty', () => { + vi.spyOn(sessionStorage, 'getItem').mockReturnValue(null); + const setItemSpy = vi.spyOn(sessionStorage, 'setItem').mockImplementation(() => undefined); + + const store = new SessionStore('none'); + + expect(store.getSessionId()).toMatch(/^sess_/); + expect(setItemSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/widget/src/__tests__/todo-tool.test.ts b/packages/widget/src/__tests__/todo-tool.test.ts new file mode 100644 index 0000000..8401232 --- /dev/null +++ b/packages/widget/src/__tests__/todo-tool.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { TodoItem } from '../agent/types'; + +type TodoAction = 'add' | 'list' | 'complete' | 'remove'; + +type TodoArgs = { + action: TodoAction; + title?: string; + description?: string; + id?: string; +}; + +function createTodoExecutor(initialTodos: TodoItem[] = []) { + let todos = [...initialTodos]; + + const getTodos = () => todos; + const setTodos = (next: TodoItem[]) => { + todos = next; + }; + + const execute = async ({ action, title, description, id }: TodoArgs) => { + const current = getTodos(); + let result: any; + + switch (action) { + case 'add': { + if (!title) return { error: 'Title is required' }; + const newTodo: TodoItem = { + id: `todo_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + title, + description, + completed: false, + createdAt: Date.now(), + }; + setTodos([...current, newTodo]); + result = { success: true, todo: newTodo }; + break; + } + case 'list': + result = { todos: current }; + break; + case 'complete': + if (!id) return { error: 'ID is required' }; + setTodos(current.map(t => (t.id === id ? { ...t, completed: true } : t))); + result = { success: true, id }; + break; + case 'remove': + if (!id) return { error: 'ID is required' }; + setTodos(current.filter(t => t.id !== id)); + result = { success: true, id }; + break; + } + + return result; + }; + + return { execute, getTodos }; +} + +describe('todo tool logic', () => { + it('add creates todo with title, description, unique id and timestamp', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1700000000000); + vi.spyOn(Math, 'random').mockReturnValue(0.123456789); + + const todoTool = createTodoExecutor(); + const result = await todoTool.execute({ + action: 'add', + title: 'Write tests', + description: 'For widget package', + }); + + expect(result.success).toBe(true); + expect(result.todo).toMatchObject({ + title: 'Write tests', + description: 'For widget package', + completed: false, + createdAt: 1700000000000, + }); + expect(result.todo.id).toMatch(/^todo_1700000000000_/); + expect(todoTool.getTodos()).toHaveLength(1); + }); + + it('add without title returns error', async () => { + const todoTool = createTodoExecutor(); + await expect(todoTool.execute({ action: 'add' })).resolves.toEqual({ + error: 'Title is required', + }); + }); + + it('list returns current todos', async () => { + const todos: TodoItem[] = [ + { + id: 'todo_1', + title: 'First', + completed: false, + createdAt: Date.now(), + }, + ]; + const todoTool = createTodoExecutor(todos); + + await expect(todoTool.execute({ action: 'list' })).resolves.toEqual({ todos }); + }); + + it('complete marks todo as completed', async () => { + const todos: TodoItem[] = [ + { + id: 'todo_1', + title: 'First', + completed: false, + createdAt: Date.now(), + }, + ]; + const todoTool = createTodoExecutor(todos); + + const result = await todoTool.execute({ action: 'complete', id: 'todo_1' }); + + expect(result).toEqual({ success: true, id: 'todo_1' }); + expect(todoTool.getTodos()[0]?.completed).toBe(true); + }); + + it('complete without id returns error', async () => { + const todoTool = createTodoExecutor(); + await expect(todoTool.execute({ action: 'complete' })).resolves.toEqual({ + error: 'ID is required', + }); + }); + + it('remove removes todo from list', async () => { + const todos: TodoItem[] = [ + { + id: 'todo_1', + title: 'First', + completed: false, + createdAt: Date.now(), + }, + ]; + const todoTool = createTodoExecutor(todos); + + const result = await todoTool.execute({ action: 'remove', id: 'todo_1' }); + + expect(result).toEqual({ success: true, id: 'todo_1' }); + expect(todoTool.getTodos()).toEqual([]); + }); + + it('remove without id returns error', async () => { + const todoTool = createTodoExecutor(); + await expect(todoTool.execute({ action: 'remove' })).resolves.toEqual({ + error: 'ID is required', + }); + }); +}); diff --git a/packages/widget/src/__tests__/types.test.ts b/packages/widget/src/__tests__/types.test.ts new file mode 100644 index 0000000..65065b2 --- /dev/null +++ b/packages/widget/src/__tests__/types.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, expectTypeOf, it } from 'vitest'; +import type { AgentState, ChatMessage, WebAgentConfig } from '../agent/types'; + +describe('types', () => { + it('WebAgentConfig requires apiBase', () => { + const validConfig: WebAgentConfig = { apiBase: 'https://api.example.com' }; + expect(validConfig.apiBase).toBe('https://api.example.com'); + + // @ts-expect-error apiBase is required + const invalidConfig: WebAgentConfig = {}; + expect(invalidConfig).toBeDefined(); + }); + + it('ChatMessage has required fields', () => { + const message: ChatMessage = { + id: 'msg_1', + role: 'user', + content: 'Hello', + timestamp: Date.now(), + }; + + expect(message.id).toBe('msg_1'); + expect(message.role).toBe('user'); + }); + + it('AgentState is a union type', () => { + expectTypeOf().toEqualTypeOf< + 'idle' | 'thinking' | 'executing-tool' | 'streaming' + >(); + + const states: AgentState[] = ['idle', 'thinking', 'executing-tool', 'streaming']; + expect(states).toHaveLength(4); + }); +}); diff --git a/packages/widget/tsconfig.json b/packages/widget/tsconfig.json index c0c3bba..6309148 100644 --- a/packages/widget/tsconfig.json +++ b/packages/widget/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "types": ["vitest/globals"], "outDir": "dist", "rootDir": "src", "jsx": "react-jsx", diff --git a/packages/widget/vitest.config.ts b/packages/widget/vitest.config.ts new file mode 100644 index 0000000..e6fe3d7 --- /dev/null +++ b/packages/widget/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'happy-dom', + globals: true, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0216cc..58a72d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,7 +35,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.1.0 - version: 3.2.4 + version: 3.2.4(@types/node@25.6.0)(happy-dom@20.9.0) wrangler: specifier: ^4.10.0 version: 4.83.0(@cloudflare/workers-types@4.20260418.1) @@ -58,12 +58,15 @@ importers: esbuild: specifier: ^0.25.0 version: 0.25.12 + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 typescript: specifier: ^5.7.0 version: 5.9.3 vitest: specifier: ^3.1.0 - version: 3.2.4 + version: 3.2.4(@types/node@25.6.0)(happy-dom@20.9.0) packages: @@ -954,6 +957,15 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -1044,6 +1056,10 @@ packages: diff-match-patch@1.0.5: resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -1086,6 +1102,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + hono@4.12.14: resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} engines: {node: '>=16.9.0'} @@ -1237,6 +1257,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici@7.24.8: resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} engines: {node: '>=20.18.1'} @@ -1322,6 +1345,10 @@ packages: jsdom: optional: true + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1354,6 +1381,18 @@ packages: utf-8-validate: optional: true + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + youch-core@0.3.3: resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} @@ -1899,6 +1938,16 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.6.0 + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -1907,13 +1956,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.2)': + '@vitest/mocker@3.2.4(vite@7.3.2(@types/node@25.6.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.2 + vite: 7.3.2(@types/node@25.6.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -1985,6 +2034,8 @@ snapshots: diff-match-patch@1.0.5: {} + entities@7.0.1: {} + error-stack-parser-es@1.0.5: {} es-module-lexer@1.7.0: {} @@ -2089,6 +2140,18 @@ snapshots: fsevents@2.3.3: optional: true + happy-dom@20.9.0: + dependencies: + '@types/node': 25.6.0 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + hono@4.12.14: {} js-tokens@9.0.1: {} @@ -2264,6 +2327,8 @@ snapshots: typescript@5.9.3: {} + undici-types@7.19.2: {} + undici@7.24.8: {} unenv@2.0.0-rc.24: @@ -2274,13 +2339,13 @@ snapshots: dependencies: react: 19.2.5 - vite-node@3.2.4: + vite-node@3.2.4(@types/node@25.6.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.2 + vite: 7.3.2(@types/node@25.6.0) transitivePeerDependencies: - '@types/node' - jiti @@ -2295,7 +2360,7 @@ snapshots: - tsx - yaml - vite@7.3.2: + vite@7.3.2(@types/node@25.6.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -2304,13 +2369,14 @@ snapshots: rollup: 4.60.2 tinyglobby: 0.2.16 optionalDependencies: + '@types/node': 25.6.0 fsevents: 2.3.3 - vitest@3.2.4: + vitest@3.2.4(@types/node@25.6.0)(happy-dom@20.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.2) + '@vitest/mocker': 3.2.4(vite@7.3.2(@types/node@25.6.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -2328,9 +2394,12 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.2 - vite-node: 3.2.4 + vite: 7.3.2(@types/node@25.6.0) + vite-node: 3.2.4(@types/node@25.6.0) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.0 + happy-dom: 20.9.0 transitivePeerDependencies: - jiti - less @@ -2345,6 +2414,8 @@ snapshots: - tsx - yaml + whatwg-mimetype@3.0.0: {} + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -2377,6 +2448,8 @@ snapshots: ws@8.18.0: {} + ws@8.20.0: {} + youch-core@0.3.3: dependencies: '@poppinss/exception': 1.2.3