diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..395e751 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v3 + with: + version: 10.10.0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check code style with Biome + run: pnpm lint + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v3 + with: + version: 10.10.0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build all packages + run: pnpm build + + - name: Type check + run: pnpm typecheck + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v3 + with: + version: 10.10.0 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages (required for tests) + run: pnpm build + + - name: Install Playwright Browsers + run: pnpm tests exec playwright install --with-deps + + - name: Run tests + run: pnpm test + + - name: Upload coverage reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-reports + path: | + packages/*/coverage/ + retention-days: 7 diff --git a/biome.json b/biome.json index 1f9bca2..01b2a3f 100644 --- a/biome.json +++ b/biome.json @@ -4,15 +4,32 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "suspicious": { + "noTemplateCurlyInString": "off" + } }, - "includes": ["**", "!**/dist/**", "!**/node_modules/**", "!**/.turbo/**"] + "includes": [ + "**", + "!**/dist/**", + "!**/node_modules/**", + "!**/.turbo/**", + "!**/generated/**", + "!**/coverage/**" + ] }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, - "includes": ["**", "!**/dist/**", "!**/node_modules/**"] + "includes": [ + "**", + "!**/dist/**", + "!**/node_modules/**", + "!**/.turbo/**", + "!**/generated/**", + "!**/coverage/**" + ] }, "javascript": { "formatter": { diff --git a/package.json b/package.json index 1e4c000..1213c08 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "vite-plugin": "pnpm -F @workerify/vite-plugin", "example-quickstart": "pnpm -F @workerify/example-quickstart", "example-htmx": "pnpm -F @workerify/example-htmx", + "tests": "pnpm -F @workerify/tests", + "test:e2e": "pnpm -F @workerify/tests test", + "test:e2e:ui": "pnpm -F @workerify/tests test:ui", "changeset": "pnpm dlx @changesets/cli", "build": "turbo run build", "clean": "turbo run clean", @@ -22,7 +25,7 @@ "@types/node": "^24.5.2", "husky": "^9.1.7", "rimraf": "^6.0.1", - "turbo": "^2.5.6", + "turbo": "^2.5.8", "typescript": "^5.9.2", "vitest": "3.2.4" }, diff --git a/packages/lib/package.json b/packages/lib/package.json index 0a6ba6c..4bd25bb 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -30,7 +30,7 @@ "lint": "biome check .", "lint:fix": "biome check --fix .", "typecheck": "tsc --noEmit", - "test": "vitest", + "test": "vitest run", "test:watch": "vitest --watch", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage" diff --git a/packages/lib/src/__tests__/multi-tab-support.test.ts b/packages/lib/src/__tests__/multi-tab-support.test.ts index de75a91..750d9b1 100644 --- a/packages/lib/src/__tests__/multi-tab-support.test.ts +++ b/packages/lib/src/__tests__/multi-tab-support.test.ts @@ -1,64 +1,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Workerify } from '../index.js'; -import type { BroadcastMessage } from '../types.js'; +import { MockBroadcastChannel } from './test-utils.js'; // Mock fetch for registration global.fetch = vi.fn(); -// Mock BroadcastChannel -class MockBroadcastChannel { - private listeners: Map void>> = - new Map(); - public name: string; - public postMessage = vi.fn(); - public close = vi.fn(); - - constructor(name: string) { - this.name = name; - } - - addEventListener(type: string, listener: (event: MessageEvent) => void) { - if (!this.listeners.has(type)) { - this.listeners.set(type, []); - } - this.listeners.get(type)?.push(listener); - } - - removeEventListener(type: string, listener: (event: MessageEvent) => void) { - const listeners = this.listeners.get(type); - if (listeners) { - const index = listeners.indexOf(listener); - if (index > -1) { - listeners.splice(index, 1); - } - } - } - - set onmessage(handler: ((event: MessageEvent) => void) | null) { - if (handler) { - this.addEventListener('message', handler); - } - } - - // Helper to simulate receiving a message - simulateMessage(data: any) { - const event = new MessageEvent('message', { data }); - this.listeners.get('message')?.forEach((listener) => listener(event)); - } -} - // @ts-expect-error global.BroadcastChannel = MockBroadcastChannel; describe('Multi-tab Support', () => { let workerify: Workerify; let mockFetch: ReturnType; + let mockChannel: MockBroadcastChannel; beforeEach(() => { vi.clearAllMocks(); mockFetch = global.fetch as ReturnType; // Mock location for proper URL - global.location = { origin: 'http://localhost:3000' } as any; + global.location = { + origin: 'http://localhost:3000', + } as unknown as Location; + + // Create a mock channel instance that will be used by Workerify + mockChannel = new MockBroadcastChannel('workerify'); + // Replace the constructor to return our mock instance + (global.BroadcastChannel as unknown as typeof BroadcastChannel) = vi.fn( + () => mockChannel, + ); + workerify = new Workerify({ logger: false }); }); @@ -66,36 +35,6 @@ describe('Multi-tab Support', () => { workerify.close(); }); - describe('Consumer ID Generation', () => { - it('should generate unique consumerId for each instance', () => { - const instance1 = new Workerify({ logger: false }); - const instance2 = new Workerify({ logger: false }); - - // Access private property through any cast - const consumerId1 = (instance1 as any).consumerId; - const consumerId2 = (instance2 as any).consumerId; - - expect(consumerId1).toBeDefined(); - expect(consumerId2).toBeDefined(); - expect(consumerId1).not.toBe(consumerId2); - expect(consumerId1).toMatch(/^consumer-[a-z0-9]+-\d+$/); - - instance1.close(); - instance2.close(); - }); - - it('should maintain the same consumerId throughout instance lifetime', () => { - const consumerId = (workerify as any).consumerId; - - // Register routes - workerify.get('/test', () => 'test'); - workerify.post('/test', () => 'test'); - - // Consumer ID should remain the same - expect((workerify as any).consumerId).toBe(consumerId); - }); - }); - describe('Async listen() method', () => { it('should return a promise', () => { mockFetch.mockResolvedValueOnce({ @@ -114,16 +53,9 @@ describe('Multi-tab Support', () => { }); // Mock location for proper URL - global.location = { origin: 'http://localhost:3000' } as any; - - // Simulate routes update acknowledgment - setTimeout(() => { - const channel = (workerify as any).channel as MockBroadcastChannel; - channel.simulateMessage({ - type: 'workerify:routes:update:response', - consumerId: (workerify as any).consumerId, - }); - }, 10); + global.location = { + origin: 'http://localhost:3000', + } as unknown as Location; await workerify.listen(); @@ -132,7 +64,7 @@ describe('Multi-tab Support', () => { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ consumerId: (workerify as any).consumerId }), + body: expect.stringContaining('consumerId'), }); }); @@ -154,16 +86,16 @@ describe('Multi-tab Support', () => { json: () => Promise.resolve({ clientId: testClientId }), }); - setTimeout(() => { - const channel = (workerify as any).channel as MockBroadcastChannel; - channel.simulateMessage({ - type: 'workerify:routes:update:response', - consumerId: (workerify as any).consumerId, - }); - }, 10); - await workerify.listen(); - expect((workerify as any).clientId).toBe(testClientId); + + // Verify that the fetch was called correctly (which means clientId was processed) + expect(mockFetch).toHaveBeenCalledWith('/__workerify/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: expect.stringContaining('consumerId'), + }); }); it('should send routes update after registration', async () => { @@ -174,23 +106,15 @@ describe('Multi-tab Support', () => { workerify.get('/test', () => 'test response'); - const channel = (workerify as any).channel as MockBroadcastChannel; - - // Simulate acknowledgment after a delay - setTimeout(() => { - channel.simulateMessage({ - type: 'workerify:routes:update:response', - consumerId: (workerify as any).consumerId, - }); - }, 10); - await workerify.listen(); - // Check that routes were sent with consumerId - expect(channel.postMessage).toHaveBeenCalledWith( + // Check that routes were sent with proper structure + const routeUpdateMessages = mockChannel.getRouteUpdateMessages(); + expect(routeUpdateMessages).toHaveLength(1); + expect(routeUpdateMessages[0]).toEqual( expect.objectContaining({ type: 'workerify:routes:update', - consumerId: (workerify as any).consumerId, + consumerId: expect.stringMatching(/^consumer-[a-z0-9]+-\d+$/), routes: expect.arrayContaining([ expect.objectContaining({ path: '/test', @@ -212,7 +136,7 @@ describe('Multi-tab Support', () => { const listenPromise = workerify.listen(); // Fast-forward time to trigger timeout - vi.advanceTimersByTime(5001); + await vi.advanceTimersByTimeAsync(5001); await listenPromise; // Should resolve due to timeout @@ -221,13 +145,19 @@ describe('Multi-tab Support', () => { }); describe('Message handling with consumerId', () => { - it('should only handle messages for its own consumerId', () => { - const consumerId = (workerify as any).consumerId; - const channel = (workerify as any).channel as MockBroadcastChannel; - const handleRequestSpy = vi.spyOn(workerify as any, 'handleRequest'); + it('should only handle messages for its own consumerId', async () => { + // Add a route to trigger route update and get the consumerId + workerify.get('/test', () => 'test response'); + workerify.updateRoutes(); + + const consumerId = mockChannel.getLastConsumerId(); + expect(consumerId).toBeTruthy(); - // Message for this consumer - channel.simulateMessage({ + // Clear previous messages + mockChannel.lastMessages = []; + + // Message for this consumer - should be handled + mockChannel.simulateMessage({ type: 'workerify:handle', id: 'msg-1', consumerId: consumerId, @@ -239,8 +169,8 @@ describe('Multi-tab Support', () => { }, }); - // Message for different consumer - channel.simulateMessage({ + // Message for different consumer - should be ignored + mockChannel.simulateMessage({ type: 'workerify:handle', id: 'msg-2', consumerId: 'different-consumer', @@ -252,29 +182,31 @@ describe('Multi-tab Support', () => { }, }); - expect(handleRequestSpy).toHaveBeenCalledTimes(1); - expect(handleRequestSpy).toHaveBeenCalledWith( - 'msg-1', - expect.any(Object), + // Wait a bit for message processing + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Should only have one response message (for msg-1) + const responseMessages = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); + expect(responseMessages).toHaveLength(1); + expect(responseMessages[0].id).toBe('msg-1'); }); }); describe('Route registration behavior', () => { it('should not automatically update service worker when adding routes', () => { - const channel = (workerify as any).channel as MockBroadcastChannel; - channel.postMessage.mockClear(); + mockChannel.lastMessages = []; workerify.get('/test', () => 'test'); workerify.post('/test', () => 'test'); // Routes should not be sent to service worker automatically - expect(channel.postMessage).not.toHaveBeenCalled(); + const routeUpdateMessages = mockChannel.getRouteUpdateMessages(); + expect(routeUpdateMessages).toHaveLength(0); }); it('should update service worker only during listen()', async () => { - const channel = (workerify as any).channel as MockBroadcastChannel; - mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ clientId: 'test-client-id' }), @@ -283,22 +215,23 @@ describe('Multi-tab Support', () => { workerify.get('/test1', () => 'test1'); workerify.post('/test2', () => 'test2'); - channel.postMessage.mockClear(); + mockChannel.lastMessages = []; - setTimeout(() => { - channel.simulateMessage({ - type: 'workerify:routes:update:response', - consumerId: (workerify as any).consumerId, - }); - }, 10); + // Set up a promise to send the acknowledgment after listen() starts + const listenPromise = workerify.listen(); - await workerify.listen(); + // Send acknowledgment after a short delay to allow listen() to set up listeners + await new Promise((resolve) => setTimeout(resolve, 10)); + + await listenPromise; // Should send routes during listen - expect(channel.postMessage).toHaveBeenCalledWith( + const routeUpdateMessages = mockChannel.getRouteUpdateMessages(); + expect(routeUpdateMessages).toHaveLength(1); + expect(routeUpdateMessages[0]).toEqual( expect.objectContaining({ type: 'workerify:routes:update', - consumerId: (workerify as any).consumerId, + consumerId: expect.stringMatching(/^consumer-[a-z0-9]+-\d+$/), routes: expect.arrayContaining([ expect.objectContaining({ path: '/test1', method: 'GET' }), expect.objectContaining({ path: '/test2', method: 'POST' }), @@ -310,12 +243,21 @@ describe('Multi-tab Support', () => { describe('Multiple instances isolation', () => { it('should allow multiple instances to coexist', async () => { + // Create separate mock channels for each instance + const mockChannel1 = new MockBroadcastChannel('workerify'); + const mockChannel2 = new MockBroadcastChannel('workerify'); + + let channelCallCount = 0; + (global.BroadcastChannel as unknown as typeof BroadcastChannel) = vi.fn( + () => { + channelCallCount++; + return channelCallCount === 1 ? mockChannel1 : mockChannel2; + }, + ); + const instance1 = new Workerify({ logger: false }); const instance2 = new Workerify({ logger: false }); - const consumerId1 = (instance1 as any).consumerId; - const consumerId2 = (instance2 as any).consumerId; - instance1.get('/api/v1', () => 'v1'); instance2.get('/api/v2', () => 'v2'); @@ -329,33 +271,44 @@ describe('Multi-tab Support', () => { json: () => Promise.resolve({ clientId: 'client-2' }), }); - // Simulate acknowledgments - setTimeout(() => { - const channel1 = (instance1 as any).channel as MockBroadcastChannel; - channel1.simulateMessage({ - type: 'workerify:routes:update:response', - consumerId: consumerId1, - }); - }, 10); + // Start both listen() calls + await Promise.all([instance1.listen(), instance2.listen()]); - setTimeout(() => { - const channel2 = (instance2 as any).channel as MockBroadcastChannel; - channel2.simulateMessage({ - type: 'workerify:routes:update:response', - consumerId: consumerId2, - }); - }, 20); + // Verify that both instances registered correctly + expect(mockFetch).toHaveBeenCalledTimes(2); - await Promise.all([instance1.listen(), instance2.listen()]); + // Check that each instance sent route updates + const routeMessages1 = mockChannel1.getRouteUpdateMessages(); + const routeMessages2 = mockChannel2.getRouteUpdateMessages(); + + expect(routeMessages1).toHaveLength(1); + expect(routeMessages2).toHaveLength(1); - expect((instance1 as any).clientId).toBe('client-1'); - expect((instance2 as any).clientId).toBe('client-2'); + // Verify different consumer IDs + const consumerId1 = routeMessages1[0].consumerId; + const consumerId2 = routeMessages2[0].consumerId; + + expect(consumerId1).not.toBe(consumerId2); + expect(consumerId1).toMatch(/^consumer-[a-z0-9]+-\d+$/); + expect(consumerId2).toMatch(/^consumer-[a-z0-9]+-\d+$/); instance1.close(); instance2.close(); }, 15000); it('should maintain separate routes for each instance', () => { + // Create separate mock channels for each instance + const mockChannel1 = new MockBroadcastChannel('workerify'); + const mockChannel2 = new MockBroadcastChannel('workerify'); + + let channelCallCount = 0; + (global.BroadcastChannel as unknown as typeof BroadcastChannel) = vi.fn( + () => { + channelCallCount++; + return channelCallCount === 1 ? mockChannel1 : mockChannel2; + }, + ); + const instance1 = new Workerify({ logger: false }); const instance2 = new Workerify({ logger: false }); @@ -365,14 +318,21 @@ describe('Multi-tab Support', () => { instance1.get('/shared', handler1); instance2.get('/shared', handler2); - // Each instance should have its own routes - const routes1 = (instance1 as any).routes; - const routes2 = (instance2 as any).routes; + // Trigger route updates to check isolation + instance1.updateRoutes(); + instance2.updateRoutes(); + const routes1 = mockChannel1.getRouteUpdateMessages(); + const routes2 = mockChannel2.getRouteUpdateMessages(); + + // Each instance should have sent its own routes expect(routes1).toHaveLength(1); expect(routes2).toHaveLength(1); - expect(routes1[0].handler).toBe(handler1); - expect(routes2[0].handler).toBe(handler2); + + // Both should have the same path but different consumer IDs + expect(routes1[0].routes[0].path).toBe('/shared'); + expect(routes2[0].routes[0].path).toBe('/shared'); + expect(routes1[0].consumerId).not.toBe(routes2[0].consumerId); instance1.close(); instance2.close(); @@ -381,17 +341,17 @@ describe('Multi-tab Support', () => { describe('Update service worker routes', () => { it('should include consumerId in manual route updates', () => { - const channel = (workerify as any).channel as MockBroadcastChannel; - workerify.get('/test', () => 'test'); - channel.postMessage.mockClear(); + mockChannel.lastMessages = []; workerify.updateRoutes(); - expect(channel.postMessage).toHaveBeenCalledWith( + const routeUpdateMessages = mockChannel.getRouteUpdateMessages(); + expect(routeUpdateMessages).toHaveLength(1); + expect(routeUpdateMessages[0]).toEqual( expect.objectContaining({ type: 'workerify:routes:update', - consumerId: (workerify as any).consumerId, + consumerId: expect.stringMatching(/^consumer-[a-z0-9]+-\d+$/), routes: expect.any(Array), }), ); diff --git a/packages/lib/src/__tests__/plugins.test.ts b/packages/lib/src/__tests__/plugins.test.ts index f8fd207..ac2b1f0 100644 --- a/packages/lib/src/__tests__/plugins.test.ts +++ b/packages/lib/src/__tests__/plugins.test.ts @@ -1,16 +1,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Workerify } from '../index.js'; -import type { WorkerifyPlugin } from '../types.js'; -import { setupBroadcastChannelMock } from './test-utils.js'; +import type { + RouteHandler, + WorkerifyPlugin, + WorkerifyReply, + WorkerifyRequest, +} from '../types.js'; +import { MockBroadcastChannel } from './test-utils.js'; -// Setup mocks -setupBroadcastChannelMock(); +// Mock fetch for testing +global.fetch = vi.fn(); + +// @ts-expect-error +global.BroadcastChannel = MockBroadcastChannel; describe('Plugin System', () => { let workerify: Workerify; + let mockFetch: ReturnType; + let mockChannel: MockBroadcastChannel; beforeEach(() => { + vi.clearAllMocks(); + mockFetch = global.fetch as ReturnType; + + // Create a mock channel instance that will be used by Workerify + mockChannel = new MockBroadcastChannel('workerify'); + // Replace the constructor to return our mock instance + (global.BroadcastChannel as unknown as typeof BroadcastChannel) = vi.fn( + () => mockChannel, + ); + workerify = new Workerify({ logger: false }); + + // Mock successful registration + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ clientId: 'test-client' }), + }); }); afterEach(() => { @@ -80,11 +106,15 @@ describe('Plugin System', () => { await workerify.register(plugin); - // Verify routes were added by checking the internal routes array - const routes = (workerify as any).routes; + // Verify routes were added by triggering route update + workerify.updateRoutes(); + + const routes = mockChannel.getRoutes(); expect(routes).toHaveLength(2); expect(routes[0].path).toBe('/plugin/test'); + expect(routes[0].method).toBe('GET'); expect(routes[1].path).toBe('/plugin/data'); + expect(routes[1].method).toBe('POST'); }); it('should allow plugins to register middleware-like functionality', async () => { @@ -93,11 +123,14 @@ describe('Plugin System', () => { const plugin: WorkerifyPlugin = (app) => { // Simulate middleware by wrapping existing route handlers const originalGet = app.get.bind(app); - app.get = (path: string, handler: any) => { - return originalGet(path, (req: any, reply: any) => { - middlewareExecuted = true; - return handler(req, reply); - }); + app.get = (path: string, handler: RouteHandler) => { + return originalGet( + path, + (req: WorkerifyRequest, reply: WorkerifyReply) => { + middlewareExecuted = true; + return handler(req, reply); + }, + ); }; }; @@ -143,7 +176,10 @@ describe('Plugin System', () => { expect(plugin2).toHaveBeenCalled(); expect(plugin3).toHaveBeenCalled(); - const routes = (workerify as any).routes; + // Verify routes were added by triggering route update + workerify.updateRoutes(); + + const routes = mockChannel.getRoutes(); expect(routes).toHaveLength(3); }); }); @@ -180,7 +216,10 @@ describe('Plugin System', () => { expect(plugin).toHaveBeenCalledWith(workerify, options); - const routes = (workerify as any).routes; + // Verify routes were added by triggering route update + workerify.updateRoutes(); + + const routes = mockChannel.getRoutes(); expect(routes).toHaveLength(2); expect(routes[0].path).toBe('/api/v1/users'); expect(routes[1].path).toBe('/api/v1/posts'); @@ -224,7 +263,10 @@ describe('Plugin System', () => { expect(result).toBe(workerify); - const routes = (workerify as any).routes; + // Verify routes were added by triggering route update + workerify.updateRoutes(); + + const routes = mockChannel.getRoutes(); expect(routes).toHaveLength(2); }); }); @@ -241,7 +283,7 @@ describe('Plugin System', () => { options; // Add OPTIONS handler for preflight - app.option('/*', (req, reply) => { + app.option('/*', (_req, reply) => { reply.headers = { 'Access-Control-Allow-Origin': origin.join(', '), 'Access-Control-Allow-Methods': methods.join(', '), @@ -257,7 +299,10 @@ describe('Plugin System', () => { methods: ['GET', 'POST'], }); - const routes = (workerify as any).routes; + // Verify route was added by triggering route update + workerify.updateRoutes(); + + const routes = mockChannel.getRoutes(); expect(routes).toHaveLength(1); expect(routes[0].path).toBe('/'); expect(routes[0].match).toBe('prefix'); @@ -270,7 +315,7 @@ describe('Plugin System', () => { const loggingPlugin: WorkerifyPlugin = (app) => { // In a real implementation, this would intercept requests // For testing, we'll just add a route that logs - app.all('/logged/*', (req, reply) => { + app.all('/logged/*', (req, _reply) => { logs.push(`${req.method} ${req.url}`); return { logged: true }; }); @@ -278,7 +323,10 @@ describe('Plugin System', () => { await workerify.register(loggingPlugin); - const routes = (workerify as any).routes; + // Verify route was added by triggering route update + workerify.updateRoutes(); + + const routes = mockChannel.getRoutes(); expect(routes).toHaveLength(1); expect(routes[0].path).toBe('/logged/'); expect(routes[0].match).toBe('prefix'); @@ -295,7 +343,7 @@ describe('Plugin System', () => { const { secret, protected: protectedRoutes } = options; // Add auth route - app.post('/auth/login', (req) => { + app.post('/auth/login', (_req) => { // Simulate authentication return { token: 'fake-jwt-token', secret }; }); @@ -318,7 +366,10 @@ describe('Plugin System', () => { protected: ['/admin', '/profile'], }); - const routes = (workerify as any).routes; + // Verify routes were added by triggering route update + workerify.updateRoutes(); + + const routes = mockChannel.getRoutes(); expect(routes).toHaveLength(3); // login + 2 protected routes expect(routes[0].path).toBe('/auth/login'); expect(routes[1].path).toBe('/admin'); diff --git a/packages/lib/src/__tests__/request-handling.test.ts b/packages/lib/src/__tests__/request-handling.test.ts index 2c398c7..cb5a371 100644 --- a/packages/lib/src/__tests__/request-handling.test.ts +++ b/packages/lib/src/__tests__/request-handling.test.ts @@ -1,22 +1,36 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Workerify } from '../index.js'; -import type { WorkerifyReply, WorkerifyRequest } from '../types.js'; -import { - createMockReply, - createMockRequest, - setupBroadcastChannelMock, - stringToArrayBuffer, - waitForAsync, -} from './test-utils.js'; - -// Setup mocks -setupBroadcastChannelMock(); +import { MockBroadcastChannel } from './test-utils.js'; + +// Mock fetch for testing +global.fetch = vi.fn(); + +// @ts-expect-error +global.BroadcastChannel = MockBroadcastChannel; describe('Request Handling', () => { let workerify: Workerify; + let mockFetch: ReturnType; + let mockChannel: MockBroadcastChannel; beforeEach(() => { + vi.clearAllMocks(); + mockFetch = global.fetch as ReturnType; + + // Create a mock channel instance that will be used by Workerify + mockChannel = new MockBroadcastChannel('workerify'); + // Replace the constructor to return our mock instance + (global.BroadcastChannel as unknown as typeof BroadcastChannel) = vi.fn( + () => mockChannel, + ); + workerify = new Workerify({ logger: false }); + + // Mock successful registration + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ clientId: 'test-client' }), + }); }); afterEach(() => { @@ -28,16 +42,24 @@ describe('Request Handling', () => { const handler = vi.fn().mockReturnValue('Hello World'); workerify.get('/test', handler); - const request = createMockRequest('GET', 'http://localhost:3000/test'); + await workerify.listen(); - // Simulate message handling - const handleRequest = (workerify as any).handleRequest.bind(workerify); - const sendResponse = vi - .spyOn(workerify as any, 'sendResponse') - .mockImplementation(() => {}); + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/test', + method: 'GET', + headers: {}, + body: null, + }, + }); - await handleRequest('test-id', request); + await new Promise((resolve) => setTimeout(resolve, 10)); + // Verify handler was called expect(handler).toHaveBeenCalledWith( expect.objectContaining({ url: 'http://localhost:3000/test', @@ -50,423 +72,459 @@ describe('Request Handling', () => { }), ); - expect(sendResponse).toHaveBeenCalledWith( - 'test-id', - expect.objectContaining({ - body: 'Hello World', - bodyType: 'text', - headers: { 'Content-Type': 'text/html' }, - }), + // Verify response was sent + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); + expect(responses).toHaveLength(1); + expect(responses[0]).toMatchObject({ + id: 'req-1', + body: 'Hello World', + bodyType: 'text', + status: 200, + }); }); it('should handle POST request with JSON body', async () => { const handler = vi.fn().mockReturnValue({ success: true }); workerify.post('/api/data', handler); - const request = createMockRequest( - 'POST', - 'http://localhost:3000/api/data', - ); - - const handleRequest = (workerify as any).handleRequest.bind(workerify); - const sendResponse = vi - .spyOn(workerify as any, 'sendResponse') - .mockImplementation(() => {}); + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/api/data', + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: null, + }, + }); - await handleRequest('test-id', request); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(handler).toHaveBeenCalled(); - expect(sendResponse).toHaveBeenCalledWith( - 'test-id', - expect.objectContaining({ - body: { success: true }, - bodyType: 'json', - headers: { 'Content-Type': 'application/json' }, - }), + + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); + expect(responses).toHaveLength(1); + expect(responses[0]).toMatchObject({ + body: { success: true }, + bodyType: 'json', + status: 200, + }); }); it('should handle request with parameters', async () => { - const handler = vi.fn().mockReturnValue('User found'); + const handler = vi.fn((req) => `User ${req.params.id} found`); workerify.get('/users/:id', handler); - const request = createMockRequest( - 'GET', - 'http://localhost:3000/users/123', - ); + await workerify.listen(); - const handleRequest = (workerify as any).handleRequest.bind(workerify); - await handleRequest('test-id', request); + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users/123', + method: 'GET', + headers: {}, + body: null, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); expect(handler).toHaveBeenCalledWith( expect.objectContaining({ + url: 'http://localhost:3000/users/123', + method: 'GET', params: { id: '123' }, }), - expect.any(Object), - ); - }); - }); - - describe('Form data handling', () => { - it('should parse form-encoded data in POST requests', async () => { - const handler = vi.fn().mockImplementation((req) => { - return { receivedData: req.body }; - }); - workerify.post('/form', handler); - - const formData = 'name=John&age=30&city=NYC'; - const request = createMockRequest( - 'POST', - 'http://localhost:3000/form', - { 'content-type': 'application/x-www-form-urlencoded' }, - stringToArrayBuffer(formData), - ); - - const handleRequest = (workerify as any).handleRequest.bind(workerify); - await handleRequest('test-id', request); - - expect(handler).toHaveBeenCalledWith( expect.objectContaining({ - body: { - name: 'John', - age: '30', - city: 'NYC', - }, + status: 200, + statusText: 'OK', }), - expect.any(Object), ); - }); - - it('should handle malformed form data gracefully', async () => { - const handler = vi.fn().mockReturnValue('ok'); - workerify.post('/form', handler); - const invalidFormData = 'invalid%form%data%'; - const request = createMockRequest( - 'POST', - 'http://localhost:3000/form', - { 'content-type': 'application/x-www-form-urlencoded' }, - stringToArrayBuffer(invalidFormData), + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); - - const handleRequest = (workerify as any).handleRequest.bind(workerify); - - // Should not throw - await expect(handleRequest('test-id', request)).resolves.not.toThrow(); + expect(responses[0].body).toBe('User 123 found'); }); - it('should not process form data for GET requests', async () => { - const handler = vi.fn().mockReturnValue('ok'); - workerify.get('/test', handler); - - const request = createMockRequest( - 'GET', - 'http://localhost:3000/test', - { 'content-type': 'application/x-www-form-urlencoded' }, - stringToArrayBuffer('name=value'), - ); + it('should handle 404 for non-existent routes', async () => { + await workerify.listen(); - const handleRequest = (workerify as any).handleRequest.bind(workerify); - await handleRequest('test-id', request); - - // Verify that handler was called and body is still an ArrayBuffer (not parsed as form data) - expect(handler).toHaveBeenCalled(); - const [requestArg] = handler.mock.calls[0]; - // For GET requests, the body should remain as ArrayBuffer and not be parsed as form data - expect(requestArg.body?.constructor?.name).toBe('ArrayBuffer'); - expect(requestArg.method).toBe('GET'); - expect(requestArg.url).toBe('http://localhost:3000/test'); - }); - }); - - describe('Response handling', () => { - it('should return string responses with correct content type', async () => { - const handler = vi.fn().mockReturnValue('

Hello

'); - workerify.get('/html', handler); - - const request = createMockRequest('GET', 'http://localhost:3000/html'); - const handleRequest = (workerify as any).handleRequest.bind(workerify); - const sendResponse = vi - .spyOn(workerify as any, 'sendResponse') - .mockImplementation(() => {}); + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/nonexistent', + method: 'GET', + headers: {}, + body: null, + }, + }); - await handleRequest('test-id', request); + await new Promise((resolve) => setTimeout(resolve, 10)); - expect(sendResponse).toHaveBeenCalledWith( - 'test-id', - expect.objectContaining({ - body: '

Hello

', - bodyType: 'text', - headers: { 'Content-Type': 'text/html' }, - }), + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); + expect(responses).toHaveLength(1); + expect(responses[0]).toMatchObject({ + status: 404, + body: { error: 'Route not found' }, + bodyType: 'json', + }); }); - it('should return JSON responses with correct content type', async () => { - const responseData = { message: 'Hello', status: 'success' }; - const handler = vi.fn().mockReturnValue(responseData); - workerify.get('/json', handler); + it('should handle async handlers', async () => { + const handler = vi.fn().mockResolvedValue({ data: 'async result' }); + workerify.get('/async', handler); - const request = createMockRequest('GET', 'http://localhost:3000/json'); - const handleRequest = (workerify as any).handleRequest.bind(workerify); - const sendResponse = vi - .spyOn(workerify as any, 'sendResponse') - .mockImplementation(() => {}); + await workerify.listen(); - await handleRequest('test-id', request); + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/async', + method: 'GET', + headers: {}, + body: null, + }, + }); - expect(sendResponse).toHaveBeenCalledWith( - 'test-id', - expect.objectContaining({ - body: responseData, - bodyType: 'json', - headers: { 'Content-Type': 'application/json' }, - }), + await new Promise((resolve) => setTimeout(resolve, 20)); + + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); + expect(responses[0].body).toEqual({ data: 'async result' }); }); - it('should return ArrayBuffer responses', async () => { - const buffer = stringToArrayBuffer('binary data'); + it('should handle ArrayBuffer responses', async () => { + const buffer = new ArrayBuffer(8); const handler = vi.fn().mockReturnValue(buffer); workerify.get('/binary', handler); - const request = createMockRequest('GET', 'http://localhost:3000/binary'); - const handleRequest = (workerify as any).handleRequest.bind(workerify); - const sendResponse = vi - .spyOn(workerify as any, 'sendResponse') - .mockImplementation(() => {}); + await workerify.listen(); - await handleRequest('test-id', request); + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/binary', + method: 'GET', + headers: {}, + body: null, + }, + }); - expect(sendResponse).toHaveBeenCalledWith( - 'test-id', - expect.objectContaining({ - body: buffer, - // Note: ArrayBuffer detection might not work in jsdom, so we check the body - }), + await new Promise((resolve) => setTimeout(resolve, 10)); + + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); + expect(responses[0]).toMatchObject({ + body: buffer, + bodyType: 'arrayBuffer', + }); }); + }); - it('should handle undefined return values', async () => { - const handler = vi.fn().mockReturnValue(undefined); - workerify.get('/undefined', handler); + describe('Form data handling', () => { + it('should parse form-encoded data in POST requests', async () => { + const handler = vi.fn((req) => req.body); + workerify.post('/form', handler); - const request = createMockRequest( - 'GET', - 'http://localhost:3000/undefined', - ); - const handleRequest = (workerify as any).handleRequest.bind(workerify); - const sendResponse = vi - .spyOn(workerify as any, 'sendResponse') - .mockImplementation(() => {}); + await workerify.listen(); + + // Create form data as ArrayBuffer + const formData = 'name=John&age=30'; + const encoder = new TextEncoder(); + const bodyBuffer = encoder.encode(formData).buffer; + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/form', + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: bodyBuffer, + }, + }); - await handleRequest('test-id', request); + await new Promise((resolve) => setTimeout(resolve, 10)); - expect(sendResponse).toHaveBeenCalledWith( - 'test-id', + expect(handler).toHaveBeenCalledWith( expect.objectContaining({ - bodyType: 'json', - // Note: undefined body won't be set on the reply object + body: { name: 'John', age: '30' }, }), - ); - }); - - it('should allow handlers to modify reply object', async () => { - const handler = vi.fn().mockImplementation((req, reply) => { - reply.status = 201; - reply.statusText = 'Created'; - reply.headers = { 'X-Custom': 'value' }; - return { id: 123 }; - }); - workerify.post('/create', handler); - - const request = createMockRequest('POST', 'http://localhost:3000/create'); - const handleRequest = (workerify as any).handleRequest.bind(workerify); - const sendResponse = vi - .spyOn(workerify as any, 'sendResponse') - .mockImplementation(() => {}); - - await handleRequest('test-id', request); - - expect(sendResponse).toHaveBeenCalledWith( - 'test-id', expect.objectContaining({ - status: 201, - statusText: 'Created', - headers: expect.objectContaining({ - 'X-Custom': 'value', - 'Content-Type': 'application/json', - }), + status: 200, + statusText: 'OK', }), ); - }); - }); - - describe('Error handling', () => { - it('should handle route not found', async () => { - const request = createMockRequest( - 'GET', - 'http://localhost:3000/nonexistent', - ); - const handleRequest = (workerify as any).handleRequest.bind(workerify); - const sendResponse = vi - .spyOn(workerify as any, 'sendResponse') - .mockImplementation(() => {}); - - await handleRequest('test-id', request); - expect(sendResponse).toHaveBeenCalledWith( - 'test-id', - expect.objectContaining({ - status: 404, - statusText: 'Not Found', - body: { error: 'Route not found' }, - bodyType: 'json', - }), + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); + expect(responses[0].body).toEqual({ name: 'John', age: '30' }); }); + }); - it('should handle handler errors', async () => { - const handler = vi.fn().mockImplementation(() => { + describe('Error handling', () => { + it('should handle handler errors gracefully', async () => { + const handler = vi.fn(() => { throw new Error('Handler error'); }); workerify.get('/error', handler); - const request = createMockRequest('GET', 'http://localhost:3000/error'); - const handleRequest = (workerify as any).handleRequest.bind(workerify); - const sendResponse = vi - .spyOn(workerify as any, 'sendResponse') - .mockImplementation(() => {}); + await workerify.listen(); - await handleRequest('test-id', request); + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/error', + method: 'GET', + headers: {}, + body: null, + }, + }); - expect(sendResponse).toHaveBeenCalledWith( - 'test-id', - expect.objectContaining({ - status: 500, - statusText: 'Internal Server Error', - body: { error: 'Internal server error' }, - bodyType: 'json', - }), + await new Promise((resolve) => setTimeout(resolve, 10)); + + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); + expect(responses).toHaveLength(1); + expect(responses[0]).toMatchObject({ + status: 500, + body: { error: 'Internal server error' }, + bodyType: 'json', + }); }); - it('should handle async handler errors', async () => { + it('should handle async handler rejection', async () => { const handler = vi.fn().mockRejectedValue(new Error('Async error')); workerify.get('/async-error', handler); - const request = createMockRequest( - 'GET', - 'http://localhost:3000/async-error', - ); - const handleRequest = (workerify as any).handleRequest.bind(workerify); - const sendResponse = vi - .spyOn(workerify as any, 'sendResponse') - .mockImplementation(() => {}); + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/async-error', + method: 'GET', + headers: {}, + body: null, + }, + }); - await handleRequest('test-id', request); + await new Promise((resolve) => setTimeout(resolve, 20)); - expect(sendResponse).toHaveBeenCalledWith( - 'test-id', - expect.objectContaining({ - status: 500, - statusText: 'Internal Server Error', - }), + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); + expect(responses).toHaveLength(1); + expect(responses[0]).toMatchObject({ + status: 500, + body: { error: 'Internal server error' }, + }); }); }); - describe('Message handling integration', () => { - it('should handle incoming messages correctly', async () => { - const handler = vi.fn().mockReturnValue('response'); - workerify.get('/test', handler); + describe('Reply object usage', () => { + it('should allow modifying status in reply', async () => { + const handler = vi.fn((_req, reply) => { + reply.status = 201; + reply.statusText = 'Created'; + return { created: true }; + }); + workerify.post('/create', handler); - // Mock the channel's message handling - const handleMessage = (workerify as any).handleMessage.bind(workerify); - const handleRequest = vi - .spyOn(workerify as any, 'handleRequest') - .mockImplementation(() => {}); - - const consumerId = (workerify as any).consumerId; - const message = { - data: { - type: 'workerify:handle', - id: 'test-id', - consumerId: consumerId, - request: createMockRequest('GET', 'http://localhost:3000/test'), + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/create', + method: 'POST', + headers: {}, + body: null, }, - }; + }); - handleMessage(message); + await new Promise((resolve) => setTimeout(resolve, 10)); - expect(handleRequest).toHaveBeenCalledWith( - 'test-id', - message.data.request, + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); + expect(responses[0]).toMatchObject({ + status: 201, + statusText: 'Created', + body: { created: true }, + }); }); - it('should ignore non-workerify messages', async () => { - const handleMessage = (workerify as any).handleMessage.bind(workerify); - const handleRequest = vi - .spyOn(workerify as any, 'handleRequest') - .mockImplementation(() => {}); + it('should allow setting custom headers in reply', async () => { + const handler = vi.fn((_req, reply) => { + reply.headers = { + 'X-Custom-Header': 'custom-value', + 'Cache-Control': 'no-cache', + }; + return 'response with headers'; + }); + workerify.get('/headers', handler); + + await workerify.listen(); - const message = { - data: { - type: 'other:message', - id: 'test-id', + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/headers', + method: 'GET', + headers: {}, + body: null, }, - }; + }); - handleMessage(message); + await new Promise((resolve) => setTimeout(resolve, 10)); - expect(handleRequest).not.toHaveBeenCalled(); + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses[0].headers).toMatchObject({ + 'X-Custom-Header': 'custom-value', + 'Cache-Control': 'no-cache', + 'Content-Type': 'text/html', + }); }); + }); + + describe('Message filtering', () => { + it('should ignore messages without proper structure', async () => { + await workerify.listen(); - it('should ignore malformed messages', async () => { - const handleMessage = (workerify as any).handleMessage.bind(workerify); - const handleRequest = vi - .spyOn(workerify as any, 'handleRequest') - .mockImplementation(() => {}); + const consumerId = mockChannel.getLastConsumerId(); + + // Send malformed messages + mockChannel.simulateMessage({ + type: 'workerify:handle', + // Missing id + consumerId, + request: { + url: 'http://localhost:3000/test', + method: 'GET', + headers: {}, + body: null, + }, + }); - const message = { - data: { - type: 'workerify:handle', - // Missing id and request + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-2', + // Missing consumerId + request: { + url: 'http://localhost:3000/test', + method: 'GET', + headers: {}, + body: null, }, - }; + }); + + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-3', + consumerId, + // Missing request + }); - handleMessage(message); + await new Promise((resolve) => setTimeout(resolve, 10)); - expect(handleRequest).not.toHaveBeenCalled(); + // No responses should be sent for malformed messages + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(0); }); - }); - describe('BroadcastChannel response sending', () => { - it('should send response via BroadcastChannel', () => { - const postMessage = vi.spyOn(workerify['channel'], 'postMessage'); - const sendResponse = (workerify as any).sendResponse.bind(workerify); + it('should ignore messages for other consumers', async () => { + workerify.get('/test', () => 'response'); + await workerify.listen(); - const reply: WorkerifyReply = { - status: 200, - statusText: 'OK', - headers: { 'Content-Type': 'application/json' }, - body: { message: 'test' }, - bodyType: 'json', - }; + const correctConsumerId = mockChannel.getLastConsumerId(); - sendResponse('test-id', reply); + // Send message with wrong consumer ID + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId: 'wrong-consumer-id', + request: { + url: 'http://localhost:3000/test', + method: 'GET', + headers: {}, + body: null, + }, + }); - expect(postMessage).toHaveBeenCalledWith({ - type: 'workerify:response', - id: 'test-id', - status: 200, - statusText: 'OK', - headers: { 'Content-Type': 'application/json' }, - body: { message: 'test' }, - bodyType: 'json', + await new Promise((resolve) => setTimeout(resolve, 10)); + + // No response should be sent + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response' && msg.id === 'req-1', + ); + expect(responses).toHaveLength(0); + + // Send with correct consumer ID + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-2', + consumerId: correctConsumerId, + request: { + url: 'http://localhost:3000/test', + method: 'GET', + headers: {}, + body: null, + }, }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Response should be sent + const correctResponses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response' && msg.id === 'req-2', + ); + expect(correctResponses).toHaveLength(1); }); }); }); diff --git a/packages/lib/src/__tests__/route-matching.test.ts b/packages/lib/src/__tests__/route-matching.test.ts index 9646219..80550e3 100644 --- a/packages/lib/src/__tests__/route-matching.test.ts +++ b/packages/lib/src/__tests__/route-matching.test.ts @@ -1,15 +1,37 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Workerify } from '../index.js'; -import { setupBroadcastChannelMock } from './test-utils.js'; +import type { HttpMethod } from '../types.js'; +import { MockBroadcastChannel } from './test-utils.js'; -// Setup mocks -setupBroadcastChannelMock(); +// Mock fetch for testing +global.fetch = vi.fn(); + +// @ts-expect-error +global.BroadcastChannel = MockBroadcastChannel; describe('Route Matching Logic', () => { let workerify: Workerify; + let mockFetch: ReturnType; + let mockChannel: MockBroadcastChannel; beforeEach(() => { + vi.clearAllMocks(); + mockFetch = global.fetch as ReturnType; + + // Create a mock channel instance that will be used by Workerify + mockChannel = new MockBroadcastChannel('workerify'); + // Replace the constructor to return our mock instance + (global.BroadcastChannel as unknown as typeof BroadcastChannel) = vi.fn( + () => mockChannel, + ); + workerify = new Workerify({ logger: false }); + + // Mock successful registration + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ clientId: 'test-client' }), + }); }); afterEach(() => { @@ -17,276 +39,608 @@ describe('Route Matching Logic', () => { }); describe('Exact path matching', () => { - it('should match exact paths', () => { - // Access private method for testing - const matchRoute = (workerify as any).matchRoute.bind(workerify); - - const result = matchRoute('/users', '/users'); - expect(result).toEqual({ match: true }); - }); + it('should match exact paths', async () => { + const handler = vi.fn(() => 'matched'); + workerify.get('/users', handler); + + await workerify.listen(); + + // Simulate a request to the exact path + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users', + method: 'GET', + headers: {}, + body: null, + }, + }); - it('should not match different paths', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); + // Wait for message processing + await new Promise((resolve) => setTimeout(resolve, 10)); - const result = matchRoute('/users', '/posts'); - expect(result).toEqual({ match: false }); + // Check that a response was sent + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); }); - it('should not match partial paths', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); + it('should not match different paths', async () => { + const handler = vi.fn(() => 'users'); + workerify.get('/users', handler); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/posts', + method: 'GET', + headers: {}, + body: null, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); - const result = matchRoute('/users', '/users/123'); - expect(result).toEqual({ match: false }); + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(404); // Not found }); - it('should handle root path', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); + it('should not match partial paths', async () => { + workerify.get('/users', () => 'users'); - const result = matchRoute('/', '/'); - expect(result).toEqual({ match: true }); - }); + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users/123', + method: 'GET', + headers: {}, + body: null, + }, + }); - it('should be case sensitive', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); + await new Promise((resolve) => setTimeout(resolve, 10)); - const result = matchRoute('/Users', '/users'); - expect(result).toEqual({ match: false }); + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(404); }); - }); - describe('Parameterized route matching', () => { - it('should match single parameter routes', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); - - const result = matchRoute('/users/:id', '/users/123'); - expect(result).toEqual({ - match: true, - params: { id: '123' }, + it('should handle root path', async () => { + workerify.get('/', () => 'home'); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/', + method: 'GET', + headers: {}, + body: null, + }, }); - }); - it('should match multiple parameter routes', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); + await new Promise((resolve) => setTimeout(resolve, 10)); - const result = matchRoute( - '/users/:userId/posts/:postId', - '/users/123/posts/456', + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); - expect(result).toEqual({ - match: true, - params: { userId: '123', postId: '456' }, - }); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); }); - it('should match parameters with special characters', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); - - const result = matchRoute('/users/:id', '/users/abc-123_def'); - expect(result).toEqual({ - match: true, - params: { id: 'abc-123_def' }, + it('should be case sensitive', async () => { + workerify.get('/Users', () => 'users'); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users', // lowercase + method: 'GET', + headers: {}, + body: null, + }, }); - }); - it('should not match routes with different segment counts', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); + await new Promise((resolve) => setTimeout(resolve, 10)); - const result = matchRoute('/users/:id', '/users/123/extra'); - expect(result).toEqual({ match: false }); + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(404); // Case mismatch }); + }); - it('should not match when static parts differ', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); - - const result = matchRoute('/users/:id/posts', '/users/123/comments'); - expect(result).toEqual({ match: false }); - }); + describe('Parameterized route matching', () => { + it('should match single parameter routes', async () => { + const handler = vi.fn((req) => `User ${req.params.id}`); + workerify.get('/users/:id', handler); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users/123', + method: 'GET', + headers: {}, + body: null, + }, + }); - it('should handle mixed static and parameter segments', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); + await new Promise((resolve) => setTimeout(resolve, 10)); - const result = matchRoute( - '/api/v1/users/:id/profile', - '/api/v1/users/123/profile', + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); - expect(result).toEqual({ - match: true, - params: { id: '123' }, - }); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); + expect(responses[0].body).toBe('User 123'); }); - it('should handle parameters at the beginning', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); - - const result = matchRoute('/:org/repos', '/github/repos'); - expect(result).toEqual({ - match: true, - params: { org: 'github' }, + it('should match multiple parameter routes', async () => { + const handler = vi.fn( + (req) => `User ${req.params.userId}, Post ${req.params.postId}`, + ); + workerify.get('/users/:userId/posts/:postId', handler); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users/123/posts/456', + method: 'GET', + headers: {}, + body: null, + }, }); - }); - it('should handle empty parameter values', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); + await new Promise((resolve) => setTimeout(resolve, 10)); - // The current implementation matches '/users/' with '/users/:id' but doesn't capture empty params - const result = matchRoute('/users/:id', '/users/'); - // Based on the actual behavior, this matches with empty params object - expect(result).toEqual({ match: true, params: {} }); + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); + expect(responses[0].body).toBe('User 123, Post 456'); }); - }); - describe('Route finding logic', () => { - it('should find exact match route', () => { - const findRoute = (workerify as any).findRoute.bind(workerify); + it('should match parameters with special characters', async () => { + const handler = vi.fn((req) => `ID: ${req.params.id}`); + workerify.get('/users/:id', handler); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users/abc-123_def', + method: 'GET', + headers: {}, + body: null, + }, + }); - // Add a route first - workerify.get('/users', () => 'users'); + await new Promise((resolve) => setTimeout(resolve, 10)); - const result = findRoute('GET', 'http://localhost:3000/users'); - expect(result.route).toBeTruthy(); - expect(result.route.path).toBe('/users'); + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); + expect(responses[0].body).toBe('ID: abc-123_def'); }); - it('should find parameterized route', () => { - const findRoute = (workerify as any).findRoute.bind(workerify); - + it('should not match routes with different segment counts', async () => { workerify.get('/users/:id', () => 'user'); - const result = findRoute('GET', 'http://localhost:3000/users/123'); - expect(result.route).toBeTruthy(); - expect(result.params).toEqual({ id: '123' }); - }); - - it('should find prefix match route', () => { - const findRoute = (workerify as any).findRoute.bind(workerify); + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users/123/extra', + method: 'GET', + headers: {}, + body: null, + }, + }); - workerify.get('/api/*', () => 'api'); + await new Promise((resolve) => setTimeout(resolve, 10)); - const result = findRoute('GET', 'http://localhost:3000/api/v1/users'); - expect(result.route).toBeTruthy(); - expect(result.route.path).toBe('/api/'); - expect(result.route.match).toBe('prefix'); + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(404); }); - it('should return null for no match', () => { - const findRoute = (workerify as any).findRoute.bind(workerify); + it('should not match when static parts differ', async () => { + workerify.get('/users/:id/posts', () => 'posts'); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users/123/comments', + method: 'GET', + headers: {}, + body: null, + }, + }); - workerify.get('/users', () => 'users'); + await new Promise((resolve) => setTimeout(resolve, 10)); - const result = findRoute('GET', 'http://localhost:3000/posts'); - expect(result.route).toBeNull(); + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(404); }); + }); - it('should match correct HTTP method', () => { - const findRoute = (workerify as any).findRoute.bind(workerify); + describe('Wildcard/prefix matching', () => { + it('should match prefix routes', async () => { + workerify.get('/api/*', () => 'api endpoint'); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + + // Test various paths under /api/ + const paths = ['/api/v1', '/api/v1/users', '/api/v2/posts/123']; + + for (const path of paths) { + mockChannel.lastMessages = []; + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: `req-${path}`, + consumerId, + request: { + url: `http://localhost:3000${path}`, + method: 'GET', + headers: {}, + body: null, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); + expect(responses[0].body).toBe('api endpoint'); + } + }); + }); + describe('HTTP method routing', () => { + it('should match correct HTTP method', async () => { workerify.get('/users', () => 'get users'); - workerify.post('/users', () => 'post users'); - - const getResult = findRoute('GET', 'http://localhost:3000/users'); - const postResult = findRoute('POST', 'http://localhost:3000/users'); + workerify.post('/users', () => 'create user'); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + + // Test GET + mockChannel.lastMessages = []; + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-get', + consumerId, + request: { + url: 'http://localhost:3000/users', + method: 'GET', + headers: {}, + body: null, + }, + }); - expect(getResult.route).toBeTruthy(); - expect(postResult.route).toBeTruthy(); - expect(getResult.route).not.toBe(postResult.route); - }); + await new Promise((resolve) => setTimeout(resolve, 10)); - it('should handle method mismatch', () => { - const findRoute = (workerify as any).findRoute.bind(workerify); + let responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses[0].body).toBe('get users'); + + // Test POST + mockChannel.lastMessages = []; + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-post', + consumerId, + request: { + url: 'http://localhost:3000/users', + method: 'POST', + headers: {}, + body: null, + }, + }); - workerify.get('/users', () => 'users'); + await new Promise((resolve) => setTimeout(resolve, 10)); - const result = findRoute('POST', 'http://localhost:3000/users'); - expect(result.route).toBeNull(); + responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses[0].body).toBe('create user'); }); - it('should match ALL method routes regardless of request method', () => { - const findRoute = (workerify as any).findRoute.bind(workerify); + it('should handle method mismatch', async () => { + workerify.get('/users', () => 'users'); - workerify.all('/api', () => 'api'); + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users', + method: 'POST', // Wrong method + headers: {}, + body: null, + }, + }); - const getResult = findRoute('GET', 'http://localhost:3000/api'); - const postResult = findRoute('POST', 'http://localhost:3000/api'); - const putResult = findRoute('PUT', 'http://localhost:3000/api'); + await new Promise((resolve) => setTimeout(resolve, 10)); - expect(getResult.route).toBeTruthy(); - expect(postResult.route).toBeTruthy(); - expect(putResult.route).toBeTruthy(); + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(404); }); - it('should handle query parameters in URL', () => { - const findRoute = (workerify as any).findRoute.bind(workerify); + it('should match ALL method routes regardless of request method', async () => { + workerify.all('/api', () => 'any method'); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + const methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; + + for (const method of methods) { + mockChannel.lastMessages = []; + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: `req-${method}`, + consumerId, + request: { + url: 'http://localhost:3000/api', + method: method as HttpMethod, + headers: {}, + body: null, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); + expect(responses[0].body).toBe('any method'); + } + }); + }); - workerify.get('/users', () => 'users'); + describe('Query parameters and fragments', () => { + it('should handle query parameters in URL', async () => { + const handler = vi.fn(() => 'users'); + workerify.get('/users', handler); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users?page=1&limit=10', + method: 'GET', + headers: {}, + body: null, + }, + }); - const result = findRoute( - 'GET', - 'http://localhost:3000/users?page=1&limit=10', + await new Promise((resolve) => setTimeout(resolve, 10)); + + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', ); - expect(result.route).toBeTruthy(); - expect(result.route.path).toBe('/users'); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); }); - it('should handle URL fragments', () => { - const findRoute = (workerify as any).findRoute.bind(workerify); - + it('should handle URL fragments', async () => { workerify.get('/users', () => 'users'); - const result = findRoute('GET', 'http://localhost:3000/users#section'); - expect(result.route).toBeTruthy(); - expect(result.route.path).toBe('/users'); - }); + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users#section', + method: 'GET', + headers: {}, + body: null, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); - it('should prioritize more specific routes', () => { - const findRoute = (workerify as any).findRoute.bind(workerify); + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); + }); + }); - // Add routes in order (first registered wins in current implementation) + describe('Route priority', () => { + it('should match first registered route when multiple match', async () => { + // Register wildcard first, then specific workerify.get('/api/*', () => 'wildcard'); workerify.get('/api/users', () => 'specific'); - const result = findRoute('GET', 'http://localhost:3000/api/users'); - // First registered route wins - expect(result.route.path).toBe('/api/'); + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/api/users', + method: 'GET', + headers: {}, + body: null, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].body).toBe('wildcard'); // First registered wins }); }); describe('Edge cases', () => { - it('should handle malformed URLs gracefully', () => { - const findRoute = (workerify as any).findRoute.bind(workerify); + it('should handle paths with trailing slashes', async () => { + workerify.get('/users/', () => 'with slash'); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + + // Test with trailing slash + mockChannel.lastMessages = []; + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/users/', + method: 'GET', + headers: {}, + body: null, + }, + }); - workerify.get('/test', () => 'test'); + await new Promise((resolve) => setTimeout(resolve, 10)); - // Invalid URL should not crash - expect(() => { - findRoute('GET', 'invalid-url'); - }).toThrow(); // URL constructor will throw, which is expected - }); + let responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses[0].status).toBe(200); + + // Test without trailing slash + mockChannel.lastMessages = []; + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-2', + consumerId, + request: { + url: 'http://localhost:3000/users', + method: 'GET', + headers: {}, + body: null, + }, + }); - it('should handle empty path', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); + await new Promise((resolve) => setTimeout(resolve, 10)); - const result = matchRoute('', ''); - expect(result).toEqual({ match: true }); + responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses[0].status).toBe(404); // No match without slash }); - it('should handle paths with trailing slashes', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); - - const result1 = matchRoute('/users/', '/users/'); - const result2 = matchRoute('/users', '/users/'); - - expect(result1).toEqual({ match: true }); - expect(result2).toEqual({ match: false }); - }); + it('should handle Unicode characters in paths', async () => { + const handler = vi.fn((req) => `ID: ${req.params.ïd}`); + workerify.get('/üsers/:ïd', handler); + + await workerify.listen(); + + const consumerId = mockChannel.getLastConsumerId(); + mockChannel.simulateMessage({ + type: 'workerify:handle', + id: 'req-1', + consumerId, + request: { + url: 'http://localhost:3000/üsers/123', + method: 'GET', + headers: {}, + body: null, + }, + }); - it('should handle Unicode characters in paths', () => { - const matchRoute = (workerify as any).matchRoute.bind(workerify); + await new Promise((resolve) => setTimeout(resolve, 10)); - const result = matchRoute('/üsers/:ïd', '/üsers/123'); - expect(result).toEqual({ - match: true, - params: { ïd: '123' }, - }); + const responses = mockChannel.lastMessages.filter( + (msg) => msg.type === 'workerify:response', + ); + expect(responses).toHaveLength(1); + expect(responses[0].status).toBe(200); + expect(responses[0].body).toBe('ID: 123'); }); }); }); diff --git a/packages/lib/src/__tests__/test-utils.ts b/packages/lib/src/__tests__/test-utils.ts index 855e6bf..4bdabe6 100644 --- a/packages/lib/src/__tests__/test-utils.ts +++ b/packages/lib/src/__tests__/test-utils.ts @@ -1,47 +1,96 @@ -import type { WorkerifyReply, WorkerifyRequest } from '../types.js'; +import { vi } from 'vitest'; +import type { + BroadcastMessage, + WorkerifyReply, + WorkerifyRequest, +} from '../types.js'; -// Mock BroadcastChannel for testing +// Comprehensive Mock BroadcastChannel for testing export class MockBroadcastChannel { - private listeners: Array<(event: { data: any }) => void> = []; - name: string; + private listeners: Map void>> = + new Map(); + public name: string; + public postMessage = vi.fn(); + public close = vi.fn(); + public lastMessages: BroadcastMessage[] = []; constructor(name: string) { this.name = name; + // Override postMessage to capture messages + this.postMessage = vi.fn((message: BroadcastMessage) => { + this.lastMessages.push(message); + // Auto-respond to certain message types to simulate service worker + if (message.type === 'workerify:routes:update') { + setTimeout(() => { + this.simulateMessage({ + type: 'workerify:routes:update:response', + consumerId: message.consumerId, + }); + }, 10); + } + if (message.type === 'workerify:sw:check-readiness') { + setTimeout(() => { + this.simulateMessage({ + type: 'workerify:sw:check-readiness:response', + body: true, + }); + }, 10); + } + }); } - postMessage(data: any) { - // Simulate async message delivery - setTimeout(() => { - this.listeners.forEach((listener) => { - listener({ data }); - }); - }, 0); - } - - addEventListener(type: string, listener: (event: { data: any }) => void) { - if (type === 'message') { - this.listeners.push(listener); + addEventListener(type: string, listener: (event: MessageEvent) => void) { + if (!this.listeners.has(type)) { + this.listeners.set(type, []); } + this.listeners.get(type)?.push(listener); } - removeEventListener(type: string, listener: (event: { data: any }) => void) { - if (type === 'message') { - const index = this.listeners.indexOf(listener); + removeEventListener(type: string, listener: (event: MessageEvent) => void) { + const listeners = this.listeners.get(type); + if (listeners) { + const index = listeners.indexOf(listener); if (index > -1) { - this.listeners.splice(index, 1); + listeners.splice(index, 1); } } } - close() { - this.listeners = []; - } - - set onmessage(handler: ((event: { data: any }) => void) | null) { + set onmessage(handler: ((event: MessageEvent) => void) | null) { if (handler) { this.addEventListener('message', handler); } } + + // Helper to simulate receiving a message + simulateMessage(data: unknown) { + const event = new MessageEvent('message', { data }); + this.listeners.get('message')?.forEach((listener) => { + listener(event); + }); + } + + // Helper to get the consumer ID from route update messages + getLastConsumerId(): string | null { + const routeUpdateMessage = this.lastMessages.find( + (msg) => msg.type === 'workerify:routes:update', + ); + return routeUpdateMessage?.consumerId || null; + } + + // Helper to get route update messages + getRouteUpdateMessages() { + return this.lastMessages.filter( + (msg) => msg.type === 'workerify:routes:update', + ); + } + + // Helper to get specific routes from route update messages + getRoutes() { + const routeMessages = this.getRouteUpdateMessages(); + if (routeMessages.length === 0) return []; + return routeMessages[routeMessages.length - 1]?.routes || []; + } } // Helper to create mock requests @@ -53,7 +102,14 @@ export function createMockRequest( ): WorkerifyRequest { return { url, - method: method as any, + method: method as + | 'GET' + | 'POST' + | 'PUT' + | 'DELETE' + | 'PATCH' + | 'HEAD' + | 'OPTIONS', headers: { 'user-agent': 'test', ...headers, diff --git a/packages/lib/src/__tests__/types.test.ts b/packages/lib/src/__tests__/types.test.ts index 911322e..f4ae798 100644 --- a/packages/lib/src/__tests__/types.test.ts +++ b/packages/lib/src/__tests__/types.test.ts @@ -123,7 +123,7 @@ describe('Type Definitions', () => { describe('RouteHandler', () => { it('should support synchronous handlers', () => { - const handler: RouteHandler = (request, reply) => { + const handler: RouteHandler = (_request, _reply) => { return 'sync response'; }; @@ -131,7 +131,7 @@ describe('Type Definitions', () => { }); it('should support asynchronous handlers', () => { - const handler: RouteHandler = async (request, reply) => { + const handler: RouteHandler = async (_request, _reply) => { return Promise.resolve('async response'); }; @@ -139,7 +139,7 @@ describe('Type Definitions', () => { }); it('should support handlers that return void', () => { - const handler: RouteHandler = (request, reply) => { + const handler: RouteHandler = (_request, reply) => { reply.status = 200; reply.body = 'modified reply'; // No return value @@ -305,7 +305,7 @@ describe('Type Definitions', () => { describe('WorkerifyPlugin', () => { it('should support synchronous plugin', () => { - const plugin: WorkerifyPlugin = (instance, options) => { + const plugin: WorkerifyPlugin = (_instance, _options) => { // Plugin implementation }; @@ -313,7 +313,7 @@ describe('Type Definitions', () => { }); it('should support asynchronous plugin', () => { - const plugin: WorkerifyPlugin = async (instance, options) => { + const plugin: WorkerifyPlugin = async (_instance, _options) => { return Promise.resolve(); }; @@ -326,7 +326,7 @@ describe('Type Definitions', () => { enabled: boolean; } - const plugin: WorkerifyPlugin = (instance, options: PluginOptions) => { + const plugin: WorkerifyPlugin = (_instance, options: PluginOptions) => { if (options?.enabled) { // Plugin logic } @@ -336,7 +336,7 @@ describe('Type Definitions', () => { }); it('should support plugin without options', () => { - const plugin: WorkerifyPlugin = (instance) => { + const plugin: WorkerifyPlugin = (_instance) => { // Plugin implementation without options }; @@ -348,7 +348,7 @@ describe('Type Definitions', () => { it('should allow route handlers to be assigned to RouteHandler type', () => { const syncHandler = () => 'sync'; const asyncHandler = async () => 'async'; - const voidHandler = (req: WorkerifyRequest, reply: WorkerifyReply) => { + const voidHandler = (_req: WorkerifyRequest, reply: WorkerifyReply) => { reply.status = 200; }; diff --git a/packages/lib/src/__tests__/workerify.test.ts b/packages/lib/src/__tests__/workerify.test.ts index 867b830..70135ab 100644 --- a/packages/lib/src/__tests__/workerify.test.ts +++ b/packages/lib/src/__tests__/workerify.test.ts @@ -1,19 +1,37 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Workerify } from '../index.js'; -import { - createMockRequest, - setupBroadcastChannelMock, - waitForAsync, -} from './test-utils.js'; +import type { RouteHandler } from '../types.js'; +import { MockBroadcastChannel } from './test-utils.js'; -// Setup mocks -setupBroadcastChannelMock(); +// Mock fetch for testing +global.fetch = vi.fn(); + +// @ts-expect-error +global.BroadcastChannel = MockBroadcastChannel; describe('Workerify', () => { let workerify: Workerify; + let mockFetch: ReturnType; + let mockChannel: MockBroadcastChannel; beforeEach(() => { + vi.clearAllMocks(); + mockFetch = global.fetch as ReturnType; + + // Create a mock channel instance that will be used by Workerify + mockChannel = new MockBroadcastChannel('workerify'); + // Replace the constructor to return our mock instance + (global.BroadcastChannel as unknown as typeof BroadcastChannel) = vi.fn( + () => mockChannel, + ); + workerify = new Workerify({ logger: false }); + + // Mock successful registration + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ clientId: 'test-client' }), + }); }); afterEach(() => { @@ -127,26 +145,11 @@ describe('Workerify', () => { describe('Server lifecycle', () => { it('should start listening', async () => { // Mock fetch for registration - global.fetch = vi.fn().mockResolvedValueOnce({ + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ clientId: 'test-client-id' }), }); - // Mock routes acknowledgment - setTimeout(() => { - const channel = workerify['channel'] as any; - if (channel && channel.listeners) { - channel.listeners.forEach((listener: any) => { - listener({ - data: { - type: 'workerify:routes:update:response', - consumerId: (workerify as any).consumerId, - }, - }); - }); - } - }, 10); - await expect(workerify.listen()).resolves.not.toThrow(); }); @@ -165,25 +168,11 @@ describe('Workerify', () => { workerify.get('/exact/path', handler); // Mock fetch for registration - global.fetch = vi.fn().mockResolvedValueOnce({ + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ clientId: 'test-client-id' }), }); - setTimeout(() => { - const channel = workerify['channel'] as any; - if (channel && channel.listeners) { - channel.listeners.forEach((listener: any) => { - listener({ - data: { - type: 'workerify:routes:update:response', - consumerId: (workerify as any).consumerId, - }, - }); - }); - } - }, 10); - await expect(workerify.listen()).resolves.not.toThrow(); }); @@ -192,25 +181,11 @@ describe('Workerify', () => { workerify.get('/prefix/*', handler); // Mock fetch for registration - global.fetch = vi.fn().mockResolvedValueOnce({ + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ clientId: 'test-client-id' }), }); - setTimeout(() => { - const channel = workerify['channel'] as any; - if (channel && channel.listeners) { - channel.listeners.forEach((listener: any) => { - listener({ - data: { - type: 'workerify:routes:update:response', - consumerId: (workerify as any).consumerId, - }, - }); - }); - } - }, 10); - await expect(workerify.listen()).resolves.not.toThrow(); }); @@ -220,25 +195,11 @@ describe('Workerify', () => { workerify.get('/users/:id/posts/:postId', handler); // Mock fetch for registration - global.fetch = vi.fn().mockResolvedValueOnce({ + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ clientId: 'test-client-id' }), }); - setTimeout(() => { - const channel = workerify['channel'] as any; - if (channel && channel.listeners) { - channel.listeners.forEach((listener: any) => { - listener({ - data: { - type: 'workerify:routes:update:response', - consumerId: (workerify as any).consumerId, - }, - }); - }); - } - }, 10); - await expect(workerify.listen()).resolves.not.toThrow(); }); }); @@ -247,7 +208,7 @@ describe('Workerify', () => { it('should handle invalid route handlers gracefully', () => { // Test with null handler (should not crash) expect(() => { - workerify.get('/test', null as any); + workerify.get('/test', null as unknown as RouteHandler); }).not.toThrow(); }); @@ -263,55 +224,42 @@ describe('Workerify', () => { describe('Integration with BroadcastChannel', () => { it('should NOT send route updates when routes are registered (deferred to listen)', async () => { - const channelSpy = vi.spyOn(workerify['channel'], 'postMessage'); + // Clear any existing messages + mockChannel.lastMessages = []; workerify.get('/test', () => 'test'); // Should NOT have sent routes update message (deferred to listen) - expect(channelSpy).not.toHaveBeenCalled(); + const routeMessages = mockChannel.getRouteUpdateMessages(); + expect(routeMessages).toHaveLength(0); }); it('should update service worker routes on listen', async () => { - const channelSpy = vi.spyOn(workerify['channel'], 'postMessage'); - workerify.get('/test1', () => 'test1'); workerify.post('/test2', () => 'test2'); - channelSpy.mockClear(); + // Clear any existing messages + mockChannel.lastMessages = []; // Mock fetch for registration - global.fetch = vi.fn().mockResolvedValueOnce({ + mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ clientId: 'test-client-id' }), }); - setTimeout(() => { - const channel = workerify['channel'] as any; - if (channel && channel.listeners) { - channel.listeners.forEach((listener: any) => { - listener({ - data: { - type: 'workerify:routes:update:response', - consumerId: (workerify as any).consumerId, - }, - }); - }); - } - }, 10); - await workerify.listen(); // Should send routes update on listen - expect(channelSpy).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'workerify:routes:update', - consumerId: expect.any(String), - routes: expect.arrayContaining([ - expect.objectContaining({ path: '/test1', method: 'GET' }), - expect.objectContaining({ path: '/test2', method: 'POST' }), - ]), - }), - ); + const routeMessages = mockChannel.getRouteUpdateMessages(); + expect(routeMessages).toHaveLength(1); + expect(routeMessages[0]).toMatchObject({ + type: 'workerify:routes:update', + consumerId: expect.stringMatching(/.+/), + routes: expect.arrayContaining([ + expect.objectContaining({ path: '/test1', method: 'GET' }), + expect.objectContaining({ path: '/test2', method: 'POST' }), + ]), + }); }); }); }); diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index b9d6779..c67a8ff 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -69,11 +69,14 @@ export class Workerify { request.headers['content-type']?.includes( 'application/x-www-form-urlencoded', ) && - request.body + request.body && + (request.body instanceof ArrayBuffer || + ArrayBuffer.isView(request.body) || + request.body.constructor?.name === 'ArrayBuffer') ) { try { const decoder = new TextDecoder(); - const text = decoder.decode(request.body); + const text = decoder.decode(request.body as ArrayBuffer); const params = new URLSearchParams(text); const formData: Record = {}; @@ -81,8 +84,8 @@ export class Workerify { formData[key] = value; } - // Replace the ArrayBuffer body with parsed JSON - request.body = formData as any; + // Replace the ArrayBuffer body with parsed form data + request.body = formData; } catch (error) { if (this.options.logger) { console.error('[Workerify] Error parsing form data:', error); @@ -183,7 +186,8 @@ export class Workerify { // This is a parameter const paramName = routePart.slice(1); if (paramName && pathPart) { - params[paramName] = pathPart; + // Decode parameter value to handle Unicode characters + params[paramName] = decodeURIComponent(pathPart); } } else if (routePart !== pathPart) { // Static part doesn't match @@ -199,7 +203,8 @@ export class Workerify { url: string, ): { route: Route | null; params?: Record } { const urlObj = new URL(url); - const pathname = urlObj.pathname; + // Decode URI components to handle Unicode characters properly + const pathname = decodeURIComponent(urlObj.pathname); for (const route of this.routes) { // Check method match @@ -354,7 +359,7 @@ export class Workerify { if (this.options.logger) { console.log('[Workerify] Routes registered successfully'); } - resolve(message.body || false); + resolve(typeof message.body === 'boolean' ? message.body : false); } }; this.channel.postMessage({ @@ -374,7 +379,10 @@ export class Workerify { resolve(false); }, 1000); }); - console.log('[Workerify] Readiness', readiness); + + if (this.options.logger) { + console.log('[Workerify] Readiness', readiness); + } await new Promise((resolve) => { setTimeout(() => { @@ -458,7 +466,10 @@ export class Workerify { } } - async register(plugin: WorkerifyPlugin, options?: any): Promise { + async register( + plugin: WorkerifyPlugin, + options?: Record, + ): Promise { await plugin(this, options); if (this.options.logger) { console.log('[Workerify] Plugin registered'); diff --git a/packages/lib/src/types.ts b/packages/lib/src/types.ts index 9aef2f4..9deb77a 100644 --- a/packages/lib/src/types.ts +++ b/packages/lib/src/types.ts @@ -7,11 +7,15 @@ export type HttpMethod = | 'HEAD' | 'OPTIONS'; +// Shared body type for consistent typing across packages +export type WorkerifyBody = ArrayBuffer | string | null | object; +export type BodyType = 'json' | 'text' | 'arrayBuffer'; + export interface WorkerifyRequest { url: string; method: HttpMethod; headers: Record; - body?: ArrayBuffer | null; + body?: WorkerifyBody; params: Record; } @@ -19,14 +23,14 @@ export interface WorkerifyReply { status?: number; statusText?: string; headers?: Record; - body?: any; - bodyType?: 'json' | 'text' | 'arrayBuffer'; + body?: WorkerifyBody; + bodyType?: BodyType; } export type RouteHandler = ( request: WorkerifyRequest, reply: WorkerifyReply, -) => Promise | any; +) => Promise | WorkerifyBody | undefined; export interface Route { method?: HttpMethod; @@ -54,11 +58,11 @@ export interface BroadcastMessage { status?: number; statusText?: string; headers?: Record; - body?: any; - bodyType?: 'json' | 'text' | 'arrayBuffer'; + body?: WorkerifyBody; + bodyType?: BodyType; } export type WorkerifyPlugin = ( - instance: any, - options?: any, + instance: object, + options?: Record, ) => Promise | void; diff --git a/packages/lib/vitest.setup.ts b/packages/lib/vitest.setup.ts index 9b6deb6..cc6f67b 100644 --- a/packages/lib/vitest.setup.ts +++ b/packages/lib/vitest.setup.ts @@ -2,7 +2,7 @@ import { vi } from 'vitest'; // Global mocks global.fetch = vi.fn(); -global.location = { origin: 'http://localhost:3000' } as any; +global.location = { origin: 'http://localhost:3000' } as unknown as Location; // Mock BroadcastChannel class MockBroadcastChannel { @@ -53,9 +53,14 @@ beforeEach(() => { // Auto-acknowledge route updates to prevent timeouts setTimeout(() => { - const channels = (global as any).mockChannels || []; - channels.forEach((channel: any) => { - if (channel && channel.simulateMessage) { + const channels = + ( + global as { + mockChannels?: Array<{ simulateMessage?: (data: unknown) => void }>; + } + ).mockChannels || []; + channels.forEach((channel) => { + if (channel?.simulateMessage) { channel.simulateMessage({ type: 'workerify:routes:update:response', consumerId: 'test-consumer-id', diff --git a/packages/tests/.gitignore b/packages/tests/.gitignore new file mode 100644 index 0000000..924431c --- /dev/null +++ b/packages/tests/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +test-results/ +playwright-report/ +playwright/.cache/ +cucumber-report.html +.features-gen/ +*.log \ No newline at end of file diff --git a/packages/tests/features/htmx-app.feature b/packages/tests/features/htmx-app.feature new file mode 100644 index 0000000..db97f6e --- /dev/null +++ b/packages/tests/features/htmx-app.feature @@ -0,0 +1,94 @@ +Feature: Todo List Application + As a user + I want to manage my todo list + So that I can track my tasks and their completion status + + Background: + Given I open the todo application + + Scenario: Viewing the initial todo list + Then I should see 3 todos in the list + And I should see "Play with Htmx" with a checkmark + And I should see "Ship Workerify" with a checkmark + And I should see "Rewrite Hubpress with Htmx and Workerify" without a checkmark + And the footer should show "3 items left" + + Scenario: Adding a new todo + When I type "Buy groceries" in the todo input + And I press Enter + Then I should see 4 todos in the list + And "Buy groceries" should appear at the top of the list + And "Buy groceries" should not have a checkmark + And the input field should be empty + And the footer should show "4 items left" + + Scenario: Marking a todo as completed + When I click the checkbox for "Rewrite Hubpress with Htmx and Workerify" + Then "Rewrite Hubpress with Htmx and Workerify" should have a checkmark + And "Rewrite Hubpress with Htmx and Workerify" should have strikethrough text + And the footer should still show "3 items left" + + Scenario: Unmarking a completed todo + When I click the checkbox for "Play with Htmx" + Then "Play with Htmx" should not have a checkmark + And "Play with Htmx" should not have strikethrough text + And the footer should show "3 items left" + + Scenario: Using the Active filter + When I click on "Active" in the filter menu + Then I should see only 1 todo in the list + And I should see "Rewrite Hubpress with Htmx and Workerify" + And I should not see "Play with Htmx" + And I should not see "Ship Workerify" + And the footer should show "1 item left" + + Scenario: Using the Completed filter + When I click on "Completed" in the filter menu + Then I should see 2 todos in the list + And I should see "Play with Htmx" with a checkmark + And I should see "Ship Workerify" with a checkmark + And I should not see "Rewrite Hubpress with Htmx and Workerify" + And the footer should show "2 items left" + + Scenario: Switching back to All filter + Given I have clicked on "Active" filter + When I click on "All" in the filter menu + Then I should see 3 todos in the list + And I should see all three default todos + + Scenario: Adding and filtering todos + Given I add a new todo "Test the filters" + When I click on "Active" in the filter menu + Then I should see 2 todos in the list + And I should see "Test the filters" in the list + And I should see "Rewrite Hubpress with Htmx and Workerify" in the list + When I click the checkbox for "Test the filters" + Then I should see only 1 todo in the list + And I should not see "Test the filters" + When I click on "Completed" in the filter menu + Then I should see "Test the filters" with a checkmark + + Scenario: No persistence after page refresh + Given I add a new todo "This should disappear" + And I see 4 todos in the list + When I refresh the page + Then I should see 3 todos in the list + And I should not see "This should disappear" + And I should see the three default todos + + Scenario: Independent state in multiple tabs + Given I add a new todo "Tab 1 todo" in the current tab + When I open a new tab with the todo app + Then the new tab should show 3 todos + And the new tab should not show "Tab 1 todo" + When I add "Tab 2 todo" in the new tab + And I switch back to the first tab + Then the first tab should still show "Tab 1 todo" + And the first tab should not show "Tab 2 todo" + + Scenario: Footer count bug with completed todos + # Note: This documents the current buggy behavior where the count shows all items + # instead of just active items + Given the default 3 todos are loaded + Then the footer should show "3 items left" + # Even though 2 are completed, it still shows 3 \ No newline at end of file diff --git a/packages/tests/package.json b/packages/tests/package.json new file mode 100644 index 0000000..921cd49 --- /dev/null +++ b/packages/tests/package.json @@ -0,0 +1,27 @@ +{ + "name": "@workerify/tests", + "version": "1.0.0", + "private": true, + "description": "End-to-end tests for Workerify examples using Playwright-BDD", + "type": "module", + "scripts": { + "test": "pnpm build:example && bddgen && playwright test", + "test:ui": "pnpm build:example && bddgen && playwright test --ui", + "test:headed": "pnpm build:example && bddgen && playwright test --headed", + "test:debug": "pnpm build:example && bddgen && playwright test --debug", + "test:codegen": "playwright codegen", + "test:report": "playwright show-report", + "bddgen": "bddgen", + "build:example": "pnpm -F @workerify/example-htmx build", + "serve:example": "sirv ../examples/htmx/dist --port 3000 --host" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "@types/node": "^22.10.2", + "playwright-bdd": "^8.0.0", + "typescript": "^5.7.3" + }, + "dependencies": { + "sirv-cli": "^2.0.2" + } +} \ No newline at end of file diff --git a/packages/tests/playwright.config.ts b/packages/tests/playwright.config.ts new file mode 100644 index 0000000..e0be582 --- /dev/null +++ b/packages/tests/playwright.config.ts @@ -0,0 +1,51 @@ +import { defineConfig, devices } from '@playwright/test'; +import { defineBddConfig, cucumberReporter } from 'playwright-bdd'; + +const testDir = defineBddConfig({ + paths: ['features/**/*.feature'], + require: ['steps/**/*.ts'], + disableWarnings: { importTestFrom: true }, +}); + +export default defineConfig({ + testDir, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + ['html'], + cucumberReporter('html', { outputFile: 'cucumber-report.html' }), + ['list'], + ], + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + + webServer: { + command: 'pnpm serve:example', + port: 3000, + reuseExistingServer: !process.env.CI, + timeout: 30 * 1000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); \ No newline at end of file diff --git a/packages/tests/steps/fixtures.ts b/packages/tests/steps/fixtures.ts new file mode 100644 index 0000000..495b372 --- /dev/null +++ b/packages/tests/steps/fixtures.ts @@ -0,0 +1,5 @@ +import { test as base } from 'playwright-bdd'; + +export const test = base.extend({ + // Add any custom fixtures here if needed +}); \ No newline at end of file diff --git a/packages/tests/steps/htmx-app.steps.ts b/packages/tests/steps/htmx-app.steps.ts new file mode 100644 index 0000000..1a278f8 --- /dev/null +++ b/packages/tests/steps/htmx-app.steps.ts @@ -0,0 +1,244 @@ +import { createBdd } from 'playwright-bdd'; +import { expect } from '@playwright/test'; +import { test } from './fixtures'; + +const { Given, When, Then } = createBdd(test); + +// Background step +Given('I open the todo application', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle'); + // Wait for the todo list to be loaded via HTMX + await page.waitForSelector('#todos', { state: 'visible', timeout: 5000 }); +}); + +// Viewing todos +Then('I should see {int} todos in the list', async ({ page }, count: number) => { + const todoItems = page.locator('#todos .flex.items-start'); + await expect(todoItems).toHaveCount(count); +}); + +Then('I should see {int} todo in the list', async ({ page }, count: number) => { + const todoItems = page.locator('#todos .flex.items-start'); + await expect(todoItems).toHaveCount(count); +}); + +Then('I should see {string} with a checkmark', async ({ page }, todoText: string) => { + const todo = page.locator('#todos .flex.items-start').filter({ hasText: todoText }); + await expect(todo).toBeVisible(); + + // Check for completed state - line-through class + const textElement = todo.locator('.line-through'); + await expect(textElement).toBeVisible(); +}); + +Then('I should see {string} without a checkmark', async ({ page }, todoText: string) => { + const todo = page.locator('#todos .flex.items-start').filter({ hasText: todoText }); + await expect(todo).toBeVisible(); + + // Check text doesn't have line-through + const textElement = todo.locator('.ml-4'); + const hasLineThrough = await textElement.evaluate(el => + el.classList.contains('line-through') + ); + expect(hasLineThrough).toBeFalsy(); +}); + +Then('{string} should have a checkmark', async ({ page }, todoText: string) => { + const todo = page.locator('#todos .flex.items-start').filter({ hasText: todoText }); + const textElement = todo.locator('.line-through'); + await expect(textElement).toBeVisible(); +}); + +Then('{string} should not have a checkmark', async ({ page }, todoText: string) => { + const todo = page.locator('#todos .flex.items-start').filter({ hasText: todoText }); + const textElement = todo.locator('.ml-4'); + const hasLineThrough = await textElement.evaluate(el => + el.classList.contains('line-through') + ); + expect(hasLineThrough).toBeFalsy(); +}); + +Then('{string} should have strikethrough text', async ({ page }, todoText: string) => { + const todo = page.locator('#todos .flex.items-start').filter({ hasText: todoText }); + const textElement = todo.locator('.line-through'); + await expect(textElement).toBeVisible(); +}); + +Then('{string} should not have strikethrough text', async ({ page }, todoText: string) => { + const todo = page.locator('#todos .flex.items-start').filter({ hasText: todoText }); + const textElement = todo.locator('.ml-4'); + const hasLineThrough = await textElement.evaluate(el => + el.classList.contains('line-through') + ); + expect(hasLineThrough).toBeFalsy(); +}); + +Then('the footer should show {string}', async ({ page }, footerText: string) => { + const footer = page.locator('#footer'); + await expect(footer).toContainText(footerText); +}); + +Then('the footer should still show {string}', async ({ page }, footerText: string) => { + const footer = page.locator('#footer'); + await expect(footer).toContainText(footerText); +}); + +// Adding todos +When('I type {string} in the todo input', async ({ page }, todoText: string) => { + const input = page.locator('#todo-input'); + await input.fill(todoText); +}); + +When('I press Enter', async ({ page }) => { + await page.locator('#todo-input').press('Enter'); + // Wait for HTMX to process the request + await page.waitForTimeout(500); +}); + +Then('{string} should appear at the top of the list', async ({ page }, todoText: string) => { + const firstTodo = page.locator('#todos .flex.items-start').first(); + await expect(firstTodo).toContainText(todoText); +}); + +Then('the input field should be empty', async ({ page }) => { + const input = page.locator('#todo-input'); + await expect(input).toHaveValue(''); +}); + +// Toggling todos +When('I click the checkbox for {string}', async ({ page }, todoText: string) => { + const todo = page.locator('#todos .flex.items-start').filter({ hasText: todoText }); + const checkbox = todo.locator('.text-gray-500.stroke-current.cursor-pointer'); + await checkbox.click(); + // Wait for HTMX to process + await page.waitForTimeout(500); +}); + +// Filters +When('I click on {string} in the filter menu', async ({ page }, filterName: string) => { + const filterButton = page.locator('#footer li').filter({ hasText: filterName }); + await filterButton.click(); + // Wait for HTMX to update the list + await page.waitForTimeout(500); +}); + +Given('I have clicked on {string} filter', async ({ page }, filterName: string) => { + const filterButton = page.locator('#footer li').filter({ hasText: filterName }); + await filterButton.click(); + await page.waitForTimeout(500); +}); + +Then('I should see only {int} todo in the list', async ({ page }, count: number) => { + const todos = page.locator('#todos .flex.items-start'); + await expect(todos).toHaveCount(count); +}); + +Then('I should see {string}', async ({ page }, todoText: string) => { + const todo = page.locator('#todos .flex.items-start').filter({ hasText: todoText }); + await expect(todo).toBeVisible(); +}); + +Then('I should not see {string}', async ({ page }, todoText: string) => { + const todo = page.locator('#todos .flex.items-start').filter({ hasText: todoText }); + await expect(todo).toBeHidden(); +}); + +Then('I should see {string} in the list', async ({ page }, todoText: string) => { + const todo = page.locator('#todos .flex.items-start').filter({ hasText: todoText }); + await expect(todo).toBeVisible(); +}); + +Then('I should see all three default todos', async ({ page }) => { + await expect(page.locator('#todos .flex.items-start').filter({ hasText: 'Play with Htmx' })).toBeVisible(); + await expect(page.locator('#todos .flex.items-start').filter({ hasText: 'Ship Workerify' })).toBeVisible(); + await expect(page.locator('#todos .flex.items-start').filter({ hasText: 'Rewrite Hubpress with Htmx and Workerify' })).toBeVisible(); +}); + +Then('I should see the three default todos', async ({ page }) => { + await expect(page.locator('#todos .flex.items-start').filter({ hasText: 'Play with Htmx' })).toBeVisible(); + await expect(page.locator('#todos .flex.items-start').filter({ hasText: 'Ship Workerify' })).toBeVisible(); + await expect(page.locator('#todos .flex.items-start').filter({ hasText: 'Rewrite Hubpress with Htmx and Workerify' })).toBeVisible(); +}); + +// Complex scenarios +Given('I add a new todo {string}', async ({ page }, todoText: string) => { + const input = page.locator('#todo-input'); + await input.fill(todoText); + await input.press('Enter'); + await page.waitForTimeout(500); +}); + +Given('I add a new todo {string} in the current tab', async ({ page }, todoText: string) => { + const input = page.locator('#todo-input'); + await input.fill(todoText); + await input.press('Enter'); + await page.waitForTimeout(500); +}); + +Given('I see {int} todos in the list', async ({ page }, count: number) => { + const todos = page.locator('#todos .flex.items-start'); + await expect(todos).toHaveCount(count); +}); + +Given('the default {int} todos are loaded', async ({ page }, count: number) => { + const todos = page.locator('#todos .flex.items-start'); + await expect(todos).toHaveCount(count); +}); + +// Page refresh +When('I refresh the page', async ({ page }) => { + await page.reload(); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('#todos', { state: 'visible', timeout: 5000 }); +}); + +// Multi-tab scenarios +When('I open a new tab with the todo app', async ({ context }) => { + const newPage = await context.newPage(); + await newPage.goto('/'); + await newPage.waitForLoadState('networkidle'); + await newPage.waitForSelector('#todos', { state: 'visible', timeout: 5000 }); +}); + +Then('the new tab should show {int} todos', async ({ context }) => { + const pages = context.pages(); + const newTab = pages[pages.length - 1]; // Get the most recently opened tab + const todos = newTab.locator('#todos .flex.items-start'); + await expect(todos).toHaveCount(3); +}); + +Then('the new tab should not show {string}', async ({ context }, todoText: string) => { + const pages = context.pages(); + const newTab = pages[pages.length - 1]; + const todo = newTab.locator('#todos .flex.items-start').filter({ hasText: todoText }); + await expect(todo).toBeHidden(); +}); + +When('I add {string} in the new tab', async ({ context }, todoText: string) => { + const pages = context.pages(); + const newTab = pages[pages.length - 1]; + const input = newTab.locator('#todo-input'); + await input.fill(todoText); + await input.press('Enter'); + await newTab.waitForTimeout(500); +}); + +When('I switch back to the first tab', async ({ context }) => { + const pages = context.pages(); + await pages[0].bringToFront(); +}); + +Then('the first tab should still show {string}', async ({ context }, todoText: string) => { + const pages = context.pages(); + const firstTab = pages[0]; + const todo = firstTab.locator('#todos .flex.items-start').filter({ hasText: todoText }); + await expect(todo).toBeVisible(); +}); + +Then('the first tab should not show {string}', async ({ context }, todoText: string) => { + const pages = context.pages(); + const firstTab = pages[0]; + const todo = firstTab.locator('#todos .flex.items-start').filter({ hasText: todoText }); + await expect(todo).toBeHidden(); +}); \ No newline at end of file diff --git a/packages/tests/tsconfig.json b/packages/tests/tsconfig.json new file mode 100644 index 0000000..382b7e0 --- /dev/null +++ b/packages/tests/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "types": ["@playwright/test", "node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "test-results", "playwright-report"] +} \ No newline at end of file diff --git a/packages/vite-plugin/package.json b/packages/vite-plugin/package.json index 4309d9c..3ee724c 100644 --- a/packages/vite-plugin/package.json +++ b/packages/vite-plugin/package.json @@ -28,16 +28,17 @@ }, "scripts": { "prebuild": "node scripts/build-templates.js", - "build": "tsc", - "dev": "npm run prebuild && tsc --watch", + "build": "vite build && tsc --emitDeclarationOnly", + "dev": "npm run prebuild && vite build --watch", "clean": "rm -rf dist src/generated", "lint": "biome check .", "lint:fix": "biome check --fix .", - "typecheck": "tsc --noEmit", - "test": "vitest", - "test:watch": "vitest --watch", - "test:ui": "vitest --ui", - "test:coverage": "vitest --coverage" + "typecheck": "npm run prebuild && tsc --noEmit", + "pretest": "npm run prebuild", + "test": "vitest run", + "test:watch": "npm run prebuild && vitest --watch", + "test:ui": "npm run prebuild && vitest --ui", + "test:coverage": "npm run prebuild && vitest --coverage" }, "keywords": [ "service-worker", diff --git a/packages/vite-plugin/scripts/build-templates.js b/packages/vite-plugin/scripts/build-templates.js index c2f68b2..7723b32 100644 --- a/packages/vite-plugin/scripts/build-templates.js +++ b/packages/vite-plugin/scripts/build-templates.js @@ -1,7 +1,7 @@ -import { execSync } from 'child_process'; -import { readFileSync, writeFileSync } from 'fs'; -import { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); diff --git a/packages/vite-plugin/src/__tests__/multi-tab-service-worker.test.ts b/packages/vite-plugin/src/__tests__/multi-tab-service-worker.test.ts index a1b81e6..a9a8534 100644 --- a/packages/vite-plugin/src/__tests__/multi-tab-service-worker.test.ts +++ b/packages/vite-plugin/src/__tests__/multi-tab-service-worker.test.ts @@ -1,5 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +// Client types +interface ServiceWorkerClient { + id: string; + url?: string; + type?: string; + frameType?: string; +} + +// Service Worker API types +interface ClientMatchAllOptions { + includeUncontrolled?: boolean; +} + // Mock the global scope const mockClients = { matchAll: vi.fn(), @@ -12,7 +25,7 @@ const mockRegistration = { const mockBroadcastChannel = { postMessage: vi.fn(), - onmessage: null as any, + onmessage: null as ((event: MessageEvent) => void) | null, addEventListener: vi.fn(), close: vi.fn(), }; @@ -25,16 +38,18 @@ global.self = { addEventListener: vi.fn(), skipWaiting: vi.fn(), BroadcastChannel: vi.fn(() => mockBroadcastChannel), -} as any; - -global.BroadcastChannel = vi.fn(() => mockBroadcastChannel) as any; +} as unknown as ServiceWorkerGlobalScope; -// Import after mocks are set up -import serviceWorkerCode from '../templates/service-worker'; +global.BroadcastChannel = vi.fn( + () => mockBroadcastChannel, +) as unknown as typeof BroadcastChannel; describe('Service Worker Multi-tab Support', () => { let clientConsumerMap: Map; - let consumerRoutesMap: Map; + let consumerRoutesMap: Map< + string, + Array<{ method: string; path: string; handler: string }> + >; beforeEach(() => { vi.clearAllMocks(); @@ -45,7 +60,7 @@ describe('Service Worker Multi-tab Support', () => { describe('Client Registration', () => { it('should handle /__workerify/register endpoint', async () => { - const mockEvent = { + const _mockEvent = { request: { url: 'http://localhost:3000/__workerify/register', method: 'POST', @@ -153,7 +168,7 @@ describe('Service Worker Multi-tab Support', () => { it('should send acknowledgment for route updates', () => { const consumerId = 'consumer-ack-456'; - const message = { + const _message = { type: 'workerify:routes:update', consumerId, routes: [], @@ -190,7 +205,9 @@ describe('Service Worker Multi-tab Support', () => { }; const resolvedConsumerId = clientConsumerMap.get(mockEvent.clientId); - const routes = consumerRoutesMap.get(resolvedConsumerId!); + const routes = resolvedConsumerId + ? consumerRoutesMap.get(resolvedConsumerId) + : undefined; expect(resolvedConsumerId).toBe(consumerId); expect(routes).toBeDefined(); @@ -231,13 +248,17 @@ describe('Service Worker Multi-tab Support', () => { // Request from client1 should only match consumer1 routes const event1 = { clientId: client1 }; const consumer1Id = clientConsumerMap.get(event1.clientId); - const routes1 = consumerRoutesMap.get(consumer1Id!); + const routes1 = consumer1Id + ? consumerRoutesMap.get(consumer1Id) + : undefined; expect(routes1?.[0].path).toBe('/api/v1'); // Request from client2 should only match consumer2 routes const event2 = { clientId: client2 }; const consumer2Id = clientConsumerMap.get(event2.clientId); - const routes2 = consumerRoutesMap.get(consumer2Id!); + const routes2 = consumer2Id + ? consumerRoutesMap.get(consumer2Id) + : undefined; expect(routes2?.[0].path).toBe('/api/v2'); }); }); @@ -261,10 +282,12 @@ describe('Service Worker Multi-tab Support', () => { const allClients = await mockClients.matchAll({ includeUncontrolled: true, }); - const activeClientIds = new Set(allClients.map((c: any) => c.id)); + const activeClientIds = new Set( + allClients.map((c: ServiceWorkerClient) => c.id), + ); const toRemove: string[] = []; - clientConsumerMap.forEach((consumerId, clientId) => { + clientConsumerMap.forEach((_consumerId, clientId) => { if (!activeClientIds.has(clientId)) { toRemove.push(clientId); } @@ -295,7 +318,9 @@ describe('Service Worker Multi-tab Support', () => { const allClients = await mockClients.matchAll({ includeUncontrolled: true, }); - const activeClientIds = new Set(allClients.map((c: any) => c.id)); + const activeClientIds = new Set( + allClients.map((c: ServiceWorkerClient) => c.id), + ); const consumersToClean = new Set(); clientConsumerMap.forEach((consumerId, clientId) => { @@ -342,27 +367,31 @@ describe('Service Worker Multi-tab Support', () => { clientConsumerMap.set(client2, consumer2); // Mock matchAll with includeUncontrolled to return both clients - mockClients.matchAll.mockImplementation((options?: any) => { - if (options?.includeUncontrolled) { + mockClients.matchAll.mockImplementation( + (options?: ClientMatchAllOptions) => { + if (options?.includeUncontrolled) { + return Promise.resolve([ + { id: client1, url: 'http://localhost:3000/' }, + { id: client2, url: 'http://localhost:3000/' }, + ]); + } + // Without includeUncontrolled, only return controlled client return Promise.resolve([ { id: client1, url: 'http://localhost:3000/' }, - { id: client2, url: 'http://localhost:3000/' }, ]); - } - // Without includeUncontrolled, only return controlled client - return Promise.resolve([ - { id: client1, url: 'http://localhost:3000/' }, - ]); - }); + }, + ); // Simulate cleanup with includeUncontrolled: true const allClients = await mockClients.matchAll({ includeUncontrolled: true, }); - const activeClientIds = new Set(allClients.map((c: any) => c.id)); + const activeClientIds = new Set( + allClients.map((c: ServiceWorkerClient) => c.id), + ); const toRemove: string[] = []; - clientConsumerMap.forEach((consumerId, clientId) => { + clientConsumerMap.forEach((_consumerId, clientId) => { if (!activeClientIds.has(clientId)) { toRemove.push(clientId); } @@ -388,23 +417,25 @@ describe('Service Worker Multi-tab Support', () => { clientConsumerMap.set(uncontrolledClient, consumer2); // Mock matchAll behavior - mockClients.matchAll.mockImplementation((options?: any) => { - if (options?.includeUncontrolled) { + mockClients.matchAll.mockImplementation( + (options?: ClientMatchAllOptions) => { + if (options?.includeUncontrolled) { + return Promise.resolve([ + { id: controlledClient, url: 'http://localhost:3000/' }, + { id: uncontrolledClient, url: 'http://localhost:3000/' }, + ]); + } + // Without includeUncontrolled, only return controlled client return Promise.resolve([ { id: controlledClient, url: 'http://localhost:3000/' }, - { id: uncontrolledClient, url: 'http://localhost:3000/' }, ]); - } - // Without includeUncontrolled, only return controlled client - return Promise.resolve([ - { id: controlledClient, url: 'http://localhost:3000/' }, - ]); - }); + }, + ); // Test cleanup WITHOUT includeUncontrolled (would be problematic) const controlledClientsOnly = await mockClients.matchAll(); const controlledClientIds = new Set( - controlledClientsOnly.map((c: any) => c.id), + controlledClientsOnly.map((c: ServiceWorkerClient) => c.id), ); expect(controlledClientIds.has(controlledClient)).toBe(true); @@ -414,7 +445,9 @@ describe('Service Worker Multi-tab Support', () => { const allClients = await mockClients.matchAll({ includeUncontrolled: true, }); - const allClientIds = new Set(allClients.map((c: any) => c.id)); + const allClientIds = new Set( + allClients.map((c: ServiceWorkerClient) => c.id), + ); expect(allClientIds.has(controlledClient)).toBe(true); expect(allClientIds.has(uncontrolledClient)).toBe(true); // This prevents premature cleanup @@ -472,10 +505,12 @@ describe('Service Worker Multi-tab Support', () => { const allClients = await mockClients.matchAll({ includeUncontrolled: true, }); - const activeClientIds = new Set(allClients.map((c: any) => c.id)); + const activeClientIds = new Set( + allClients.map((c: ServiceWorkerClient) => c.id), + ); const toRemove: string[] = []; - clientConsumerMap.forEach((consumerId, clientId) => { + clientConsumerMap.forEach((_consumerId, clientId) => { if (!activeClientIds.has(clientId)) { toRemove.push(clientId); } @@ -516,7 +551,7 @@ describe('Service Worker Multi-tab Support', () => { const periodicCleanup = async () => { try { - const allClients = await mockClients.matchAll({ + const _allClients = await mockClients.matchAll({ includeUncontrolled: true, }); // ... cleanup logic @@ -542,7 +577,7 @@ describe('Service Worker Multi-tab Support', () => { const periodicCleanup = async () => { try { await mockClients.matchAll({ includeUncontrolled: true }); - } catch (error) { + } catch (_error) { // Silently handle error to continue periodic execution } }; diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 948fc4f..a0c3551 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -152,7 +152,7 @@ describe('Workerify Vite Plugin', () => { 'Content-Type', 'application/javascript', ); - expect(res.end).toHaveBeenCalledWith(expect.any(String)); + expect(res.end).toHaveBeenCalledWith(expect.stringMatching(/.+/)); }); it('should not serve SW at wrong base path', async () => { @@ -224,7 +224,7 @@ describe('Workerify Vite Plugin', () => { 'Content-Type', 'application/javascript', ); - expect(res.end).toHaveBeenCalledWith(expect.any(String)); + expect(res.end).toHaveBeenCalledWith(expect.stringMatching(/.+/)); }); it('should pass through non-SW requests', async () => { @@ -257,7 +257,7 @@ describe('Workerify Vite Plugin', () => { 'Content-Type', 'application/javascript', ); - expect(res.end).toHaveBeenCalledWith(expect.any(String)); + expect(res.end).toHaveBeenCalledWith(expect.stringMatching(/.+/)); }); it('should handle requests with query parameters', async () => { @@ -287,7 +287,7 @@ describe('Workerify Vite Plugin', () => { expect(mockContext.emitFile).toHaveBeenCalledWith({ type: 'asset', fileName: 'build-sw.js', - source: expect.any(String), + source: expect.stringMatching(/.+/), }); }); @@ -299,7 +299,7 @@ describe('Workerify Vite Plugin', () => { expect(mockContext.emitFile).toHaveBeenCalledWith({ type: 'asset', fileName: 'workerify-sw.js', - source: expect.any(String), + source: expect.stringMatching(/.+/), }); }); diff --git a/packages/vite-plugin/src/__tests__/service-worker.test.ts b/packages/vite-plugin/src/__tests__/service-worker.test.ts index 16bf4f4..44fc22d 100644 --- a/packages/vite-plugin/src/__tests__/service-worker.test.ts +++ b/packages/vite-plugin/src/__tests__/service-worker.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { SW_TEMPLATE } from '../generated/service-worker-template.js'; +import { SW_TEMPLATE } from '../generated/service-worker-template'; import { isValidJavaScript } from './test-utils.js'; describe('Service Worker Template', () => { @@ -141,8 +141,15 @@ describe('Service Worker Template', () => { describe('Security considerations', () => { it('should not contain hardcoded credentials', () => { - expect(SW_TEMPLATE).not.toMatch(/password|secret|key|token/i); - expect(SW_TEMPLATE).not.toMatch(/api[_-]?key/i); + expect(SW_TEMPLATE).not.toMatch( + /password\s*[:=]|secret\s*[:=]|api[_-]?key\s*[:=]|token\s*[:=]/i, + ); + expect(SW_TEMPLATE).not.toMatch( + /'[^']*(?:password|secret|api_?key|token)[^']*'/i, + ); + expect(SW_TEMPLATE).not.toMatch( + /"[^"]*(?:password|secret|api_?key|token)[^"]*"/i, + ); }); it('should not contain eval or Function constructor', () => { diff --git a/packages/vite-plugin/src/__tests__/template-compilation.test.ts b/packages/vite-plugin/src/__tests__/template-compilation.test.ts index 4cb2467..4671733 100644 --- a/packages/vite-plugin/src/__tests__/template-compilation.test.ts +++ b/packages/vite-plugin/src/__tests__/template-compilation.test.ts @@ -1,7 +1,7 @@ -import { exec } from 'child_process'; -import { promises as fs } from 'fs'; -import path from 'path'; -import { promisify } from 'util'; +import { exec } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; const execAsync = promisify(exec); @@ -32,7 +32,7 @@ describe('Template Compilation System', () => { try { const stats = await fs.stat(scriptPath); expect(stats.isFile()).toBe(true); - } catch (error) { + } catch (_error) { throw new Error(`build-templates.js script not found at ${scriptPath}`); } }); @@ -58,7 +58,7 @@ describe('Template Compilation System', () => { try { const stats = await fs.stat(templatePath); expect(stats.isFile()).toBe(true); - } catch (error) { + } catch (_error) { throw new Error( `service-worker.ts template not found at ${templatePath}`, ); @@ -71,7 +71,7 @@ describe('Template Compilation System', () => { try { const stats = await fs.stat(templatePath); expect(stats.isFile()).toBe(true); - } catch (error) { + } catch (_error) { throw new Error(`register.ts template not found at ${templatePath}`); } }); @@ -117,7 +117,7 @@ describe('Template Compilation System', () => { try { const stats = await fs.stat(outputPath); expect(stats.isFile()).toBe(true); - } catch (error) { + } catch (_error) { throw new Error( `Generated service-worker-template.ts not found at ${outputPath}`, ); @@ -130,7 +130,7 @@ describe('Template Compilation System', () => { try { const stats = await fs.stat(outputPath); expect(stats.isFile()).toBe(true); - } catch (error) { + } catch (_error) { throw new Error( `Generated register-template.ts not found at ${outputPath}`, ); @@ -175,7 +175,7 @@ describe('Template Compilation System', () => { try { const stats = await fs.stat(tsconfigPath); expect(stats.isFile()).toBe(true); - } catch (error) { + } catch (_error) { throw new Error(`tsconfig.templates.json not found at ${tsconfigPath}`); } @@ -296,7 +296,7 @@ describe('Template Compilation System', () => { }); // Should not contain TypeScript errors expect(result.stderr).not.toContain('error TS'); - } catch (error) { + } catch (_error) { // If it fails, at least check that generated files exist const swExists = await fs .stat(path.join(generatedDir, 'service-worker-template.ts')) diff --git a/packages/vite-plugin/src/__tests__/virtual-module.test.ts b/packages/vite-plugin/src/__tests__/virtual-module.test.ts index 4eb0cc7..8fd96eb 100644 --- a/packages/vite-plugin/src/__tests__/virtual-module.test.ts +++ b/packages/vite-plugin/src/__tests__/virtual-module.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { getRegisterModule } from '../generated/register-template.js'; +import { getRegisterModule } from '../generated/register-template'; import { isValidJavaScript } from './test-utils.js'; describe('Virtual Module - Workerify Register', () => { diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index e021c97..4c4c875 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -1,12 +1,18 @@ -import { getRegisterModule } from './generated/register-template.js'; -import { SW_TEMPLATE } from './generated/service-worker-template.js'; +import { getRegisterModule } from './generated/register-template'; +import { SW_TEMPLATE } from './generated/service-worker-template'; // Plugin interface definition to avoid vite dependency interface Plugin { name: string; enforce?: 'pre' | 'post'; - configResolved?: (config: any) => void; - configureServer?: (server: any) => void; + configResolved?: (config: { base?: string }) => void; + configureServer?: (server: { + middlewares: { + use: ( + middleware: (req: unknown, res: unknown, next: () => void) => void, + ) => void; + }; + }) => void; generateBundle?: (this: PluginContext) => void; resolveId?: (id: string) => string | null; load?: (id: string) => string | null; @@ -26,7 +32,7 @@ export default function workerifyPlugin( ): Plugin { const scope = (opts.scope ?? '/').endsWith('/') ? (opts.scope ?? '/') - : (opts.scope ?? '') + '/'; + : `${opts.scope ?? ''}/`; const swFileName = opts.swFileName ?? 'workerify-sw.js'; // keep content in memory, no need for file on disk @@ -36,30 +42,41 @@ export default function workerifyPlugin( let viteBasePath = '/'; // Final public SW URL will be calculated with the base path - let publicSwUrl = '/' + swFileName.replace(/^\//, ''); + let publicSwUrl = `/${swFileName.replace(/^\//, '')}`; return { name: 'vite:workerify', enforce: 'pre', // Hook into Vite config to get base path - configResolved(config: any) { + configResolved(config: { base?: string }) { // Get the base path from Vite config (defaults to '/') viteBasePath = config.base || '/'; // Ensure base path ends with '/' if (!viteBasePath.endsWith('/')) { - viteBasePath = viteBasePath + '/'; + viteBasePath = `${viteBasePath}/`; } // Update the public SW URL with the base path publicSwUrl = viteBasePath + swFileName.replace(/^\//, ''); }, // Dev server: serve the SW file from memory - configureServer(server: any) { - server.middlewares.use((req: any, res: any, next: any) => { - if (req.url === publicSwUrl) { - res.setHeader('Content-Type', 'application/javascript'); - res.end(swSource); + configureServer(server: { + middlewares: { + use: ( + middleware: (req: unknown, res: unknown, next: () => void) => void, + ) => void; + }; + }) { + server.middlewares.use((req: unknown, res: unknown, next: () => void) => { + const typedReq = req as { url?: string }; + const typedRes = res as { + setHeader: (name: string, value: string) => void; + end: (data: string) => void; + }; + if (typedReq.url === publicSwUrl) { + typedRes.setHeader('Content-Type', 'application/javascript'); + typedRes.end(swSource); return; } next(); diff --git a/packages/vite-plugin/src/templates/service-worker.ts b/packages/vite-plugin/src/templates/service-worker.ts index 8a457cb..21d0e8e 100644 --- a/packages/vite-plugin/src/templates/service-worker.ts +++ b/packages/vite-plugin/src/templates/service-worker.ts @@ -1,4 +1,21 @@ // === Workerify SW === +// Body types (matching lib package) +type WorkerifyBody = ArrayBuffer | string | null | object; +type BodyType = 'json' | 'text' | 'arrayBuffer'; + +// HTTP response types +interface ResponseData { + status?: number; + statusText?: string; + headers?: Record; + body?: WorkerifyBody; + bodyType?: BodyType; +} + +interface WorkerifyResponse extends ResponseData { + type: string; + id?: string; +} console.log('[Workerify SW] Service worker script loaded'); console.log('[Workerify SW] Current location:', self.location.href); console.log('[Workerify SW] Scope:', self.registration?.scope); @@ -85,14 +102,14 @@ async function loadState() { // Restore Maps from arrays clientConsumerMap.clear(); - clientConsumerArray.forEach(([key, value]) => - clientConsumerMap.set(key, value), - ); + clientConsumerArray.forEach(([key, value]) => { + clientConsumerMap.set(key, value); + }); consumerRoutesMap.clear(); - consumerRoutesArray.forEach(([key, value]) => - consumerRoutesMap.set(key, value), - ); + consumerRoutesArray.forEach(([key, value]) => { + consumerRoutesMap.set(key, value); + }); db.close(); console.log('[Workerify SW] State loaded from IndexedDB'); @@ -178,7 +195,7 @@ CHANNEL.onmessage = (ev) => { ), ); }) - .catch((error: any) => { + .catch((error: unknown) => { console.error('[Workerify SW] Error listing clients:', error); }); } @@ -304,7 +321,10 @@ function matchRoute( return null; } -const pending = new Map void }>(); +const pending = new Map< + string, + { resolve: (value: WorkerifyResponse) => void } +>(); CHANNEL.addEventListener('message', (ev) => { const m = ev.data; @@ -345,7 +365,10 @@ self.addEventListener('fetch', (event: FetchEvent) => { } // Get the consumer ID for this client - const clientId = event.clientId || (event as any).resultingClientId; + const clientId = + event.clientId || + (event as FetchEvent & { resultingClientId?: string }) + .resultingClientId; const consumerId = clientId ? clientConsumerMap.get(clientId) : undefined; if (!consumerId) { @@ -370,7 +393,9 @@ async function handleRegistration(event: FetchEvent): Promise { try { const data = await event.request.json(); const { consumerId } = data; - const clientId = event.clientId || (event as any).resultingClientId; + const clientId = + event.clientId || + (event as FetchEvent & { resultingClientId?: string }).resultingClientId; if (!clientId || !consumerId) { return new Response(JSON.stringify({ error: 'Invalid registration' }), { @@ -442,8 +467,9 @@ async function handle( const req = event.request; const id = Math.random().toString(36).slice(2); const headers: Record = {}; - // biome: ignore - req.headers.forEach((v, k) => (headers[k] = v)); + req.headers.forEach((v, k) => { + headers[k] = v; + }); const body = req.method === 'GET' || req.method === 'HEAD' ? null @@ -462,7 +488,7 @@ async function handle( }, }); - const resp: any = await p; + const resp = (await p) as WorkerifyResponse; const h = new Headers(resp.headers || {}); const init: ResponseInit = { @@ -472,7 +498,7 @@ async function handle( }; if (resp.body && resp.bodyType === 'arrayBuffer') { - return new Response(resp.body, init); + return new Response(resp.body as ArrayBuffer, init); } if (resp.body && resp.bodyType === 'json') { @@ -480,7 +506,14 @@ async function handle( return new Response(JSON.stringify(resp.body), init); } - return new Response(resp.body ?? '', init); + return new Response( + typeof resp.body === 'string' + ? resp.body + : resp.body + ? JSON.stringify(resp.body) + : '', + init, + ); } // === End Workerify SW === diff --git a/packages/vite-plugin/vite.config.ts b/packages/vite-plugin/vite.config.ts new file mode 100644 index 0000000..56990a5 --- /dev/null +++ b/packages/vite-plugin/vite.config.ts @@ -0,0 +1,27 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'WorkerifyVitePlugin', + fileName: 'index', + formats: ['es'], + }, + rollupOptions: { + external: ['vite'], + output: { + globals: { + vite: 'vite', + }, + }, + }, + outDir: 'dist', + sourcemap: true, + emptyOutDir: true, + }, + esbuild: { + target: 'es2022', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22a35ec..f6114c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: ^6.0.1 version: 6.0.1 turbo: - specifier: ^2.5.6 - version: 2.5.6 + specifier: ^2.5.8 + version: 2.5.8 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -121,6 +121,25 @@ importers: specifier: 3.2.4 version: 3.2.4(@types/node@24.5.2)(@vitest/ui@3.2.4)(jiti@2.6.0)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1) + packages/tests: + dependencies: + sirv-cli: + specifier: ^2.0.2 + version: 2.0.2 + devDependencies: + '@playwright/test': + specifier: ^1.49.0 + version: 1.55.1 + '@types/node': + specifier: ^22.10.2 + version: 22.18.6 + playwright-bdd: + specifier: ^8.0.0 + version: 8.4.1(@playwright/test@1.55.1) + typescript: + specifier: ^5.7.3 + version: 5.9.2 + packages/vite-plugin: dependencies: vite: @@ -242,6 +261,10 @@ packages: '@clack/prompts@0.11.0': resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + '@commitlint/cli@19.8.1': resolution: {integrity: sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==} engines: {node: '>=v18'} @@ -345,6 +368,43 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@cucumber/cucumber-expressions@18.0.1': + resolution: {integrity: sha512-NSid6bI+7UlgMywl5octojY5NXnxR9uq+JisjOrO52VbFsQM6gTWuQFE8syI10KnIBEdPzuEUSVEeZ0VFzRnZA==} + + '@cucumber/gherkin-utils@9.2.0': + resolution: {integrity: sha512-3nmRbG1bUAZP3fAaUBNmqWO0z0OSkykZZotfLjyhc8KWwDSOrOmMJlBTd474lpA8EWh4JFLAX3iXgynBqBvKzw==} + hasBin: true + + '@cucumber/gherkin@31.0.0': + resolution: {integrity: sha512-wlZfdPif7JpBWJdqvHk1Mkr21L5vl4EfxVUOS4JinWGf3FLRV6IKUekBv5bb5VX79fkDcfDvESzcQ8WQc07Wgw==} + + '@cucumber/gherkin@32.2.0': + resolution: {integrity: sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==} + + '@cucumber/html-formatter@21.15.1': + resolution: {integrity: sha512-tjxEpP161sQ7xc3VREc94v1ymwIckR3ySViy7lTvfi1jUpyqy2Hd/p4oE3YT1kQ9fFDvUflPwu5ugK5mA7BQLA==} + peerDependencies: + '@cucumber/messages': '>=18' + + '@cucumber/junit-xml-formatter@0.7.1': + resolution: {integrity: sha512-AzhX+xFE/3zfoYeqkT7DNq68wAQfBcx4Dk9qS/ocXM2v5tBv6eFQ+w8zaSfsktCjYzu4oYRH/jh4USD1CYHfaQ==} + peerDependencies: + '@cucumber/messages': '*' + + '@cucumber/messages@26.0.1': + resolution: {integrity: sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==} + + '@cucumber/messages@27.2.0': + resolution: {integrity: sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==} + + '@cucumber/query@13.6.0': + resolution: {integrity: sha512-tiDneuD5MoWsJ9VKPBmQok31mSX9Ybl+U4wqDoXeZgsXHDURqzM3rnpWVV3bC34y9W6vuFxrlwF/m7HdOxwqRw==} + peerDependencies: + '@cucumber/messages': '*' + + '@cucumber/tag-expressions@6.2.0': + resolution: {integrity: sha512-KIF0eLcafHbWOuSDWFw0lMmgJOLdDRWjEL1kfXEWrqHmx2119HxVAr35WuEd9z542d3Yyg+XNqSr+81rIKqEdg==} + '@esbuild/aix-ppc64@0.25.10': resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} engines: {node: '>=18'} @@ -537,10 +597,27 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.55.1': + resolution: {integrity: sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -744,6 +821,10 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@teppeis/multimaps@3.0.0': + resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==} + engines: {node: '>=14'} + '@types/chai@5.2.2': resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} @@ -765,6 +846,9 @@ packages: '@types/serviceworker@0.0.153': resolution: {integrity: sha512-/cg6dFEkNchJLyRCGo4Gb8mF200qr3xskM5dCPgbtK0OzXxcFcXa6BEBdyG7JksRsTrvCR+V6aFPncoOYAwYhQ==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@vitest/coverage-v8@3.2.4': resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} peerDependencies: @@ -857,6 +941,13 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -884,6 +975,13 @@ packages: citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -895,6 +993,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} @@ -905,6 +1007,10 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + console-clear@1.1.1: + resolution: {integrity: sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==} + engines: {node: '>=4'} + conventional-changelog-angular@7.0.0: resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==} engines: {node: '>=16'} @@ -1031,9 +1137,16 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1046,6 +1159,10 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + find-up@7.0.0: resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} engines: {node: '>=18'} @@ -1057,6 +1174,11 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1066,6 +1188,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-port@3.2.0: + resolution: {integrity: sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==} + engines: {node: '>=4'} + giget@2.0.0: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true @@ -1075,6 +1201,10 @@ packages: engines: {node: '>=16'} hasBin: true + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true @@ -1136,10 +1266,22 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-obj@2.0.0: resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} engines: {node: '>=8'} @@ -1210,6 +1352,10 @@ packages: resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} engines: {'0': node >= 0.2.0} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -1277,6 +1423,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + local-access@1.1.0: + resolution: {integrity: sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==} + engines: {node: '>=6'} + locate-path@7.2.0: resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1299,6 +1449,9 @@ packages: lodash.snakecase@4.1.1: resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} @@ -1318,6 +1471,14 @@ packages: resolution: {integrity: sha512-r8LA6i4LP4EeWOhqBaZZjDWwehd1xUJPCJd9Sv300H0ZmcUER4+JPh7bqqZeqs1o5pgtgvXm+d9UGrB5zZGDiQ==} engines: {node: 20 || >=22} + lru-cache@11.2.2: + resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} + engines: {node: 20 || >=22} + + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} @@ -1335,6 +1496,22 @@ packages: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + minimatch@10.0.3: resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} engines: {node: 20 || >=22} @@ -1359,6 +1536,10 @@ packages: engines: {node: '>=10'} hasBin: true + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -1427,6 +1608,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} @@ -1434,6 +1619,23 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-bdd@8.4.1: + resolution: {integrity: sha512-2KM6yHKjpfCKVv0j8lhJkSLbhgfX2yTZLPM+Q9WnnBk/1oa3bmXaHoyokX5Sby2NU/culwC6pErdQAmVfxFnAQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@playwright/test': '>=1.44' + + playwright-core@1.55.1: + resolution: {integrity: sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.55.1: + resolution: {integrity: sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1442,6 +1644,19 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + regexp-match-indices@1.0.2: + resolution: {integrity: sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -1458,6 +1673,10 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@6.0.1: resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} engines: {node: 20 || >=22} @@ -1471,6 +1690,13 @@ packages: rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1478,6 +1704,10 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} + semiver@1.1.0: + resolution: {integrity: sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==} + engines: {node: '>=6'} + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -1498,6 +1728,15 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv-cli@2.0.2: + resolution: {integrity: sha512-OtSJDwxsF1NWHc7ps3Sa0s+dPtP15iQNJzfKVz+MxkEo3z72mCD+yu30ct79rPr0CaV1HXSOBp+MIY5uIhHZ1A==} + engines: {node: '>= 10'} + hasBin: true + + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -1509,6 +1748,13 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -1535,8 +1781,8 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} - strip-literal@3.0.0: - resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} @@ -1570,6 +1816,10 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinydate@1.3.0: + resolution: {integrity: sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==} + engines: {node: '>=4'} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -1599,6 +1849,10 @@ packages: resolution: {integrity: sha512-heYRCiGLhtI+U/D0V8YM3QRwPfsLJiP+HX+YwiHZTnWzjIKC+ZCxQRYlzvOoTEc6KIP62B1VeAN63diGCng2hg==} hasBin: true + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -1611,38 +1865,38 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} - turbo-darwin-64@2.5.6: - resolution: {integrity: sha512-3C1xEdo4aFwMJAPvtlPqz1Sw/+cddWIOmsalHFMrsqqydcptwBfu26WW2cDm3u93bUzMbBJ8k3zNKFqxJ9ei2A==} + turbo-darwin-64@2.5.8: + resolution: {integrity: sha512-Dh5bCACiHO8rUXZLpKw+m3FiHtAp2CkanSyJre+SInEvEr5kIxjGvCK/8MFX8SFRjQuhjtvpIvYYZJB4AGCxNQ==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.5.6: - resolution: {integrity: sha512-LyiG+rD7JhMfYwLqB6k3LZQtYn8CQQUePbpA8mF/hMLPAekXdJo1g0bUPw8RZLwQXUIU/3BU7tXENvhSGz5DPA==} + turbo-darwin-arm64@2.5.8: + resolution: {integrity: sha512-f1H/tQC9px7+hmXn6Kx/w8Jd/FneIUnvLlcI/7RGHunxfOkKJKvsoiNzySkoHQ8uq1pJnhJ0xNGTlYM48ZaJOQ==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.5.6: - resolution: {integrity: sha512-GOcUTT0xiT/pSnHL4YD6Yr3HreUhU8pUcGqcI2ksIF9b2/r/kRHwGFcsHgpG3+vtZF/kwsP0MV8FTlTObxsYIA==} + turbo-linux-64@2.5.8: + resolution: {integrity: sha512-hMyvc7w7yadBlZBGl/bnR6O+dJTx3XkTeyTTH4zEjERO6ChEs0SrN8jTFj1lueNXKIHh1SnALmy6VctKMGnWfw==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.5.6: - resolution: {integrity: sha512-10Tm15bruJEA3m0V7iZcnQBpObGBcOgUcO+sY7/2vk1bweW34LMhkWi8svjV9iDF68+KJDThnYDlYE/bc7/zzQ==} + turbo-linux-arm64@2.5.8: + resolution: {integrity: sha512-LQELGa7bAqV2f+3rTMRPnj5G/OHAe2U+0N9BwsZvfMvHSUbsQ3bBMWdSQaYNicok7wOZcHjz2TkESn1hYK6xIQ==} cpu: [arm64] os: [linux] - turbo-windows-64@2.5.6: - resolution: {integrity: sha512-FyRsVpgaj76It0ludwZsNN40ytHN+17E4PFJyeliBEbxrGTc5BexlXVpufB7XlAaoaZVxbS6KT8RofLfDRyEPg==} + turbo-windows-64@2.5.8: + resolution: {integrity: sha512-3YdcaW34TrN1AWwqgYL9gUqmZsMT4T7g8Y5Azz+uwwEJW+4sgcJkIi9pYFyU4ZBSjBvkfuPZkGgfStir5BBDJQ==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.5.6: - resolution: {integrity: sha512-j/tWu8cMeQ7HPpKri6jvKtyXg9K1gRyhdK4tKrrchH8GNHscPX/F71zax58yYtLRWTiK04zNzPcUJuoS0+v/+Q==} + turbo-windows-arm64@2.5.8: + resolution: {integrity: sha512-eFC5XzLmgXJfnAK3UMTmVECCwuBcORrWdewoiXBnUm934DY6QN8YowC/srhNnROMpaKaqNeRpoB5FxCww3eteQ==} cpu: [arm64] os: [win32] - turbo@2.5.6: - resolution: {integrity: sha512-gxToHmi9oTBNB05UjUsrWf0OyN5ZXtD0apOarC1KIx232Vp3WimRNy3810QzeNSgyD5rsaIDXlxlbnOzlouo+w==} + turbo@2.5.8: + resolution: {integrity: sha512-5c9Fdsr9qfpT3hA0EyYSFRZj1dVVsb6KIWubA9JBYZ/9ZEAijgUEae0BBR/Xl/wekt4w65/lYLTFaP3JmwSO8w==} hasBin: true typescript@5.8.3: @@ -1665,6 +1919,14 @@ packages: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + uuid@11.0.5: + resolution: {integrity: sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==} + hasBin: true + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1792,6 +2054,10 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -1906,6 +2172,9 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 + '@colors/colors@1.5.0': + optional: true + '@commitlint/cli@19.8.1(@types/node@24.5.2)(typescript@5.9.2)': dependencies: '@commitlint/format': 19.8.1 @@ -2040,6 +2309,60 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@cucumber/cucumber-expressions@18.0.1': + dependencies: + regexp-match-indices: 1.0.2 + + '@cucumber/gherkin-utils@9.2.0': + dependencies: + '@cucumber/gherkin': 31.0.0 + '@cucumber/messages': 27.2.0 + '@teppeis/multimaps': 3.0.0 + commander: 13.1.0 + source-map-support: 0.5.21 + + '@cucumber/gherkin@31.0.0': + dependencies: + '@cucumber/messages': 26.0.1 + + '@cucumber/gherkin@32.2.0': + dependencies: + '@cucumber/messages': 27.2.0 + + '@cucumber/html-formatter@21.15.1(@cucumber/messages@27.2.0)': + dependencies: + '@cucumber/messages': 27.2.0 + + '@cucumber/junit-xml-formatter@0.7.1(@cucumber/messages@27.2.0)': + dependencies: + '@cucumber/messages': 27.2.0 + '@cucumber/query': 13.6.0(@cucumber/messages@27.2.0) + '@teppeis/multimaps': 3.0.0 + luxon: 3.7.2 + xmlbuilder: 15.1.1 + + '@cucumber/messages@26.0.1': + dependencies: + '@types/uuid': 10.0.0 + class-transformer: 0.5.1 + reflect-metadata: 0.2.2 + uuid: 10.0.0 + + '@cucumber/messages@27.2.0': + dependencies: + '@types/uuid': 10.0.0 + class-transformer: 0.5.1 + reflect-metadata: 0.2.2 + uuid: 11.0.5 + + '@cucumber/query@13.6.0(@cucumber/messages@27.2.0)': + dependencies: + '@cucumber/messages': 27.2.0 + '@teppeis/multimaps': 3.0.0 + lodash.sortby: 4.7.0 + + '@cucumber/tag-expressions@6.2.0': {} + '@esbuild/aix-ppc64@0.25.10': optional: true @@ -2158,9 +2481,25 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.55.1': + dependencies: + playwright: 1.55.1 + '@polka/url@1.0.0-next.29': {} '@rollup/rollup-android-arm-eabi@4.52.0': @@ -2300,6 +2639,8 @@ snapshots: tailwindcss: 4.1.13 vite: 7.1.7(@types/node@24.5.2)(jiti@2.6.0)(lightningcss@1.30.1) + '@teppeis/multimaps@3.0.0': {} + '@types/chai@5.2.2': dependencies: '@types/deep-eql': 4.0.2 @@ -2322,6 +2663,8 @@ snapshots: '@types/serviceworker@0.0.153': {} + '@types/uuid@10.0.0': {} + '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': dependencies: '@ampproject/remapping': 2.3.0 @@ -2365,7 +2708,7 @@ snapshots: dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 - strip-literal: 3.0.0 + strip-literal: 3.1.0 '@vitest/snapshot@3.2.4': dependencies: @@ -2440,6 +2783,12 @@ snapshots: dependencies: balanced-match: 1.0.2 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer-from@1.1.2: {} + cac@6.7.14: {} callsites@3.1.0: {} @@ -2462,6 +2811,14 @@ snapshots: dependencies: consola: 3.4.2 + class-transformer@0.5.1: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -2474,6 +2831,8 @@ snapshots: color-name@1.1.4: {} + commander@13.1.0: {} + compare-func@2.0.0: dependencies: array-ify: 1.0.0 @@ -2483,6 +2842,8 @@ snapshots: consola@3.4.2: {} + console-clear@1.1.1: {} + conventional-changelog-angular@7.0.0: dependencies: compare-func: 2.0.0 @@ -2618,14 +2979,30 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-uri@3.1.0: {} + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 fflate@0.8.2: {} + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + find-up@7.0.0: dependencies: locate-path: 7.2.0 @@ -2639,11 +3016,16 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true get-caller-file@2.0.5: {} + get-port@3.2.0: {} + giget@2.0.0: dependencies: citty: 0.1.6 @@ -2659,6 +3041,10 @@ snapshots: meow: 12.1.1 split2: 4.2.0 + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob@10.4.5: dependencies: foreground-child: 3.3.1 @@ -2724,8 +3110,16 @@ snapshots: is-arrayish@0.2.1: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + is-obj@2.0.0: {} is-potential-custom-element-name@1.0.1: {} @@ -2811,6 +3205,8 @@ snapshots: jsonparse@1.3.1: {} + kleur@4.1.5: {} + lightningcss-darwin-arm64@1.30.1: optional: true @@ -2858,6 +3254,8 @@ snapshots: lines-and-columns@1.2.4: {} + local-access@1.1.0: {} + locate-path@7.2.0: dependencies: p-locate: 6.0.0 @@ -2874,6 +3272,8 @@ snapshots: lodash.snakecase@4.1.1: {} + lodash.sortby@4.7.0: {} + lodash.startcase@4.4.0: {} lodash.uniq@4.5.0: {} @@ -2886,6 +3286,10 @@ snapshots: lru-cache@11.2.1: {} + lru-cache@11.2.2: {} + + luxon@3.7.2: {} + magic-string@0.30.19: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2904,6 +3308,19 @@ snapshots: meow@12.1.1: {} + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.54.0: {} + + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + minimatch@10.0.3: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -2922,6 +3339,8 @@ snapshots: mkdirp@3.0.1: {} + mri@1.2.0: {} + mrmime@2.0.1: {} ms@2.1.3: {} @@ -2974,7 +3393,7 @@ snapshots: path-scurry@2.0.0: dependencies: - lru-cache: 11.2.1 + lru-cache: 11.2.2 minipass: 7.1.2 pathe@2.0.3: {} @@ -2983,6 +3402,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.1: {} + picomatch@4.0.3: {} pkg-types@2.3.0: @@ -2991,6 +3412,30 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + playwright-bdd@8.4.1(@playwright/test@1.55.1): + dependencies: + '@cucumber/cucumber-expressions': 18.0.1 + '@cucumber/gherkin': 32.2.0 + '@cucumber/gherkin-utils': 9.2.0 + '@cucumber/html-formatter': 21.15.1(@cucumber/messages@27.2.0) + '@cucumber/junit-xml-formatter': 0.7.1(@cucumber/messages@27.2.0) + '@cucumber/messages': 27.2.0 + '@cucumber/tag-expressions': 6.2.0 + '@playwright/test': 1.55.1 + cli-table3: 0.6.5 + commander: 13.1.0 + fast-glob: 3.3.3 + mime-types: 3.0.1 + xmlbuilder: 15.1.1 + + playwright-core@1.55.1: {} + + playwright@1.55.1: + dependencies: + playwright-core: 1.55.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2999,6 +3444,16 @@ snapshots: punycode@2.3.1: {} + queue-microtask@1.2.3: {} + + reflect-metadata@0.2.2: {} + + regexp-match-indices@1.0.2: + dependencies: + regexp-tree: 0.1.27 + + regexp-tree@0.1.27: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -3007,6 +3462,8 @@ snapshots: resolve-from@5.0.0: {} + reusify@1.1.0: {} + rimraf@6.0.1: dependencies: glob: 11.0.3 @@ -3042,12 +3499,22 @@ snapshots: rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + safer-buffer@2.1.2: {} saxes@6.0.0: dependencies: xmlchars: 2.2.0 + semiver@1.1.0: {} + semver@7.7.2: {} shebang-command@2.0.0: @@ -3060,6 +3527,23 @@ snapshots: signal-exit@4.1.0: {} + sirv-cli@2.0.2: + dependencies: + console-clear: 1.1.1 + get-port: 3.2.0 + kleur: 4.1.5 + local-access: 1.1.0 + sade: 1.8.1 + semiver: 1.1.0 + sirv: 2.0.4 + tinydate: 1.3.0 + + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -3070,6 +3554,13 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + split2@4.2.0: {} stackback@0.0.2: {} @@ -3096,7 +3587,7 @@ snapshots: dependencies: ansi-regex: 6.2.2 - strip-literal@3.0.0: + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 @@ -3131,6 +3622,8 @@ snapshots: tinybench@2.9.0: {} + tinydate@1.3.0: {} + tinyexec@0.3.2: {} tinyexec@1.0.1: {} @@ -3152,6 +3645,10 @@ snapshots: dependencies: tldts-core: 7.0.15 + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + totalist@3.0.1: {} tough-cookie@6.0.0: @@ -3162,32 +3659,32 @@ snapshots: dependencies: punycode: 2.3.1 - turbo-darwin-64@2.5.6: + turbo-darwin-64@2.5.8: optional: true - turbo-darwin-arm64@2.5.6: + turbo-darwin-arm64@2.5.8: optional: true - turbo-linux-64@2.5.6: + turbo-linux-64@2.5.8: optional: true - turbo-linux-arm64@2.5.6: + turbo-linux-arm64@2.5.8: optional: true - turbo-windows-64@2.5.6: + turbo-windows-64@2.5.8: optional: true - turbo-windows-arm64@2.5.6: + turbo-windows-arm64@2.5.8: optional: true - turbo@2.5.6: + turbo@2.5.8: optionalDependencies: - turbo-darwin-64: 2.5.6 - turbo-darwin-arm64: 2.5.6 - turbo-linux-64: 2.5.6 - turbo-linux-arm64: 2.5.6 - turbo-windows-64: 2.5.6 - turbo-windows-arm64: 2.5.6 + turbo-darwin-64: 2.5.8 + turbo-darwin-arm64: 2.5.8 + turbo-linux-64: 2.5.8 + turbo-linux-arm64: 2.5.8 + turbo-windows-64: 2.5.8 + turbo-windows-arm64: 2.5.8 typescript@5.8.3: {} @@ -3199,6 +3696,10 @@ snapshots: unicorn-magic@0.1.0: {} + uuid@10.0.0: {} + + uuid@11.0.5: {} + vite-node@3.2.4(@types/node@24.5.2)(jiti@2.6.0)(lightningcss@1.30.1): dependencies: cac: 6.7.14 @@ -3319,6 +3820,8 @@ snapshots: xml-name-validator@5.0.0: {} + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} y18n@5.0.8: {}