From 075c83528627c190d476abc33450a52b72bcb130 Mon Sep 17 00:00:00 2001
From: Ylber Gashi <58399076+ylber-gashi@users.noreply.github.com>
Date: Mon, 9 Feb 2026 15:24:22 +0100
Subject: [PATCH] Web: add test setup and first tests (#313)
* test(server): add vitest harness and initial integration tests
* add web test setup and initial tests
---
README.md | 22 ++
apps/web/package.json | 8 +-
apps/web/src/workers/service.ts | 6 +-
.../components/browser-not-supported.test.tsx | 51 +++
.../components/mobile-not-supported.test.tsx | 29 ++
apps/web/test/helpers/mock-file-system.ts | 136 +++++++
apps/web/test/helpers/mock-opfs.ts | 204 ++++++++++
apps/web/test/lib/utils.test.ts | 139 +++++++
apps/web/test/services/bootstrap.test.ts | 287 ++++++++++++++
apps/web/test/services/file-system.test.ts | 358 ++++++++++++++++++
apps/web/test/services/path-service.test.ts | 256 +++++++++++++
apps/web/test/setup-dom.ts | 44 +++
apps/web/test/test-utils.tsx | 16 +
apps/web/test/tsconfig.json | 12 +
apps/web/test/workers/service.test.ts | 171 +++++++++
apps/web/vite.config.js | 1 +
package-lock.json | 263 ++++++++++++-
17 files changed, 1997 insertions(+), 6 deletions(-)
create mode 100644 apps/web/test/components/browser-not-supported.test.tsx
create mode 100644 apps/web/test/components/mobile-not-supported.test.tsx
create mode 100644 apps/web/test/helpers/mock-file-system.ts
create mode 100644 apps/web/test/helpers/mock-opfs.ts
create mode 100644 apps/web/test/lib/utils.test.ts
create mode 100644 apps/web/test/services/bootstrap.test.ts
create mode 100644 apps/web/test/services/file-system.test.ts
create mode 100644 apps/web/test/services/path-service.test.ts
create mode 100644 apps/web/test/setup-dom.ts
create mode 100644 apps/web/test/test-utils.tsx
create mode 100644 apps/web/test/tsconfig.json
create mode 100644 apps/web/test/workers/service.test.ts
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",