diff --git a/README.md b/README.md index f79ac41f..5cb70829 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,28 @@ To run Colanode locally in development mode: npm run dev ``` +## Testing + +Colanode includes tests for both server and web. + +### Server tests + +From `apps/server`: + +```bash +npm run test +``` + +Server tests use Testcontainers for Postgres and Redis, so Docker must be running. See [`apps/server/README.md`](apps/server/README.md) for details. + +### Web tests + +From `apps/web`: + +```bash +npm run test +``` + ## License Colanode is released under the [Apache 2.0 License](LICENSE). diff --git a/apps/web/package.json b/apps/web/package.json index 02ae1629..97a87c65 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "start": "vite --port 4000", "build": "vite build && tsc", "serve": "vite preview", - "test": "vitest --passWithNoTests" + "test": "vitest" }, "dependencies": { "@colanode/client": "*", @@ -18,10 +18,14 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^5.1.2", "jsdom": "^27.4.0", "tailwindcss": "^4.1.18", "vite": "^7.3.1", - "vite-plugin-pwa": "^1.2.0" + "vite-plugin-pwa": "^1.2.0", + "vitest": "^4.0.17" } } diff --git a/apps/web/src/workers/service.ts b/apps/web/src/workers/service.ts index ad41553c..4944cd98 100644 --- a/apps/web/src/workers/service.ts +++ b/apps/web/src/workers/service.ts @@ -25,11 +25,11 @@ registerRoute( }) ); -const downloadDbs = async () => { +export const downloadDbs = async () => { await Promise.all([downloadEmojis(), downloadIcons()]); }; -const downloadEmojis = async () => { +export const downloadEmojis = async () => { try { const emojiResponse = await fetch('/assets/emojis.db'); if (!emojiResponse.ok) { @@ -44,7 +44,7 @@ const downloadEmojis = async () => { } }; -const downloadIcons = async () => { +export const downloadIcons = async () => { try { const iconResponse = await fetch('/assets/icons.db'); if (!iconResponse.ok) { diff --git a/apps/web/test/components/browser-not-supported.test.tsx b/apps/web/test/components/browser-not-supported.test.tsx new file mode 100644 index 00000000..fea93505 --- /dev/null +++ b/apps/web/test/components/browser-not-supported.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { BrowserNotSupported } from '@colanode/web/components/browser-not-supported'; +import { customRender, screen } from '../test-utils'; + +describe('components/BrowserNotSupported', () => { + it('renders the main heading', () => { + customRender(); + + const heading = screen.getByRole('heading', { + name: /browser not supported/i, + }); + expect(heading).toBeInTheDocument(); + }); + + it('displays a link to download the desktop app', () => { + customRender(); + + const desktopLink = screen.getByRole('link', { + name: /desktop app/i, + }); + expect(desktopLink).toBeInTheDocument(); + expect(desktopLink).toHaveAttribute( + 'href', + 'https://colanode.com/downloads' + ); + expect(desktopLink).toHaveAttribute('target', '_blank'); + expect(desktopLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + + it('displays a link to GitHub', () => { + customRender(); + + const githubLink = screen.getByRole('link', { + name: /github/i, + }); + expect(githubLink).toBeInTheDocument(); + expect(githubLink).toHaveAttribute( + 'href', + 'https://github.com/colanode/colanode' + ); + expect(githubLink).toHaveAttribute('target', '_blank'); + expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer'); + }); + it('renders the MonitorOff icon', () => { + const { container } = customRender(); + + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/components/mobile-not-supported.test.tsx b/apps/web/test/components/mobile-not-supported.test.tsx new file mode 100644 index 00000000..0fff9a4d --- /dev/null +++ b/apps/web/test/components/mobile-not-supported.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { MobileNotSupported } from '@colanode/web/components/mobile-not-supported'; +import { customRender, screen } from '../test-utils'; + +describe('components/MobileNotSupported', () => { + it('renders the main heading', () => { + customRender(); + + const heading = screen.getByRole('heading', { + name: /mobile not supported/i, + }); + expect(heading).toBeInTheDocument(); + }); + + it('displays a greeting message', () => { + customRender(); + + expect(screen.getByText(/hey there!/i)).toBeInTheDocument(); + }); + + it('renders the Smartphone icon', () => { + const { container } = customRender(); + + // lucide-react renders SVGs + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + }); +}); diff --git a/apps/web/test/helpers/mock-file-system.ts b/apps/web/test/helpers/mock-file-system.ts new file mode 100644 index 00000000..531760c4 --- /dev/null +++ b/apps/web/test/helpers/mock-file-system.ts @@ -0,0 +1,136 @@ +import { FileSystem } from '@colanode/client/services'; + +/** + * Mock implementation of the FileSystem interface for testing. + * Stores files in memory using a Map. + */ +export class MockFileSystem implements FileSystem { + private files = new Map(); + private directories = new Set(); + + constructor(initialFiles?: Record) { + if (initialFiles) { + for (const [path, content] of Object.entries(initialFiles)) { + if (typeof content === 'string') { + this.files.set(path, new TextEncoder().encode(content)); + } else { + this.files.set(path, content); + } + this.ensureParentDirectories(path); + } + } + } + + private ensureParentDirectories(path: string): void { + const parts = path.split('/'); + for (let i = 0; i < parts.length - 1; i++) { + const dirPath = parts.slice(0, i + 1).join('/'); + if (dirPath) { + this.directories.add(dirPath); + } + } + } + + async reset(): Promise { + this.files.clear(); + this.directories.clear(); + } + + async makeDirectory(path: string): Promise { + this.directories.add(path); + this.ensureParentDirectories(path); + } + + async exists(path: string): Promise { + return this.files.has(path) || this.directories.has(path); + } + + async delete(path: string): Promise { + this.files.delete(path); + this.directories.delete(path); + } + + async copy(source: string, destination: string): Promise { + const data = this.files.get(source); + if (!data) { + throw new Error(`Source file not found: ${source}`); + } + this.files.set(destination, data); + this.ensureParentDirectories(destination); + } + + async readStream(_path: string): Promise { + throw new Error('readStream not implemented in MockFileSystem'); + } + + async writeStream(_path: string): Promise> { + throw new Error('writeStream not implemented in MockFileSystem'); + } + + async listFiles(path: string): Promise { + const files: string[] = []; + const prefix = path ? `${path}/` : ''; + + for (const filePath of this.files.keys()) { + if (filePath.startsWith(prefix)) { + const relativePath = filePath.substring(prefix.length); + if (!relativePath.includes('/')) { + files.push(relativePath); + } + } + } + + for (const dirPath of this.directories) { + if (dirPath.startsWith(prefix) && dirPath !== path) { + const relativePath = dirPath.substring(prefix.length); + if (!relativePath.includes('/')) { + files.push(relativePath); + } + } + } + + return files; + } + + async readFile(path: string): Promise { + const data = this.files.get(path); + if (!data) { + throw new Error(`File not found: ${path}`); + } + return data; + } + + async writeFile(path: string, data: Uint8Array): Promise { + this.files.set(path, data); + this.ensureParentDirectories(path); + } + + async url(): Promise { + return null; + } + + // Helper methods for testing + getFileAsString(path: string): string { + const data = this.files.get(path); + if (!data) { + throw new Error(`File not found: ${path}`); + } + return new TextDecoder().decode(data); + } + + hasFile(path: string): boolean { + return this.files.has(path); + } + + hasDirectory(path: string): boolean { + return this.directories.has(path); + } + + getAllFiles(): string[] { + return Array.from(this.files.keys()); + } + + getAllDirectories(): string[] { + return Array.from(this.directories); + } +} diff --git a/apps/web/test/helpers/mock-opfs.ts b/apps/web/test/helpers/mock-opfs.ts new file mode 100644 index 00000000..01783571 --- /dev/null +++ b/apps/web/test/helpers/mock-opfs.ts @@ -0,0 +1,204 @@ +/** + * Mock implementation of OPFS FileSystemDirectoryHandle and FileSystemFileHandle + * for testing WebFileSystem without real browser APIs. + * + * Uses 'any' type assertions to avoid strict TypeScript interface conflicts + * while maintaining the behavior needed for tests. + */ + +export class MockFileSystemFileHandle { + public readonly kind = 'file' as const; + public readonly name: string; + private data: Uint8Array; + + constructor(name: string, data: Uint8Array = new Uint8Array()) { + this.name = name; + this.data = data; + } + + async getFile(): Promise { + // Create a proper File mock with arrayBuffer method + const arrayBuffer: ArrayBuffer = this.data.buffer.slice( + this.data.byteOffset, + this.data.byteOffset + this.data.byteLength + ) as ArrayBuffer; + + const file = new File([arrayBuffer], this.name); + // Ensure arrayBuffer method exists for tests + if (!file.arrayBuffer) { + (file as any).arrayBuffer = async () => arrayBuffer; + } + return file; + } + + async createWritable(): Promise { + const handle = this; + let buffer: Uint8Array = new Uint8Array(); + + return { + async write(data: any) { + let chunk: Uint8Array | null = null; + if (data instanceof Uint8Array) { + chunk = data; + } else if (data instanceof ArrayBuffer) { + chunk = new Uint8Array(data); + } else if (typeof data === 'string') { + chunk = new TextEncoder().encode(data); + } + + if (!chunk) { + return; + } + + const next = new Uint8Array(buffer.length + chunk.length); + next.set(buffer); + next.set(chunk, buffer.length); + buffer = next; + }, + async close() { + handle.data = buffer; + }, + async abort() { + buffer = new Uint8Array(); + }, + }; + } + + async isSameEntry(): Promise { + return false; + } + + async queryPermission(): Promise { + return 'granted'; + } + + async requestPermission(): Promise { + return 'granted'; + } +} + +export class MockFileSystemDirectoryHandle { + public readonly kind = 'directory' as const; + public readonly name: string; + private _entries = new Map< + string, + MockFileSystemDirectoryHandle | MockFileSystemFileHandle + >(); + + constructor(name: string = '') { + this.name = name; + } + + async getDirectoryHandle( + name: string, + options?: { create?: boolean } + ): Promise { + const entry = this._entries.get(name); + + if (entry) { + if (entry.kind === 'directory') { + return entry; + } + throw new DOMException('Entry is a file', 'TypeMismatchError'); + } + + if (!options?.create) { + throw new DOMException('Directory not found', 'NotFoundError'); + } + + const newDir = new MockFileSystemDirectoryHandle(name); + this._entries.set(name, newDir); + return newDir; + } + + async getFileHandle( + name: string, + options?: { create?: boolean } + ): Promise { + const entry = this._entries.get(name); + + if (entry) { + if (entry.kind === 'file') { + return entry; + } + throw new DOMException('Entry is a directory', 'TypeMismatchError'); + } + + if (!options?.create) { + throw new DOMException('File not found', 'NotFoundError'); + } + + const newFile = new MockFileSystemFileHandle(name); + this._entries.set(name, newFile); + return newFile; + } + + async removeEntry(name: string, options?: { recursive?: boolean }) { + const entry = this._entries.get(name); + + if (!entry) { + throw new DOMException('Entry not found', 'NotFoundError'); + } + + if (entry.kind === 'directory' && !options?.recursive) { + const dirHandle = entry as MockFileSystemDirectoryHandle; + if (dirHandle._entries.size > 0) { + throw new DOMException( + 'Directory not empty', + 'InvalidModificationError' + ); + } + } + + this._entries.delete(name); + } + + async resolve(): Promise { + return null; + } + + async isSameEntry(): Promise { + return false; + } + + async queryPermission(): Promise { + return 'granted'; + } + + async requestPermission(): Promise { + return 'granted'; + } + + async *entries(): AsyncIterableIterator<[string, any]> { + for (const [name, handle] of this._entries) { + yield [name, handle]; + } + } + + async *keys(): AsyncIterableIterator { + for (const name of this._entries.keys()) { + yield name; + } + } + + async *values(): AsyncIterableIterator { + for (const handle of this._entries.values()) { + yield handle; + } + } + + // Helper methods for testing + hasEntry(name: string): boolean { + return this._entries.has(name); + } + + getEntrySync( + name: string + ): MockFileSystemDirectoryHandle | MockFileSystemFileHandle | undefined { + return this._entries.get(name); + } + + clear(): void { + this._entries.clear(); + } +} diff --git a/apps/web/test/lib/utils.test.ts b/apps/web/test/lib/utils.test.ts new file mode 100644 index 00000000..f64736cb --- /dev/null +++ b/apps/web/test/lib/utils.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { isOpfsSupported, isMobileDevice } from '@colanode/web/lib/utils'; + +describe('lib/utils', () => { + describe('isMobileDevice', () => { + it('returns true for Android devices', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36', + }); + expect(isMobileDevice()).toBe(true); + }); + + it('returns true for iPhone devices', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1', + }); + expect(isMobileDevice()).toBe(true); + }); + + it('returns true for iPad devices', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 14_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1', + }); + expect(isMobileDevice()).toBe(true); + }); + + it('returns true for iPod devices', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (iPod; CPU iPhone OS 14_6 like Mac OS X) AppleWebKit/605.1.15', + }); + expect(isMobileDevice()).toBe(true); + }); + + it('returns true for Opera Mini', () => { + vi.stubGlobal('navigator', { + userAgent: 'Opera/9.80 (J2ME/MIDP; Opera Mini/9.80 (S60; SymbOS)', + }); + expect(isMobileDevice()).toBe(true); + }); + + it('returns true for IEMobile', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0)', + }); + expect(isMobileDevice()).toBe(true); + }); + + it('returns false for desktop Chrome', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + }); + expect(isMobileDevice()).toBe(false); + }); + + it('returns false for desktop Firefox', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0', + }); + expect(isMobileDevice()).toBe(false); + }); + + it('returns false for desktop Safari (Mac)', () => { + vi.stubGlobal('navigator', { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15', + }); + expect(isMobileDevice()).toBe(false); + }); + }); + + describe('isOpfsSupported', () => { + it('returns false when navigator.storage is undefined', async () => { + vi.stubGlobal('navigator', {}); + const result = await isOpfsSupported(); + expect(result).toBe(false); + }); + + it('returns false when navigator.storage.getDirectory is undefined', async () => { + vi.stubGlobal('navigator', { + storage: {}, + }); + const result = await isOpfsSupported(); + expect(result).toBe(false); + }); + + it('returns false when getDirectory returns null', async () => { + vi.stubGlobal('navigator', { + storage: { + getDirectory: vi.fn().mockResolvedValue(null), + }, + }); + const result = await isOpfsSupported(); + expect(result).toBe(false); + }); + + it('returns false when getDirectory throws an error', async () => { + vi.stubGlobal('navigator', { + storage: { + getDirectory: vi.fn().mockRejectedValue(new Error('Not supported')), + }, + }); + const result = await isOpfsSupported(); + expect(result).toBe(false); + }); + + it('returns true when getDirectory returns a valid directory handle', async () => { + const mockDirectoryHandle = { + kind: 'directory', + name: 'root', + }; + vi.stubGlobal('navigator', { + storage: { + getDirectory: vi.fn().mockResolvedValue(mockDirectoryHandle), + }, + }); + const result = await isOpfsSupported(); + expect(result).toBe(true); + }); + + it('calls getDirectory to verify OPFS functionality', async () => { + const getDirectoryMock = vi.fn().mockResolvedValue({ kind: 'directory' }); + vi.stubGlobal('navigator', { + storage: { + getDirectory: getDirectoryMock, + }, + }); + await isOpfsSupported(); + expect(getDirectoryMock).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/apps/web/test/services/bootstrap.test.ts b/apps/web/test/services/bootstrap.test.ts new file mode 100644 index 00000000..b43a0480 --- /dev/null +++ b/apps/web/test/services/bootstrap.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; + +import { build } from '@colanode/core'; +import { WebBootstrapService } from '@colanode/web/services/bootstrap'; +import { WebPathService } from '@colanode/web/services/path-service'; +import { MockFileSystem } from '../helpers/mock-file-system'; + +describe('services/bootstrap', () => { + let pathService: WebPathService; + let fs: MockFileSystem; + + beforeEach(() => { + pathService = new WebPathService(); + fs = new MockFileSystem(); + }); + + describe('create', () => { + it('creates a new service with default data when bootstrap file does not exist', async () => { + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.version).toBe(build.version); + expect(service.needsFreshInstall).toBe(false); + }); + + it('loads existing bootstrap data when file exists', async () => { + const bootstrapData = { + version: '1.2.3', + }; + + fs = new MockFileSystem({ + 'bootstrap.json': JSON.stringify(bootstrapData), + }); + + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.version).toBe('1.2.3'); + expect(service.needsFreshInstall).toBe(false); + }); + + it('detects fresh install when temp exists but bootstrap does not', async () => { + fs = new MockFileSystem(); + await fs.makeDirectory('temp'); + + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.needsFreshInstall).toBe(true); + }); + + it('does not detect fresh install when both bootstrap and temp exist', async () => { + fs = new MockFileSystem({ + 'bootstrap.json': JSON.stringify({ version: '1.0.0' }), + }); + await fs.makeDirectory('temp'); + + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.needsFreshInstall).toBe(false); + }); + + it('handles invalid JSON in bootstrap file gracefully', async () => { + fs = new MockFileSystem({ + 'bootstrap.json': 'invalid{json}here', + }); + + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.version).toBe(build.version); + expect(service.needsFreshInstall).toBe(false); + }); + + it('handles empty bootstrap file gracefully', async () => { + fs = new MockFileSystem({ + 'bootstrap.json': '', + }); + + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.version).toBe(build.version); + expect(service.needsFreshInstall).toBe(false); + }); + + it('handles bootstrap file with partial data', async () => { + fs = new MockFileSystem({ + 'bootstrap.json': JSON.stringify({}), + }); + + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.version).toBe(build.version); + }); + + it('handles bootstrap file with extra fields', async () => { + fs = new MockFileSystem({ + 'bootstrap.json': JSON.stringify({ + version: '2.0.0', + extraField: 'should be ignored', + }), + }); + + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.version).toBe('2.0.0'); + }); + }); + + describe('updateVersion', () => { + it('updates the version and persists to file system', async () => { + const service = await WebBootstrapService.create(pathService, fs as any); + + await service.updateVersion('2.0.0'); + + expect(service.version).toBe('2.0.0'); + expect(fs.hasFile('bootstrap.json')).toBe(true); + + const savedData = fs.getFileAsString('bootstrap.json'); + const parsed = JSON.parse(savedData); + expect(parsed.version).toBe('2.0.0'); + }); + + it('preserves proper JSON formatting', async () => { + const service = await WebBootstrapService.create(pathService, fs as any); + + await service.updateVersion('3.1.0'); + + const savedData = fs.getFileAsString('bootstrap.json'); + // Should be pretty-printed with 2 spaces + expect(savedData).toContain('\n'); + expect(savedData).toContain(' '); + + const parsed = JSON.parse(savedData); + expect(parsed.version).toBe('3.1.0'); + }); + + it('overwrites existing bootstrap file', async () => { + fs = new MockFileSystem({ + 'bootstrap.json': JSON.stringify({ version: '1.0.0' }), + }); + + const service = await WebBootstrapService.create(pathService, fs as any); + await service.updateVersion('4.0.0'); + + const savedData = fs.getFileAsString('bootstrap.json'); + const parsed = JSON.parse(savedData); + expect(parsed.version).toBe('4.0.0'); + }); + + it('handles multiple version updates', async () => { + const service = await WebBootstrapService.create(pathService, fs as any); + + await service.updateVersion('1.0.0'); + expect(service.version).toBe('1.0.0'); + + await service.updateVersion('2.0.0'); + expect(service.version).toBe('2.0.0'); + + await service.updateVersion('3.0.0'); + expect(service.version).toBe('3.0.0'); + + const savedData = fs.getFileAsString('bootstrap.json'); + const parsed = JSON.parse(savedData); + expect(parsed.version).toBe('3.0.0'); + }); + }); + + describe('version getter', () => { + it('returns the current version', async () => { + fs = new MockFileSystem({ + 'bootstrap.json': JSON.stringify({ version: '5.0.0' }), + }); + + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.version).toBe('5.0.0'); + }); + + it('returns default version when no file exists', async () => { + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.version).toBe(build.version); + }); + }); + + describe('needsFreshInstall getter', () => { + it('returns true when temp directory exists without bootstrap file', async () => { + await fs.makeDirectory('temp'); + + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.needsFreshInstall).toBe(true); + }); + + it('returns false when bootstrap file exists regardless of temp', async () => { + fs = new MockFileSystem({ + 'bootstrap.json': JSON.stringify({ version: '1.0.0' }), + }); + await fs.makeDirectory('temp'); + + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.needsFreshInstall).toBe(false); + }); + + it('returns false when neither bootstrap nor temp exist', async () => { + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.needsFreshInstall).toBe(false); + }); + + it('returns the same value on multiple calls', async () => { + await fs.makeDirectory('temp'); + + const service = await WebBootstrapService.create(pathService, fs as any); + + expect(service.needsFreshInstall).toBe(true); + expect(service.needsFreshInstall).toBe(true); + expect(service.needsFreshInstall).toBe(true); + }); + }); + + describe('file system error handling', () => { + it('handles file system errors when reading bootstrap', async () => { + const errorFs = { + ...fs, + exists: vi.fn().mockResolvedValue(true), + readFile: vi.fn().mockRejectedValue(new Error('Read error')), + } as any; + + const service = await WebBootstrapService.create(pathService, errorFs); + + // Should fall back to default values + expect(service.version).toBe(build.version); + }); + + it('handles file system errors when checking directories', async () => { + const errorFs = { + ...fs, + exists: vi.fn().mockRejectedValue(new Error('FS error')), + } as any; + + // fs.exists errors will propagate - this is expected behavior + await expect( + WebBootstrapService.create(pathService, errorFs) + ).rejects.toThrow('FS error'); + }); + }); + + describe('integration scenarios', () => { + it('handles a complete bootstrap lifecycle', async () => { + // Initial creation + const service1 = await WebBootstrapService.create(pathService, fs as any); + expect(service1.version).toBe(build.version); + + // Update version + await service1.updateVersion('1.0.0'); + + // Create new service instance (simulating app restart) + const service2 = await WebBootstrapService.create(pathService, fs as any); + expect(service2.version).toBe('1.0.0'); + + // Another update + await service2.updateVersion('2.0.0'); + + // Verify persistence + const service3 = await WebBootstrapService.create(pathService, fs as any); + expect(service3.version).toBe('2.0.0'); + }); + + it('handles upgrade scenario from old version', async () => { + // Simulate old installation + fs = new MockFileSystem({ + 'bootstrap.json': JSON.stringify({ version: '0.9.0' }), + }); + + const service = await WebBootstrapService.create(pathService, fs as any); + expect(service.version).toBe('0.9.0'); + + // Upgrade + await service.updateVersion('1.0.0'); + expect(service.version).toBe('1.0.0'); + + // Verify + const savedData = fs.getFileAsString('bootstrap.json'); + const parsed = JSON.parse(savedData); + expect(parsed.version).toBe('1.0.0'); + }); + }); +}); diff --git a/apps/web/test/services/file-system.test.ts b/apps/web/test/services/file-system.test.ts new file mode 100644 index 00000000..52f42b7d --- /dev/null +++ b/apps/web/test/services/file-system.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest'; + +import { WebFileSystem } from '@colanode/web/services/file-system'; +import { MockFileSystemDirectoryHandle } from '../helpers/mock-opfs'; + +describe('services/file-system', () => { + let fs: WebFileSystem; + let mockRoot: MockFileSystemDirectoryHandle; + + beforeEach(() => { + mockRoot = new MockFileSystemDirectoryHandle(); + + // Mock navigator.storage.getDirectory + vi.stubGlobal('navigator', { + storage: { + getDirectory: vi.fn().mockResolvedValue(mockRoot), + }, + }); + + // Mock URL.createObjectURL for url() tests + if (!URL.createObjectURL) { + URL.createObjectURL = vi.fn(() => `blob:mock-${Date.now()}`); + } + + fs = new WebFileSystem(); + }); + + describe('makeDirectory', () => { + it('creates a simple directory', async () => { + await fs.makeDirectory('test'); + + expect(mockRoot.hasEntry('test')).toBe(true); + const entry = mockRoot.getEntrySync('test'); + expect(entry?.kind).toBe('directory'); + }); + + it('creates nested directories', async () => { + await fs.makeDirectory('a/b/c'); + + const dirA = mockRoot.getEntrySync('a'); + expect(dirA?.kind).toBe('directory'); + + if (dirA?.kind === 'directory') { + const dirB = dirA.getEntrySync('b'); + expect(dirB?.kind).toBe('directory'); + + if (dirB?.kind === 'directory') { + const dirC = dirB.getEntrySync('c'); + expect(dirC?.kind).toBe('directory'); + } + } + }); + + it('handles existing directories without error', async () => { + await fs.makeDirectory('test'); + await expect(fs.makeDirectory('test')).resolves.not.toThrow(); + }); + }); + + describe('exists', () => { + it('returns false for non-existent files', async () => { + const result = await fs.exists('nonexistent.txt'); + expect(result).toBe(false); + }); + + it('returns true for existing files', async () => { + await fs.writeFile('test.txt', new Uint8Array([1, 2, 3])); + + const result = await fs.exists('test.txt'); + expect(result).toBe(true); + }); + + it('returns true for existing directories', async () => { + await fs.makeDirectory('test'); + + const result = await fs.exists('test'); + expect(result).toBe(true); + }); + + it('returns true for root directory', async () => { + const result = await fs.exists(''); + expect(result).toBe(true); + }); + + it('returns false for files in non-existent directories', async () => { + const result = await fs.exists('nonexistent/file.txt'); + expect(result).toBe(false); + }); + }); + + describe('writeFile and readFile', () => { + it('writes and reads a file', async () => { + const data = new Uint8Array([1, 2, 3, 4, 5]); + + await fs.writeFile('test.txt', data); + const result = await fs.readFile('test.txt'); + + expect(result).toEqual(data); + }); + + it('creates parent directories automatically', async () => { + const data = new Uint8Array([1, 2, 3]); + + await fs.writeFile('a/b/c/file.txt', data); + + expect(await fs.exists('a')).toBe(true); + expect(await fs.exists('a/b')).toBe(true); + expect(await fs.exists('a/b/c')).toBe(true); + expect(await fs.exists('a/b/c/file.txt')).toBe(true); + }); + + it('overwrites existing files', async () => { + await fs.writeFile('test.txt', new Uint8Array([1, 2, 3])); + await fs.writeFile('test.txt', new Uint8Array([4, 5, 6])); + + const result = await fs.readFile('test.txt'); + expect(result).toEqual(new Uint8Array([4, 5, 6])); + }); + + it('throws error when reading non-existent file', async () => { + await expect(fs.readFile('nonexistent.txt')).rejects.toThrow(); + }); + + it('handles empty files', async () => { + await fs.writeFile('empty.txt', new Uint8Array([])); + + const result = await fs.readFile('empty.txt'); + expect(result).toEqual(new Uint8Array([])); + }); + + it('handles large files', async () => { + const largeData = new Uint8Array(1024 * 1024); // 1MB + for (let i = 0; i < largeData.length; i++) { + largeData[i] = i % 256; + } + + await fs.writeFile('large.bin', largeData); + const result = await fs.readFile('large.bin'); + + expect(result.length).toBe(largeData.length); + expect(result).toEqual(largeData); + }); + }); + + describe('delete', () => { + it('deletes an existing file', async () => { + await fs.writeFile('test.txt', new Uint8Array([1, 2, 3])); + + await fs.delete('test.txt'); + + expect(await fs.exists('test.txt')).toBe(false); + }); + + it('deletes an existing directory recursively', async () => { + await fs.makeDirectory('test/nested'); + await fs.writeFile('test/nested/file.txt', new Uint8Array([1, 2, 3])); + + await fs.delete('test'); + + expect(await fs.exists('test')).toBe(false); + }); + + it('handles deleting non-existent files gracefully', async () => { + await expect(fs.delete('nonexistent.txt')).resolves.not.toThrow(); + }); + + it('deletes nested files', async () => { + await fs.writeFile('a/b/c/file.txt', new Uint8Array([1, 2, 3])); + + await fs.delete('a/b/c/file.txt'); + + expect(await fs.exists('a/b/c/file.txt')).toBe(false); + expect(await fs.exists('a/b/c')).toBe(true); // parent dir still exists + }); + }); + + describe('copy', () => { + it('copies a file to a new location', async () => { + const data = new Uint8Array([1, 2, 3, 4, 5]); + await fs.writeFile('source.txt', data); + + await fs.copy('source.txt', 'dest.txt'); + + const result = await fs.readFile('dest.txt'); + expect(result).toEqual(data); + expect(await fs.exists('source.txt')).toBe(true); // original still exists + }); + + it('copies a file to a nested directory', async () => { + await fs.writeFile('source.txt', new Uint8Array([1, 2, 3])); + + await fs.copy('source.txt', 'a/b/dest.txt'); + + expect(await fs.exists('a/b/dest.txt')).toBe(true); + const result = await fs.readFile('a/b/dest.txt'); + expect(result).toEqual(new Uint8Array([1, 2, 3])); + }); + + it('overwrites destination if it exists', async () => { + await fs.writeFile('source.txt', new Uint8Array([1, 2, 3])); + await fs.writeFile('dest.txt', new Uint8Array([4, 5, 6])); + + await fs.copy('source.txt', 'dest.txt'); + + const result = await fs.readFile('dest.txt'); + expect(result).toEqual(new Uint8Array([1, 2, 3])); + }); + + it('throws error when source does not exist', async () => { + await expect(fs.copy('nonexistent.txt', 'dest.txt')).rejects.toThrow(); + }); + }); + + describe('listFiles', () => { + it('lists files in a directory', async () => { + await fs.writeFile('dir/file1.txt', new Uint8Array([1])); + await fs.writeFile('dir/file2.txt', new Uint8Array([2])); + await fs.makeDirectory('dir/subdir'); + + const files = await fs.listFiles('dir'); + + expect(files).toHaveLength(3); + expect(files).toContain('file1.txt'); + expect(files).toContain('file2.txt'); + expect(files).toContain('subdir'); + }); + + it('returns empty array for empty directory', async () => { + await fs.makeDirectory('empty'); + + const files = await fs.listFiles('empty'); + + expect(files).toEqual([]); + }); + + it('only lists direct children', async () => { + await fs.writeFile('dir/file.txt', new Uint8Array([1])); + await fs.writeFile('dir/sub/nested.txt', new Uint8Array([2])); + + const files = await fs.listFiles('dir'); + + expect(files).toContain('file.txt'); + expect(files).toContain('sub'); + expect(files).not.toContain('nested.txt'); + }); + + it('throws error for non-existent directory', async () => { + await expect(fs.listFiles('nonexistent')).rejects.toThrow(); + }); + }); + + describe('reset', () => { + it('deletes all files and directories', async () => { + await fs.writeFile('file1.txt', new Uint8Array([1])); + await fs.writeFile('dir/file2.txt', new Uint8Array([2])); + await fs.makeDirectory('emptydir'); + + await fs.reset(); + + expect(await fs.exists('file1.txt')).toBe(false); + expect(await fs.exists('dir')).toBe(false); + expect(await fs.exists('emptydir')).toBe(false); + }); + + it('allows creating new files after reset', async () => { + await fs.writeFile('test.txt', new Uint8Array([1])); + await fs.reset(); + + await fs.writeFile('new.txt', new Uint8Array([2])); + + expect(await fs.exists('new.txt')).toBe(true); + const data = await fs.readFile('new.txt'); + expect(data).toEqual(new Uint8Array([2])); + }); + }); + + describe('url', () => { + it('returns null for non-existent files', async () => { + const url = await fs.url('nonexistent.txt'); + expect(url).toBeNull(); + }); + + it('returns a blob URL for existing files', async () => { + await fs.writeFile('test.txt', new Uint8Array([1, 2, 3])); + + const url = await fs.url('test.txt'); + + expect(url).toBeTruthy(); + expect(typeof url).toBe('string'); + // Blob URLs start with 'blob:' + if (url) { + expect(url.startsWith('blob:')).toBe(true); + } + }); + }); + + describe('writeStream and readStream', () => { + it('writes data via stream', async () => { + const stream = await fs.writeStream('stream.txt'); + const writer = stream.getWriter(); + + await writer.write(new Uint8Array([1, 2, 3])); + await writer.write(new Uint8Array([4, 5, 6])); + await writer.close(); + + const data = await fs.readFile('stream.txt'); + expect(data).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6])); + }); + + it('reads data via stream', async () => { + await fs.writeFile('test.txt', new Uint8Array([1, 2, 3])); + + const fileStream = await fs.readStream('test.txt'); + + expect(fileStream).toBeTruthy(); + // FileReadStream is a File or Blob-like object + expect(fileStream).toBeInstanceOf(File); + }); + + it('creates parent directories for writeStream', async () => { + const stream = await fs.writeStream('a/b/c/stream.txt'); + const writer = stream.getWriter(); + + await writer.write(new Uint8Array([1, 2, 3])); + await writer.close(); + + expect(await fs.exists('a/b/c')).toBe(true); + expect(await fs.exists('a/b/c/stream.txt')).toBe(true); + }); + }); + + describe('edge cases', () => { + it('handles paths with dots correctly', async () => { + await fs.writeFile('file.with.dots.txt', new Uint8Array([1])); + + expect(await fs.exists('file.with.dots.txt')).toBe(true); + }); + + it('handles paths with special characters', async () => { + await fs.writeFile('file_with-special.txt', new Uint8Array([1])); + + expect(await fs.exists('file_with-special.txt')).toBe(true); + }); + + it('handles deeply nested paths', async () => { + const deepPath = 'a/b/c/d/e/f/g/h/i/j/file.txt'; + await fs.writeFile(deepPath, new Uint8Array([1])); + + expect(await fs.exists(deepPath)).toBe(true); + }); + + it('normalizes empty path segments', async () => { + await fs.writeFile('dir/./file.txt', new Uint8Array([1])); + + expect(await fs.exists('dir/file.txt')).toBe(true); + }); + }); +}); diff --git a/apps/web/test/services/path-service.test.ts b/apps/web/test/services/path-service.test.ts new file mode 100644 index 00000000..c22b7c32 --- /dev/null +++ b/apps/web/test/services/path-service.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { WebPathService } from '@colanode/web/services/path-service'; + +describe('services/path-service', () => { + let pathService: WebPathService; + + beforeEach(() => { + pathService = new WebPathService(); + }); + + describe('basic paths', () => { + it('returns empty string for app path', () => { + expect(pathService.app).toBe(''); + }); + + it('returns "assets" for assets source path', () => { + expect(pathService.assets).toBe('assets'); + }); + + it('returns "app.db" for app database path', () => { + expect(pathService.appDatabase).toBe('app.db'); + }); + + it('returns "bootstrap.json" for bootstrap path', () => { + expect(pathService.bootstrap).toBe('bootstrap.json'); + }); + + it('returns "avatars" for avatars path', () => { + expect(pathService.avatars).toBe('avatars'); + }); + + it('returns "temp" for temp path', () => { + expect(pathService.temp).toBe('temp'); + }); + + it('returns "assets/fonts" for fonts path', () => { + expect(pathService.fonts).toBe('assets/fonts'); + }); + + it('returns "assets/emojis.db" for emojis database path', () => { + expect(pathService.emojisDatabase).toBe('assets/emojis.db'); + }); + + it('returns "assets/icons.db" for icons database path', () => { + expect(pathService.iconsDatabase).toBe('assets/icons.db'); + }); + }); + + describe('tempFile', () => { + it('builds temp file paths correctly', () => { + expect(pathService.tempFile('test.txt')).toBe('temp/test.txt'); + }); + + it('handles file names with special characters', () => { + expect(pathService.tempFile('file-name_123.pdf')).toBe( + 'temp/file-name_123.pdf' + ); + }); + }); + + describe('avatar', () => { + it('builds avatar paths with .jpeg extension', () => { + expect(pathService.avatar('avatar-123')).toBe('avatars/avatar-123.jpeg'); + }); + + it('handles UUID-like avatar IDs', () => { + expect(pathService.avatar('550e8400-e29b-41d4-a716-446655440000')).toBe( + 'avatars/550e8400-e29b-41d4-a716-446655440000.jpeg' + ); + }); + }); + + describe('workspace paths', () => { + const userId = 'user-123'; + + it('builds workspace root path', () => { + expect(pathService.workspace(userId)).toBe('workspaces/user-123'); + }); + + it('builds workspace database path', () => { + expect(pathService.workspaceDatabase(userId)).toBe( + 'workspaces/user-123/workspace.db' + ); + }); + + it('builds workspace files directory path', () => { + expect(pathService.workspaceFiles(userId)).toBe( + 'workspaces/user-123/files' + ); + }); + + it('builds workspace file path with extension', () => { + expect(pathService.workspaceFile(userId, 'file-456', '.pdf')).toBe( + 'workspaces/user-123/files/file-456.pdf' + ); + }); + + it('builds workspace file path with different extensions', () => { + expect(pathService.workspaceFile(userId, 'doc-789', '.docx')).toBe( + 'workspaces/user-123/files/doc-789.docx' + ); + expect(pathService.workspaceFile(userId, 'img-001', '.jpg')).toBe( + 'workspaces/user-123/files/img-001.jpg' + ); + }); + }); + + describe('join', () => { + it('joins multiple path segments with forward slashes', () => { + expect(pathService.join('a', 'b', 'c')).toBe('a/b/c'); + }); + + it('filters out empty strings', () => { + expect(pathService.join('a', '', 'b', '', 'c')).toBe('a/b/c'); + }); + + it('filters out null and undefined values', () => { + expect( + pathService.join('a', null as any, 'b', undefined as any, 'c') + ).toBe('a/b/c'); + }); + + it('handles single path segment', () => { + expect(pathService.join('path')).toBe('path'); + }); + + it('handles empty inputs', () => { + expect(pathService.join()).toBe(''); + expect(pathService.join('', '', '')).toBe(''); + }); + + it('trims the result', () => { + expect(pathService.join(' a', 'b ')).toBe('a/b'); + }); + }); + + describe('dirname', () => { + it('returns parent directory for a nested path', () => { + expect(pathService.dirname('foo/bar/baz.txt')).toBe('foo/bar'); + }); + + it('returns parent for two-level path', () => { + expect(pathService.dirname('foo/bar.txt')).toBe('foo'); + }); + + it('returns empty string for single-level path', () => { + expect(pathService.dirname('file.txt')).toBe(''); + }); + + it('returns empty string for root path', () => { + expect(pathService.dirname('')).toBe(''); + }); + + it('handles paths without extensions', () => { + expect(pathService.dirname('foo/bar/baz')).toBe('foo/bar'); + }); + + it('handles deeply nested paths', () => { + expect(pathService.dirname('a/b/c/d/e/f.txt')).toBe('a/b/c/d/e'); + }); + }); + + describe('filename', () => { + it('extracts filename without extension', () => { + expect(pathService.filename('document.pdf')).toBe('document'); + }); + + it('extracts filename from nested path', () => { + expect(pathService.filename('path/to/file.txt')).toBe('file'); + }); + + it('handles files with multiple dots', () => { + expect(pathService.filename('archive.tar.gz')).toBe('archive.tar'); + }); + + it('handles files without extension', () => { + expect(pathService.filename('README')).toBe('README'); + }); + + it('handles empty path', () => { + expect(pathService.filename('')).toBe(''); + }); + + it('handles path ending with slash', () => { + expect(pathService.filename('path/to/')).toBe(''); + }); + + it('handles deeply nested path', () => { + expect(pathService.filename('a/b/c/d/e/file.txt')).toBe('file'); + }); + }); + + describe('extension', () => { + it('extracts file extension with dot', () => { + expect(pathService.extension('file.txt')).toBe('.txt'); + }); + + it('extracts extension from nested path', () => { + expect(pathService.extension('path/to/document.pdf')).toBe('.pdf'); + }); + + it('handles files with multiple dots', () => { + expect(pathService.extension('archive.tar.gz')).toBe('.gz'); + }); + + it('returns empty string for files without extension', () => { + expect(pathService.extension('README')).toBe(''); + }); + + it('returns empty string for empty path', () => { + expect(pathService.extension('')).toBe(''); + }); + + it('handles common file extensions', () => { + expect(pathService.extension('file.js')).toBe('.js'); + expect(pathService.extension('file.ts')).toBe('.ts'); + expect(pathService.extension('file.jsx')).toBe('.jsx'); + expect(pathService.extension('file.tsx')).toBe('.tsx'); + expect(pathService.extension('file.json')).toBe('.json'); + expect(pathService.extension('file.md')).toBe('.md'); + }); + }); + + describe('font', () => { + it('builds font path with font name', () => { + expect(pathService.font('antonio.ttf')).toBe('assets/fonts/antonio.ttf'); + }); + + it('handles different font formats', () => { + expect(pathService.font('satoshi-variable.woff2')).toBe( + 'assets/fonts/satoshi-variable.woff2' + ); + }); + }); + + describe('edge cases and special characters', () => { + it('handles workspace with special user ID characters', () => { + const specialUserId = 'user-123_abc-def'; + expect(pathService.workspace(specialUserId)).toBe( + 'workspaces/user-123_abc-def' + ); + }); + + it('handles file IDs with UUIDs', () => { + const fileId = '550e8400-e29b-41d4-a716-446655440000'; + expect(pathService.workspaceFile('user', fileId, '.txt')).toBe( + 'workspaces/user/files/550e8400-e29b-41d4-a716-446655440000.txt' + ); + }); + + it('handles paths with trailing slashes in dirname', () => { + expect(pathService.dirname('foo/bar/')).toBe('foo/bar'); + }); + }); +}); diff --git a/apps/web/test/setup-dom.ts b/apps/web/test/setup-dom.ts new file mode 100644 index 00000000..414432f6 --- /dev/null +++ b/apps/web/test/setup-dom.ts @@ -0,0 +1,44 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach, vi } from 'vitest'; + +// Clean up after each test +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +// Mock window.matchMedia (commonly used by UI components) +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Mock IntersectionObserver +global.IntersectionObserver = class IntersectionObserver { + constructor() {} + disconnect() {} + observe() {} + takeRecords() { + return []; + } + unobserve() {} +} as any; + +// Mock ResizeObserver +global.ResizeObserver = class ResizeObserver { + constructor() {} + disconnect() {} + observe() {} + unobserve() {} +} as any; diff --git a/apps/web/test/test-utils.tsx b/apps/web/test/test-utils.tsx new file mode 100644 index 00000000..d2edde5c --- /dev/null +++ b/apps/web/test/test-utils.tsx @@ -0,0 +1,16 @@ +import { render, RenderOptions } from '@testing-library/react'; +import { ReactElement, ReactNode } from 'react'; + +/** + * Custom render function that wraps components with common providers. + */ +export function customRender(ui: ReactElement, options?: RenderOptions) { + const Wrapper = ({ children }: { children: ReactNode }) => { + // Add any providers here if needed in the future + return <>{children}; + }; + + return render(ui, { wrapper: Wrapper, ...options }); +} + +export * from '@testing-library/react'; diff --git a/apps/web/test/tsconfig.json b/apps/web/test/tsconfig.json new file mode 100644 index 00000000..f54f5660 --- /dev/null +++ b/apps/web/test/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Web Tests", + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "baseUrl": "..", + "rootDir": "../../..", + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["./**/*.ts", "./**/*.tsx"] +} diff --git a/apps/web/test/workers/service.test.ts b/apps/web/test/workers/service.test.ts new file mode 100644 index 00000000..9e117c2a --- /dev/null +++ b/apps/web/test/workers/service.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { WebFileSystem } from '@colanode/web/services/file-system'; +import { WebPathService } from '@colanode/web/services/path-service'; + +import { MockFileSystemDirectoryHandle } from '../helpers/mock-opfs'; + +vi.mock('workbox-precaching', () => ({ + precacheAndRoute: vi.fn(), +})); + +vi.mock('workbox-routing', () => ({ + registerRoute: vi.fn(), +})); + +vi.mock('workbox-strategies', () => ({ + StaleWhileRevalidate: vi.fn(), +})); + +describe('Service Worker', () => { + let mockRoot: MockFileSystemDirectoryHandle; + let path: WebPathService; + let fs: WebFileSystem; + let mockFetch: ReturnType; + let consoleErrorSpy: ReturnType; + let installHandler: ((event: ExtendableEvent) => void) | null = null; + + const importWorker = async () => { + vi.resetModules(); + return import('@colanode/web/workers/service'); + }; + + beforeEach(async () => { + installHandler = null; + mockRoot = new MockFileSystemDirectoryHandle('root'); + const mockGetDirectory = vi.fn().mockResolvedValue(mockRoot); + vi.stubGlobal('navigator', { + storage: { + getDirectory: mockGetDirectory, + }, + }); + + path = new WebPathService(); + fs = new WebFileSystem(); + + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + vi.stubGlobal('self', { + __WB_DISABLE_DEV_LOGS: false, + __WB_MANIFEST: [], + location: { origin: 'https://example.com' }, + addEventListener: vi.fn((type, handler) => { + if (type === 'install') { + installHandler = handler as (event: ExtendableEvent) => void; + } + }), + skipWaiting: vi.fn().mockResolvedValue(undefined), + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + consoleErrorSpy.mockRestore(); + }); + + it('downloads emojis database and writes to filesystem', async () => { + const mockEmojiData = new Uint8Array([1, 2, 3, 4]); + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: vi.fn().mockResolvedValue(mockEmojiData.buffer), + }); + + const service = await importWorker(); + await service.downloadEmojis(); + + expect(mockFetch).toHaveBeenCalledWith('/assets/emojis.db'); + const exists = await fs.exists(path.emojisDatabase); + expect(exists).toBe(true); + const content = await fs.readFile(path.emojisDatabase); + expect(content).toEqual(mockEmojiData); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('logs an error when emoji download fails', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const service = await importWorker(); + await service.downloadEmojis(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to download emojis:', + expect.any(Error) + ); + const exists = await fs.exists(path.emojisDatabase); + expect(exists).toBe(false); + }); + + it('downloads both databases in parallel', async () => { + const mockEmojiData = new Uint8Array([1, 2, 3]); + const mockIconData = new Uint8Array([4, 5, 6]); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: vi.fn().mockResolvedValue(mockEmojiData.buffer), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: vi.fn().mockResolvedValue(mockIconData.buffer), + }); + + const service = await importWorker(); + await service.downloadDbs(); + + expect(mockFetch).toHaveBeenCalledTimes(2); + const emojisExist = await fs.exists(path.emojisDatabase); + const iconsExist = await fs.exists(path.iconsDatabase); + expect(emojisExist).toBe(true); + expect(iconsExist).toBe(true); + }); + + it('registers an install handler that runs downloadDbs and skipWaiting', async () => { + const mockEmojiData = new Uint8Array([1, 2, 3]); + const mockIconData = new Uint8Array([4, 5, 6]); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: vi.fn().mockResolvedValue(mockEmojiData.buffer), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + arrayBuffer: vi.fn().mockResolvedValue(mockIconData.buffer), + }); + + await importWorker(); + + expect(installHandler).toBeTruthy(); + + const waitUntil = vi.fn((promise: Promise) => promise); + const event = { + waitUntil, + } as unknown as ExtendableEvent; + + installHandler?.(event); + + expect(waitUntil).toHaveBeenCalledTimes(1); + const waitPromise = waitUntil.mock.calls[0]?.[0] as Promise; + await waitPromise; + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenCalledWith('/assets/emojis.db'); + expect(mockFetch).toHaveBeenCalledWith('/assets/icons.db'); + const worker = globalThis.self as unknown as { + skipWaiting: ReturnType; + }; + expect(worker.skipWaiting).toHaveBeenCalledTimes(1); + const emojisExist = await fs.exists(path.emojisDatabase); + const iconsExist = await fs.exists(path.iconsDatabase); + expect(emojisExist).toBe(true); + expect(iconsExist).toBe(true); + }); +}); diff --git a/apps/web/vite.config.js b/apps/web/vite.config.js index 4402d6de..a4c62807 100644 --- a/apps/web/vite.config.js +++ b/apps/web/vite.config.js @@ -9,6 +9,7 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', + setupFiles: ['./test/setup-dom.ts'], }, resolve: { alias: { diff --git a/package-lock.json b/package-lock.json index a6037940..d239d84f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -165,11 +165,15 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@vitejs/plugin-react": "^5.1.2", "jsdom": "^27.4.0", "tailwindcss": "^4.1.18", "vite": "^7.3.1", - "vite-plugin-pwa": "^1.2.0" + "vite-plugin-pwa": "^1.2.0", + "vitest": "^4.0.17" } }, "node_modules/@0no-co/graphql.web": { @@ -193,6 +197,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -13300,6 +13311,134 @@ "testcontainers": "^11.11.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tiptap/core": { "version": "3.18.0", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.18.0.tgz", @@ -13879,6 +14018,14 @@ "@types/node": "*" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/async-lock": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@types/async-lock/-/async-lock-1.4.2.tgz", @@ -14388,6 +14535,15 @@ "@types/node": "*" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/wrap-ansi": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz", @@ -15648,6 +15804,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "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", @@ -17893,6 +18059,13 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csso": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", @@ -18462,6 +18635,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -21893,6 +22074,38 @@ "uglify-js": "^3.1.4" } }, + "node_modules/happy-dom": { + "version": "20.3.7", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.3.7.tgz", + "integrity": "sha512-sb5IzoRl1WJKsUSRe+IloJf3z1iDq5PQ7Yk/ULMsZ5IAQEs9ZL7RsFfiKBXU7nK9QmO+iz0e59EH8r8jexTZ/g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^4.5.0", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -24585,6 +24798,17 @@ "node": ">=12" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/macos-alias": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/macos-alias/-/macos-alias-0.2.12.tgz", @@ -25236,6 +25460,16 @@ "node": ">=4" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -28213,6 +28447,20 @@ "node": ">= 10.13.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -30148,6 +30396,19 @@ "node": ">=0.10.0" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",