sotashimozono_obsidian-remo.../plugin/tests/PathMapper.test.ts
Souta b0e2773f1a fix(config+vault): per-device Obsidian config (kills the perpetual write-conflict) + hide dotfiles
Dogfooding follow-up to the lazy loading in this PR. Two real-machine problems:

1. `.obsidian` config kept raising write-conflicts, blocking work. Root cause
   (read from the code, not guessed): a conflict fires when the read cache's
   mtime ≠ the remote file's mtime (SftpDataAdapter.writeBuffer). The daemon
   compares mtime as UnixMilli on both sides, so a single device's sequential
   note edits can't perpetually conflict. What CAN: app.json / appearance.json /
   core-plugins.json / hotkeys.json were the only config files NOT redirected
   per-client — they sat at the shared identity path, so two sessions (or one
   device across reconnects) both wrote the SAME remote file and every settings
   save tripped PreconditionFailed on the other's mtime.

   Fix = make them per-device: add the four to DEFAULT_PRIVATE_PATTERN_BASENAMES
   so PathMapper redirects each into this client's `<configDir>/user/<id>/`
   subtree (exactly what workspace.json/graph/cache already do). No shared path
   → the conflict is impossible by construction. The existing shared-config
   round-trip keeps working — now on the per-client path, so each machine gets
   its own remote backup + cross-session persistence. This is the user's own
   ask: "config should be settable per accessing device".

2. `.julia` (and other dot-dirs) cluttered + slowed the tree. Now:
   - BulkWalker drops dot-prefixed entries from every walk result (full walk AND
     each lazy per-folder deepen), keeping only the vault config dir — matching
     Obsidian's own default of hiding dot-names. A dot-DIR hides its whole
     subtree, not just its row.
   - DEFAULT_WALK_IGNORE_DIRS gains `.julia .cargo .rustup .npm .conda .gem` so
     the daemon prunes these huge caches server-side (never walked/transferred),
     the perf half of "hide `.julia` by default".

tsc / lint clean; vitest 1204 passed (+ per-device PathMapper assertions and a
BulkWalker dotfile-filter test; config round-trip integration tests still green
because seed-write and pull-read go through the same PathMapper). Ships beta.17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:41:35 +09:00

187 lines
7.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
PathMapper,
sanitizeClientId,
defaultClientId,
defaultUserName,
DEFAULT_PRIVATE_PATTERNS,
} from '../src/path/PathMapper';
const ID = 'host-a';
describe('sanitizeClientId', () => {
it('passes through ASCII alphanumerics + dot/hyphen/underscore', () => {
expect(sanitizeClientId('GERMI')).toBe('GERMI');
expect(sanitizeClientId('node-1.local_2')).toBe('node-1.local_2');
});
it('replaces unsafe characters with hyphen and trims them at the edges', () => {
expect(sanitizeClientId('host with spaces')).toBe('host-with-spaces');
expect(sanitizeClientId('!!evil!!')).toBe('evil');
});
it('falls back to "unknown" when the input is empty after sanitisation', () => {
expect(sanitizeClientId('')).toBe('unknown');
expect(sanitizeClientId('!!!')).toBe('unknown');
});
});
describe('defaultClientId / defaultUserName', () => {
it('defaultClientId returns a non-empty sanitized string', () => {
const id = defaultClientId();
expect(id).not.toBe('');
// The same string should pass through sanitize unchanged.
expect(sanitizeClientId(id)).toBe(id);
});
it('defaultUserName returns a non-empty string', () => {
const u = defaultUserName();
expect(u).not.toBe('');
});
});
describe('PathMapper.isPrivate', () => {
const m = new PathMapper(ID);
it('matches the canonical private files', () => {
expect(m.isPrivate('.obsidian/workspace.json')).toBe(true);
expect(m.isPrivate('.obsidian/cache.zlib')).toBe(true);
expect(m.isPrivate('.obsidian/types.json')).toBe(true);
});
it('treats per-device settings (app/appearance/core-plugins/hotkeys) as private', () => {
// Moved from shared → per-client so two devices (or one device
// across reconnects) never collide on a single shared copy — the
// perpetual write-conflict this fixes.
expect(m.isPrivate('.obsidian/app.json')).toBe(true);
expect(m.isPrivate('.obsidian/appearance.json')).toBe(true);
expect(m.isPrivate('.obsidian/core-plugins.json')).toBe(true);
expect(m.isPrivate('.obsidian/hotkeys.json')).toBe(true);
});
it('matches inside a private directory pattern', () => {
expect(m.isPrivate('.obsidian/cache/index')).toBe(true);
expect(m.isPrivate('.obsidian/cache/sub/x.bin')).toBe(true);
});
it('rejects sibling paths that share a prefix', () => {
expect(m.isPrivate('.obsidian/cache.zlib2')).toBe(false);
expect(m.isPrivate('.obsidian/workspace.json.bak')).toBe(false);
});
it('rejects regular vault content', () => {
expect(m.isPrivate('Notes/foo.md')).toBe(false);
// community-plugins.json stays shared (round-tripped with a forced
// remote-ssh union), so it is NOT redirected per-client.
expect(m.isPrivate('.obsidian/community-plugins.json')).toBe(false);
expect(m.isPrivate('.obsidian/plugins/myplugin/data.json')).toBe(false);
});
it('tolerates a leading slash on the input', () => {
expect(m.isPrivate('/.obsidian/workspace.json')).toBe(true);
});
});
describe('PathMapper.isCrossingPoint', () => {
it('flags `.obsidian/` because every private pattern lives directly under it', () => {
const m = new PathMapper(ID);
expect(m.isCrossingPoint('.obsidian')).toBe(true);
});
it('does not flag the private dirs themselves (those are private, not crossing)', () => {
const m = new PathMapper(ID);
expect(m.isCrossingPoint('.obsidian/cache')).toBe(false);
expect(m.isCrossingPoint('.obsidian/workspace.json')).toBe(false);
});
it('does not flag unrelated parents', () => {
const m = new PathMapper(ID);
expect(m.isCrossingPoint('Notes')).toBe(false);
expect(m.isCrossingPoint('')).toBe(false);
});
});
describe('PathMapper.toRemote / toVault', () => {
const m = new PathMapper(ID);
it('redirects private files into the per-client subtree', () => {
expect(m.toRemote('.obsidian/workspace.json'))
.toBe('.obsidian/user/host-a/workspace.json');
expect(m.toRemote('.obsidian/cache/foo.bin'))
.toBe('.obsidian/user/host-a/cache/foo.bin');
expect(m.toRemote('.obsidian/app.json'))
.toBe('.obsidian/user/host-a/app.json');
});
it('passes non-private paths through unchanged', () => {
expect(m.toRemote('Notes/foo.md')).toBe('Notes/foo.md');
expect(m.toRemote('.obsidian/community-plugins.json')).toBe('.obsidian/community-plugins.json');
expect(m.toRemote('.obsidian')).toBe('.obsidian');
});
it('toVault inverts a redirected path back to its vault-relative form', () => {
expect(m.toVault('.obsidian/user/host-a/workspace.json'))
.toBe('.obsidian/workspace.json');
expect(m.toVault('.obsidian/user/host-a/cache/foo.bin'))
.toBe('.obsidian/cache/foo.bin');
});
it('leaves another client\'s subtree alone (so the caller can filter it out)', () => {
expect(m.toVault('.obsidian/user/host-b/workspace.json'))
.toBe('.obsidian/user/host-b/workspace.json');
});
it('uses the configured client id for the redirect prefix', () => {
const other = new PathMapper('SomeBox');
expect(other.toRemote('.obsidian/workspace.json'))
.toBe('.obsidian/user/SomeBox/workspace.json');
});
});
describe('PathMapper.resolveListing', () => {
const m = new PathMapper(ID);
it('asks the caller to merge `.obsidian` with the user subtree, hiding the user/ dir', () => {
const r = m.resolveListing('.obsidian');
expect(r.primary).toBe('.obsidian');
expect(r.mergeFromUser).toBe(true);
expect(r.userSubtree).toBe('.obsidian/user/host-a');
expect(r.hideUserDirName).toBe('user');
});
it('redirects a list of a private directory entirely', () => {
const r = m.resolveListing('.obsidian/cache');
expect(r.primary).toBe('.obsidian/user/host-a/cache');
expect(r.mergeFromUser).toBe(false);
});
it('passes ordinary listings through', () => {
const r = m.resolveListing('Notes');
expect(r.primary).toBe('Notes');
expect(r.mergeFromUser).toBe(false);
});
it('passes `.obsidian/plugins` through unmerged (not a crossing point under default patterns)', () => {
const r = m.resolveListing('.obsidian/plugins');
expect(r.primary).toBe('.obsidian/plugins');
expect(r.mergeFromUser).toBe(false);
});
});
describe('PathMapper with custom patterns', () => {
it('respects caller-supplied private patterns instead of the defaults', () => {
// Patterns must live under .obsidian/ so they redirect cleanly into the
// per-client subtree; this test extends the list with a hypothetical
// graph-experimental.json that ships with a future Obsidian version.
const m = new PathMapper(ID, ['.obsidian/graph-experimental.json']);
expect(m.isPrivate('.obsidian/graph-experimental.json')).toBe(true);
expect(m.isPrivate('.obsidian/workspace.json')).toBe(false); // not in custom list
expect(m.toRemote('.obsidian/graph-experimental.json'))
.toBe('.obsidian/user/host-a/graph-experimental.json');
});
it('exports a stable default pattern list', () => {
expect(DEFAULT_PRIVATE_PATTERNS).toContain('.obsidian/workspace.json');
expect(DEFAULT_PRIVATE_PATTERNS).toContain('.obsidian/cache');
});
});