mirror of
https://github.com/colanode/colanode.git
synced 2026-02-24 03:49:48 +01:00
Web: add test setup and first tests (#313)
* test(server): add vitest harness and initial integration tests * add web test setup and initial tests
This commit is contained in:
22
README.md
22
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).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
51
apps/web/test/components/browser-not-supported.test.tsx
Normal file
51
apps/web/test/components/browser-not-supported.test.tsx
Normal file
@@ -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(<BrowserNotSupported />);
|
||||
|
||||
const heading = screen.getByRole('heading', {
|
||||
name: /browser not supported/i,
|
||||
});
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays a link to download the desktop app', () => {
|
||||
customRender(<BrowserNotSupported />);
|
||||
|
||||
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(<BrowserNotSupported />);
|
||||
|
||||
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(<BrowserNotSupported />);
|
||||
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
29
apps/web/test/components/mobile-not-supported.test.tsx
Normal file
29
apps/web/test/components/mobile-not-supported.test.tsx
Normal file
@@ -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(<MobileNotSupported />);
|
||||
|
||||
const heading = screen.getByRole('heading', {
|
||||
name: /mobile not supported/i,
|
||||
});
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays a greeting message', () => {
|
||||
customRender(<MobileNotSupported />);
|
||||
|
||||
expect(screen.getByText(/hey there!/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Smartphone icon', () => {
|
||||
const { container } = customRender(<MobileNotSupported />);
|
||||
|
||||
// lucide-react renders SVGs
|
||||
const svg = container.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
136
apps/web/test/helpers/mock-file-system.ts
Normal file
136
apps/web/test/helpers/mock-file-system.ts
Normal file
@@ -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<string, Uint8Array>();
|
||||
private directories = new Set<string>();
|
||||
|
||||
constructor(initialFiles?: Record<string, Uint8Array | string>) {
|
||||
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<void> {
|
||||
this.files.clear();
|
||||
this.directories.clear();
|
||||
}
|
||||
|
||||
async makeDirectory(path: string): Promise<void> {
|
||||
this.directories.add(path);
|
||||
this.ensureParentDirectories(path);
|
||||
}
|
||||
|
||||
async exists(path: string): Promise<boolean> {
|
||||
return this.files.has(path) || this.directories.has(path);
|
||||
}
|
||||
|
||||
async delete(path: string): Promise<void> {
|
||||
this.files.delete(path);
|
||||
this.directories.delete(path);
|
||||
}
|
||||
|
||||
async copy(source: string, destination: string): Promise<void> {
|
||||
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<any> {
|
||||
throw new Error('readStream not implemented in MockFileSystem');
|
||||
}
|
||||
|
||||
async writeStream(_path: string): Promise<WritableStream<Uint8Array>> {
|
||||
throw new Error('writeStream not implemented in MockFileSystem');
|
||||
}
|
||||
|
||||
async listFiles(path: string): Promise<string[]> {
|
||||
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<Uint8Array> {
|
||||
const data = this.files.get(path);
|
||||
if (!data) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async writeFile(path: string, data: Uint8Array): Promise<void> {
|
||||
this.files.set(path, data);
|
||||
this.ensureParentDirectories(path);
|
||||
}
|
||||
|
||||
async url(): Promise<string | null> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
204
apps/web/test/helpers/mock-opfs.ts
Normal file
204
apps/web/test/helpers/mock-opfs.ts
Normal file
@@ -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<File> {
|
||||
// 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<any> {
|
||||
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<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async queryPermission(): Promise<PermissionState> {
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
async requestPermission(): Promise<PermissionState> {
|
||||
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<any> {
|
||||
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<any> {
|
||||
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<string[] | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async isSameEntry(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async queryPermission(): Promise<PermissionState> {
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
async requestPermission(): Promise<PermissionState> {
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
async *entries(): AsyncIterableIterator<[string, any]> {
|
||||
for (const [name, handle] of this._entries) {
|
||||
yield [name, handle];
|
||||
}
|
||||
}
|
||||
|
||||
async *keys(): AsyncIterableIterator<string> {
|
||||
for (const name of this._entries.keys()) {
|
||||
yield name;
|
||||
}
|
||||
}
|
||||
|
||||
async *values(): AsyncIterableIterator<any> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
139
apps/web/test/lib/utils.test.ts
Normal file
139
apps/web/test/lib/utils.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
287
apps/web/test/services/bootstrap.test.ts
Normal file
287
apps/web/test/services/bootstrap.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
358
apps/web/test/services/file-system.test.ts
Normal file
358
apps/web/test/services/file-system.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
256
apps/web/test/services/path-service.test.ts
Normal file
256
apps/web/test/services/path-service.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
44
apps/web/test/setup-dom.ts
Normal file
44
apps/web/test/setup-dom.ts
Normal file
@@ -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;
|
||||
16
apps/web/test/test-utils.tsx
Normal file
16
apps/web/test/test-utils.tsx
Normal file
@@ -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';
|
||||
12
apps/web/test/tsconfig.json
Normal file
12
apps/web/test/tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
171
apps/web/test/workers/service.test.ts
Normal file
171
apps/web/test/workers/service.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
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<unknown>) => promise);
|
||||
const event = {
|
||||
waitUntil,
|
||||
} as unknown as ExtendableEvent;
|
||||
|
||||
installHandler?.(event);
|
||||
|
||||
expect(waitUntil).toHaveBeenCalledTimes(1);
|
||||
const waitPromise = waitUntil.mock.calls[0]?.[0] as Promise<unknown>;
|
||||
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<typeof vi.fn>;
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./test/setup-dom.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
263
package-lock.json
generated
263
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user