test: expand coverage for binary files, hidden paths, and edge cases

- Add sync-manager-binary.test.ts: push/pull ArrayBuffer via adapter.readBinary/writeBinary
- Add sync-manager-hidden.test.ts: hidden path mkdir, push, pull via string paths
- Add utils/path.test.ts: isBinaryPath and contentsEqual full coverage
- Add metadata-on-equal assertion to sync-manager tests
- Add GitHub truncated result and GitLab pagination boundary tests
- Add hidden file gitignore filter tests
- Add docs/test-coverage.md with all 189 test cases documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ClaudiaFang 2026-05-22 03:35:36 +00:00
parent 49acf05241
commit fcb9447733
8 changed files with 853 additions and 0 deletions

227
docs/test-coverage.md Normal file
View file

@ -0,0 +1,227 @@
# Test Coverage
All tests are in `tests/` and run with `npm run test` (Vitest).
---
## utils/path — `tests/utils/path.test.ts`
### `isBinaryPath()`
| Group | Cases |
|---|---|
| Image formats | png, jpg, gif, bmp, webp (legacy); heic, heif, avif, tiff, tif, jxl (modern) |
| Audio formats | mp3, wav, ogg, flac, aac, m4a, opus, aiff |
| Video formats | mp4, webm, mov, mkv, avi, wmv, flv, m4v |
| Archive formats | zip, tar, gz, bz2, xz, zst, 7z, rar |
| Font formats | ttf, otf, woff, woff2 |
| Database formats | sqlite, db |
| Text files | md, json, ts, yml, txt → returns false |
| Hidden file paths | `.gitignore`, `.env` → false; `.claude/settings.json` → false; `.claude/photo.png` → true |
| Case insensitivity | `.PNG`, `.JPG`, `.FLAC` |
| BINARY_EXTENSIONS completeness | Set membership checks for major categories |
### `contentsEqual()`
| Group | Cases |
|---|---|
| String | identical, different, empty, case-sensitive |
| ArrayBuffer | identical, different bytes, different lengths, empty buffers |
| Mixed types | string vs ArrayBuffer → false |
---
## logic/SyncManager — `tests/logic/sync-manager.test.ts`
| Case |
|---|
| Push file content correctly |
| Detect conflict when remote SHA differs from last synced SHA |
| Resolve conflict by choosing local |
| Resolve conflict by choosing remote |
| Update metadata when file is already in sync (contentsEqual) |
| Update metadata after successful push |
| Pull and modify file content, update metadata |
| Handle file not existing in vault |
| Add new file to repo when exists locally but not on remote |
| **Renames:** detect and handle file rename |
| **Error handling:** push errors, rename errors |
| **pullFile:** file not in remote, pull new file, pull errors |
---
## logic/SyncManager Batch — `tests/logic/sync-manager-batch.test.ts`
| Case |
|---|
| Push multiple files (TFile and string paths) |
| Handle failures during batch push |
| Pull multiple files (TFile and string paths) |
| Handle missing remote files during batch pull |
| onProgress callback called for each file |
| Detect and handle rename during batch push |
---
## logic/SyncManager Path Mapping — `tests/logic/sync-manager-mapping.test.ts`
| Case |
|---|
| Strip vaultFolder prefix when pushing |
| Re-add vaultFolder prefix when pulling |
| Handle root-level files with no vaultFolder |
---
## logic/SyncManager Binary Files — `tests/logic/sync-manager-binary.test.ts`
### `pushFile` with binary path (string)
| Case |
|---|
| Reads via `adapter.readBinary` for binary extensions |
| Skips push when binary content is already in sync |
| Updates metadata when binary is already in sync |
### `pullFile` with binary content
| Case |
|---|
| Writes via `adapter.writeBinary` when remote content is ArrayBuffer |
| Creates parent directory before writing binary |
| Skips pull when binary content is already in sync |
| Updates metadata after pulling binary file |
---
## logic/SyncManager Hidden Files — `tests/logic/sync-manager-hidden.test.ts`
### `pullFile` with hidden paths
| Case |
|---|
| Creates single hidden parent directory (`.claude/`) on pull |
| Creates all nested hidden parent directories (`.claude/memory/`) on pull |
| Does not throw if hidden directory already exists (mkdir error is swallowed) |
| Updates syncMetadata after pulling hidden file |
### `pushFile` with hidden paths
| Case |
|---|
| Pushes hidden file content via string path |
| Skips push when hidden file is already in sync (same content) |
| Does not push when hidden file is missing from vault |
---
## logic/GitignoreManager — `tests/logic/gitignore-manager.test.ts`
### `loadGitignores()`
| Case |
|---|
| Load root .gitignore from local if it exists |
| Load gitignores from remote as fallback |
| Fall back to `[".gitignore"]` when getRepoGitignores throws |
| Fall back to remote when local .gitignore read throws |
| Handle subdirectory .gitignores correctly |
| Pick up local-only subdirectory .gitignore not yet on remote |
### `isIgnored()` with rootPath
| Case |
|---|
| Correctly resolve paths relative to git root |
### Complex patterns
| Case |
|---|
| Negative patterns (`!important.log`) |
| Directory-only patterns (`build/`) |
| Deep wildcards (`**/temp/*`) |
### Hidden file and directory filtering
| Case |
|---|
| Ignore hidden directory when listed in .gitignore (`.obsidian/`, `.trash/`) |
| Pass through hidden directory absent from .gitignore (`.claude/`) |
| Ignore `.claude/` when explicitly added to .gitignore |
| Pass normal files when only hidden dirs are ignored |
| Find `.gitignore` inside a hidden directory via scanDir |
---
## services/GitServiceBase — `tests/services/git-service-base.test.ts`
| Case |
|---|
| No double-slash when rootPath already ends with `/` |
| Wrap non-Error throws in a new Error |
| Correctly encode and decode UTF-8 content |
---
## services/GitHubService — `tests/services/github-service.test.ts`
| Group | Cases |
|---|---|
| getFile | fetch and decode content, 404 handling, bypass rootPath with `/` prefix, no double-prefix, return sha |
| pushFile | new file (no sha), update existing file (with sha) |
| listFiles | list blob files, filter by rootPath, no sibling path collision, truncated result warning |
| deleteFile | delete using file sha |
---
## services/GitLabService — `tests/services/gitlab-service.test.ts`
| Group | Cases |
|---|---|
| getFile | fetch and decode content, 404 handling, return last_commit_id as sha |
| pushFile | POST for new file, PUT for existing file |
| listFiles | list blob files, filter by rootPath, no sibling path collision, pagination boundary (100 items), multi-page (200 items), stop on partial page |
| deleteFile | delete with commit message |
---
## ui/ActionBar — `tests/ui/ActionBar.test.ts`
| Group | Cases |
|---|---|
| No files | always renders refresh button; no push/pull/delete; no select-all |
| Has files | renders push, pull, delete, select-all; disabled states when count is 0; enabled when count > 0 |
| Interactions | onRefresh, onPush, onPull, onDelete, onSelectAll(true/false) |
---
## ui/DiffPanel — `tests/ui/DiffPanel.test.ts`
| Case |
|---|
| Returns element with `ssv-diff` class |
| Renders Remote and Local column headers |
| Renders unchanged lines |
| Renders added line |
| Renders removed line |
| Renders replacement (removed + added) |
| Renders unchanged lines for identical content |
---
## ui/FileListItem — `tests/ui/FileListItem.test.ts`
| Group | Cases |
|---|---|
| Status CSS | distinct class for each status |
| Rendering | file path, status badge, checkbox (selected/unselected) |
| Interactions | onSelect(path, true/false) |
| Synced status | no action buttons |
| Remote-only status | no action buttons |
| Modified status | push + pull buttons; onPush/onPull callbacks; diff toggle; diff panel visibility toggle; diff button label toggle |
| Local-only status | push + delete buttons; no pull; onDelete callback |
| Missing status | pull button; no push |
---
## utils/diff — `tests/utils/diff.test.ts`
| Group | Cases |
|---|---|
| Identical content | single-line, multi-line, empty strings |
| CRLF normalisation | CRLF = LF, CR-only |
| Additions only | single added line, multiple added lines, single-line change |
| Removals only | single removed line, multiple removed lines |
| Mixed changes | paired removed+added, unchanged lines between changes |
| Line numbers | correct numbering |

View file

@ -223,4 +223,63 @@ describe('GitignoreManager', () => {
expect(manager.isIgnored('a/temp/foo')).toBe(true);
});
});
describe('hidden file and directory filtering', () => {
beforeEach(async () => {
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.exists).mockResolvedValue(true);
});
it('should ignore hidden directory when listed in .gitignore', async () => {
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.read).mockResolvedValue('.private/\n.trash/');
await manager.loadGitignores();
expect(manager.isIgnored('.private/secrets.md')).toBe(true);
expect(manager.isIgnored('.trash/note.md')).toBe(true);
});
it('should not ignore hidden directory absent from .gitignore', async () => {
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.read).mockResolvedValue('node_modules/\n*.log');
await manager.loadGitignores();
expect(manager.isIgnored('.claude/settings.json')).toBe(false);
expect(manager.isIgnored('.claude/CLAUDE.md')).toBe(false);
});
it('should ignore .claude/ when explicitly added to .gitignore', async () => {
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.read).mockResolvedValue('.claude/');
await manager.loadGitignores();
expect(manager.isIgnored('.claude/settings.json')).toBe(true);
expect(manager.isIgnored('.claude/memory/user.md')).toBe(true);
});
it('should still pass normal files when only hidden dirs are ignored', async () => {
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.read).mockResolvedValue('.private/');
await manager.loadGitignores();
expect(manager.isIgnored('notes/my-note.md')).toBe(false);
expect(manager.isIgnored('projects/work.md')).toBe(false);
});
it('should find .gitignore inside a hidden directory via scanDir', async () => {
const adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
vi.mocked(adapter.list)
.mockResolvedValueOnce({ files: ['.gitignore'], folders: ['.claude'] })
.mockResolvedValueOnce({ files: ['.claude/.gitignore'], folders: [] });
vi.mocked(adapter.exists).mockResolvedValue(true);
vi.mocked(adapter.read).mockResolvedValue('*.secret');
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
await manager.loadGitignores();
expect(adapter.list).toHaveBeenCalledWith('');
expect(adapter.list).toHaveBeenCalledWith('.claude');
});
});
});

View file

@ -0,0 +1,157 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest';
import { SyncManager } from '../../src/logic/sync-manager';
import { App, DataAdapter } from 'obsidian';
import { GitLabFilesPushSettings } from '../../src/settings';
import { GitServiceInterface } from '../../src/services/git-service-interface';
vi.mock('obsidian');
describe('SyncManager binary file handling', () => {
let manager: SyncManager;
let mockApp: Mocked<App>;
let mockGitService: Mocked<GitServiceInterface>;
let mockAdapter: Mocked<DataAdapter>;
let mockSettings: GitLabFilesPushSettings;
const makeBuf = (bytes: number[]) => new Uint8Array(bytes).buffer;
beforeEach(() => {
vi.clearAllMocks();
mockAdapter = {
exists: vi.fn(),
read: vi.fn(),
write: vi.fn(),
readBinary: vi.fn(),
writeBinary: vi.fn(),
mkdir: vi.fn().mockResolvedValue(undefined),
} as unknown as Mocked<DataAdapter>;
mockApp = {
vault: {
read: vi.fn(),
readBinary: vi.fn(),
modify: vi.fn(),
modifyBinary: vi.fn(),
getFileByPath: vi.fn().mockReturnValue(null),
adapter: mockAdapter,
},
} as unknown as Mocked<App>;
mockGitService = {
pushFile: vi.fn(),
getFile: vi.fn(),
testConnection: vi.fn(),
listFiles: vi.fn(),
deleteFile: vi.fn(),
getRepoGitignores: vi.fn(),
updateConfig: vi.fn(),
} as unknown as Mocked<GitServiceInterface>;
mockSettings = {
serviceType: 'github',
githubToken: 'token',
githubOwner: 'owner',
githubRepo: 'repo',
branch: 'main',
syncMetadata: {},
vaultFolder: '',
rootPath: '',
} as unknown as GitLabFilesPushSettings;
manager = new SyncManager(mockApp, mockGitService, mockSettings);
// @ts-ignore
manager.saveSettings = vi.fn().mockResolvedValue(undefined);
});
describe('pushFile with binary path (string)', () => {
it('reads via adapter.readBinary for binary extensions', async () => {
const buf = makeBuf([137, 80, 78, 71]);
vi.mocked(mockAdapter.exists).mockResolvedValue(true);
vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf);
vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: '', content: '' });
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: 'photo.png', sha: 'new-sha' });
await manager.pushFile('photo.png');
expect(mockAdapter.readBinary).toHaveBeenCalledWith('photo.png');
expect(mockAdapter.read).not.toHaveBeenCalled();
expect(mockGitService.pushFile).toHaveBeenCalledWith(
'photo.png', buf, 'main', expect.any(String), ''
);
});
it('skips push when binary content is already in sync', async () => {
const buf = makeBuf([1, 2, 3, 4]);
vi.mocked(mockAdapter.exists).mockResolvedValue(true);
vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf);
vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'existing-sha', content: buf });
await manager.pushFile('photo.png');
expect(mockGitService.pushFile).not.toHaveBeenCalled();
});
it('updates metadata when binary is already in sync', async () => {
const buf = makeBuf([1, 2, 3]);
vi.mocked(mockAdapter.exists).mockResolvedValue(true);
vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf);
vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'existing-sha', content: buf });
await manager.pushFile('photo.png');
expect(mockSettings.syncMetadata['photo.png']).toMatchObject({
lastSyncedSha: 'existing-sha',
});
});
});
describe('pullFile with binary content', () => {
it('writes via adapter.writeBinary when remote content is ArrayBuffer', async () => {
const buf = makeBuf([137, 80, 78, 71]);
vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf });
vi.mocked(mockAdapter.exists).mockResolvedValue(false);
await manager.pullFile('photo.png');
expect(mockAdapter.writeBinary).toHaveBeenCalledWith('photo.png', buf);
expect(mockAdapter.write).not.toHaveBeenCalled();
});
it('creates parent directory before writing binary', async () => {
const buf = makeBuf([255, 216, 255]);
vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf });
vi.mocked(mockAdapter.exists).mockResolvedValue(false);
await manager.pullFile('attachments/photo.jpg');
expect(mockAdapter.mkdir).toHaveBeenCalledWith('attachments');
expect(mockAdapter.writeBinary).toHaveBeenCalledWith('attachments/photo.jpg', buf);
});
it('skips pull when binary content is already in sync', async () => {
const buf = makeBuf([1, 2, 3]);
vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf });
vi.mocked(mockAdapter.exists).mockResolvedValue(true);
vi.mocked(mockAdapter.readBinary).mockResolvedValue(buf);
mockSettings.syncMetadata['photo.png'] = { lastSyncedSha: 'bin-sha', lastSyncedAt: 0, lastKnownPath: 'photo.png' };
await manager.pullFile('photo.png');
expect(mockAdapter.writeBinary).not.toHaveBeenCalled();
});
it('updates metadata after pulling binary file', async () => {
const buf = makeBuf([0, 1, 2]);
vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'bin-sha', content: buf });
vi.mocked(mockAdapter.exists).mockResolvedValue(false);
await manager.pullFile('photo.png');
expect(mockSettings.syncMetadata['photo.png']).toMatchObject({
lastSyncedSha: 'bin-sha',
});
});
});
});

View file

@ -0,0 +1,155 @@
/* eslint-disable @typescript-eslint/unbound-method */
import { describe, it, expect, vi, beforeEach, Mocked } from 'vitest';
import { SyncManager } from '../../src/logic/sync-manager';
import { App, DataAdapter } from 'obsidian';
import { GitLabFilesPushSettings } from '../../src/settings';
import { GitServiceInterface } from '../../src/services/git-service-interface';
vi.mock('obsidian');
describe('SyncManager hidden file support', () => {
let manager: SyncManager;
let mockApp: Mocked<App>;
let mockGitService: Mocked<GitServiceInterface>;
let mockAdapter: Mocked<DataAdapter>;
let mockSettings: GitLabFilesPushSettings;
beforeEach(() => {
vi.clearAllMocks();
mockAdapter = {
exists: vi.fn(),
read: vi.fn(),
write: vi.fn(),
writeBinary: vi.fn(),
mkdir: vi.fn().mockResolvedValue(undefined),
} as unknown as Mocked<DataAdapter>;
mockApp = {
vault: {
read: vi.fn(),
modify: vi.fn(),
getFileByPath: vi.fn().mockReturnValue(null),
adapter: mockAdapter,
},
} as unknown as Mocked<App>;
mockGitService = {
pushFile: vi.fn(),
getFile: vi.fn(),
testConnection: vi.fn(),
listFiles: vi.fn(),
deleteFile: vi.fn(),
getRepoGitignores: vi.fn(),
updateConfig: vi.fn(),
} as unknown as Mocked<GitServiceInterface>;
mockSettings = {
serviceType: 'github',
githubToken: 'token',
githubOwner: 'owner',
githubRepo: 'repo',
branch: 'main',
syncMetadata: {},
vaultFolder: '',
rootPath: '',
} as unknown as GitLabFilesPushSettings;
manager = new SyncManager(mockApp, mockGitService, mockSettings);
// @ts-ignore - accessing private for test
manager.saveSettings = vi.fn().mockResolvedValue(undefined);
});
describe('pullFile with hidden paths', () => {
it('creates single hidden parent directory on pull', async () => {
vi.mocked(mockGitService.getFile).mockResolvedValue({
sha: 'abc123',
content: '{"key":"value"}',
});
vi.mocked(mockAdapter.exists).mockResolvedValue(false);
await manager.pullFile('.claude/settings.json');
expect(mockAdapter.mkdir).toHaveBeenCalledWith('.claude');
expect(mockAdapter.write).toHaveBeenCalledWith('.claude/settings.json', '{"key":"value"}');
});
it('creates all nested hidden parent directories on pull', async () => {
vi.mocked(mockGitService.getFile).mockResolvedValue({
sha: 'def456',
content: 'nested content',
});
vi.mocked(mockAdapter.exists).mockResolvedValue(false);
await manager.pullFile('.claude/memory/user.md');
expect(mockAdapter.mkdir).toHaveBeenCalledWith('.claude');
expect(mockAdapter.mkdir).toHaveBeenCalledWith('.claude/memory');
expect(mockAdapter.write).toHaveBeenCalledWith('.claude/memory/user.md', 'nested content');
});
it('does not fail if hidden directory already exists (mkdir throws)', async () => {
vi.mocked(mockGitService.getFile).mockResolvedValue({
sha: 'abc123',
content: 'content',
});
vi.mocked(mockAdapter.exists).mockResolvedValue(false);
vi.mocked(mockAdapter.mkdir).mockRejectedValue(new Error('already exists'));
await expect(manager.pullFile('.claude/settings.json')).resolves.not.toThrow();
expect(mockAdapter.write).toHaveBeenCalledWith('.claude/settings.json', 'content');
});
it('updates metadata after pulling hidden file', async () => {
vi.mocked(mockGitService.getFile).mockResolvedValue({
sha: 'sha-hidden',
content: 'file content',
});
vi.mocked(mockAdapter.exists).mockResolvedValue(false);
await manager.pullFile('.claude/CLAUDE.md');
expect(mockSettings.syncMetadata['.claude/CLAUDE.md']).toMatchObject({
lastSyncedSha: 'sha-hidden',
});
});
});
describe('pushFile with hidden paths', () => {
it('pushes hidden file content via string path', async () => {
vi.mocked(mockAdapter.exists).mockResolvedValue(true);
vi.mocked(mockAdapter.read).mockResolvedValue('# Memory\n\nsome content');
vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: '', content: '' });
vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: '.claude/CLAUDE.md', sha: 'new-sha' });
await manager.pushFile('.claude/CLAUDE.md');
expect(mockGitService.pushFile).toHaveBeenCalledWith(
'.claude/CLAUDE.md',
'# Memory\n\nsome content',
'main',
expect.any(String),
''
);
});
it('skips push when hidden file is already in sync', async () => {
const content = 'same content';
vi.mocked(mockAdapter.exists).mockResolvedValue(true);
vi.mocked(mockAdapter.read).mockResolvedValue(content);
vi.mocked(mockGitService.getFile).mockResolvedValue({ sha: 'existing-sha', content });
await manager.pushFile('.claude/settings.json');
expect(mockGitService.pushFile).not.toHaveBeenCalled();
});
it('does not exist notice when hidden file is missing', async () => {
vi.mocked(mockAdapter.exists).mockResolvedValue(false);
await manager.pushFile('.claude/missing.json');
expect(mockGitService.pushFile).not.toHaveBeenCalled();
});
});
});

View file

@ -173,6 +173,19 @@ describe('SyncManager', () => {
expect(mockSettings.syncMetadata['test.md']?.lastSyncedSha).toBe('remote-sha');
});
it('should update metadata even when file is already in sync (contentsEqual)', async () => {
const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' });
mockSettings.syncMetadata = {};
vi.spyOn(mockApp.vault, 'read').mockResolvedValue('same content');
vi.spyOn(mockGitLab, 'getFile').mockResolvedValueOnce({ content: 'same content', sha: 'remote-sha' });
await manager.pushFile(mockFile);
expect(mockGitLab.pushFile).not.toHaveBeenCalled();
expect(mockSettings.syncMetadata['test.md']?.lastSyncedSha).toBe('remote-sha');
});
it('should update metadata after successful push', async () => {
const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' });
mockSettings.syncMetadata = {};

View file

@ -110,6 +110,17 @@ describe('GitHubService', () => {
] } });
expect(await service.listFiles('main')).toEqual(['src/content/index.md']);
});
it('should return files and log warning when result is truncated', async () => {
mockRequest({ status: 200, json: { truncated: true, tree: [
{ path: 'file1.md', type: 'blob' },
{ path: 'file2.md', type: 'blob' },
] } });
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await service.listFiles('main');
expect(result).toEqual(['file1.md', 'file2.md']);
warnSpy.mockRestore();
});
});
describe('deleteFile', () => {

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GitLabService } from '../../src/services/gitlab-service';
import { RequestUrlResponse, requestUrl } from 'obsidian';
import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling, sharedGetRepoGitignores } from './service-test-helpers';
describe('GitLabService', () => {
@ -87,6 +88,42 @@ describe('GitLabService', () => {
] });
expect(await service.listFiles('main')).toEqual(['src/content/index.md']);
});
it('should fetch all pages when first page is exactly full (100 items)', async () => {
const page1 = Array.from({ length: 100 }, (_, i) => ({ path: `file${i}.md`, type: 'blob' }));
vi.mocked(requestUrl)
.mockResolvedValueOnce({ status: 200, json: page1 } as unknown as RequestUrlResponse)
.mockResolvedValueOnce({ status: 200, json: [] } as unknown as RequestUrlResponse);
const result = await service.listFiles('main');
expect(result).toHaveLength(100);
expect(vi.mocked(requestUrl)).toHaveBeenCalledTimes(2);
});
it('should fetch all pages across multiple full pages (200 items)', async () => {
const page1 = Array.from({ length: 100 }, (_, i) => ({ path: `a/file${i}.md`, type: 'blob' }));
const page2 = Array.from({ length: 100 }, (_, i) => ({ path: `b/file${i}.md`, type: 'blob' }));
vi.mocked(requestUrl)
.mockResolvedValueOnce({ status: 200, json: page1 } as unknown as RequestUrlResponse)
.mockResolvedValueOnce({ status: 200, json: page2 } as unknown as RequestUrlResponse)
.mockResolvedValueOnce({ status: 200, json: [] } as unknown as RequestUrlResponse);
const result = await service.listFiles('main');
expect(result).toHaveLength(200);
expect(vi.mocked(requestUrl)).toHaveBeenCalledTimes(3);
});
it('should stop pagination when page has fewer than 100 items', async () => {
const page1 = Array.from({ length: 100 }, (_, i) => ({ path: `file${i}.md`, type: 'blob' }));
const page2 = Array.from({ length: 42 }, (_, i) => ({ path: `extra/file${i}.md`, type: 'blob' }));
vi.mocked(requestUrl)
.mockResolvedValueOnce({ status: 200, json: page1 } as unknown as RequestUrlResponse)
.mockResolvedValueOnce({ status: 200, json: page2 } as unknown as RequestUrlResponse);
const result = await service.listFiles('main');
expect(result).toHaveLength(142);
expect(vi.mocked(requestUrl)).toHaveBeenCalledTimes(2);
});
});
describe('deleteFile', () => {

194
tests/utils/path.test.ts Normal file
View file

@ -0,0 +1,194 @@
import { describe, it, expect } from 'vitest';
import { isBinaryPath, contentsEqual, BINARY_EXTENSIONS } from '../../src/utils/path';
describe('isBinaryPath', () => {
describe('image formats', () => {
it('detects legacy formats', () => {
expect(isBinaryPath('photo.png')).toBe(true);
expect(isBinaryPath('photo.jpg')).toBe(true);
expect(isBinaryPath('photo.gif')).toBe(true);
expect(isBinaryPath('photo.bmp')).toBe(true);
expect(isBinaryPath('photo.webp')).toBe(true);
});
it('detects modern formats', () => {
expect(isBinaryPath('photo.heic')).toBe(true);
expect(isBinaryPath('photo.heif')).toBe(true);
expect(isBinaryPath('photo.avif')).toBe(true);
expect(isBinaryPath('photo.tiff')).toBe(true);
expect(isBinaryPath('photo.tif')).toBe(true);
expect(isBinaryPath('photo.jxl')).toBe(true);
});
});
describe('audio formats', () => {
it('detects common audio formats', () => {
expect(isBinaryPath('song.mp3')).toBe(true);
expect(isBinaryPath('song.wav')).toBe(true);
expect(isBinaryPath('song.ogg')).toBe(true);
expect(isBinaryPath('song.flac')).toBe(true);
expect(isBinaryPath('song.aac')).toBe(true);
expect(isBinaryPath('song.m4a')).toBe(true);
expect(isBinaryPath('song.opus')).toBe(true);
expect(isBinaryPath('song.aiff')).toBe(true);
});
});
describe('video formats', () => {
it('detects common video formats', () => {
expect(isBinaryPath('video.mp4')).toBe(true);
expect(isBinaryPath('video.webm')).toBe(true);
expect(isBinaryPath('video.mov')).toBe(true);
expect(isBinaryPath('video.mkv')).toBe(true);
expect(isBinaryPath('video.avi')).toBe(true);
expect(isBinaryPath('video.wmv')).toBe(true);
expect(isBinaryPath('video.flv')).toBe(true);
expect(isBinaryPath('video.m4v')).toBe(true);
});
});
describe('archive formats', () => {
it('detects archive formats', () => {
expect(isBinaryPath('file.zip')).toBe(true);
expect(isBinaryPath('file.tar')).toBe(true);
expect(isBinaryPath('file.gz')).toBe(true);
expect(isBinaryPath('file.bz2')).toBe(true);
expect(isBinaryPath('file.xz')).toBe(true);
expect(isBinaryPath('file.zst')).toBe(true);
expect(isBinaryPath('file.7z')).toBe(true);
expect(isBinaryPath('file.rar')).toBe(true);
});
});
describe('font formats', () => {
it('detects font formats including otf', () => {
expect(isBinaryPath('font.ttf')).toBe(true);
expect(isBinaryPath('font.otf')).toBe(true);
expect(isBinaryPath('font.woff')).toBe(true);
expect(isBinaryPath('font.woff2')).toBe(true);
});
});
describe('database formats', () => {
it('detects database files', () => {
expect(isBinaryPath('data.sqlite')).toBe(true);
expect(isBinaryPath('data.db')).toBe(true);
});
});
describe('text files', () => {
it('returns false for markdown', () => expect(isBinaryPath('note.md')).toBe(false));
it('returns false for JSON', () => expect(isBinaryPath('settings.json')).toBe(false));
it('returns false for TypeScript', () => expect(isBinaryPath('main.ts')).toBe(false));
it('returns false for YAML', () => expect(isBinaryPath('config.yml')).toBe(false));
it('returns false for plain text', () => expect(isBinaryPath('readme.txt')).toBe(false));
});
describe('hidden file paths', () => {
it('returns false for dotfiles with no extension', () => {
expect(isBinaryPath('.gitignore')).toBe(false);
expect(isBinaryPath('.env')).toBe(false);
});
it('returns false for hidden directory text files', () => {
expect(isBinaryPath('.claude/settings.json')).toBe(false);
expect(isBinaryPath('.claude/CLAUDE.md')).toBe(false);
});
it('returns true for hidden directory binary files', () => {
expect(isBinaryPath('.claude/photo.png')).toBe(true);
});
it('returns false for hidden file with no extension', () => {
expect(isBinaryPath('.claude')).toBe(false);
});
});
describe('case insensitivity', () => {
it('handles uppercase extensions', () => {
expect(isBinaryPath('photo.PNG')).toBe(true);
expect(isBinaryPath('photo.JPG')).toBe(true);
expect(isBinaryPath('font.TTF')).toBe(true);
});
it('handles mixed case extensions', () => {
expect(isBinaryPath('photo.Heic')).toBe(true);
expect(isBinaryPath('song.FLAC')).toBe(true);
});
});
describe('BINARY_EXTENSIONS set completeness', () => {
it('contains all major image formats', () => {
['png', 'jpg', 'jpeg', 'gif', 'webp', 'heic', 'heif', 'avif', 'tif', 'tiff'].forEach(ext => {
expect(BINARY_EXTENSIONS.has(ext), `missing: ${ext}`).toBe(true);
});
});
it('contains all major audio formats', () => {
['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'opus'].forEach(ext => {
expect(BINARY_EXTENSIONS.has(ext), `missing: ${ext}`).toBe(true);
});
});
it('contains both ttf and otf fonts', () => {
expect(BINARY_EXTENSIONS.has('ttf')).toBe(true);
expect(BINARY_EXTENSIONS.has('otf')).toBe(true);
});
});
});
describe('contentsEqual', () => {
describe('string comparison', () => {
it('returns true for identical strings', () => {
expect(contentsEqual('hello', 'hello')).toBe(true);
});
it('returns false for different strings', () => {
expect(contentsEqual('hello', 'world')).toBe(false);
});
it('returns true for empty strings', () => {
expect(contentsEqual('', '')).toBe(true);
});
it('is case-sensitive', () => {
expect(contentsEqual('Hello', 'hello')).toBe(false);
});
});
describe('ArrayBuffer comparison', () => {
it('returns true for identical buffers', () => {
const bufA = new Uint8Array([1, 2, 3]).buffer;
const bufB = new Uint8Array([1, 2, 3]).buffer;
expect(contentsEqual(bufA, bufB)).toBe(true);
});
it('returns false for different buffers', () => {
const bufA = new Uint8Array([1, 2, 3]).buffer;
const bufB = new Uint8Array([1, 2, 4]).buffer;
expect(contentsEqual(bufA, bufB)).toBe(false);
});
it('returns false for buffers of different length', () => {
const bufA = new Uint8Array([1, 2, 3]).buffer;
const bufB = new Uint8Array([1, 2]).buffer;
expect(contentsEqual(bufA, bufB)).toBe(false);
});
it('returns true for two empty buffers', () => {
expect(contentsEqual(new ArrayBuffer(0), new ArrayBuffer(0))).toBe(true);
});
});
describe('mixed type comparison', () => {
it('returns false when comparing string with ArrayBuffer', () => {
const buf = new Uint8Array([1, 2, 3]).buffer;
expect(contentsEqual('hello', buf)).toBe(false);
});
it('returns false when comparing ArrayBuffer with string', () => {
const buf = new Uint8Array([1, 2, 3]).buffer;
expect(contentsEqual(buf, 'hello')).toBe(false);
});
});
});