sotashimozono_obsidian-remo.../plugin/tests/PathMapper.test.ts
Souta ceb3356242 feat(plugin): expose clientId + userName in settings (F19/F20)
PathMapper currently derives clientId from os.hostname(), which is
fine for the single-machine case but unreadable when multiple
clients share a remote vault. Surface two settings so users can
pick a friendly identity per device:

- clientId — overrides the per-client subtree name on the remote
  (.obsidian/user/<id>/...). Empty falls back to defaultClientId().
  Inputs are sanitised before save so a typo can't produce an
  invalid path. Changing leaves the old subtree behind; a one-line
  description in the UI flags that.
- userName — display-only for now; surfaces in connect notices and
  is reserved for the future multi-client presence file. Empty
  falls back to the OS username.

Both fields live under a new "This device" section in the
SettingsTab. main.ts reads them through a new resolveClientId()
that's used at adapter-patch time.

defaultUserName() helper added next to defaultClientId() in
PathMapper.ts.

Tests: 2 new sanity cases (defaultClientId is round-trip-safe under
sanitize, defaultUserName is non-empty); 195 total green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:46:53 +09:00

173 lines
6.3 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('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);
expect(m.isPrivate('.obsidian/hotkeys.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');
});
it('passes non-private paths through unchanged', () => {
expect(m.toRemote('Notes/foo.md')).toBe('Notes/foo.md');
expect(m.toRemote('.obsidian/hotkeys.json')).toBe('.obsidian/hotkeys.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');
});
});