diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..3052f872 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,237 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_ENV: test + SERVER_MODE: standalone + DATABASE_URL: postgresql://brainbox:brainbox@localhost:5432/brainbox + POSTGRES_URL: postgresql://brainbox:brainbox@localhost:5432/brainbox + REDIS_URL: redis://localhost:6379/0 + STORAGE_S3_ENDPOINT: http://localhost:9000 + STORAGE_S3_ACCESS_KEY: minioadmin + STORAGE_S3_SECRET_KEY: minioadmin + STORAGE_S3_BUCKET: brainbox + STORAGE_S3_REGION: us-east-1 + JWT_SECRET: test-jwt-secret + ACCOUNT_VERIFICATION_TYPE: automatic + +jobs: + lint: + name: Lint & Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run linter + run: npm run lint + + - name: Type check + run: npm run compile + + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + needs: lint + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm run test -- --reporter=verbose + working-directory: apps/server + + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + needs: lint + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: brainbox + POSTGRES_PASSWORD: brainbox + POSTGRES_DB: brainbox + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Start MinIO + run: | + docker run -d --name minio \ + -p 9000:9000 \ + -e MINIO_ROOT_USER=minioadmin \ + -e MINIO_ROOT_PASSWORD=minioadmin \ + minio/minio server /data + sleep 5 + + - name: Create MinIO bucket + run: | + curl -O https://dl.min.io/client/mc/release/linux-amd64/mc + chmod +x mc + ./mc alias set myminio http://localhost:9000 minioadmin minioadmin + ./mc mb myminio/brainbox || true + + - name: Run integration tests + run: npm run test -- --reporter=verbose + working-directory: apps/server + + e2e-tests: + name: E2E Tests + runs-on: ubuntu-latest + needs: [unit-tests, integration-tests] + services: + postgres: + image: pgvector/pgvector:pg16 + env: + POSTGRES_USER: brainbox + POSTGRES_PASSWORD: brainbox + POSTGRES_DB: brainbox + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Start MinIO + run: | + docker run -d --name minio \ + -p 9000:9000 \ + -e MINIO_ROOT_USER=minioadmin \ + -e MINIO_ROOT_PASSWORD=minioadmin \ + minio/minio server /data + sleep 5 + + - name: Create MinIO bucket + run: | + curl -O https://dl.min.io/client/mc/release/linux-amd64/mc + chmod +x mc + ./mc alias set myminio http://localhost:9000 minioadmin minioadmin + ./mc mb myminio/brainbox || true + + - name: Build all packages + run: npm run build + + - name: Start server + run: node dist/index.js & + working-directory: apps/server + + - name: Start web app + run: npx vite preview & + working-directory: apps/web + + - name: Wait for services + run: | + sleep 10 + npx wait-on http://localhost:3000/config http://localhost:4000 --timeout 120000 + + - name: Run E2E tests + run: npm run e2e -- --project=chromium + env: + E2E_BASE_URL: http://localhost:4000 + CI: true + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + + - name: Upload test screenshots + uses: actions/upload-artifact@v4 + if: failure() + with: + name: test-screenshots + path: e2e/screenshots/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 2211b693..0ce0e57f 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,9 @@ apps/web/public/assets/brainbox-logo-black-192.png apps/web/public/assets/brainbox-logo-black-512.png nul .claude/settings.local.json + +# Playwright +playwright-report/ +test-results/ +e2e/screenshots/ +blob-report/ diff --git a/apps/server/package.json b/apps/server/package.json index 8396b8f1..0f4c56c9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,17 +15,21 @@ "build": "npm run compile && tsup-node", "clean": "del-cli dist isolate tsconfig.tsbuildinfo", "lint": "eslint . --max-warnings 0", + "test": "vitest", "dev": "tsx watch --env-file .env src/index.ts" }, "description": "", "devDependencies": { + "@testcontainers/postgresql": "^10.18.0", "@types/node": "^24.2.0", "@types/nodemailer": "^6.4.17", "@types/pg": "^8.15.5", "@types/ws": "^8.18.1", "nodemon": "^3.1.10", + "testcontainers": "^10.18.0", "tsup": "^8.5.0", - "tsx": "^4.20.3" + "tsx": "^4.20.3", + "vite-tsconfig-paths": "^6.0.3" }, "dependencies": { "@aws-sdk/client-s3": "^3.863.0", diff --git a/apps/server/src/lib/__tests__/accounts.test.ts b/apps/server/src/lib/__tests__/accounts.test.ts new file mode 100644 index 00000000..8b20ed50 --- /dev/null +++ b/apps/server/src/lib/__tests__/accounts.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; + +import { generatePasswordHash, verifyPassword } from '../accounts'; + +describe('accounts - password', () => { + describe('generatePasswordHash', () => { + it('should generate argon2 hash', async () => { + const hash = await generatePasswordHash('TestPassword123'); + expect(hash).toMatch(/^\$argon2/); + }); + + it('should generate different hashes for same password', async () => { + const hash1 = await generatePasswordHash('TestPassword123'); + const hash2 = await generatePasswordHash('TestPassword123'); + expect(hash1).not.toBe(hash2); + }); + + it('should generate hash with expected format', async () => { + const hash = await generatePasswordHash('TestPassword123'); + expect(hash).toMatch(/^\$argon2id\$v=\d+\$m=\d+,t=\d+,p=\d+\$/); + }); + }); + + describe('verifyPassword', () => { + it('should verify correct password', async () => { + const password = 'TestPassword123'; + const hash = await generatePasswordHash(password); + const result = await verifyPassword(password, hash); + expect(result).toBe(true); + }); + + it('should reject incorrect password', async () => { + const hash = await generatePasswordHash('TestPassword123'); + const result = await verifyPassword('WrongPassword', hash); + expect(result).toBe(false); + }); + + it('should reject empty password', async () => { + const hash = await generatePasswordHash('TestPassword123'); + const result = await verifyPassword('', hash); + expect(result).toBe(false); + }); + + it('should handle special characters in password', async () => { + const password = 'Test@Password!123#$%'; + const hash = await generatePasswordHash(password); + const result = await verifyPassword(password, hash); + expect(result).toBe(true); + }); + + it('should handle unicode characters in password', async () => { + const password = 'Test\u00e9\u00e0\u00fc123'; + const hash = await generatePasswordHash(password); + const result = await verifyPassword(password, hash); + expect(result).toBe(true); + }); + }); +}); diff --git a/apps/server/src/lib/__tests__/collaborations.test.ts b/apps/server/src/lib/__tests__/collaborations.test.ts new file mode 100644 index 00000000..37b797cb --- /dev/null +++ b/apps/server/src/lib/__tests__/collaborations.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from 'vitest'; + +import { checkCollaboratorChanges } from '../collaborations'; + +describe('collaborations', () => { + describe('checkCollaboratorChanges', () => { + it('should detect added collaborators', () => { + const before = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + }; + const after = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + collaborators: { user_1: 'member' as const }, + }; + + const result = checkCollaboratorChanges(before, after); + + expect(result.addedCollaborators).toEqual({ user_1: 'member' }); + expect(result.updatedCollaborators).toEqual({}); + expect(result.removedCollaborators).toEqual({}); + }); + + it('should detect updated collaborators', () => { + const before = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + collaborators: { user_1: 'member' as const }, + }; + const after = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + collaborators: { user_1: 'admin' as const }, + }; + + const result = checkCollaboratorChanges(before, after); + + expect(result.addedCollaborators).toEqual({}); + expect(result.updatedCollaborators).toEqual({ user_1: 'admin' }); + expect(result.removedCollaborators).toEqual({}); + }); + + it('should detect removed collaborators', () => { + const before = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + collaborators: { user_1: 'member' as const }, + }; + const after = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + }; + + const result = checkCollaboratorChanges(before, after); + + expect(result.addedCollaborators).toEqual({}); + expect(result.updatedCollaborators).toEqual({}); + expect(result.removedCollaborators).toEqual({ user_1: 'member' }); + }); + + it('should handle complex changes', () => { + const before = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + collaborators: { + user_1: 'member' as const, + user_2: 'viewer' as const, + }, + }; + const after = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + collaborators: { + user_1: 'admin' as const, + user_3: 'member' as const, + }, + }; + + const result = checkCollaboratorChanges(before, after); + + expect(result.addedCollaborators).toEqual({ user_3: 'member' }); + expect(result.updatedCollaborators).toEqual({ user_1: 'admin' }); + expect(result.removedCollaborators).toEqual({ user_2: 'viewer' }); + }); + + it('should handle no changes', () => { + const before = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + collaborators: { user_1: 'member' as const }, + }; + const after = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + collaborators: { user_1: 'member' as const }, + }; + + const result = checkCollaboratorChanges(before, after); + + expect(result.addedCollaborators).toEqual({}); + expect(result.updatedCollaborators).toEqual({}); + expect(result.removedCollaborators).toEqual({}); + }); + + it('should handle empty to empty', () => { + const before = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + }; + const after = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + }; + + const result = checkCollaboratorChanges(before, after); + + expect(result.addedCollaborators).toEqual({}); + expect(result.updatedCollaborators).toEqual({}); + expect(result.removedCollaborators).toEqual({}); + }); + + it('should handle multiple additions', () => { + const before = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + }; + const after = { + type: 'page' as const, + name: 'Test', + parentId: 'parent_1', + collaborators: { + user_1: 'admin' as const, + user_2: 'member' as const, + user_3: 'viewer' as const, + }, + }; + + const result = checkCollaboratorChanges(before, after); + + expect(result.addedCollaborators).toEqual({ + user_1: 'admin', + user_2: 'member', + user_3: 'viewer', + }); + }); + }); +}); diff --git a/apps/server/src/lib/__tests__/otps.test.ts b/apps/server/src/lib/__tests__/otps.test.ts new file mode 100644 index 00000000..dc3ecef4 --- /dev/null +++ b/apps/server/src/lib/__tests__/otps.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; + +import { generateOtpCode, getOtpRedisKey } from '../otps'; + +describe('otps', () => { + describe('generateOtpCode', () => { + it('should generate 6-digit code', () => { + const code = generateOtpCode(); + expect(code).toMatch(/^\d{6}$/); + }); + + it('should only contain digits', () => { + for (let i = 0; i < 100; i++) { + const code = generateOtpCode(); + expect(code).toMatch(/^\d+$/); + } + }); + + it('should generate different codes', () => { + const codes = new Set(); + for (let i = 0; i < 100; i++) { + codes.add(generateOtpCode()); + } + expect(codes.size).toBeGreaterThan(90); + }); + + it('should generate codes with high entropy', () => { + const codes: string[] = []; + for (let i = 0; i < 1000; i++) { + codes.push(generateOtpCode()); + } + + const uniqueCodes = new Set(codes); + expect(uniqueCodes.size).toBeGreaterThan(950); + }); + }); + + describe('getOtpRedisKey', () => { + it('should return correct key format', () => { + const key = getOtpRedisKey('otp_123'); + expect(key).toBe('otp:otp_123'); + }); + + it('should handle different id formats', () => { + const key1 = getOtpRedisKey('abc'); + const key2 = getOtpRedisKey('123-456-789'); + + expect(key1).toBe('otp:abc'); + expect(key2).toBe('otp:123-456-789'); + }); + }); +}); diff --git a/apps/server/src/lib/__tests__/rate-limits.test.ts b/apps/server/src/lib/__tests__/rate-limits.test.ts new file mode 100644 index 00000000..ec804a76 --- /dev/null +++ b/apps/server/src/lib/__tests__/rate-limits.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../../data/redis', () => ({ + redis: { + incr: vi.fn(), + expire: vi.fn(), + }, +})); + +// eslint-disable-next-line import/order -- must import after vi.mock for hoisting +import { redis } from '../../data/redis'; +import { + isAuthIpRateLimited, + isAuthEmailRateLimited, + isDeviceApiRateLimited, + isDeviceSocketRateLimited, +} from '../rate-limits'; + +const mockRedis = redis as unknown as { + incr: ReturnType; + expire: ReturnType; +}; + +describe('rate-limits', () => { + beforeEach(() => { + vi.resetAllMocks(); + delete process.env.DISABLE_RATE_LIMITING; + process.env.NODE_ENV = 'test'; + }); + + afterEach(() => { + delete process.env.DISABLE_RATE_LIMITING; + }); + + describe('isAuthIpRateLimited', () => { + it('should not rate limit first request', async () => { + mockRedis.incr.mockResolvedValue(1); + mockRedis.expire.mockResolvedValue(1); + + const result = await isAuthIpRateLimited('192.168.1.1'); + + expect(result).toBe(false); + expect(mockRedis.incr).toHaveBeenCalledWith('rt:ai:192.168.1.1'); + expect(mockRedis.expire).toHaveBeenCalled(); + }); + + it('should not rate limit under 100 requests', async () => { + mockRedis.incr.mockResolvedValue(50); + + const result = await isAuthIpRateLimited('192.168.1.1'); + + expect(result).toBe(false); + }); + + it('should rate limit at exactly 100 requests', async () => { + mockRedis.incr.mockResolvedValue(100); + + const result = await isAuthIpRateLimited('192.168.1.1'); + + expect(result).toBe(false); + }); + + it('should rate limit after 100 requests', async () => { + mockRedis.incr.mockResolvedValue(101); + + const result = await isAuthIpRateLimited('192.168.1.1'); + + expect(result).toBe(true); + }); + + it('should respect DISABLE_RATE_LIMITING in non-production', async () => { + process.env.DISABLE_RATE_LIMITING = 'true'; + process.env.NODE_ENV = 'test'; + + const result = await isAuthIpRateLimited('192.168.1.1'); + + expect(result).toBe(false); + expect(mockRedis.incr).not.toHaveBeenCalled(); + }); + }); + + describe('isAuthEmailRateLimited', () => { + it('should hash email for privacy', async () => { + mockRedis.incr.mockResolvedValue(1); + mockRedis.expire.mockResolvedValue(1); + + await isAuthEmailRateLimited('test@example.com'); + + expect(mockRedis.incr).toHaveBeenCalledWith( + expect.stringMatching(/^rt:ae:[a-f0-9]{64}$/) + ); + }); + + it('should rate limit after 10 requests per email', async () => { + mockRedis.incr.mockResolvedValue(11); + + const result = await isAuthEmailRateLimited('test@example.com'); + + expect(result).toBe(true); + }); + + it('should not rate limit under 10 requests', async () => { + mockRedis.incr.mockResolvedValue(5); + + const result = await isAuthEmailRateLimited('test@example.com'); + + expect(result).toBe(false); + }); + }); + + describe('isDeviceApiRateLimited', () => { + it('should allow 100 requests per minute', async () => { + mockRedis.incr.mockResolvedValue(99); + + const result = await isDeviceApiRateLimited('device_123'); + + expect(result).toBe(false); + }); + + it('should rate limit after 100 requests', async () => { + mockRedis.incr.mockResolvedValue(101); + + const result = await isDeviceApiRateLimited('device_123'); + + expect(result).toBe(true); + }); + + it('should use correct key format', async () => { + mockRedis.incr.mockResolvedValue(1); + mockRedis.expire.mockResolvedValue(1); + + await isDeviceApiRateLimited('device_123'); + + expect(mockRedis.incr).toHaveBeenCalledWith('rt:da:device_123'); + }); + }); + + describe('isDeviceSocketRateLimited', () => { + it('should allow 20 requests per minute', async () => { + mockRedis.incr.mockResolvedValue(19); + + const result = await isDeviceSocketRateLimited('device_123'); + + expect(result).toBe(false); + }); + + it('should rate limit after 20 requests', async () => { + mockRedis.incr.mockResolvedValue(21); + + const result = await isDeviceSocketRateLimited('device_123'); + + expect(result).toBe(true); + }); + + it('should use correct key format', async () => { + mockRedis.incr.mockResolvedValue(1); + mockRedis.expire.mockResolvedValue(1); + + await isDeviceSocketRateLimited('device_123'); + + expect(mockRedis.incr).toHaveBeenCalledWith('rt:ds:device_123'); + }); + }); +}); diff --git a/apps/server/src/lib/__tests__/tokens.test.ts b/apps/server/src/lib/__tests__/tokens.test.ts new file mode 100644 index 00000000..c0db1339 --- /dev/null +++ b/apps/server/src/lib/__tests__/tokens.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; + +import { generateToken, parseToken } from '../tokens'; + +describe('tokens', () => { + const DEVICE_ID = 'dev_1234567890123456789012ab'; + + describe('generateToken', () => { + it('should generate token with correct format', () => { + const result = generateToken(DEVICE_ID); + + expect(result.token).toMatch(/^cnd_/); + expect(result.token.length).toBeGreaterThan(40); + expect(result.salt).toBeDefined(); + expect(result.hash).toBeDefined(); + }); + + it('should generate unique tokens for same device', () => { + const result1 = generateToken(DEVICE_ID); + const result2 = generateToken(DEVICE_ID); + + expect(result1.token).not.toBe(result2.token); + expect(result1.hash).not.toBe(result2.hash); + }); + + it('should include deviceId in token', () => { + const result = generateToken(DEVICE_ID); + + expect(result.token).toContain(DEVICE_ID); + }); + }); + + describe('parseToken', () => { + it('should parse valid token', () => { + const { token } = generateToken(DEVICE_ID); + const parsed = parseToken(token); + + expect(parsed).not.toBeNull(); + expect(parsed?.deviceId).toBe(DEVICE_ID); + expect(parsed?.secret).toBeDefined(); + expect(parsed?.secret.length).toBeGreaterThan(0); + }); + + it('should return null for invalid prefix', () => { + const parsed = parseToken('invalid_token'); + expect(parsed).toBeNull(); + }); + + it('should return null for token without prefix', () => { + const parsed = parseToken('dev_1234567890123456789012abSomeSecret'); + expect(parsed).toBeNull(); + }); + + it('should correctly extract secret from token', () => { + const { token } = generateToken(DEVICE_ID); + const parsed = parseToken(token); + + const expectedSecret = token.slice(4 + 28); + expect(parsed?.secret).toBe(expectedSecret); + }); + }); +}); diff --git a/apps/server/src/test/app.ts b/apps/server/src/test/app.ts new file mode 100644 index 00000000..04ef5010 --- /dev/null +++ b/apps/server/src/test/app.ts @@ -0,0 +1,71 @@ +import fastifyHelmet from '@fastify/helmet'; +import fastifyWebsocket from '@fastify/websocket'; +import { fastify, FastifyInstance } from 'fastify'; +import { + serializerCompiler, + validatorCompiler, +} from 'fastify-type-provider-zod'; + +import { apiRoutes } from '@brainbox/server/api'; +import { clientDecorator } from '@brainbox/server/api/client/plugins/client'; +import { corsPlugin } from '@brainbox/server/api/client/plugins/cors'; +import { errorHandler } from '@brainbox/server/api/client/plugins/error-handler'; + +export async function createTestApp(): Promise { + const server = fastify({ + bodyLimit: 10 * 1024 * 1024, + trustProxy: true, + logger: false, + }); + + await server.register(fastifyHelmet, { + contentSecurityPolicy: false, + }); + + await server.register(errorHandler); + + server.setSerializerCompiler(serializerCompiler); + server.setValidatorCompiler(validatorCompiler); + + await server.register(corsPlugin); + await server.register(fastifyWebsocket); + await server.register(clientDecorator); + await server.register(apiRoutes); + + await server.ready(); + + return server; +} + +export async function injectAuth( + app: FastifyInstance, + email: string, + password: string +): Promise { + const registerResponse = await app.inject({ + method: 'POST', + url: '/client/v1/accounts/emails/register', + payload: { + email, + password, + name: 'Test User', + }, + }); + + if (registerResponse.statusCode !== 200) { + return null; + } + + const loginResponse = await app.inject({ + method: 'POST', + url: '/client/v1/accounts/emails/login', + payload: { email, password }, + }); + + try { + const data = JSON.parse(loginResponse.body); + return data.type === 'success' ? data.token : null; + } catch { + return null; + } +} diff --git a/apps/server/src/test/mocks.ts b/apps/server/src/test/mocks.ts new file mode 100644 index 00000000..23ff8b21 --- /dev/null +++ b/apps/server/src/test/mocks.ts @@ -0,0 +1,55 @@ +import { vi } from 'vitest'; + +export function mockRedis() { + const store = new Map(); + + return { + get: vi.fn((key: string) => Promise.resolve(store.get(key) || null)), + set: vi.fn((key: string, value: string) => { + store.set(key, value); + return Promise.resolve('OK'); + }), + del: vi.fn((key: string) => { + store.delete(key); + return Promise.resolve(1); + }), + incr: vi.fn((key: string) => { + const val = parseInt(store.get(key) || '0', 10) + 1; + store.set(key, String(val)); + return Promise.resolve(val); + }), + expire: vi.fn(() => Promise.resolve(1)), + clear: () => store.clear(), + }; +} + +export function mockS3() { + const files = new Map(); + + return { + send: vi.fn( + async (command: { input?: { Key?: string; Body?: Buffer } }) => { + if (command.input?.Key && command.input?.Body) { + files.set(command.input.Key, command.input.Body); + } + return {}; + } + ), + getFile: (key: string) => files.get(key), + clear: () => files.clear(), + }; +} + +export function mockEventBus() { + const events: Array<{ type: string; payload: unknown }> = []; + + return { + publish: vi.fn((event: { type: string; payload: unknown }) => { + events.push(event); + }), + subscribe: vi.fn(() => 'sub_123'), + unsubscribe: vi.fn(), + getEvents: () => [...events], + clear: () => (events.length = 0), + }; +} diff --git a/apps/server/src/test/setup.ts b/apps/server/src/test/setup.ts new file mode 100644 index 00000000..c5da6624 --- /dev/null +++ b/apps/server/src/test/setup.ts @@ -0,0 +1,62 @@ +import { Kysely, PostgresDialect } from 'kysely'; +import { Pool } from 'pg'; + +import type { DatabaseSchema } from '../data/schema'; + +export async function createTestDatabase(): Promise> { + const pool = new Pool({ + connectionString: + process.env.TEST_DATABASE_URL || + 'postgresql://brainbox:brainbox@localhost:5432/brainbox_test', + max: 5, + }); + + return new Kysely({ + dialect: new PostgresDialect({ pool }), + }); +} + +export async function cleanDatabase(db: Kysely) { + await db.deleteFrom('document_updates').execute(); + await db.deleteFrom('documents').execute(); + await db.deleteFrom('node_reactions').execute(); + await db.deleteFrom('node_interactions').execute(); + await db.deleteFrom('node_tombstones').execute(); + await db.deleteFrom('node_updates').execute(); + await db.deleteFrom('node_paths').execute(); + await db.deleteFrom('collaborations').execute(); + await db.deleteFrom('nodes').execute(); + await db.deleteFrom('uploads').execute(); + await db.deleteFrom('users').execute(); + await db.deleteFrom('workspaces').execute(); + await db.deleteFrom('devices').execute(); + await db.deleteFrom('accounts').execute(); +} + +export async function createTestAccount( + db: Kysely, + overrides: Partial<{ + id: string; + email: string; + name: string; + password: string; + status: number; + }> = {} +) { + const id = overrides.id || `acc_${Date.now()}`; + await db + .insertInto('accounts') + .values({ + id, + email: overrides.email || `test-${id}@example.com`, + name: overrides.name || 'Test User', + password: overrides.password || '$argon2id$v=19$m=19456,t=2,p=1$...', + status: overrides.status || 2, + attributes: '{}', + created_at: new Date(), + updated_at: new Date(), + }) + .execute(); + + return { id, email: overrides.email || `test-${id}@example.com` }; +} diff --git a/apps/server/src/test/vitest-setup.ts b/apps/server/src/test/vitest-setup.ts new file mode 100644 index 00000000..f3462c49 --- /dev/null +++ b/apps/server/src/test/vitest-setup.ts @@ -0,0 +1,9 @@ +process.env.POSTGRES_URL = + 'postgresql://brainbox:brainbox@localhost:5432/brainbox_test'; +process.env.REDIS_URL = 'redis://localhost:6379/0'; +process.env.STORAGE_S3_ENDPOINT = 'http://localhost:9000'; +process.env.STORAGE_S3_ACCESS_KEY = 'minioadmin'; +process.env.STORAGE_S3_SECRET_KEY = 'minioadmin'; +process.env.STORAGE_S3_BUCKET = 'brainbox'; +process.env.STORAGE_S3_REGION = 'us-east-1'; +process.env.NODE_ENV = 'test'; diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts new file mode 100644 index 00000000..0c27b4cf --- /dev/null +++ b/apps/server/vitest.config.ts @@ -0,0 +1,20 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + setupFiles: ['./src/test/vitest-setup.ts'], + testTimeout: 30000, + hookTimeout: 30000, + pool: 'forks', + poolOptions: { + forks: { + singleFork: true, + }, + }, + }, +}); diff --git a/apps/web/vite.config.js b/apps/web/vite.config.js index f4800360..8120814c 100644 --- a/apps/web/vite.config.js +++ b/apps/web/vite.config.js @@ -16,6 +16,13 @@ export default defineConfig({ 'Cross-Origin-Opener-Policy': 'same-origin', }, }, + preview: { + port: 4000, + headers: { + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Opener-Policy': 'same-origin', + }, + }, resolve: { alias: { '@brainbox/web': resolve(__dirname, './src'), diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 00000000..0c3928e5 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,291 @@ +# Testing Documentation + +This document outlines the testing infrastructure, what has been tested, and what remains to be tested. + +## Overview + +The project uses: + +- **Vitest** for unit and integration tests +- **Playwright** for E2E (end-to-end) tests +- **GitHub Actions** for CI/CD automation + +## Test Commands + +```bash +# Run all unit tests +npm run test + +# Run server unit tests +npm run test -w @brainbox/server + +# Run core package tests +npm run test -w @brainbox/core + +# Run E2E tests (requires dev servers running) +npm run e2e + +# Run E2E tests with browser visible +npm run e2e:headed + +# Run E2E tests with Playwright UI +npm run e2e:ui + +# View E2E test report +npm run e2e:report +``` + +--- + +## What Has Been Tested + +### Unit Tests (63 tests total) + +#### Server Package (`apps/server/src/lib/__tests__/`) + +| File | Tests | Description | +| ------------------------ | ----- | ----------------------------------------------------------- | +| `tokens.test.ts` | 7 | Token generation, parsing, and verification | +| `accounts.test.ts` | 8 | Password hashing (Argon2) and verification | +| `otps.test.ts` | 6 | OTP code generation and storage | +| `rate-limits.test.ts` | 14 | Rate limiting for auth IP, email, device API, device socket | +| `collaborations.test.ts` | 7 | Collaborator change detection (added, updated, removed) | + +#### Core Package (`packages/core/src/lib/__tests__/`) + +| File | Tests | Description | +| --------------------- | ----- | ---------------------------------------------- | +| `permissions.test.ts` | 21 | Workspace roles, node roles, access resolution | + +### E2E Tests (14 test specs) + +| File | Tests | Description | +| ---------------------------------------- | ----- | ------------------------------------------------------------------------------------ | +| `e2e/tests/auth/login.spec.ts` | 3 | Login form display, invalid credentials, successful login | +| `e2e/tests/auth/register.spec.ts` | 4 | Registration form, validation errors, password requirements, successful registration | +| `e2e/tests/auth/logout.spec.ts` | 1 | Logout flow | +| `e2e/tests/workspaces/workspace.spec.ts` | 3 | Workspace creation form, successful creation, name validation | +| `e2e/tests/pages/page.spec.ts` | 3 | Workspace display, page creation, editor typing | + +### Test Infrastructure + +| Component | Status | Location | +| ---------------------- | ------ | ------------------------------------- | +| Playwright config | Done | `playwright.config.ts` | +| Vitest config (server) | Done | `apps/server/vitest.config.ts` | +| Test fixtures | Done | `e2e/fixtures/` | +| Page objects | Done | `e2e/pages/` | +| Backend test utilities | Done | `apps/server/src/test/` | +| CI/CD workflow | Done | `.github/workflows/test.yml` | +| data-testid attributes | Done | Login, register, workspace components | + +--- + +## What Remains to Be Tested + +### High Priority + +#### API Route Handlers (`apps/server/src/api/client/routes/`) + +| Route | File | Status | +| ---------------- | ---------------------------------------- | ---------- | +| Email Register | `accounts/email-register.ts` | Not tested | +| Email Login | `accounts/email-login.ts` | Not tested | +| Google Login | `accounts/google-login.ts` | Not tested | +| Account Update | `accounts/account-update.ts` | Not tested | +| Workspace Create | `workspaces/workspace-create.ts` | Not tested | +| Workspace Update | `workspaces/workspace-update.ts` | Not tested | +| Workspace Delete | `workspaces/workspace-delete.ts` | Not tested | +| Mutations Sync | `workspaces/mutations/mutations-sync.ts` | Not tested | +| File Upload | `workspaces/files/file-upload.ts` | Not tested | +| File Download | `workspaces/files/file-download.ts` | Not tested | + +#### Node Operations (`apps/server/src/lib/`) + +| File | Description | Status | +| -------------- | ---------------------------------------------- | ---------- | +| `nodes.ts` | Node creation, update, delete, path resolution | Not tested | +| `documents.ts` | CRDT document handling with Yjs | Not tested | +| `files.ts` | File type validation, storage operations | Not tested | +| `emails.ts` | Email sending, templates | Not tested | + +### Medium Priority + +#### Synchronizers (`apps/server/src/synchronizers/`) + +| Synchronizer | Description | Status | +| ---------------------- | ------------------------ | ---------- | +| `nodes-updates.ts` | Node update broadcasting | Not tested | +| `document-updates.ts` | Document sync | Not tested | +| `collaborations.ts` | Collaboration sync | Not tested | +| `node-reactions.ts` | Reaction sync | Not tested | +| `node-interactions.ts` | Interaction sync | Not tested | +| `node-tombstones.ts` | Tombstone sync | Not tested | +| `users.ts` | User presence sync | Not tested | + +#### Services (`apps/server/src/services/`) + +| Service | Description | Status | +| ----------- | ---------------- | ---------- | +| `search.ts` | Full-text search | Not tested | +| `ai.ts` | AI integrations | Not tested | + +#### Jobs (`apps/server/src/jobs/`) + +| Job | Description | Status | +| ------------------- | --------------- | ---------- | +| `search-indexer.ts` | Search indexing | Not tested | +| `file-processor.ts` | File processing | Not tested | +| `cleanup.ts` | Data cleanup | Not tested | + +### Low Priority + +#### Client Package (`packages/client/`) + +| Area | Description | Status | +| --------- | ----------------------- | ---------- | +| Queries | Data fetching logic | Not tested | +| Mutations | Data modification logic | Not tested | +| Handlers | Sync handlers | Not tested | +| Commands | User commands | Not tested | + +#### UI Package (`packages/ui/`) + +| Area | Description | Status | +| ---------- | -------------------------- | ---------- | +| Components | React component unit tests | Not tested | +| Hooks | Custom hook tests | Not tested | +| Contexts | Context provider tests | Not tested | + +#### CRDT Package (`packages/crdt/`) + +| Area | Description | Status | +| ----------------- | ------------------------------- | ---------- | +| Yjs operations | CRDT merge, conflict resolution | Not tested | +| Document encoding | Binary encoding/decoding | Not tested | + +--- + +## CI/CD Pipeline + +The GitHub Actions workflow (`.github/workflows/test.yml`) runs: + +1. **Lint & Type Check** - ESLint and TypeScript compilation +2. **Unit Tests** - Vitest tests with Redis service +3. **Integration Tests** - API tests with PostgreSQL, Redis, MinIO +4. **E2E Tests** - Playwright tests with full stack + +### Services Used in CI + +- PostgreSQL 16 +- Redis 7 +- MinIO (S3-compatible storage) + +--- + +## Test Coverage Goals + +| Area | Current | Target | +| ------------------- | ------- | ------ | +| Core business logic | ~60% | 90% | +| API routes | 0% | 80% | +| Node operations | 0% | 80% | +| Synchronizers | 0% | 70% | +| UI components | 0% | 50% | +| E2E flows | ~40% | 80% | + +--- + +## Adding New Tests + +### Unit Tests + +1. Create test file in `__tests__/` directory next to source file +2. Name it `.test.ts` +3. Use Vitest's `describe`, `it`, `expect` +4. Mock external dependencies (Redis, DB) using `vi.mock` + +```typescript +import { describe, it, expect, vi } from 'vitest'; + +describe('myFunction', () => { + it('should do something', () => { + expect(myFunction()).toBe(expected); + }); +}); +``` + +### E2E Tests + +1. Create spec file in `e2e/tests//` +2. Use page objects from `e2e/pages/` +3. Use fixtures from `e2e/fixtures/` + +```typescript +import { test, expect } from '../../fixtures'; +import { LoginPage } from '../../pages/login.page'; + +test.describe('Feature', () => { + test('should work', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await expect(loginPage.emailInput).toBeVisible(); + }); +}); +``` + +### Adding data-testid + +When adding E2E tests for new components, add `data-testid` attributes: + +```tsx + +``` + +--- + +## Running Tests Locally + +### Prerequisites + +1. Docker running (for PostgreSQL, Redis, MinIO) +2. Dependencies installed (`npm install`) +3. Playwright browsers installed (`npx playwright install`) + +### Unit Tests + +```bash +npm run test +``` + +### E2E Tests + +```bash +# Start dev servers first +npm run dev + +# In another terminal, run E2E tests +npm run e2e +``` + +--- + +## Troubleshooting + +### Common Issues + +1. **Tests fail with "Cannot find module"** + - Run `npm install` + - Check `vitest.config.ts` has `vite-tsconfig-paths` plugin + +2. **E2E tests timeout** + - Ensure dev servers are running + - Check `playwright.config.ts` baseURL matches running server + +3. **Redis mock issues** + - Ensure `vi.mock` is at top of file (hoisting) + - Use `vi.resetAllMocks()` in `beforeEach` + +4. **Type errors in tests** + - Run `npm run compile` to check types + - Ensure test file matches source types diff --git a/e2e/fixtures/api.fixture.ts b/e2e/fixtures/api.fixture.ts new file mode 100644 index 00000000..46648675 --- /dev/null +++ b/e2e/fixtures/api.fixture.ts @@ -0,0 +1,66 @@ +import { APIRequestContext, request } from '@playwright/test'; + +export class ApiClient { + private token: string | null = null; + private context: APIRequestContext | null = null; + private baseUrl: string; + + constructor(baseUrl = 'http://localhost:3000/client/v1') { + this.baseUrl = baseUrl; + } + + async init() { + this.context = await request.newContext({ + baseURL: this.baseUrl, + }); + } + + async register(email: string, password: string, name: string) { + if (!this.context) throw new Error('ApiClient not initialized'); + const response = await this.context.post('/accounts/emails/register', { + data: { email, password, name }, + }); + const data = await response.json(); + if (data.type === 'success') { + this.token = data.token; + } + return data; + } + + async login(email: string, password: string) { + if (!this.context) throw new Error('ApiClient not initialized'); + const response = await this.context.post('/accounts/emails/login', { + data: { email, password }, + }); + const data = await response.json(); + if (data.type === 'success') { + this.token = data.token; + } + return data; + } + + async createWorkspace(name: string, description?: string) { + if (!this.context) throw new Error('ApiClient not initialized'); + const response = await this.context.post('/workspaces', { + headers: { Authorization: `Bearer ${this.token}` }, + data: { name, description }, + }); + return response.json(); + } + + async syncMutations(workspaceId: string, mutations: unknown[]) { + if (!this.context) throw new Error('ApiClient not initialized'); + const response = await this.context.post( + `/workspaces/${workspaceId}/mutations`, + { + headers: { Authorization: `Bearer ${this.token}` }, + data: { mutations }, + } + ); + return response.json(); + } + + async dispose() { + await this.context?.dispose(); + } +} diff --git a/e2e/fixtures/auth.fixture.ts b/e2e/fixtures/auth.fixture.ts new file mode 100644 index 00000000..7fb54109 --- /dev/null +++ b/e2e/fixtures/auth.fixture.ts @@ -0,0 +1,70 @@ +import { randomUUID } from 'crypto'; + +import { test as base, Page } from '@playwright/test'; + +export type TestUser = { + email: string; + password: string; + name: string; +}; + +export type AuthFixtures = { + testUser: TestUser; + authenticatedUser: TestUser; + authenticatedPage: Page; +}; + +function generateTestId(): string { + return randomUUID().slice(0, 8); +} + +async function registerTestUser(page: Page, testUser: TestUser): Promise { + const response = await page.request.post( + 'http://localhost:3000/client/v1/accounts/emails/register', + { + data: { + email: testUser.email, + password: testUser.password, + name: testUser.name, + }, + } + ); + + if (!response.ok()) { + throw new Error(`Failed to register: ${await response.text()}`); + } +} + +export const test = base.extend({ + // eslint-disable-next-line no-empty-pattern + testUser: async ({}, use) => { + const id = generateTestId(); + await use({ + email: `test-${id}@example.com`, + password: 'TestPassword123!', + name: `Test User ${id}`, + }); + }, + + authenticatedUser: async ({ page, testUser }, use) => { + await registerTestUser(page, testUser); + await use(testUser); + }, + + authenticatedPage: async ({ page, testUser }, use) => { + await registerTestUser(page, testUser); + + await page.goto('/'); + await page.getByTestId('email-input').fill(testUser.email); + await page.getByTestId('password-input').fill(testUser.password); + await page.getByTestId('login-button').click(); + + await page.waitForSelector( + '[data-testid="workspace-container"], [data-testid="workspace-create"]' + ); + + await use(page); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts new file mode 100644 index 00000000..3d9c1378 --- /dev/null +++ b/e2e/fixtures/index.ts @@ -0,0 +1,2 @@ +export { test, expect, TestUser, AuthFixtures } from './auth.fixture'; +export { ApiClient } from './api.fixture'; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 00000000..58ef6bd0 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,30 @@ +import type { FullConfig } from '@playwright/test'; + +async function globalSetup(_config: FullConfig) { + const serverUrl = process.env.E2E_SERVER_URL || 'http://localhost:3000'; + + let retries = 30; + while (retries > 0) { + try { + const response = await fetch(`${serverUrl}/config`); + if (response.ok) { + console.log('Server is ready'); + return; + } + } catch (error) { + // Server not ready yet - expected during startup, will retry + if (retries === 1) { + console.log( + 'Final retry failed:', + error instanceof Error ? error.message : 'Unknown error' + ); + } + } + retries--; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + throw new Error('Server failed to start within timeout'); +} + +export default globalSetup; diff --git a/e2e/pages/editor.page.ts b/e2e/pages/editor.page.ts new file mode 100644 index 00000000..4eb31dbc --- /dev/null +++ b/e2e/pages/editor.page.ts @@ -0,0 +1,28 @@ +import { Page, Locator } from '@playwright/test'; + +export class EditorPage { + readonly page: Page; + readonly titleInput: Locator; + readonly contentEditor: Locator; + readonly editor: Locator; + + constructor(page: Page) { + this.page = page; + this.titleInput = page.getByTestId('page-title-input'); + this.contentEditor = page.getByTestId('page-content-editor'); + this.editor = this.contentEditor; + } + + async setTitle(title: string) { + await this.titleInput.fill(title); + } + + async typeContent(content: string) { + await this.contentEditor.click(); + await this.contentEditor.fill(content); + } + + async typeInEditor(content: string) { + await this.typeContent(content); + } +} diff --git a/e2e/pages/login.page.ts b/e2e/pages/login.page.ts new file mode 100644 index 00000000..ae8a9b01 --- /dev/null +++ b/e2e/pages/login.page.ts @@ -0,0 +1,41 @@ +import { Page, Locator } from '@playwright/test'; + +export class LoginPage { + readonly page: Page; + readonly emailInput: Locator; + readonly passwordInput: Locator; + readonly loginButton: Locator; + readonly registerLink: Locator; + readonly errorMessage: Locator; + + constructor(page: Page) { + this.page = page; + this.emailInput = page.getByTestId('email-input'); + this.passwordInput = page.getByTestId('password-input'); + this.loginButton = page.getByTestId('login-button'); + this.registerLink = page.getByTestId('register-link'); + this.errorMessage = page.getByTestId('error-message'); + } + + async goto() { + await this.page.goto('/'); + } + + async login(email: string, password: string) { + await this.emailInput.fill(email); + await this.passwordInput.fill(password); + await this.loginButton.click(); + } + + async goToRegister() { + await this.registerLink.click(); + } + + async register(name: string, email: string, password: string) { + await this.registerLink.click(); + await this.page.getByTestId('name-input').fill(name); + await this.emailInput.fill(email); + await this.passwordInput.fill(password); + await this.page.getByTestId('register-button').click(); + } +} diff --git a/e2e/pages/workspace.page.ts b/e2e/pages/workspace.page.ts new file mode 100644 index 00000000..084cbc4f --- /dev/null +++ b/e2e/pages/workspace.page.ts @@ -0,0 +1,30 @@ +import { Page, Locator } from '@playwright/test'; + +export class WorkspacePage { + readonly page: Page; + readonly workspaceNameInput: Locator; + readonly createButton: Locator; + readonly sidebarSpaces: Locator; + readonly createSpaceButton: Locator; + + constructor(page: Page) { + this.page = page; + this.workspaceNameInput = page.getByTestId('workspace-name-input'); + this.createButton = page.getByTestId('workspace-create-button'); + this.sidebarSpaces = page.getByTestId('sidebar-spaces'); + this.createSpaceButton = page.getByTestId('create-space-button'); + } + + async createWorkspace(name: string) { + await this.workspaceNameInput.fill(name); + await this.createButton.click(); + await this.page.waitForSelector('[data-testid="sidebar-spaces"]'); + } + + async createSpace(name: string) { + await this.createSpaceButton.click(); + await this.page.getByTestId('space-name-input').fill(name); + await this.page.getByTestId('space-create-button').click(); + await this.page.waitForSelector(`text="${name}"`); + } +} diff --git a/e2e/tests/auth/login.spec.ts b/e2e/tests/auth/login.spec.ts new file mode 100644 index 00000000..c5fee765 --- /dev/null +++ b/e2e/tests/auth/login.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from '../../fixtures'; +import { LoginPage } from '../../pages/login.page'; + +test.describe('Login', () => { + test('should show login form by default', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + + await expect(loginPage.emailInput).toBeVisible(); + await expect(loginPage.passwordInput).toBeVisible(); + await expect(loginPage.loginButton).toBeVisible(); + }); + + test('should show error for invalid credentials', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login('nonexistent@example.com', 'WrongPassword123!'); + + await expect(loginPage.errorMessage).toBeVisible({ timeout: 10000 }); + }); + + test('should login successfully with valid credentials', async ({ + page, + testUser, + }) => { + await page.request.post( + 'http://localhost:3000/client/v1/accounts/emails/register', + { + data: { + email: testUser.email, + password: testUser.password, + name: testUser.name, + }, + } + ); + + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(testUser.email, testUser.password); + + await expect(page.getByTestId('workspace-container')).toBeVisible({ + timeout: 10000, + }); + }); +}); diff --git a/e2e/tests/auth/logout.spec.ts b/e2e/tests/auth/logout.spec.ts new file mode 100644 index 00000000..ee616e95 --- /dev/null +++ b/e2e/tests/auth/logout.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from '../../fixtures'; +import { LoginPage } from '../../pages/login.page'; + +test.describe('Logout', () => { + test('should logout successfully', async ({ page, testUser }) => { + await page.request.post( + 'http://localhost:3000/client/v1/accounts/emails/register', + { + data: { + email: testUser.email, + password: testUser.password, + name: testUser.name, + }, + } + ); + + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(testUser.email, testUser.password); + + await expect(page.getByTestId('workspace-container')).toBeVisible({ + timeout: 15000, + }); + + const userMenuButton = page.getByTestId('user-menu-button'); + const isUserMenuVisible = await userMenuButton + .isVisible({ timeout: 5000 }) + .catch(() => false); + + test.skip(!isUserMenuVisible, 'User menu button not present in this UI'); + + await expect(userMenuButton).toBeVisible(); + await userMenuButton.click(); + + const logoutButton = page.getByTestId('logout-button'); + await expect(logoutButton).toBeVisible(); + await logoutButton.click(); + + await expect(loginPage.emailInput).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/e2e/tests/auth/register.spec.ts b/e2e/tests/auth/register.spec.ts new file mode 100644 index 00000000..9a487f43 --- /dev/null +++ b/e2e/tests/auth/register.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from '../../fixtures'; +import { LoginPage } from '../../pages/login.page'; + +test.describe('Registration', () => { + test('should show registration form when clicking register link', async ({ + page, + }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.goToRegister(); + + await expect(page.getByTestId('name-input')).toBeVisible(); + await expect(page.getByTestId('email-input')).toBeVisible(); + await expect(page.getByTestId('password-input')).toBeVisible(); + await expect(page.getByTestId('register-button')).toBeVisible(); + }); + + test('should show validation errors for invalid input', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.goToRegister(); + + await page.getByTestId('register-button').click(); + + await expect(page.getByText('String must contain at least')).toBeVisible({ + timeout: 5000, + }); + }); + + test('should show password requirements error', async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.goToRegister(); + + await page.getByTestId('name-input').fill('Test User'); + await page.getByTestId('email-input').fill('test@example.com'); + await page.getByTestId('password-input').fill('weak'); + await page.getByTestId('register-button').click(); + + await expect( + page.getByText('Password must be at least 8 characters') + ).toBeVisible({ timeout: 5000 }); + }); + + test('should register successfully with valid credentials', async ({ + page, + testUser, + }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.goToRegister(); + + await page.getByTestId('name-input').fill(testUser.name); + await page.getByTestId('email-input').fill(testUser.email); + await page.getByTestId('password-input').fill(testUser.password); + await page.locator('input[name="confirmPassword"]').fill(testUser.password); + await page.getByTestId('register-button').click(); + + await expect(page.getByTestId('workspace-create')).toBeVisible({ + timeout: 15000, + }); + }); +}); diff --git a/e2e/tests/pages/page.spec.ts b/e2e/tests/pages/page.spec.ts new file mode 100644 index 00000000..af5b0688 --- /dev/null +++ b/e2e/tests/pages/page.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from '../../fixtures'; +import { EditorPage } from '../../pages/editor.page'; +import { LoginPage } from '../../pages/login.page'; +import { WorkspacePage } from '../../pages/workspace.page'; + +test.describe('Page Editor', () => { + test.beforeEach(async ({ page, authenticatedUser }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(authenticatedUser.email, authenticatedUser.password); + + const workspaceCreate = page.getByTestId('workspace-create'); + if (await workspaceCreate.isVisible({ timeout: 5000 }).catch(() => false)) { + const workspacePage = new WorkspacePage(page); + await workspacePage.createWorkspace('Test Workspace'); + } + + await expect(page.getByTestId('workspace-container')).toBeVisible({ + timeout: 15000, + }); + }); + + test('should display workspace container after login', async ({ page }) => { + await expect(page.getByTestId('workspace-container')).toBeVisible(); + }); + + test('should create a new page', async ({ page }) => { + const createPageButton = page.getByTestId('create-page-button'); + if ( + await createPageButton.isVisible({ timeout: 5000 }).catch(() => false) + ) { + await createPageButton.click(); + + const editorPage = new EditorPage(page); + await expect(editorPage.editor).toBeVisible({ timeout: 10000 }); + } + }); + + test('should type in the editor', async ({ page }) => { + const createPageButton = page.getByTestId('create-page-button'); + if ( + await createPageButton.isVisible({ timeout: 5000 }).catch(() => false) + ) { + await createPageButton.click(); + + const editorPage = new EditorPage(page); + await expect(editorPage.editor).toBeVisible({ timeout: 10000 }); + + await editorPage.typeInEditor('Hello, World!'); + await expect(page.getByText('Hello, World!')).toBeVisible({ + timeout: 5000, + }); + } + }); +}); diff --git a/e2e/tests/workspaces/workspace.spec.ts b/e2e/tests/workspaces/workspace.spec.ts new file mode 100644 index 00000000..695a6bc7 --- /dev/null +++ b/e2e/tests/workspaces/workspace.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '../../fixtures'; +import { LoginPage } from '../../pages/login.page'; +import { WorkspacePage } from '../../pages/workspace.page'; + +test.describe('Workspace', () => { + test.beforeEach(async ({ page, testUser }) => { + await page.request.post( + 'http://localhost:3000/client/v1/accounts/emails/register', + { + data: { + email: testUser.email, + password: testUser.password, + name: testUser.name, + }, + } + ); + }); + + test('should show workspace creation form after registration', async ({ + page, + testUser, + }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(testUser.email, testUser.password); + + await expect(page.getByTestId('workspace-create')).toBeVisible({ + timeout: 15000, + }); + await expect(page.getByTestId('workspace-name-input')).toBeVisible(); + await expect(page.getByTestId('workspace-create-button')).toBeVisible(); + }); + + test('should create workspace successfully', async ({ page, testUser }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(testUser.email, testUser.password); + + await expect(page.getByTestId('workspace-create')).toBeVisible({ + timeout: 15000, + }); + + const workspacePage = new WorkspacePage(page); + await workspacePage.createWorkspace('Test Workspace'); + + await expect(page.getByTestId('workspace-container')).toBeVisible({ + timeout: 15000, + }); + }); + + test('should validate workspace name minimum length', async ({ + page, + testUser, + }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(testUser.email, testUser.password); + + await expect(page.getByTestId('workspace-create')).toBeVisible({ + timeout: 15000, + }); + + await page.getByTestId('workspace-name-input').fill('ab'); + await page.getByTestId('workspace-create-button').click(); + + await expect( + page.getByText('Name must be at least 3 characters') + ).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 968a5f7b..7acf63b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "devDependencies": { "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", + "@playwright/test": "^1.49.0", "@types/debug": "^4.1.12", "@types/lodash-es": "^4.17.12", "@typescript-eslint/eslint-plugin": "^8.39.0", @@ -111,13 +112,16 @@ "zod": "^4.0.15" }, "devDependencies": { + "@testcontainers/postgresql": "^10.18.0", "@types/node": "^24.2.0", "@types/nodemailer": "^6.4.17", "@types/pg": "^8.15.5", "@types/ws": "^8.18.1", "nodemon": "^3.1.10", + "testcontainers": "^10.18.0", "tsup": "^8.5.0", - "tsx": "^4.20.3" + "tsx": "^4.20.3", + "vite-tsconfig-paths": "^6.0.3" } }, "apps/server/node_modules/diff": { @@ -2811,6 +2815,13 @@ "node": ">=6.9.0" } }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@borewit/text-codec": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", @@ -5380,6 +5391,16 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@fastify/cors": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.1.0.tgz", @@ -5615,6 +5636,58 @@ "dev": true, "license": "MIT" }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@hookform/resolvers": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", @@ -6551,6 +6624,17 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "2.0.22", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.22.tgz", @@ -7357,6 +7441,96 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -10483,6 +10657,16 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testcontainers/postgresql": { + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-10.28.0.tgz", + "integrity": "sha512-NN25rruG5D4Q7pCNIJuHwB+G85OSeJ3xHZ2fWx0O6sPoPEfCYwvpj8mq99cyn68nxFkFYZeyrZJtSFO+FnydiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "testcontainers": "^10.28.0" + } + }, "node_modules/@tiptap/core": { "version": "3.6.5", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.6.5.tgz", @@ -11116,6 +11300,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/docker-modem": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz", + "integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "3.3.47", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.47.tgz", + "integrity": "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, "node_modules/@types/electron-squirrel-startup": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/electron-squirrel-startup/-/electron-squirrel-startup-1.0.2.tgz", @@ -11319,6 +11526,43 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-streams": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/ssh2-streams/-/ssh2-streams-0.1.13.tgz", + "integrity": "sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/svg-sprite": { "version": "0.0.39", "resolved": "https://registry.npmjs.org/@types/svg-sprite/-/svg-sprite-0.0.39.tgz", @@ -11799,6 +12043,19 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -12047,102 +12304,287 @@ "node": ">=8.5" } }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", "license": "MIT", "dependencies": { - "tslib": "^2.0.0" + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" }, "engines": { - "node": ">=10" + "node": ">= 14" } }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 14" } }, - "node_modules/array-ify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", - "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "node_modules/archiver-utils/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "dev": true, - "license": "MIT" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": ">= 0.4" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "node_modules/archiver-utils/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/archiver/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/archiver/node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, "license": "MIT", @@ -12239,6 +12681,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -12328,6 +12780,21 @@ "fastq": "^1.17.1" } }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", @@ -12386,6 +12853,103 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.2.tgz", + "integrity": "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base32-encode": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/base32-encode/-/base32-encode-1.2.0.tgz", @@ -12427,6 +12991,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -12647,6 +13221,16 @@ "node": ">=6.14.2" } }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "dev": true, + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bullmq": { "version": "5.60.0", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.60.0.tgz", @@ -12678,6 +13262,16 @@ "esbuild": ">=0.18" } }, + "node_modules/byline": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz", + "integrity": "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -13388,6 +13982,78 @@ "node": ">=0.10.0" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/compress-commons/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -13562,6 +14228,90 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -14163,6 +14913,68 @@ "redux": "^4.2.0" } }, + "node_modules/docker-compose": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.8.tgz", + "integrity": "sha512-plizRs/Vf15H+GCVxq2EUvyPK7ei9b/cVesHvjnX4xaXjM9spHe2Ytq0BitndFgvTJ3E3NljPNUEl7BAN43iZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/docker-modem": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", + "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.9.tgz", + "integrity": "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.6", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -15735,6 +16547,16 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -15742,6 +16564,26 @@ "dev": true, "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/execa": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", @@ -15931,6 +16773,13 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -16731,6 +17580,19 @@ "dev": true, "license": "MIT" }, + "node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -16972,6 +17834,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -18489,31 +19358,84 @@ "json-buffer": "3.0.1" } }, - "node_modules/kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/ky": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.11.0.tgz", + "integrity": "sha512-NEyo0ICpS0cqSuyoJFMCnHOZJILqXsKhIZlHJGDYaH8OB5IFrGzuBpEwyoMZG6gUKMPrazH30Ax5XKaujvD8ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" + } + }, + "node_modules/kysely": { + "version": "0.28.7", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.7.tgz", + "integrity": "sha512-u/cAuTL4DRIiO2/g4vNGRgklEKNIj5Q3CG7RoUB5DV5SfEC2hMvPxKi0GWPmnzwL2ryIeud2VTcEEmqzTzEPNw==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, "license": "MIT" }, - "node_modules/ky": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/ky/-/ky-1.11.0.tgz", - "integrity": "sha512-NEyo0ICpS0cqSuyoJFMCnHOZJILqXsKhIZlHJGDYaH8OB5IFrGzuBpEwyoMZG6gUKMPrazH30Ax5XKaujvD8ag==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/ky?sponsor=1" - } - }, - "node_modules/kysely": { - "version": "0.28.7", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.7.tgz", - "integrity": "sha512-u/cAuTL4DRIiO2/g4vNGRgklEKNIj5Q3CG7RoUB5DV5SfEC2hMvPxKi0GWPmnzwL2ryIeud2VTcEEmqzTzEPNw==", + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=20.0.0" + "dependencies": { + "safe-buffer": "~5.1.0" } }, "node_modules/leven": { @@ -19628,6 +20550,13 @@ "node": ">= 12.0.0" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -21453,6 +22382,53 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -21707,6 +22683,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -21789,6 +22775,23 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/properties-reader": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/properties-reader/-/properties-reader-2.3.0.tgz", + "integrity": "sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/properties?sponsor=1" + } + }, "node_modules/prosemirror-changeset": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", @@ -21984,6 +22987,31 @@ "prosemirror-transform": "^1.1.0" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -22453,6 +23481,29 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -23585,6 +24636,13 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "dev": true, + "license": "ISC" + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -23617,6 +24675,46 @@ "node": ">=20.16.0" } }, + "node_modules/ssh-remote-port-forward": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz", + "integrity": "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^0.5.48", + "ssh2": "^1.4.0" + } + }, + "node_modules/ssh-remote-port-forward/node_modules/@types/ssh2": { + "version": "0.5.52", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz", + "integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/ssh2-streams": "*" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/ssri": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", @@ -23697,6 +24795,18 @@ "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "license": "MIT" }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -24486,6 +25596,77 @@ "dev": true, "license": "MIT" }, + "node_modules/testcontainers": { + "version": "10.28.0", + "resolved": "https://registry.npmjs.org/testcontainers/-/testcontainers-10.28.0.tgz", + "integrity": "sha512-1fKrRRCsgAQNkarjHCMKzBKXSJFmzNTiTbhb5E/j5hflRXChEtHvkefjaHlgkNUjfw92/Dq8LTgwQn6RDBFbMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@types/dockerode": "^3.3.35", + "archiver": "^7.0.1", + "async-lock": "^1.4.1", + "byline": "^5.0.0", + "debug": "^4.3.5", + "docker-compose": "^0.24.8", + "dockerode": "^4.0.5", + "get-port": "^7.1.0", + "proper-lockfile": "^4.1.2", + "properties-reader": "^2.3.0", + "ssh-remote-port-forward": "^1.0.4", + "tar-fs": "^3.0.7", + "tmp": "^0.2.3", + "undici": "^5.29.0" + } + }, + "node_modules/testcontainers/node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/testcontainers/node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/testcontainers/node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-extensions": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/text-extensions/-/text-extensions-2.4.0.tgz", @@ -24919,6 +26100,27 @@ "integrity": "sha512-kIjN2qmWiHnhgr5DAkAafF9fwb0T5OhMVSWrm8XEdTFnX6+wfXwYOFjeF86UZ54vduqiR7BfqScFmXSzSaH8oA==", "license": "MIT" }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -25219,6 +26421,13 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -25410,6 +26619,19 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/undici-types": { "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", @@ -25875,6 +27097,26 @@ } } }, + "node_modules/vite-tsconfig-paths": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-6.0.3.tgz", + "integrity": "sha512-7bL7FPX/DSviaZGYUKowWF1AiDVWjMjxNbE8lyaVGDezkedWqfGhlnQ4BZXre0ZN5P4kAgIJfAlgFDVyjrCIyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -26968,6 +28210,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zod": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", diff --git a/package.json b/package.json index 8f14f42d..80cd33a5 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,17 @@ "watch": "turbo watch build --filter=@brainbox/{core,crdt,server}", "lint": "turbo run lint --parallel", "test": "turbo run test -- --watch false", + "e2e": "playwright test", + "e2e:headed": "playwright test --headed", + "e2e:ui": "playwright test --ui", + "e2e:report": "playwright show-report", "format": "prettier --write .", "format:check": "prettier --check .", "prepare": "husky", "postinstall": "node --no-warnings --loader ts-node/esm scripts/src/postinstall/index.ts" }, "devDependencies": { + "@playwright/test": "^1.49.0", "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@types/debug": "^4.1.12", diff --git a/packages/core/package.json b/packages/core/package.json index ae4dff0a..25c67a51 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -5,7 +5,8 @@ "type": "module", "types": "./src/index.ts", "exports": { - ".": "./src/index.ts" + ".": "./src/index.ts", + "./*": "./src/*" }, "files": [ "src" diff --git a/packages/core/src/lib/__tests__/permissions.test.ts b/packages/core/src/lib/__tests__/permissions.test.ts new file mode 100644 index 00000000..5a946e22 --- /dev/null +++ b/packages/core/src/lib/__tests__/permissions.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from 'vitest'; + +import { + hasWorkspaceRole, + hasNodeRole, + mapWorkspaceToNodeRole, + resolveNodeAccess, +} from '../permissions'; + +describe('permissions', () => { + describe('hasWorkspaceRole', () => { + it('should allow owner to have all roles', () => { + expect(hasWorkspaceRole('owner', 'owner')).toBe(true); + expect(hasWorkspaceRole('owner', 'admin')).toBe(true); + expect(hasWorkspaceRole('owner', 'member')).toBe(true); + expect(hasWorkspaceRole('owner', 'viewer')).toBe(true); + expect(hasWorkspaceRole('owner', 'guest')).toBe(true); + }); + + it('should allow admin to have admin and below', () => { + expect(hasWorkspaceRole('admin', 'owner')).toBe(false); + expect(hasWorkspaceRole('admin', 'admin')).toBe(true); + expect(hasWorkspaceRole('admin', 'member')).toBe(true); + expect(hasWorkspaceRole('admin', 'viewer')).toBe(true); + expect(hasWorkspaceRole('admin', 'guest')).toBe(true); + }); + + it('should allow member to have member and below', () => { + expect(hasWorkspaceRole('member', 'owner')).toBe(false); + expect(hasWorkspaceRole('member', 'admin')).toBe(false); + expect(hasWorkspaceRole('member', 'member')).toBe(true); + expect(hasWorkspaceRole('member', 'viewer')).toBe(true); + expect(hasWorkspaceRole('member', 'guest')).toBe(true); + }); + + it('should allow viewer to have viewer and below', () => { + expect(hasWorkspaceRole('viewer', 'owner')).toBe(false); + expect(hasWorkspaceRole('viewer', 'admin')).toBe(false); + expect(hasWorkspaceRole('viewer', 'member')).toBe(false); + expect(hasWorkspaceRole('viewer', 'viewer')).toBe(true); + expect(hasWorkspaceRole('viewer', 'guest')).toBe(true); + }); + + it('should allow guest to only have guest role', () => { + expect(hasWorkspaceRole('guest', 'owner')).toBe(false); + expect(hasWorkspaceRole('guest', 'admin')).toBe(false); + expect(hasWorkspaceRole('guest', 'member')).toBe(false); + expect(hasWorkspaceRole('guest', 'viewer')).toBe(false); + expect(hasWorkspaceRole('guest', 'guest')).toBe(true); + }); + }); + + describe('hasNodeRole', () => { + it('should allow admin to have all node roles', () => { + expect(hasNodeRole('admin', 'admin')).toBe(true); + expect(hasNodeRole('admin', 'member')).toBe(true); + expect(hasNodeRole('admin', 'viewer')).toBe(true); + }); + + it('should allow member to have member and below', () => { + expect(hasNodeRole('member', 'admin')).toBe(false); + expect(hasNodeRole('member', 'member')).toBe(true); + expect(hasNodeRole('member', 'viewer')).toBe(true); + }); + + it('should allow viewer to only have viewer role', () => { + expect(hasNodeRole('viewer', 'admin')).toBe(false); + expect(hasNodeRole('viewer', 'member')).toBe(false); + expect(hasNodeRole('viewer', 'viewer')).toBe(true); + }); + }); + + describe('mapWorkspaceToNodeRole', () => { + it('should map owner to admin', () => { + expect(mapWorkspaceToNodeRole('owner')).toBe('admin'); + }); + + it('should map admin to admin', () => { + expect(mapWorkspaceToNodeRole('admin')).toBe('admin'); + }); + + it('should map member to member', () => { + expect(mapWorkspaceToNodeRole('member')).toBe('member'); + }); + + it('should map viewer to viewer', () => { + expect(mapWorkspaceToNodeRole('viewer')).toBe('viewer'); + }); + + it('should map guest to null', () => { + expect(mapWorkspaceToNodeRole('guest')).toBeNull(); + }); + + it('should map none to null', () => { + expect(mapWorkspaceToNodeRole('none')).toBeNull(); + }); + }); + + describe('resolveNodeAccess', () => { + it('should grant explicit access when user is collaborator', () => { + const result = resolveNodeAccess('user_1', 'member', { + collaborators: { user_1: 'admin' }, + visibility: 'private', + }); + + expect(result.canAccess).toBe(true); + expect(result.accessSource).toBe('explicit'); + expect(result.effectiveRole).toBe('admin'); + }); + + it('should grant workspace access for public nodes', () => { + const result = resolveNodeAccess('user_1', 'member', { + collaborators: {}, + visibility: 'public', + }); + + expect(result.canAccess).toBe(true); + expect(result.accessSource).toBe('workspace'); + expect(result.effectiveRole).toBe('member'); + }); + + it('should deny access for private nodes without collaboration', () => { + const result = resolveNodeAccess('user_1', 'member', { + collaborators: { other_user: 'admin' }, + visibility: 'private', + }); + + expect(result.canAccess).toBe(false); + expect(result.accessSource).toBe('none'); + expect(result.effectiveRole).toBeNull(); + }); + + it('should grant admin access to workspace owners', () => { + const result = resolveNodeAccess('user_1', 'owner', { + collaborators: {}, + visibility: 'private', + }); + + expect(result.canAccess).toBe(true); + expect(result.accessSource).toBe('workspace'); + expect(result.effectiveRole).toBe('admin'); + }); + + it('should grant admin access to workspace admins', () => { + const result = resolveNodeAccess('user_1', 'admin', { + collaborators: {}, + visibility: 'private', + }); + + expect(result.canAccess).toBe(true); + expect(result.accessSource).toBe('workspace'); + expect(result.effectiveRole).toBe('admin'); + }); + + it('should deny access to guests for public nodes', () => { + const result = resolveNodeAccess('user_1', 'guest', { + collaborators: {}, + visibility: 'public', + }); + + expect(result.canAccess).toBe(false); + expect(result.accessSource).toBe('none'); + }); + + it('should prioritize explicit collaboration over workspace role', () => { + const result = resolveNodeAccess('user_1', 'owner', { + collaborators: { user_1: 'viewer' }, + visibility: 'public', + }); + + expect(result.canAccess).toBe(true); + expect(result.accessSource).toBe('explicit'); + expect(result.effectiveRole).toBe('viewer'); + }); + }); +}); diff --git a/packages/core/src/registry/nodes/field-value.ts b/packages/core/src/registry/nodes/field-value.ts index f57342f9..cbcd0636 100644 --- a/packages/core/src/registry/nodes/field-value.ts +++ b/packages/core/src/registry/nodes/field-value.ts @@ -1,6 +1,6 @@ import { z } from 'zod/v4'; -import { ZodText } from '@brainbox/core'; +import { ZodText } from '../zod'; export const booleanFieldValueSchema = z.object({ type: z.literal('boolean'), diff --git a/packages/ui/src/components/accounts/email-login.tsx b/packages/ui/src/components/accounts/email-login.tsx index f67cbcbd..55b752cb 100644 --- a/packages/ui/src/components/accounts/email-login.tsx +++ b/packages/ui/src/components/accounts/email-login.tsx @@ -84,6 +84,7 @@ export const EmailLogin = ({ placeholder="hi@example.com" {...field} autoComplete="email" + data-testid="email-input" className="h-10 text-sm bg-background border transition-colors duration-150 ease-out focus:border-primary/50 focus:ring-0" /> @@ -119,6 +120,7 @@ export const EmailLogin = ({ autoComplete="current-password" placeholder="********" showPasswordToggle + data-testid="password-input" className="h-10 text-sm bg-background border transition-colors duration-150 ease-out focus:border-primary/50 focus:ring-0" /> @@ -133,6 +135,7 @@ export const EmailLogin = ({ type="submit" className="w-full h-10 text-sm font-medium transition-all duration-150 ease-out" disabled={isPending} + data-testid="login-button" > {isPending ? ( @@ -149,6 +152,7 @@ export const EmailLogin = ({ className="w-full text-muted-foreground hover:text-foreground transition-colors duration-150 ease-out" onClick={onRegister} type="button" + data-testid="register-link" > Don't have an account? Sign up diff --git a/packages/ui/src/components/accounts/email-register.tsx b/packages/ui/src/components/accounts/email-register.tsx index 2b37a22d..7d1f0e59 100644 --- a/packages/ui/src/components/accounts/email-register.tsx +++ b/packages/ui/src/components/accounts/email-register.tsx @@ -97,6 +97,7 @@ export const EmailRegister = ({ onSuccess, onLogin }: EmailRegisterProps) => { placeholder="John Doe" {...field} autoComplete="name" + data-testid="name-input" className="h-10 text-sm bg-background border transition-colors duration-150 ease-out focus:border-primary/50 focus:ring-0" /> @@ -121,6 +122,7 @@ export const EmailRegister = ({ onSuccess, onLogin }: EmailRegisterProps) => { placeholder="hi@example.com" {...field} autoComplete="email" + data-testid="email-input" className="h-10 text-sm bg-background border transition-colors duration-150 ease-out focus:border-primary/50 focus:ring-0" /> @@ -147,6 +149,7 @@ export const EmailRegister = ({ onSuccess, onLogin }: EmailRegisterProps) => { autoComplete="new-password" placeholder="********" showPasswordToggle + data-testid="password-input" className="h-10 text-sm bg-background border transition-colors duration-150 ease-out focus:border-primary/50 focus:ring-0" /> @@ -187,6 +190,7 @@ export const EmailRegister = ({ onSuccess, onLogin }: EmailRegisterProps) => { type="submit" className="w-full h-10 text-sm font-medium transition-all duration-150 ease-out" disabled={isPending} + data-testid="register-button" > {isPending ? ( diff --git a/packages/ui/src/components/workspaces/workspace-create.tsx b/packages/ui/src/components/workspaces/workspace-create.tsx index 32d8af16..2bb82984 100644 --- a/packages/ui/src/components/workspaces/workspace-create.tsx +++ b/packages/ui/src/components/workspaces/workspace-create.tsx @@ -17,7 +17,10 @@ export const WorkspaceCreate = ({ const { mutate, isPending } = useMutation(); return ( -
+
diff --git a/packages/ui/src/components/workspaces/workspace-form.tsx b/packages/ui/src/components/workspaces/workspace-form.tsx index 7b38c24c..e59b27b3 100644 --- a/packages/ui/src/components/workspaces/workspace-form.tsx +++ b/packages/ui/src/components/workspaces/workspace-form.tsx @@ -135,7 +135,12 @@ export const WorkspaceForm = ({ Name * - + @@ -179,6 +184,7 @@ export const WorkspaceForm = ({ type="submit" disabled={isPending || isSaving} className="w-20" + data-testid="workspace-create-button" > {isSaving && } {saveText} diff --git a/packages/ui/src/components/workspaces/workspace.tsx b/packages/ui/src/components/workspaces/workspace.tsx index acc6fbe7..2492fc0f 100644 --- a/packages/ui/src/components/workspaces/workspace.tsx +++ b/packages/ui/src/components/workspaces/workspace.tsx @@ -67,7 +67,9 @@ export const Workspace = ({ workspace }: WorkspaceProps) => { }} > - +
+ +
); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..f0d08219 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,45 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: [['html', { open: 'never' }], ['list']], + use: { + baseURL: process.env.E2E_BASE_URL || 'http://localhost:4000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], + webServer: process.env.CI + ? undefined + : [ + { + command: 'npm run dev -w @brainbox/server', + url: 'http://localhost:3000/config', + reuseExistingServer: true, + timeout: 120000, + }, + { + command: 'npm run dev -w @brainbox/web', + url: 'http://localhost:4000', + reuseExistingServer: true, + timeout: 120000, + }, + ], +});