mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
Merge pull request #25 from firstsun-dev/issue-23-code-quality
feat: support hidden file sync and expand binary extension list
This commit is contained in:
commit
eefd72846e
25 changed files with 1068 additions and 128 deletions
|
|
@ -0,0 +1,47 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from 'builtin-modules'
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
`;
|
||||
|
||||
const prod = (process.argv[2] === 'production');
|
||||
|
||||
const buildOptions = {
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ['src/main.ts'],
|
||||
bundle: true,
|
||||
external: [
|
||||
'obsidian',
|
||||
'electron',
|
||||
'@codemirror/autocomplete',
|
||||
'@codemirror/collab',
|
||||
'@codemirror/commands',
|
||||
'@codemirror/language',
|
||||
'@codemirror/lint',
|
||||
'@codemirror/search',
|
||||
'@codemirror/state',
|
||||
'@codemirror/view',
|
||||
'@lezer/common',
|
||||
'@lezer/highlight',
|
||||
'@lezer/lr',
|
||||
...builtins],
|
||||
format: 'cjs',
|
||||
target: 'es2018',
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : 'inline',
|
||||
treeShaking: true,
|
||||
outfile: 'main.js',
|
||||
};
|
||||
|
||||
if (prod) {
|
||||
esbuild.build(buildOptions).catch(() => process.exit(1));
|
||||
} else {
|
||||
esbuild.context(buildOptions).then(ctx => ctx.watch()).catch(() => process.exit(1));
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"id": "my-plugin-id",
|
||||
"name": "My Plugin Name",
|
||||
"version": "1.0.0",
|
||||
"minAppVersion": "1.4.0",
|
||||
"description": "A concise description under 250 characters that explains exactly what this plugin does for the user.",
|
||||
"author": "Your Name",
|
||||
"authorUrl": "https://your-website.com",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// Minimal stub so uploader.ts can be imported in the test environment
|
||||
export const requestUrl = () => Promise.resolve({ headers: {}, status: 200, arrayBuffer: new ArrayBuffer(0) });
|
||||
export type RequestUrlParam = unknown;
|
||||
export class Notice { constructor(_msg: string) {} }
|
||||
export class Plugin {}
|
||||
export class PluginSettingTab {}
|
||||
export class Setting {}
|
||||
export class TextComponent {}
|
||||
export const setIcon = () => {};
|
||||
export class TFile {}
|
||||
export class MarkdownView {}
|
||||
export class Editor {}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"name": "watermark-s3-uploader",
|
||||
"version": "1.1.0",
|
||||
"description": "Obsidian plugin: upload images to Cloudflare R2 with WebP conversion and watermark support.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json",
|
||||
"deploy": "node esbuild.config.mjs production && cp main.js manifest.json styles.css ../../src/content/.obsidian/plugins/watermark-s3-uploader/",
|
||||
"prepare": "husky",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"semantic-release": "npx semantic-release"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "ClaudiaFang",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/exec": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^10.3.5",
|
||||
"@semantic-release/npm": "^12.0.1",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/node": "^20.19.37",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"browser-resolve": "^2.0.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"conventional-changelog-conventionalcommits": "^8.0.0",
|
||||
"detective": "^5.2.1",
|
||||
"esbuild": "^0.27.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-obsidianmd": "^0.2.4",
|
||||
"eslint-plugin-sonarjs": "^4.0.3",
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jiti": "^2.0.0",
|
||||
"obsidian": "^1.12.3",
|
||||
"process": "^0.11.10",
|
||||
"semantic-release": "^24.2.3",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vitest": "4.1.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1019.0",
|
||||
"@aws-sdk/fetch-http-handler": "^3.310.0",
|
||||
"@aws-sdk/protocol-http": "^3.310.0",
|
||||
"@aws-sdk/querystring-builder": "^3.310.0",
|
||||
"@aws-sdk/types": "^3.310.0",
|
||||
"@smithy/fetch-http-handler": "^2.2.7",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"filesize": "^10.1.6",
|
||||
"minimatch": "^10.2.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
name: CI/CD
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master, '**']
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
CI:
|
||||
uses: firstsun-dev/.github/.github/workflows/obsidian-plugin-ci.yml@main
|
||||
with:
|
||||
plugin-id: "watermark-image-bucket-uploader"
|
||||
secrets:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
coverage: {
|
||||
reporter: ["text", "lcov", "html"],
|
||||
exclude: ["node_modules/**", "tests/**"],
|
||||
include: ["src/**"],
|
||||
all: true,
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
obsidian: new URL("./tests/__mocks__/obsidian.ts", import.meta.url).pathname,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
---
|
||||
name: obsidian-development
|
||||
description: Use when developing Obsidian plugins to ensure TDD with Vitest, linting, and adherence to Obsidian API conventions. Trigger this skill when the user mentions building, testing, or modifying an Obsidian plugin, or when they ask for help with Obsidian-specific APIs (commands, ribbon icons, workspace events).
|
||||
---
|
||||
|
||||
# Obsidian Plugin Development
|
||||
|
||||
You are a specialist in building Obsidian plugins. This skill ensures you follow the project's specific conventions for testing, linting, and API usage.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Test-Driven Development (TDD)**: Always write tests before implementation for logic and service classes.
|
||||
2. **API Hygiene**: Use Obsidian's lifecycle methods correctly (`onload`, `onunload`) and register all events/intervals for automatic cleanup.
|
||||
3. **Linting**: Adhere to the project's ESLint configuration (`npm run lint`).
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Testing with Vitest
|
||||
|
||||
The project uses `vitest` for unit and integration testing. Tests are located in the `tests/` directory.
|
||||
|
||||
- **Command**: `npm run test` or `npm run test:ui` for the interactive dashboard.
|
||||
- **Pattern**: Create a corresponding `.test.ts` file for every logic or service file.
|
||||
- **Example**:
|
||||
```typescript
|
||||
// tests/logic/sync-manager.test.ts
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
// ... test implementation
|
||||
```
|
||||
|
||||
### 2. Obsidian API Conventions
|
||||
|
||||
- **Settings**: Define settings in `src/settings.ts`. Use `this.loadData()` and `this.saveData()` in the main plugin class.
|
||||
- **Commands**: Register commands using `this.addCommand()`. Always provide an `id` and `name`.
|
||||
- **UI Elements**: Use `this.addRibbonIcon()` for sidebar buttons.
|
||||
- **Cleanup**:
|
||||
- Use `this.registerEvent()` for workspace events.
|
||||
- Use `this.registerDomEvent()` for DOM events.
|
||||
- Use `this.registerInterval()` for recurring tasks.
|
||||
- **Why**: These ensure that when the plugin is disabled or uninstalled, Obsidian automatically cleans up the resources to prevent memory leaks.
|
||||
|
||||
### 3. Build & Deployment
|
||||
|
||||
- **Dev Mode**: `npm run dev` (watches for changes and rebuilds).
|
||||
- **Production Build**: `npm run build` (runs type checking and minification).
|
||||
- **Versioning**: `npm run version` (bumps version in `manifest.json` and updates `versions.json`).
|
||||
|
||||
## Project Structure Reference
|
||||
|
||||
- `src/main.ts`: Entry point (Main Plugin Class).
|
||||
- `src/settings.ts`: Settings definitions and UI tab.
|
||||
- `src/services/`: External integrations (e.g., GitLab API).
|
||||
- `src/logic/`: Core business logic.
|
||||
- `src/ui/`: Custom modals, views, or setting components.
|
||||
|
||||
## Checklist for New Features
|
||||
|
||||
- [ ] Draft test cases in `tests/` before writing logic.
|
||||
- [ ] Use `registerEvent` instead of raw `on` handlers where possible.
|
||||
- [ ] Verify `npm run lint` passes before completion.
|
||||
- [ ] Ensure all persistent data is saved via `this.saveSettings()`.
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"skill_name": "obsidian-development",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"prompt": "Add a new command to the plugin that allows users to manually check for GitLab updates for the current file. Make sure it follows the project's testing and API conventions.",
|
||||
"expected_output": "The model should first create a test case for the new logic, then implement the command in src/main.ts using this.addCommand(), and ensure any events are registered correctly.",
|
||||
"files": ["src/main.ts", "src/logic/sync-manager.ts"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"prompt": "I need a way to see the current sync status in the status bar. How should I implement this?",
|
||||
"expected_output": "The model should recommend using this.addStatusBarItem() and registering any update events with this.registerEvent() to ensure cleanup.",
|
||||
"files": ["src/main.ts"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# Pull Request Template
|
||||
|
||||
## Template
|
||||
|
||||
```markdown
|
||||
# I am submitting a new Community Plugin
|
||||
|
||||
- [ ] I attest that I have done my best to deliver a high-quality plugin, am proud of the code I have written, and would recommend it to others. I commit to maintaining the plugin and being responsive to bug reports. If I am no longer able to maintain it, I will make reasonable efforts to find a successor maintainer or withdraw the plugin from the directory.
|
||||
|
||||
## Repo URL
|
||||
|
||||
<!--- Paste a link to your repo here for easy access -->
|
||||
Link to my plugin:
|
||||
|
||||
## Release Checklist
|
||||
- [ ] I have tested the plugin on
|
||||
- [ ] Windows
|
||||
- [ ] macOS
|
||||
- [ ] Linux
|
||||
- [ ] Android _(if applicable)_
|
||||
- [ ] iOS _(if applicable)_
|
||||
- [ ] My GitHub release contains all required files (as individual files, not just in the source.zip / source.tar.gz)
|
||||
- [ ] `main.js`
|
||||
- [ ] `manifest.json`
|
||||
- [ ] `styles.css` _(optional)_
|
||||
- [ ] GitHub release name matches the exact version number specified in my manifest.json (_**Note:** Use the exact version number, don't include a prefix `v`_)
|
||||
- [ ] The `id` in my `manifest.json` matches the `id` in the `community-plugins.json` file.
|
||||
- [ ] My README.md describes the plugin's purpose and provides clear usage instructions.
|
||||
- [ ] I have read the developer policies at https://docs.obsidian.md/Developer+policies, and have assessed my plugin's adherence to these policies.
|
||||
- [ ] I have read the tips in https://docs.obsidian.md/Plugins/Releasing/Plugin+guidelines and have self-reviewed my plugin to avoid these common pitfalls.
|
||||
- [ ] I have added a license in the LICENSE file.
|
||||
- [ ] My project respects and is compatible with the original license of any code from other plugins that I'm using.
|
||||
I have given proper attribution to these other projects in my `README.md`.
|
||||
|
||||
```
|
||||
42
.github/workflows/codeql.yml
vendored
Normal file
42
.github/workflows/codeql.yml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "master" ]
|
||||
pull_request:
|
||||
branches: [ "main", "master" ]
|
||||
schedule:
|
||||
- cron: '30 1 * * 1'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript-typescript' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# Queries can be 'security-extended' or 'security-and-quality'
|
||||
queries: security-extended
|
||||
|
||||
- name: Auto-build
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
# Git File Sync
|
||||
|
||||
[](https://github.com/firstsun-dev/git-files-sync/actions/workflows/ci.yml)
|
||||
[](https://github.com/firstsun-dev/git-files-sync/releases)
|
||||
[](https://github.com/firstsun-dev/git-files-sync/releases)
|
||||
[](LICENSE)
|
||||
[](https://obsidian.md)
|
||||
[](https://sonarcloud.io/summary/new_code?id=firstsun-dev_git-files-sync)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://conventionalcommits.org)
|
||||
[](LICENSE)
|
||||
|
||||
**Git File Sync** is a powerful Obsidian plugin that enables seamless synchronization of individual notes with GitLab or GitHub repositories. Unlike full-vault sync solutions, it gives you granular control over what gets pushed and pulled, making it perfect for shared projects, selective backups, and cross-platform workflows.
|
||||
|
||||
|
|
|
|||
227
docs/test-coverage.md
Normal file
227
docs/test-coverage.md
Normal 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 |
|
||||
|
|
@ -36,6 +36,7 @@ export default tseslint.config(
|
|||
"versions.json",
|
||||
"main.js",
|
||||
".claude/**",
|
||||
".agents/**",
|
||||
"coverage/**",
|
||||
]),
|
||||
);
|
||||
|
|
|
|||
11
skills-lock.json
Normal file
11
skills-lock.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"obsidian-development": {
|
||||
"source": "firstsun-dev/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "custom/obsidian/obsidian-development/SKILL.md",
|
||||
"computedHash": "087f548b82f8c150fcc2879488109bdda5d21c91fc4c9e379765150a4acbff82"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -260,12 +260,10 @@ export class SyncManager {
|
|||
let cur = '';
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
cur += (i > 0 ? '/' : '') + parts[i];
|
||||
if (!this.app.vault.getAbstractFileByPath(cur)) {
|
||||
try {
|
||||
await this.app.vault.createFolder(cur);
|
||||
} catch {
|
||||
// already exists or failed
|
||||
}
|
||||
try {
|
||||
await this.app.vault.adapter.mkdir(cur);
|
||||
} catch {
|
||||
// already exists or failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
src/main.ts
19
src/main.ts
|
|
@ -138,13 +138,26 @@ export default class GitLabFilesPush extends Plugin {
|
|||
await this.runAllFiles('pull');
|
||||
}
|
||||
|
||||
private async listAllFilesFromAdapter(dirPath: string): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
try {
|
||||
const { files, folders } = await this.app.vault.adapter.list(dirPath || '');
|
||||
results.push(...files);
|
||||
for (const folder of folders) {
|
||||
const sub = await this.listAllFilesFromAdapter(folder);
|
||||
results.push(...sub);
|
||||
}
|
||||
} catch { /* ignore inaccessible dirs */ }
|
||||
return results;
|
||||
}
|
||||
|
||||
private async runAllFiles(op: 'push' | 'pull'): Promise<void> {
|
||||
const allFiles = this.app.vault.getFiles();
|
||||
let files = this.filterFilesByVaultFolder(allFiles);
|
||||
const startPath = this.settings.vaultFolder || '';
|
||||
const allPaths = await this.listAllFilesFromAdapter(startPath);
|
||||
|
||||
await this.gitService.listFiles(this.settings.branch);
|
||||
await this.gitignoreManager.loadGitignores();
|
||||
files = files.filter(f => !this.gitignoreManager.isIgnored(this.getNormalizedPath(f.path)));
|
||||
const files = allPaths.filter(p => !this.gitignoreManager.isIgnored(this.getNormalizedPath(p)));
|
||||
|
||||
if (files.length === 0) {
|
||||
new Notice(`No files to ${op} in the configured vault folder`);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
export const BINARY_EXTENSIONS = new Set([
|
||||
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'pdf', 'zip', 'gz', '7z', 'rar',
|
||||
'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi', 'wmv', 'webp',
|
||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'epub', 'exe', 'dll', 'so',
|
||||
'ttf', 'woff', 'woff2', 'eot', 'wasm', 'dmg', 'iso'
|
||||
// Images
|
||||
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp',
|
||||
'tif', 'tiff', 'heic', 'heif', 'avif', 'jxl',
|
||||
// Documents
|
||||
'pdf', 'epub',
|
||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||
'odt', 'ods', 'odp',
|
||||
// Archives
|
||||
'zip', 'gz', 'tar', 'bz2', 'xz', 'zst', '7z', 'rar',
|
||||
// Audio
|
||||
'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'opus', 'aiff',
|
||||
// Video
|
||||
'mp4', 'webm', 'mov', 'avi', 'wmv', 'mkv', 'flv', 'm4v',
|
||||
// Fonts
|
||||
'ttf', 'otf', 'woff', 'woff2', 'eot',
|
||||
// Executables & binaries
|
||||
'exe', 'dll', 'so', 'wasm', 'dmg', 'iso', 'apk', 'deb', 'rpm',
|
||||
// Databases
|
||||
'sqlite', 'db',
|
||||
// Design
|
||||
'psd', 'sketch', 'fig',
|
||||
]);
|
||||
|
||||
export function isBinaryPath(path: string): boolean {
|
||||
|
|
|
|||
|
|
@ -223,4 +223,54 @@ describe('GitignoreManager', () => {
|
|||
expect(manager.isIgnored('a/temp/foo')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hidden file and directory filtering', () => {
|
||||
let adapter: Mocked<DataAdapter>;
|
||||
|
||||
beforeEach(async () => {
|
||||
adapter = mockApp.vault.adapter as Mocked<DataAdapter>;
|
||||
vi.mocked(mockGitService.getRepoGitignores).mockResolvedValue(['.gitignore']);
|
||||
vi.mocked(adapter.exists).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
async function loadWith(rules: string) {
|
||||
vi.mocked(adapter.read).mockResolvedValue(rules);
|
||||
await manager.loadGitignores();
|
||||
}
|
||||
|
||||
it('should ignore hidden directory when listed in .gitignore', async () => {
|
||||
await loadWith('.private/\n.trash/');
|
||||
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 () => {
|
||||
await loadWith('node_modules/\n*.log');
|
||||
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 () => {
|
||||
await loadWith('.claude/');
|
||||
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 () => {
|
||||
await loadWith('.private/');
|
||||
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 () => {
|
||||
vi.mocked(adapter.list)
|
||||
.mockResolvedValueOnce({ files: ['.gitignore'], folders: ['.claude'] })
|
||||
.mockResolvedValueOnce({ files: ['.claude/.gitignore'], folders: [] });
|
||||
vi.mocked(adapter.read).mockResolvedValue('*.secret');
|
||||
await manager.loadGitignores();
|
||||
|
||||
expect(adapter.list).toHaveBeenCalledWith('');
|
||||
expect(adapter.list).toHaveBeenCalledWith('.claude');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
111
tests/logic/sync-manager-binary.test.ts
Normal file
111
tests/logic/sync-manager-binary.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createSyncManagerMocks, makeBuf, SyncManagerMocks } from './sync-manager-test-helpers';
|
||||
|
||||
vi.mock('obsidian');
|
||||
|
||||
describe('SyncManager – binary file handling', () => {
|
||||
let mocks: SyncManagerMocks;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks = createSyncManagerMocks();
|
||||
});
|
||||
|
||||
describe('pushFile with binary path (string)', () => {
|
||||
it('reads via adapter.readBinary for binary extensions', async () => {
|
||||
const { manager, mockAdapter, mockGitService } = mocks;
|
||||
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 { manager, mockAdapter, mockGitService } = mocks;
|
||||
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 { manager, mockAdapter, mockGitService, mockSettings } = mocks;
|
||||
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 { manager, mockAdapter, mockGitService } = mocks;
|
||||
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 { manager, mockAdapter, mockGitService } = mocks;
|
||||
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 { manager, mockAdapter, mockGitService, mockSettings } = mocks;
|
||||
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 { manager, mockAdapter, mockGitService, mockSettings } = mocks;
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
98
tests/logic/sync-manager-hidden.test.ts
Normal file
98
tests/logic/sync-manager-hidden.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createSyncManagerMocks, SyncManagerMocks } from './sync-manager-test-helpers';
|
||||
|
||||
vi.mock('obsidian');
|
||||
|
||||
describe('SyncManager – hidden file support', () => {
|
||||
let mocks: SyncManagerMocks;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks = createSyncManagerMocks();
|
||||
});
|
||||
|
||||
describe('pullFile with hidden paths', () => {
|
||||
it('creates single hidden parent directory on pull', async () => {
|
||||
const { manager, mockAdapter, mockGitService } = mocks;
|
||||
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 () => {
|
||||
const { manager, mockAdapter, mockGitService } = mocks;
|
||||
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 () => {
|
||||
const { manager, mockAdapter, mockGitService } = mocks;
|
||||
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 () => {
|
||||
const { manager, mockAdapter, mockGitService, mockSettings } = mocks;
|
||||
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 () => {
|
||||
const { manager, mockAdapter, mockGitService } = mocks;
|
||||
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 { manager, mockAdapter, mockGitService } = mocks;
|
||||
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 push when hidden file is missing from vault', async () => {
|
||||
const { manager, mockAdapter, mockGitService } = mocks;
|
||||
vi.mocked(mockAdapter.exists).mockResolvedValue(false);
|
||||
|
||||
await manager.pushFile('.claude/missing.json');
|
||||
|
||||
expect(mockGitService.pushFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
64
tests/logic/sync-manager-test-helpers.ts
Normal file
64
tests/logic/sync-manager-test-helpers.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { vi, 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';
|
||||
|
||||
export interface SyncManagerMocks {
|
||||
manager: SyncManager;
|
||||
mockApp: Mocked<App>;
|
||||
mockGitService: Mocked<GitServiceInterface>;
|
||||
mockAdapter: Mocked<DataAdapter>;
|
||||
mockSettings: GitLabFilesPushSettings;
|
||||
}
|
||||
|
||||
export function createSyncManagerMocks(): SyncManagerMocks {
|
||||
const 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>;
|
||||
|
||||
const 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>;
|
||||
|
||||
const 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>;
|
||||
|
||||
const mockSettings = {
|
||||
serviceType: 'github',
|
||||
githubToken: 'token',
|
||||
githubOwner: 'owner',
|
||||
githubRepo: 'repo',
|
||||
branch: 'main',
|
||||
syncMetadata: {},
|
||||
vaultFolder: '',
|
||||
rootPath: '',
|
||||
} as unknown as GitLabFilesPushSettings;
|
||||
|
||||
const manager = new SyncManager(mockApp, mockGitService, mockSettings);
|
||||
// @ts-ignore - accessing private for test
|
||||
manager.saveSettings = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
return { manager, mockApp, mockGitService, mockAdapter, mockSettings };
|
||||
}
|
||||
|
||||
export const makeBuf = (bytes: number[]) => new Uint8Array(bytes).buffer;
|
||||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
190
tests/utils/path.test.ts
Normal file
190
tests/utils/path.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
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', () => {
|
||||
const buf123 = new Uint8Array([1, 2, 3]).buffer;
|
||||
|
||||
it('returns true for identical buffers', () => {
|
||||
expect(contentsEqual(buf123, new Uint8Array([1, 2, 3]).buffer)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for different buffers', () => {
|
||||
expect(contentsEqual(buf123, new Uint8Array([1, 2, 4]).buffer)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for buffers of different length', () => {
|
||||
expect(contentsEqual(buf123, new Uint8Array([1, 2]).buffer)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for two empty buffers', () => {
|
||||
expect(contentsEqual(new ArrayBuffer(0), new ArrayBuffer(0))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed type comparison', () => {
|
||||
const buf = new Uint8Array([1, 2, 3]).buffer;
|
||||
|
||||
it('returns false when comparing string with ArrayBuffer', () => {
|
||||
expect(contentsEqual('hello', buf)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when comparing ArrayBuffer with string', () => {
|
||||
expect(contentsEqual(buf, 'hello')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue