sotashimozono_obsidian-remo.../plugin/tests/PathMapper.test.ts
Souta 2827823879 feat(plugin): per-client path mapping (PathMapper)
Vault-relative paths matching `.obsidian/workspace.json`, `.obsidian/
cache`, `.obsidian/types.json`, etc. are redirected into a
per-client subtree on the remote (`.obsidian/user/<client-id>/...`)
so two machines on the same vault don't trample each other's UI
state. The client id defaults to a sanitized OS hostname; multi-
instance setups can override it later when a settings field lands.

src/path/PathMapper.ts
- DEFAULT_PRIVATE_PATTERNS: workspace.json, workspace-mobile.json,
  cache, cache.zlib, types.json, file-recovery.json, graph.json,
  canvas.json. Errors on the side of "private" — the cost of an
  unnecessary per-machine copy is low; the cost of a shared
  workspace.json being clobbered is loud.
- isPrivate(path): exact match or directory-prefix match against
  the patterns. Tolerates a leading slash on the input.
- isCrossingPoint(path): true when `path` is the parent of one or
  more private patterns but not itself private. Today this matches
  exactly `.obsidian` — listing it requires merging the shared
  remote `.obsidian/` with the per-client subtree.
- toRemote / toVault: round-trip path translation. Foreign clients'
  subtrees are preserved unchanged on the way back so the caller
  can decide whether to filter them out.
- resolveListing(path): planning helper for `list()` — returns
  { primary, mergeFromUser, userSubtree?, hideUserDirName? }.
- defaultClientId() / sanitizeClientId(): hostname-based id with
  unsafe characters replaced by hyphens; falls back to "unknown"
  when sanitization yields an empty string.

src/adapter/SftpDataAdapter.ts
- Optional 6th constructor param `pathMapper`. When supplied,
  `toRemote()` first runs the vault path through the mapper, then
  joins with `remoteBasePath` as before. A new private `joinRemote`
  exposes the join-only step for the listing planner.
- `list()` consults the mapper's `resolveListing(...)` plan: it
  always lists the primary path (and prunes the `user/` directory
  on a crossing-point listing), then optionally lists the user
  subtree and merges the entries. User-subtree entries take
  precedence on name conflicts so private files always show up
  under their nominal `.obsidian/...` name.
- A small `planList(path)` shim exists so adapter tests can see
  what the mapper decided without going through a real
  RemoteFsClient.

src/main.ts
- `debugPatchAdapter` constructs a PathMapper with `defaultClientId`
  and passes it to the adapter on every patch. The clientId is
  logged so diagnostics in console.log show which subtree the
  current session writes into.

Tests
- tests/PathMapper.test.ts (22 cases): isPrivate matching including
  prefix/sibling distinction, isCrossingPoint, toRemote/toVault
  round-trip, foreign-client subtree pass-through, resolveListing
  for `.obsidian` (merge with hidden user/), private-dir
  redirection, ordinary path pass-through, custom-pattern
  override, and the sanitizeClientId helper for the canonical
  cases (alphanumerics + dot/hyphen/underscore preserved, unsafe
  characters folded, empty-after-sanitization → "unknown").
- tests/SftpDataAdapter.test.ts grew a "with PathMapper" describe
  block (5 cases): writes to private paths land in the per-client
  subtree, reads come back from there, non-private vault content is
  not redirected, `.obsidian` listing merges shared + per-client
  while hiding the user/ dir, and a private-directory listing
  walks the per-client subtree.

Verification
- cd plugin && npx tsc --noEmit            clean
- cd plugin && npm test                    151 / 151 pass (28 new)
- cd plugin && npm run build:full          all green; bundle 386 KB
                                           (+1 KB over Phase 5-D.5).

Follow-ups
- Settings field for explicit clientId overrides (multi-instance
  setups, anonymisation).
- Bootstrap: copy local `.obsidian/` skeleton into the per-client
  subtree on first connect so Obsidian's first read of
  workspace.json doesn't fail.
- fs.watch (Phase 5-E) and getResourcePath (Phase 5-F) are still
  the next big features.
2026-04-25 18:07:29 +09:00

153 lines
5.8 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { PathMapper, sanitizeClientId, 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('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');
});
});