sotashimozono_obsidian-remo.../plugin/tests/PathMapper.test.ts

174 lines
6.3 KiB
TypeScript
Raw Permalink Normal View History

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 09:07:13 +00:00
import { describe, it, expect } from 'vitest';
import {
PathMapper,
sanitizeClientId,
defaultClientId,
defaultUserName,
DEFAULT_PRIVATE_PATTERNS,
} from '../src/path/PathMapper';
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 09:07:13 +00:00
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('');
});
});
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 09:07:13 +00:00
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');
});
});