mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
Two product defects the new E2E matrix pinned red. Both fixed; both tests go
green off the product, not off a weakened assertion.
1. STALE READ (cache-pressure.spec). `readBuffer` revalidated a cached read on
`s.mtime === cached.mtime` ALONE and never compared the size — though the
very same `stat` returns it, and uses it two lines later to size the
transfer. SFTP reports mtime at 1-SECOND resolution
(`SftpClient`: `mtime: stats.mtime * 1000`), so two edits inside one
wall-clock second collapse onto the same value: the user keeps reading a
version that no longer exists on the server, then saves it back over the
real one. Not theoretical — `reflect.spec.ts` sleeps 1.1 s between remote
edits to stay green, which is this bug wearing a workaround.
Now revalidates on mtime AND size. A same-second edit that preserves the
byte length is still invisible (that needs a content hash or a server-side
change counter); this closes the common case for free. Verified the guard is
load-bearing: disabling the size check makes the new unit test fail.
2. UNINSTALL NEVER PROPAGATED (plugin-code-roundtrip.spec). Both
pullCommunityPlugins and pushCommunityPlugins used a monotonic UNION, so
`community-plugins.json` could only ever GROW: a local uninstall never
reached the remote, and the next connect's pull RESURRECTED it. The user
could not uninstall anything, ever.
A union cannot tell "I never had it" from "I removed it", so `mergePluginIds`
is now a 3-WAY merge against a per-device BASE — the list as it stood at the
end of the last successful sync on this device. Base lives at
`~/.obsidian-remote/state/<profile-id>/community-plugins.base.json`,
deliberately OUTSIDE every vault so writeThroughConfig never mirrors it and
PathMapper never redirects it.
- No base yet -> degrades exactly to the old union (first-run fallback:
removals can't be inferred, but nothing is lost).
- Only the PUSH commits a base, and only once both sides hold the converged
list — a base written by preSpawnPull's pull would make the later connect
read `base \ remote` as a remote removal of this device's own additions.
Never committing on pull also makes pre-spawn + connect idempotent.
- An unreadable or ABSENT remote contributes NO removals — "absent" must
never be read as "everything was uninstalled elsewhere".
- remote-ssh is never removable from either side.
- Tie-break is ADD WINS: re-uninstalling is one click; silently losing a
plugin you just installed is invisible data loss.
- A first bootstrap DISCARDS a stale base: deleting the shadow vault and
reconnecting is the documented recovery step, and without this the first
push would have stripped the user's entire enabled-plugin list off the
remote.
Full suite: 1238 passed (was 1227), no regressions.
1703 lines
72 KiB
TypeScript
1703 lines
72 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { SftpDataAdapter } from '../src/adapter/SftpDataAdapter';
|
|
import { ReadCache } from '../src/cache/ReadCache';
|
|
import { DirCache } from '../src/cache/DirCache';
|
|
import { PathMapper } from '../src/path/PathMapper';
|
|
import { AncestorTracker } from '../src/conflict/AncestorTracker';
|
|
import { ConflictResolver } from '../src/conflict/ConflictResolver';
|
|
import { OfflineQueue } from '../src/offline/OfflineQueue';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import type { RemoteEntry, RemoteStat } from '../src/types';
|
|
|
|
/** Minimal stand-in for SftpClient covering both read- and write-side calls. */
|
|
function makeFakeClient(initial: {
|
|
files?: Record<string, { data: Buffer; mtime: number }>;
|
|
dirs?: Record<string, RemoteEntry[]>;
|
|
} = {}) {
|
|
const files: Record<string, { data: Buffer; mtime: number }> = { ...(initial.files ?? {}) };
|
|
const dirs: Record<string, RemoteEntry[]> = { ...(initial.dirs ?? {}) };
|
|
let clock = 1000;
|
|
const tick = () => ++clock;
|
|
|
|
const dirEntry = (name: string, isDirectory: boolean): RemoteEntry => ({
|
|
name, isDirectory,
|
|
isFile: !isDirectory,
|
|
isSymbolicLink: false,
|
|
mtime: 0, size: 0,
|
|
});
|
|
|
|
/** Walk parent dirs and add `name` to each parent's dir listing. */
|
|
const linkIntoParent = (fullPath: string, isDirectory: boolean): void => {
|
|
const i = fullPath.lastIndexOf('/');
|
|
if (i <= 0) return;
|
|
const parent = fullPath.slice(0, i) || '/';
|
|
const name = fullPath.slice(i + 1);
|
|
if (!(parent in dirs)) dirs[parent] = [];
|
|
const existingIdx = dirs[parent].findIndex(e => e.name === name);
|
|
if (existingIdx >= 0) dirs[parent][existingIdx] = dirEntry(name, isDirectory);
|
|
else dirs[parent].push(dirEntry(name, isDirectory));
|
|
};
|
|
|
|
const unlinkFromParent = (fullPath: string): void => {
|
|
const i = fullPath.lastIndexOf('/');
|
|
if (i <= 0) return;
|
|
const parent = fullPath.slice(0, i) || '/';
|
|
const name = fullPath.slice(i + 1);
|
|
if (!(parent in dirs)) return;
|
|
dirs[parent] = dirs[parent].filter(e => e.name !== name);
|
|
};
|
|
|
|
const stat = vi.fn(async (p: string): Promise<RemoteStat> => {
|
|
if (files[p]) {
|
|
const f = files[p];
|
|
return {
|
|
isDirectory: false, isFile: true, isSymbolicLink: false,
|
|
mtime: f.mtime, size: f.data.byteLength, mode: 0o100644,
|
|
};
|
|
}
|
|
if (dirs[p]) {
|
|
return {
|
|
isDirectory: true, isFile: false, isSymbolicLink: false,
|
|
mtime: 0, size: 0, mode: 0o040755,
|
|
};
|
|
}
|
|
throw new Error('No such file');
|
|
});
|
|
|
|
const exists = vi.fn(async (p: string): Promise<boolean> => {
|
|
return p in files || p in dirs;
|
|
});
|
|
|
|
const list = vi.fn(async (p: string): Promise<RemoteEntry[]> => {
|
|
if (!(p in dirs)) throw new Error(`No such dir: ${p}`);
|
|
return dirs[p];
|
|
});
|
|
|
|
const readBinary = vi.fn(async (p: string): Promise<Buffer> => {
|
|
if (!(p in files)) throw new Error(`No such file: ${p}`);
|
|
return files[p].data;
|
|
});
|
|
|
|
const writeBinary = vi.fn(async (p: string, data: Buffer): Promise<void> => {
|
|
files[p] = { data: Buffer.from(data), mtime: tick() };
|
|
linkIntoParent(p, false);
|
|
});
|
|
|
|
const mkdirp = vi.fn(async (p: string): Promise<void> => {
|
|
if (!p) return;
|
|
const parts = p.split('/').filter(Boolean);
|
|
const isAbs = p.startsWith('/');
|
|
let current = '';
|
|
for (const part of parts) {
|
|
current = isAbs ? current + '/' + part : (current ? current + '/' + part : part);
|
|
if (!(current in dirs)) {
|
|
dirs[current] = [];
|
|
linkIntoParent(current, true);
|
|
}
|
|
}
|
|
});
|
|
|
|
const rename = vi.fn(async (oldPath: string, newPath: string): Promise<void> => {
|
|
if (oldPath in files) {
|
|
files[newPath] = files[oldPath];
|
|
delete files[oldPath];
|
|
unlinkFromParent(oldPath);
|
|
linkIntoParent(newPath, false);
|
|
} else if (oldPath in dirs) {
|
|
dirs[newPath] = dirs[oldPath];
|
|
delete dirs[oldPath];
|
|
unlinkFromParent(oldPath);
|
|
linkIntoParent(newPath, true);
|
|
} else {
|
|
throw new Error(`rename: no such path "${oldPath}"`);
|
|
}
|
|
});
|
|
|
|
const copy = vi.fn(async (src: string, dst: string): Promise<void> => {
|
|
if (!(src in files)) throw new Error(`copy: no such file "${src}"`);
|
|
files[dst] = { data: Buffer.from(files[src].data), mtime: tick() };
|
|
linkIntoParent(dst, false);
|
|
});
|
|
|
|
const remove = vi.fn(async (p: string): Promise<void> => {
|
|
if (!(p in files)) throw new Error(`remove: no such file "${p}"`);
|
|
delete files[p];
|
|
unlinkFromParent(p);
|
|
});
|
|
|
|
const rmdir = vi.fn(async (p: string, recursive = false): Promise<void> => {
|
|
if (!(p in dirs)) throw new Error(`rmdir: no such dir "${p}"`);
|
|
if (recursive) {
|
|
const prefix = p.endsWith('/') ? p : p + '/';
|
|
for (const f of Object.keys(files)) {
|
|
if (f === p || f.startsWith(prefix)) { delete files[f]; }
|
|
}
|
|
for (const d of Object.keys(dirs)) {
|
|
if (d !== p && (d === p || d.startsWith(prefix))) { delete dirs[d]; }
|
|
}
|
|
} else if (dirs[p].length > 0) {
|
|
throw new Error('rmdir: not empty');
|
|
}
|
|
delete dirs[p];
|
|
unlinkFromParent(p);
|
|
});
|
|
|
|
return {
|
|
files, dirs,
|
|
client: {
|
|
stat, exists, list, readBinary,
|
|
writeBinary, mkdirp, rename, copy, remove, rmdir,
|
|
} as unknown as import('../src/ssh/SftpClient').SftpClient,
|
|
spies: { stat, exists, list, readBinary, writeBinary, mkdirp, rename, copy, remove, rmdir },
|
|
};
|
|
}
|
|
|
|
const e = (name: string, isDirectory = false): RemoteEntry => ({
|
|
name, isDirectory,
|
|
isFile: !isDirectory,
|
|
isSymbolicLink: false,
|
|
mtime: 1000,
|
|
size: 0,
|
|
});
|
|
|
|
describe('SftpDataAdapter (read-side)', () => {
|
|
let readCache: ReadCache;
|
|
let dirCache: DirCache;
|
|
|
|
beforeEach(() => {
|
|
readCache = new ReadCache({ maxBytes: 1024 });
|
|
dirCache = new DirCache({ ttlMs: 10000 });
|
|
});
|
|
|
|
describe('getName / toRemote', () => {
|
|
it('returns the configured vault name', () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(fake.client, '/srv/vault', readCache, dirCache, 'TestVault');
|
|
expect(adapter.getName()).toBe('TestVault');
|
|
});
|
|
|
|
it('exposes the shadow base path via the basePath getter and getBasePath()', () => {
|
|
// #170: when patched onto FileSystemAdapter, both the property
|
|
// and the method form must return the shadow vault's local
|
|
// root so plugins (Templater / Kanban / Importer / Copilot)
|
|
// join their fs.* calls against a real local mirror.
|
|
const fake = makeFakeClient();
|
|
const shadow = '/Users/me/.obsidian-remote/vaults/abc/';
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/srv/vault', readCache, dirCache, 'v',
|
|
null, null, null, null, null,
|
|
shadow,
|
|
);
|
|
expect(adapter.basePath).toBe(shadow);
|
|
expect(adapter.getBasePath()).toBe(shadow);
|
|
});
|
|
|
|
it('basePath / getBasePath default to "" when not provided (test-friendly fallback)', () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(fake.client, '/srv/vault', readCache, dirCache, 'v');
|
|
expect(adapter.basePath).toBe('');
|
|
expect(adapter.getBasePath()).toBe('');
|
|
});
|
|
|
|
it('joins normalized path under the remote base', () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(fake.client, '/srv/vault', readCache, dirCache, 'v');
|
|
expect(adapter.toRemote('')).toBe('/srv/vault');
|
|
expect(adapter.toRemote('/')).toBe('/srv/vault');
|
|
expect(adapter.toRemote('foo.md')).toBe('/srv/vault/foo.md');
|
|
expect(adapter.toRemote('dir/sub/note.md')).toBe('/srv/vault/dir/sub/note.md');
|
|
});
|
|
|
|
it('handles a home-relative base ("work/VaultDev")', () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(fake.client, 'work/VaultDev', readCache, dirCache, 'v');
|
|
expect(adapter.toRemote('foo.md')).toBe('work/VaultDev/foo.md');
|
|
expect(adapter.toRemote('')).toBe('work/VaultDev');
|
|
});
|
|
|
|
it('handles a root base ("/")', () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(fake.client, '/', readCache, dirCache, 'v');
|
|
expect(adapter.toRemote('foo.md')).toBe('/foo.md');
|
|
expect(adapter.toRemote('')).toBe('/');
|
|
});
|
|
|
|
it('handles an empty base ("") — used for RPC mode where the daemon already knows the vault root', () => {
|
|
// In RPC mode the Go daemon's `--vault-root` provides the
|
|
// absolute prefix; the client must send vault-relative paths to
|
|
// avoid a double-prefix at the daemon's `Resolve` step.
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(fake.client, '', readCache, dirCache, 'v');
|
|
expect(adapter.toRemote('foo.md')).toBe('foo.md');
|
|
expect(adapter.toRemote('dir/sub/note.md')).toBe('dir/sub/note.md');
|
|
// Vault root maps to the empty string so the daemon's Resolve()
|
|
// returns its absRoot unchanged.
|
|
expect(adapter.toRemote('')).toBe('');
|
|
expect(adapter.toRemote('/')).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('exists', () => {
|
|
it('returns true for a file or folder that exists remotely', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('hi'), mtime: 1 } },
|
|
dirs: { '/v': [e('note.md')] },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await expect(adapter.exists('note.md')).resolves.toBe(true);
|
|
await expect(adapter.exists('')).resolves.toBe(true);
|
|
});
|
|
|
|
it('returns false when the remote path is missing', async () => {
|
|
const fake = makeFakeClient({});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await expect(adapter.exists('missing.md')).resolves.toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('stat', () => {
|
|
it('returns a Stat with mtime/size for a file', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('hello'), mtime: 1234 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
const s = await adapter.stat('note.md');
|
|
expect(s).toEqual({ type: 'file', ctime: 1234, mtime: 1234, size: 5 });
|
|
});
|
|
|
|
it('returns "folder" type for a directory', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v/sub': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
const s = await adapter.stat('sub');
|
|
expect(s?.type).toBe('folder');
|
|
});
|
|
|
|
it('returns null when the path does not exist', async () => {
|
|
const fake = makeFakeClient({});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await expect(adapter.stat('missing.md')).resolves.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('list', () => {
|
|
it('separates files and folders and returns vault-relative paths', async () => {
|
|
const fake = makeFakeClient({
|
|
dirs: { '/v/docs': [e('a.md'), e('b.md'), e('sub', true)] },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
const out = await adapter.list('docs');
|
|
expect(out.files.sort()).toEqual(['docs/a.md', 'docs/b.md']);
|
|
expect(out.folders).toEqual(['docs/sub']);
|
|
});
|
|
|
|
it('serves a vault-root listing without a leading slash', async () => {
|
|
const fake = makeFakeClient({
|
|
dirs: { '/v': [e('a.md'), e('docs', true)] },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
const out = await adapter.list('');
|
|
expect(out.files).toEqual(['a.md']);
|
|
expect(out.folders).toEqual(['docs']);
|
|
});
|
|
|
|
it('caches the second call within DirCache TTL', async () => {
|
|
const fake = makeFakeClient({
|
|
dirs: { '/v/docs': [e('a.md')] },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.list('docs');
|
|
await adapter.list('docs');
|
|
expect(fake.spies.list).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('read', () => {
|
|
it('returns utf8 string content', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('# Hello', 'utf8'), mtime: 1 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await expect(adapter.read('note.md')).resolves.toBe('# Hello');
|
|
});
|
|
|
|
it('serves from ReadCache when mtime is unchanged (1 stat, 0 reads on the second call)', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('hi'), mtime: 1 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.read('note.md'); // miss: 1 read + 1 stat (post-read)
|
|
const before = fake.spies.readBinary.mock.calls.length;
|
|
await adapter.read('note.md'); // hit: 1 stat, 0 reads
|
|
expect(fake.spies.readBinary.mock.calls.length - before).toBe(0);
|
|
});
|
|
|
|
it('refetches when mtime has advanced on the remote', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('v1'), mtime: 1 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.read('note.md'); // populates cache with mtime=1
|
|
// Simulate an out-of-band modification.
|
|
fake.files['/v/note.md'] = { data: Buffer.from('v2'), mtime: 2 };
|
|
await expect(adapter.read('note.md')).resolves.toBe('v2');
|
|
});
|
|
});
|
|
|
|
describe('readBinary', () => {
|
|
it('returns an ArrayBuffer copy (independent of the cached Buffer)', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/img.png': { data: Buffer.from([1, 2, 3, 4]), mtime: 1 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
const ab = await adapter.readBinary('img.png');
|
|
expect(ab).toBeInstanceOf(ArrayBuffer);
|
|
const view = new Uint8Array(ab);
|
|
expect([...view]).toEqual([1, 2, 3, 4]);
|
|
view[0] = 99;
|
|
const cached = readCache.peek('/v/img.png');
|
|
expect(cached?.data[0]).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('write', () => {
|
|
it('persists text content and refreshes ReadCache with the new mtime', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.write('note.md', 'hello');
|
|
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('hello');
|
|
const cached = readCache.peek('/v/note.md');
|
|
expect(cached?.data.toString('utf8')).toBe('hello');
|
|
expect(cached?.mtime).toBe(fake.files['/v/note.md'].mtime);
|
|
});
|
|
|
|
it('writeBinary round-trips an ArrayBuffer faithfully', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
const ab = new ArrayBuffer(4);
|
|
new Uint8Array(ab).set([10, 20, 30, 40]);
|
|
await adapter.writeBinary('blob.bin', ab);
|
|
expect([...fake.files['/v/blob.bin'].data]).toEqual([10, 20, 30, 40]);
|
|
});
|
|
|
|
it('creates parent directories on the way to the file', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.write('docs/sub/a.md', 'x');
|
|
expect(fake.dirs['/v/docs']).toBeDefined();
|
|
expect(fake.dirs['/v/docs/sub']).toBeDefined();
|
|
expect(fake.files['/v/docs/sub/a.md'].data.toString('utf8')).toBe('x');
|
|
});
|
|
|
|
it('invalidates the parent dir entry in DirCache after a write', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
// Prime DirCache with an empty listing so we can observe invalidation.
|
|
await adapter.list('');
|
|
expect(dirCache.get('/v')).not.toBeNull();
|
|
await adapter.write('note.md', 'hi');
|
|
expect(dirCache.get('/v')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('append', () => {
|
|
it('appends text to an existing file', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/log.md': { data: Buffer.from('first\n'), mtime: 1 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.append('log.md', 'second\n');
|
|
expect(fake.files['/v/log.md'].data.toString('utf8')).toBe('first\nsecond\n');
|
|
});
|
|
|
|
it('creates the file when it does not exist yet', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.append('new.md', 'hello');
|
|
expect(fake.files['/v/new.md'].data.toString('utf8')).toBe('hello');
|
|
});
|
|
|
|
it('appendBinary concatenates bytes', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/blob.bin': { data: Buffer.from([1, 2]), mtime: 1 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
const more = new ArrayBuffer(2);
|
|
new Uint8Array(more).set([3, 4]);
|
|
await adapter.appendBinary('blob.bin', more);
|
|
expect([...fake.files['/v/blob.bin'].data]).toEqual([1, 2, 3, 4]);
|
|
});
|
|
});
|
|
|
|
describe('process', () => {
|
|
it('reads, transforms, writes back, and returns the new content', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('hello'), mtime: 1 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
const result = await adapter.process('note.md', s => s.toUpperCase());
|
|
expect(result).toBe('HELLO');
|
|
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('HELLO');
|
|
});
|
|
});
|
|
|
|
describe('mkdir / remove / rmdir', () => {
|
|
it('mkdir creates the directory chain', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.mkdir('a/b/c');
|
|
expect(fake.dirs['/v/a/b/c']).toBeDefined();
|
|
});
|
|
|
|
it('remove deletes the file and invalidates the entry in ReadCache', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('hi'), mtime: 1 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.read('note.md'); // populate the cache
|
|
expect(readCache.peek('/v/note.md')).not.toBeNull();
|
|
await adapter.remove('note.md');
|
|
expect('/v/note.md' in fake.files).toBe(false);
|
|
expect(readCache.peek('/v/note.md')).toBeNull();
|
|
});
|
|
|
|
it('rmdir(recursive) removes the dir and invalidates the prefix in both caches', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/dir/a.md': { data: Buffer.from('a'), mtime: 1 } },
|
|
dirs: { '/v': [], '/v/dir': [] },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.read('dir/a.md'); // populate ReadCache
|
|
await adapter.list('dir'); // populate DirCache
|
|
await adapter.rmdir('dir', true);
|
|
expect('/v/dir' in fake.dirs).toBe(false);
|
|
expect(readCache.peek('/v/dir/a.md')).toBeNull();
|
|
expect(dirCache.get('/v/dir')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('rename / copy', () => {
|
|
it('rename moves a file and invalidates the old prefix in caches', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/old.md': { data: Buffer.from('content'), mtime: 1 } },
|
|
dirs: { '/v': [] },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.read('old.md'); // cache it
|
|
await adapter.rename('old.md', 'new.md');
|
|
expect(fake.files['/v/new.md'].data.toString('utf8')).toBe('content');
|
|
expect('/v/old.md' in fake.files).toBe(false);
|
|
expect(readCache.peek('/v/old.md')).toBeNull();
|
|
});
|
|
|
|
it('rename creates the destination parent dirs', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/a.md': { data: Buffer.from('x'), mtime: 1 } },
|
|
dirs: { '/v': [] },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.rename('a.md', 'archived/2026/a.md');
|
|
expect(fake.dirs['/v/archived/2026']).toBeDefined();
|
|
expect(fake.files['/v/archived/2026/a.md'].data.toString('utf8')).toBe('x');
|
|
});
|
|
|
|
it('copy duplicates the file and invalidates the new path in ReadCache', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/src.md': { data: Buffer.from('src'), mtime: 1 } },
|
|
dirs: { '/v': [] },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.copy('src.md', 'dst.md');
|
|
expect(fake.files['/v/dst.md'].data.toString('utf8')).toBe('src');
|
|
expect(fake.files['/v/src.md'].data.toString('utf8')).toBe('src'); // src untouched
|
|
});
|
|
});
|
|
|
|
describe('trash', () => {
|
|
it('trashSystem returns false unconditionally so Obsidian falls back to local trash', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/a.md': { data: Buffer.from('x'), mtime: 1 } },
|
|
dirs: { '/v': [] },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await expect(adapter.trashSystem('a.md')).resolves.toBe(false);
|
|
// Must not be moved or deleted.
|
|
expect(fake.files['/v/a.md'].data.toString('utf8')).toBe('x');
|
|
});
|
|
|
|
it('trashLocal moves the path under <vault>/.trash/', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('bye'), mtime: 1 } },
|
|
dirs: { '/v': [] },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.trashLocal('note.md');
|
|
expect('/v/note.md' in fake.files).toBe(false);
|
|
expect(fake.files['/v/.trash/note.md'].data.toString('utf8')).toBe('bye');
|
|
});
|
|
});
|
|
|
|
// ─── PathMapper integration ───────────────────────────────────────────────
|
|
//
|
|
// These tests confirm that when an adapter is constructed with a
|
|
// PathMapper, vault-relative reads/writes/lists for client-private
|
|
// paths land in the per-client subtree on the remote, while ordinary
|
|
// vault content (and shared `.obsidian/*` files) go to their nominal
|
|
// locations unchanged.
|
|
|
|
describe('with PathMapper', () => {
|
|
it('redirects writes for private vault paths into .obsidian/user/<id>/', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v', new PathMapper('host-a'),
|
|
);
|
|
|
|
await adapter.write('.obsidian/workspace.json', '{"open":[]}');
|
|
// The remote sees the per-client subtree, not the bare path.
|
|
expect('/v/.obsidian/workspace.json' in fake.files).toBe(false);
|
|
expect(fake.files['/v/.obsidian/user/host-a/workspace.json'].data.toString('utf8'))
|
|
.toBe('{"open":[]}');
|
|
});
|
|
|
|
it('reads private paths from the per-client subtree', async () => {
|
|
const fake = makeFakeClient({
|
|
files: {
|
|
'/v/.obsidian/user/host-a/workspace.json': { data: Buffer.from('{"a":1}'), mtime: 1 },
|
|
},
|
|
dirs: { '/v': [], '/v/.obsidian': [], '/v/.obsidian/user': [], '/v/.obsidian/user/host-a': [] },
|
|
});
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v', new PathMapper('host-a'),
|
|
);
|
|
|
|
await expect(adapter.read('.obsidian/workspace.json')).resolves.toBe('{"a":1}');
|
|
});
|
|
|
|
it('passes non-private paths through to their nominal remote locations', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v', new PathMapper('host-a'),
|
|
);
|
|
|
|
await adapter.write('Notes/foo.md', '# Hello');
|
|
expect(fake.files['/v/Notes/foo.md'].data.toString('utf8')).toBe('# Hello');
|
|
// Definitely not redirected.
|
|
expect(Object.keys(fake.files).some(k => k.includes('user/host-a/Notes'))).toBe(false);
|
|
});
|
|
|
|
it('list(".obsidian") merges shared and per-client entries, hiding the user/ dir', async () => {
|
|
const fake = makeFakeClient({
|
|
files: {
|
|
'/v/.obsidian/hotkeys.json': { data: Buffer.from('{}'), mtime: 1 },
|
|
'/v/.obsidian/user/host-a/workspace.json': { data: Buffer.from('{}'), mtime: 1 },
|
|
},
|
|
dirs: {
|
|
'/v': [],
|
|
// Shared .obsidian (hotkeys.json + a plugins dir + the user-subtree dir)
|
|
'/v/.obsidian': [
|
|
{ name: 'hotkeys.json', isFile: true, isDirectory: false, isSymbolicLink: false, mtime: 1, size: 2 },
|
|
{ name: 'plugins', isFile: false, isDirectory: true, isSymbolicLink: false, mtime: 1, size: 0 },
|
|
{ name: 'user', isFile: false, isDirectory: true, isSymbolicLink: false, mtime: 1, size: 0 },
|
|
],
|
|
'/v/.obsidian/plugins': [],
|
|
'/v/.obsidian/user': [
|
|
{ name: 'host-a', isFile: false, isDirectory: true, isSymbolicLink: false, mtime: 1, size: 0 },
|
|
],
|
|
'/v/.obsidian/user/host-a': [
|
|
{ name: 'workspace.json', isFile: true, isDirectory: false, isSymbolicLink: false, mtime: 1, size: 2 },
|
|
],
|
|
},
|
|
});
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v', new PathMapper('host-a'),
|
|
);
|
|
|
|
const out = await adapter.list('.obsidian');
|
|
// The `user/` directory is hidden; both private and shared
|
|
// children appear under their nominal `.obsidian/...` paths.
|
|
expect(out.folders.sort()).toEqual(['.obsidian/plugins']);
|
|
expect(out.files.sort()).toEqual(['.obsidian/hotkeys.json', '.obsidian/workspace.json']);
|
|
});
|
|
|
|
it('list of a fully-private directory walks the per-client subtree', async () => {
|
|
const fake = makeFakeClient({
|
|
dirs: {
|
|
'/v': [],
|
|
'/v/.obsidian/user/host-a/cache': [
|
|
{ name: 'index.bin', isFile: true, isDirectory: false, isSymbolicLink: false, mtime: 1, size: 0 },
|
|
],
|
|
},
|
|
});
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v', new PathMapper('host-a'),
|
|
);
|
|
|
|
const out = await adapter.list('.obsidian/cache');
|
|
expect(out.files).toEqual(['.obsidian/cache/index.bin']);
|
|
expect(out.folders).toEqual([]);
|
|
});
|
|
|
|
it('toRemote on a private path returns the per-client absolute path', () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v', new PathMapper('host-a'),
|
|
);
|
|
expect(adapter.toRemote('.obsidian/workspace.json'))
|
|
.toBe('/v/.obsidian/user/host-a/workspace.json');
|
|
expect(adapter.toRemote('Notes/x.md')).toBe('/v/Notes/x.md');
|
|
});
|
|
});
|
|
|
|
describe('swapClient', () => {
|
|
it('routes subsequent reads through the new client', async () => {
|
|
const oldClient = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('OLD'), mtime: 1 } },
|
|
});
|
|
const newClient = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('NEW'), mtime: 2 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(oldClient.client, '/v', readCache, dirCache, 'v');
|
|
// Sanity: the adapter sees the old client's data first.
|
|
expect(await adapter.read('note.md')).toBe('OLD');
|
|
adapter.swapClient(newClient.client);
|
|
// After swap, mtime mismatch invalidates the cache and the
|
|
// newer client's data flows through.
|
|
expect(await adapter.read('note.md')).toBe('NEW');
|
|
});
|
|
|
|
it('preserves cached entries across swaps when mtimes still match', async () => {
|
|
const oldClient = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('SAME'), mtime: 7 } },
|
|
});
|
|
const newClient = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('SAME'), mtime: 7 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(oldClient.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.read('note.md'); // primes cache
|
|
adapter.swapClient(newClient.client);
|
|
// Same mtime → cache hit, the new client only sees a stat call.
|
|
const out = await adapter.read('note.md');
|
|
expect(out).toBe('SAME');
|
|
expect(newClient.client.readBinary).not.toHaveBeenCalled();
|
|
expect(newClient.client.stat).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('setReconnecting (gate)', () => {
|
|
it('serves reads from the cache while reconnecting', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('CACHED'), mtime: 1 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.read('note.md'); // prime cache
|
|
fake.client.stat.mockClear();
|
|
fake.client.readBinary.mockClear();
|
|
adapter.setReconnecting(true);
|
|
// Cached read is served without touching the (dead) client.
|
|
expect(await adapter.read('note.md')).toBe('CACHED');
|
|
expect(fake.client.stat).not.toHaveBeenCalled();
|
|
expect(fake.client.readBinary).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws on a cache miss while reconnecting', async () => {
|
|
const fake = makeFakeClient({});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
adapter.setReconnecting(true);
|
|
await expect(adapter.read('absent.md')).rejects.toThrow(/reconnecting/i);
|
|
});
|
|
|
|
it('throws on every write-side method while reconnecting', async () => {
|
|
const fake = makeFakeClient({});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
adapter.setReconnecting(true);
|
|
const ab = new ArrayBuffer(0);
|
|
await expect(adapter.write('a', 'x')).rejects.toThrow(/reconnecting/i);
|
|
await expect(adapter.writeBinary('a', ab)).rejects.toThrow(/reconnecting/i);
|
|
await expect(adapter.append('a', 'x')).rejects.toThrow(/reconnecting/i);
|
|
await expect(adapter.appendBinary('a', ab)).rejects.toThrow(/reconnecting/i);
|
|
await expect(adapter.process('a', x => x)).rejects.toThrow(/reconnecting/i);
|
|
await expect(adapter.mkdir('d')).rejects.toThrow(/reconnecting/i);
|
|
await expect(adapter.remove('a')).rejects.toThrow(/reconnecting/i);
|
|
await expect(adapter.rmdir('d', false)).rejects.toThrow(/reconnecting/i);
|
|
await expect(adapter.rename('a', 'b')).rejects.toThrow(/reconnecting/i);
|
|
await expect(adapter.copy('a', 'b')).rejects.toThrow(/reconnecting/i);
|
|
await expect(adapter.trashLocal('a')).rejects.toThrow(/reconnecting/i);
|
|
});
|
|
|
|
it('throws on stat / list / exists while reconnecting', async () => {
|
|
const fake = makeFakeClient({});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
adapter.setReconnecting(true);
|
|
await expect(adapter.exists('x')).rejects.toThrow(/reconnecting/i);
|
|
await expect(adapter.stat('x')).rejects.toThrow(/reconnecting/i);
|
|
await expect(adapter.list('')).rejects.toThrow(/reconnecting/i);
|
|
});
|
|
|
|
it('returns to normal once the gate is cleared', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('hi'), mtime: 9 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
adapter.setReconnecting(true);
|
|
await expect(adapter.write('note.md', 'data')).rejects.toThrow(/reconnecting/i);
|
|
adapter.setReconnecting(false);
|
|
await expect(adapter.read('note.md')).resolves.toBe('hi');
|
|
});
|
|
});
|
|
|
|
describe('write conflict (expectedMtime + onWriteConflict)', () => {
|
|
/**
|
|
* Build a client whose `writeBinary` rejects with
|
|
* PreconditionFailed whenever `expectedMtime` is supplied, and
|
|
* succeeds (writing into the in-memory store) when it isn't. Lets
|
|
* tests drive the "conflict, retry, success" path deterministically.
|
|
*/
|
|
function makeConflictingClient() {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('initial'), mtime: 100 } },
|
|
});
|
|
const writeCalls: Array<{ path: string; expected?: number }> = [];
|
|
fake.spies.writeBinary.mockImplementation(
|
|
async (p: string, data: Buffer, expectedMtime?: number) => {
|
|
writeCalls.push({ path: p, expected: expectedMtime });
|
|
if (expectedMtime !== undefined) {
|
|
const err = new Error('precondition failed') as Error & { code: number };
|
|
err.code = -32020;
|
|
throw err;
|
|
}
|
|
// Success path: write into the underlying in-memory store
|
|
// directly so we don't recurse through the spy.
|
|
fake.files[p] = { data: Buffer.from(data), mtime: 200 };
|
|
},
|
|
);
|
|
return { fake, writeCalls };
|
|
}
|
|
|
|
it('passes the cached mtime as expectedMtime when the path was recently read', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('hi'), mtime: 7 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.read('note.md'); // primes cache with mtime=7
|
|
fake.spies.writeBinary.mockClear();
|
|
await adapter.write('note.md', 'updated');
|
|
expect(fake.spies.writeBinary).toHaveBeenCalledWith('/v/note.md', expect.any(Buffer), 7);
|
|
});
|
|
|
|
it('omits expectedMtime when the cache has no entry for the path', async () => {
|
|
const fake = makeFakeClient({});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.write('new.md', 'hello'); // first-touch write, no cache hit
|
|
expect(fake.spies.writeBinary).toHaveBeenCalledWith('/v/new.md', expect.any(Buffer), undefined);
|
|
});
|
|
|
|
it('asks onWriteConflict + retries without expectedMtime when user picks overwrite', async () => {
|
|
const { fake, writeCalls } = makeConflictingClient();
|
|
const onConflict = vi.fn(async () => true);
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v',
|
|
null, null, new ConflictResolver(fake.client, readCache, null, null, onConflict),
|
|
);
|
|
await adapter.read('note.md'); // primes mtime
|
|
await adapter.write('note.md', 'force');
|
|
expect(onConflict).toHaveBeenCalledWith('note.md');
|
|
expect(writeCalls).toHaveLength(2);
|
|
expect(writeCalls[0].expected).toBeDefined();
|
|
expect(writeCalls[1].expected).toBeUndefined();
|
|
});
|
|
|
|
it('rethrows the precondition error when user cancels the conflict', async () => {
|
|
const { fake } = makeConflictingClient();
|
|
const onConflict = vi.fn(async () => false);
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v',
|
|
null, null, new ConflictResolver(fake.client, readCache, null, null, onConflict),
|
|
);
|
|
await adapter.read('note.md');
|
|
await expect(adapter.write('note.md', 'no')).rejects.toMatchObject({ code: -32020 });
|
|
expect(onConflict).toHaveBeenCalledWith('note.md');
|
|
});
|
|
|
|
it('rethrows when there is no onWriteConflict callback', async () => {
|
|
const { fake } = makeConflictingClient();
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.read('note.md');
|
|
await expect(adapter.write('note.md', 'no')).rejects.toMatchObject({ code: -32020 });
|
|
});
|
|
|
|
it('rethrows non-precondition errors without consulting the callback', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('hi'), mtime: 1 } },
|
|
});
|
|
fake.spies.writeBinary.mockImplementation(async () => {
|
|
throw new Error('disk full');
|
|
});
|
|
const onConflict = vi.fn(async () => true);
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v',
|
|
null, null, new ConflictResolver(fake.client, readCache, null, null, onConflict),
|
|
);
|
|
await adapter.read('note.md');
|
|
await expect(adapter.write('note.md', 'no')).rejects.toThrow(/disk full/);
|
|
expect(onConflict).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('text 3-way merge conflict (AncestorTracker + onTextConflict)', () => {
|
|
/**
|
|
* Local copy of the parent describe's `makeConflictingClient`
|
|
* helper — JS scoping keeps the original out of reach. Same
|
|
* contract: any write with `expectedMtime` rejects with
|
|
* PreconditionFailed; writes without it succeed.
|
|
*/
|
|
function makeConflictingClient() {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('initial'), mtime: 100 } },
|
|
});
|
|
const writeCalls: Array<{ path: string; expected?: number }> = [];
|
|
fake.spies.writeBinary.mockImplementation(
|
|
async (p: string, data: Buffer, expectedMtime?: number) => {
|
|
writeCalls.push({ path: p, expected: expectedMtime });
|
|
if (expectedMtime !== undefined) {
|
|
const err = new Error('precondition failed') as Error & { code: number };
|
|
err.code = -32020;
|
|
throw err;
|
|
}
|
|
fake.files[p] = { data: Buffer.from(data), mtime: 200 };
|
|
},
|
|
);
|
|
return { fake, writeCalls };
|
|
}
|
|
|
|
/**
|
|
* Same shape as `makeConflictingClient` above, but the underlying
|
|
* file's mtime advances on a successful (precondition-less) write
|
|
* — the 3-way path does both the failed precondition write AND a
|
|
* follow-up write, so we need the second write to land cleanly.
|
|
*/
|
|
function makeTextConflictingClient(opts: {
|
|
initialContent: string;
|
|
initialMtime: number;
|
|
theirsContent: string;
|
|
theirsMtime: number;
|
|
}) {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from(opts.initialContent), mtime: opts.initialMtime } },
|
|
});
|
|
// The "theirs" content is what the remote returns when the
|
|
// adapter re-reads after the precondition failure. We stash it
|
|
// straight into the fake's store so the conflict path's
|
|
// `client.readBinary(remote)` picks it up.
|
|
fake.files['/v/note.md'] = { data: Buffer.from(opts.theirsContent), mtime: opts.theirsMtime };
|
|
|
|
const writeCalls: Array<{ path: string; expected?: number; data: string }> = [];
|
|
fake.spies.writeBinary.mockImplementation(
|
|
async (p: string, data: Buffer, expectedMtime?: number) => {
|
|
writeCalls.push({ path: p, expected: expectedMtime, data: data.toString('utf8') });
|
|
// Only the FIRST attempt (with expectedMtime) gets the
|
|
// precondition error — retries land cleanly so the test can
|
|
// observe what we ended up writing.
|
|
if (expectedMtime !== undefined && writeCalls.length === 1) {
|
|
const err = new Error('precondition failed') as Error & { code: number };
|
|
err.code = -32020;
|
|
throw err;
|
|
}
|
|
fake.files[p] = { data: Buffer.from(data), mtime: opts.theirsMtime + 1 };
|
|
},
|
|
);
|
|
return { fake, writeCalls };
|
|
}
|
|
|
|
/** Re-prime the ancestor by reading first, then re-stash "theirs" into the fake store. */
|
|
async function primeAndConflict(opts: {
|
|
ancestorContent: string;
|
|
mineContent: string;
|
|
theirsContent: string;
|
|
decision:
|
|
| { decision: 'keep-mine' }
|
|
| { decision: 'keep-theirs' }
|
|
| { decision: 'merged'; content: string }
|
|
| { decision: 'cancel' };
|
|
}) {
|
|
const tracker = new AncestorTracker();
|
|
const onText = vi.fn(async () => opts.decision);
|
|
const onLegacy = vi.fn(async () => false);
|
|
const { fake, writeCalls } = makeTextConflictingClient({
|
|
initialContent: opts.ancestorContent,
|
|
initialMtime: 100,
|
|
theirsContent: opts.theirsContent,
|
|
theirsMtime: 200,
|
|
});
|
|
// Reset the file to the ANCESTOR state for the priming read,
|
|
// then swap it to THEIRS afterwards so the precondition fires.
|
|
fake.files['/v/note.md'] = { data: Buffer.from(opts.ancestorContent), mtime: 100 };
|
|
const resolver = new ConflictResolver(fake.client, readCache, tracker, onText, onLegacy);
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v',
|
|
null, null, resolver, tracker,
|
|
);
|
|
await adapter.read('note.md'); // ancestor primed: "ancestorContent" @ mtime 100
|
|
// Now flip the file under the adapter's nose to simulate
|
|
// another client having written "theirsContent" at mtime 200.
|
|
fake.files['/v/note.md'] = { data: Buffer.from(opts.theirsContent), mtime: 200 };
|
|
return { adapter, onText, onLegacy, writeCalls, fake };
|
|
}
|
|
|
|
it('routes a text PreconditionFailed through onTextConflict with the right panes', async () => {
|
|
const { adapter, onText } = await primeAndConflict({
|
|
ancestorContent: 'v0',
|
|
mineContent: 'mine',
|
|
theirsContent: 'theirs',
|
|
decision: { decision: 'keep-mine' },
|
|
});
|
|
await adapter.write('note.md', 'mine');
|
|
expect(onText).toHaveBeenCalledTimes(1);
|
|
const [path, panes] = onText.mock.calls[0];
|
|
expect(path).toBe('note.md');
|
|
expect(panes).toEqual({ ancestor: 'v0', mine: 'mine', theirs: 'theirs' });
|
|
});
|
|
|
|
it('keep-mine retries the write without expectedMtime and persists "mine"', async () => {
|
|
const { adapter, writeCalls, fake } = await primeAndConflict({
|
|
ancestorContent: 'v0',
|
|
mineContent: 'mine',
|
|
theirsContent: 'theirs',
|
|
decision: { decision: 'keep-mine' },
|
|
});
|
|
await adapter.write('note.md', 'mine');
|
|
// First write: with expectedMtime (rejected). Second: without.
|
|
expect(writeCalls).toHaveLength(2);
|
|
expect(writeCalls[0].expected).toBeDefined();
|
|
expect(writeCalls[1].expected).toBeUndefined();
|
|
expect(writeCalls[1].data).toBe('mine');
|
|
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('mine');
|
|
});
|
|
|
|
it('merged writes the user-supplied merged content', async () => {
|
|
const { adapter, writeCalls, fake } = await primeAndConflict({
|
|
ancestorContent: 'v0',
|
|
mineContent: 'mine',
|
|
theirsContent: 'theirs',
|
|
decision: { decision: 'merged', content: 'mine + theirs handled' },
|
|
});
|
|
await adapter.write('note.md', 'mine');
|
|
expect(writeCalls[1].data).toBe('mine + theirs handled');
|
|
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('mine + theirs handled');
|
|
});
|
|
|
|
it('keep-theirs rethrows + leaves the remote alone, refreshes cache to theirs', async () => {
|
|
const { adapter, writeCalls, fake } = await primeAndConflict({
|
|
ancestorContent: 'v0',
|
|
mineContent: 'mine',
|
|
theirsContent: 'theirs',
|
|
decision: { decision: 'keep-theirs' },
|
|
});
|
|
await expect(adapter.write('note.md', 'mine')).rejects.toMatchObject({ code: -32020 });
|
|
// Only the failed first attempt happened.
|
|
expect(writeCalls).toHaveLength(1);
|
|
// Remote still has theirs.
|
|
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('theirs');
|
|
// Cache was refreshed: a follow-up read serves theirs without
|
|
// another network round-trip — but we can verify by reading
|
|
// through the adapter and confirming the value.
|
|
expect(await adapter.read('note.md')).toBe('theirs');
|
|
});
|
|
|
|
it('cancel rethrows + leaves the remote alone', async () => {
|
|
const { adapter, writeCalls, fake } = await primeAndConflict({
|
|
ancestorContent: 'v0',
|
|
mineContent: 'mine',
|
|
theirsContent: 'theirs',
|
|
decision: { decision: 'cancel' },
|
|
});
|
|
await expect(adapter.write('note.md', 'mine')).rejects.toMatchObject({ code: -32020 });
|
|
expect(writeCalls).toHaveLength(1);
|
|
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('theirs');
|
|
});
|
|
|
|
it('falls back to the legacy two-choice modal when no ancestor is recorded', async () => {
|
|
const tracker = new AncestorTracker();
|
|
const onLegacy = vi.fn(async () => true /* overwrite */);
|
|
const onText = vi.fn();
|
|
const { fake, writeCalls } = makeConflictingClient();
|
|
const resolver = new ConflictResolver(fake.client, readCache, tracker, onText, onLegacy);
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v',
|
|
null, null, resolver, tracker,
|
|
);
|
|
// Prime the readCache (so the write sends expectedMtime) but do
|
|
// NOT populate the ancestor tracker — `readBinary` is the path
|
|
// that touches readCache without touching the text-only tracker.
|
|
await adapter.readBinary('note.md');
|
|
await adapter.write('note.md', 'force');
|
|
expect(onText).not.toHaveBeenCalled();
|
|
expect(onLegacy).toHaveBeenCalledWith('note.md');
|
|
expect(writeCalls[1].expected).toBeUndefined();
|
|
});
|
|
|
|
it('binary writeBinary uses the legacy two-choice modal even when ancestor + onTextConflict are wired', async () => {
|
|
const tracker = new AncestorTracker();
|
|
const onLegacy = vi.fn(async () => true);
|
|
const onText = vi.fn();
|
|
const { fake, writeCalls } = makeConflictingClient();
|
|
// Prime the ancestor so a TEXT write would route through 3-way;
|
|
// assert that the BINARY write does NOT.
|
|
fake.files['/v/note.md'] = { data: Buffer.from('initial'), mtime: 100 };
|
|
const resolver = new ConflictResolver(fake.client, readCache, tracker, onText, onLegacy);
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v',
|
|
null, null, resolver, tracker,
|
|
);
|
|
await adapter.read('note.md');
|
|
await adapter.writeBinary('note.md', new Uint8Array([1, 2, 3]).buffer);
|
|
expect(onText).not.toHaveBeenCalled();
|
|
expect(onLegacy).toHaveBeenCalledWith('note.md');
|
|
expect(writeCalls).toHaveLength(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('SftpDataAdapter (offline queue while reconnecting)', () => {
|
|
let readCache: ReadCache;
|
|
let dirCache: DirCache;
|
|
let queue: OfflineQueue;
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
readCache = new ReadCache();
|
|
dirCache = new DirCache();
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-ssh-adapter-queue-'));
|
|
queue = await OfflineQueue.open(tmpDir);
|
|
});
|
|
|
|
function makeAdapterWithQueue(opts: {
|
|
client: ReturnType<typeof makeFakeClient>['client'];
|
|
tracker?: AncestorTracker;
|
|
}) {
|
|
const tracker = opts.tracker ?? new AncestorTracker();
|
|
return new SftpDataAdapter(
|
|
opts.client, '/v', readCache, dirCache, 'v',
|
|
null, null, null, tracker,
|
|
queue,
|
|
);
|
|
}
|
|
|
|
it('queues a text write instead of throwing while reconnecting', async () => {
|
|
const fake = makeFakeClient({});
|
|
const adapter = makeAdapterWithQueue({ client: fake.client });
|
|
adapter.setReconnecting(true);
|
|
|
|
await adapter.write('a.md', 'hello');
|
|
expect(fake.spies.writeBinary).not.toHaveBeenCalled();
|
|
|
|
const pending = queue.pending();
|
|
expect(pending).toHaveLength(1);
|
|
expect(pending[0].op).toMatchObject({
|
|
kind: 'write',
|
|
path: 'a.md',
|
|
contentBase64: Buffer.from('hello', 'utf8').toString('base64'),
|
|
});
|
|
});
|
|
|
|
it('post-queue read returns the just-written content from local cache', async () => {
|
|
const fake = makeFakeClient({});
|
|
const adapter = makeAdapterWithQueue({ client: fake.client });
|
|
adapter.setReconnecting(true);
|
|
|
|
await adapter.write('a.md', 'queued content');
|
|
expect(await adapter.read('a.md')).toBe('queued content');
|
|
// No remote call happened — the read served from the cache the
|
|
// queue path populated.
|
|
expect(fake.client.readBinary).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('captures expectedMtime from the existing read cache entry', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/a.md': { data: Buffer.from('v0'), mtime: 100 } },
|
|
});
|
|
const adapter = makeAdapterWithQueue({ client: fake.client });
|
|
await adapter.read('a.md'); // primes cache with mtime=100
|
|
adapter.setReconnecting(true);
|
|
|
|
await adapter.write('a.md', 'v1');
|
|
const op = queue.pending()[0].op as { expectedMtime?: number };
|
|
expect(op.expectedMtime).toBe(100);
|
|
});
|
|
|
|
it('queues writeBinary as kind=writeBinary', async () => {
|
|
const fake = makeFakeClient({});
|
|
const adapter = makeAdapterWithQueue({ client: fake.client });
|
|
adapter.setReconnecting(true);
|
|
await adapter.writeBinary('img.png', new Uint8Array([1, 2, 3]).buffer);
|
|
expect(queue.pending()[0].op.kind).toBe('writeBinary');
|
|
});
|
|
|
|
it('queues append as a single full-content write (no separate read+write entries)', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/log.md': { data: Buffer.from('one\n'), mtime: 1 } },
|
|
});
|
|
const adapter = makeAdapterWithQueue({ client: fake.client });
|
|
await adapter.read('log.md'); // prime cache
|
|
adapter.setReconnecting(true);
|
|
|
|
await adapter.append('log.md', 'two\n');
|
|
const pending = queue.pending();
|
|
expect(pending).toHaveLength(1);
|
|
const op = pending[0].op as { kind: string; contentBase64: string };
|
|
expect(op.kind).toBe('write');
|
|
expect(Buffer.from(op.contentBase64, 'base64').toString('utf8')).toBe('one\ntwo\n');
|
|
});
|
|
|
|
it('queues mkdir / remove / rmdir / rename / copy with their right shapes', async () => {
|
|
const fake = makeFakeClient({});
|
|
const adapter = makeAdapterWithQueue({ client: fake.client });
|
|
adapter.setReconnecting(true);
|
|
|
|
await adapter.mkdir('newdir');
|
|
await adapter.remove('a.md');
|
|
await adapter.rmdir('newdir', true);
|
|
await adapter.rename('old.md', 'new.md');
|
|
await adapter.copy('src.md', 'dst.md');
|
|
|
|
const ops = queue.pending().map(e => e.op);
|
|
expect(ops).toEqual([
|
|
{ kind: 'mkdir', path: 'newdir' },
|
|
{ kind: 'remove', path: 'a.md' },
|
|
{ kind: 'rmdir', path: 'newdir', recursive: true },
|
|
{ kind: 'rename', oldPath: 'old.md', newPath: 'new.md' },
|
|
{ kind: 'copy', srcPath: 'src.md', dstPath: 'dst.md' },
|
|
]);
|
|
});
|
|
|
|
it('still throws when no offline queue is wired', async () => {
|
|
const fake = makeFakeClient({});
|
|
// Adapter built WITHOUT the queue arg — the legacy reconnecting
|
|
// behaviour kicks in.
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
adapter.setReconnecting(true);
|
|
await expect(adapter.write('a.md', 'x')).rejects.toThrow(/reconnecting/i);
|
|
});
|
|
|
|
it('queued ops persist across re-opens of the queue (round-trip via disk)', async () => {
|
|
const fake = makeFakeClient({});
|
|
const adapter = makeAdapterWithQueue({ client: fake.client });
|
|
adapter.setReconnecting(true);
|
|
await adapter.write('a.md', 'hello');
|
|
await adapter.mkdir('sub');
|
|
|
|
// Re-open the queue from the same dir to confirm the on-disk log
|
|
// round-trips the adapter's enqueues.
|
|
const reopened = await OfflineQueue.open(tmpDir);
|
|
const ops = reopened.pending().map(e => e.op.kind);
|
|
expect(ops).toEqual(['write', 'mkdir']);
|
|
});
|
|
|
|
// ─── replayQueuedOp (E2-β.3 entry point used by QueueReplayer) ─────────
|
|
|
|
it('replayQueuedOp(write) honours the queued expectedMtime, not the cached one', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/a.md': { data: Buffer.from('initial'), mtime: 100 } },
|
|
});
|
|
const adapter = makeAdapterWithQueue({ client: fake.client });
|
|
// Replay scenario: caller passes the mtime captured at enqueue
|
|
// time. We assert the spy received it as expectedMtime.
|
|
const result = await adapter.replayQueuedOp({
|
|
kind: 'write',
|
|
path: 'a.md',
|
|
contentBase64: Buffer.from('replayed', 'utf8').toString('base64'),
|
|
expectedMtime: 42,
|
|
});
|
|
expect(result).toEqual({ result: 'ok' });
|
|
expect(fake.spies.writeBinary).toHaveBeenCalledWith('/v/a.md', expect.any(Buffer), 42);
|
|
});
|
|
|
|
it('replayQueuedOp returns conflict on PreconditionFailed without an ancestor', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/a.md': { data: Buffer.from('initial'), mtime: 100 } },
|
|
});
|
|
fake.spies.writeBinary.mockImplementation(async (_p: string, _data: Buffer, expectedMtime?: number) => {
|
|
if (expectedMtime !== undefined) {
|
|
const err = new Error('precondition failed') as Error & { code: number };
|
|
err.code = -32020;
|
|
throw err;
|
|
}
|
|
});
|
|
const adapter = makeAdapterWithQueue({ client: fake.client });
|
|
// No prior read → no ancestor → adapter falls through to the
|
|
// legacy two-choice path. Without an `onWriteConflict` callback,
|
|
// the precondition error rethrows; replayQueuedOp surfaces it as
|
|
// 'conflict' (= a user-decided outcome, queue moves on).
|
|
const result = await adapter.replayQueuedOp({
|
|
kind: 'write',
|
|
path: 'a.md',
|
|
contentBase64: Buffer.from('replayed', 'utf8').toString('base64'),
|
|
expectedMtime: 42,
|
|
});
|
|
expect(result).toEqual({ result: 'conflict' });
|
|
});
|
|
|
|
it('replayQueuedOp dispatches mkdir / remove / rename / copy to the underlying client', async () => {
|
|
// Pre-populate source files so the fake's rename/copy don't bail
|
|
// with "no such file" — the adapter just relays the call.
|
|
const fake = makeFakeClient({
|
|
files: {
|
|
'/v/old.md': { data: Buffer.from('x'), mtime: 1 },
|
|
'/v/a': { data: Buffer.from('a'), mtime: 1 },
|
|
'/v/c': { data: Buffer.from('c'), mtime: 1 },
|
|
},
|
|
});
|
|
const adapter = makeAdapterWithQueue({ client: fake.client });
|
|
|
|
expect((await adapter.replayQueuedOp({ kind: 'mkdir', path: 'newdir' })).result).toBe('ok');
|
|
expect(fake.spies.mkdirp).toHaveBeenCalledWith('/v/newdir');
|
|
|
|
expect((await adapter.replayQueuedOp({ kind: 'remove', path: 'old.md' })).result).toBe('ok');
|
|
expect(fake.spies.remove).toHaveBeenCalledWith('/v/old.md');
|
|
|
|
expect((await adapter.replayQueuedOp({ kind: 'rename', oldPath: 'a', newPath: 'b' })).result).toBe('ok');
|
|
expect(fake.spies.rename).toHaveBeenCalledWith('/v/a', '/v/b');
|
|
|
|
expect((await adapter.replayQueuedOp({ kind: 'copy', srcPath: 'c', dstPath: 'd' })).result).toBe('ok');
|
|
expect(fake.spies.copy).toHaveBeenCalledWith('/v/c', '/v/d');
|
|
});
|
|
|
|
it('replayQueuedOp returns error when the underlying client throws non-precondition', async () => {
|
|
const fake = makeFakeClient({});
|
|
fake.spies.writeBinary.mockImplementation(async () => { throw new Error('disk full'); });
|
|
const adapter = makeAdapterWithQueue({ client: fake.client });
|
|
const result = await adapter.replayQueuedOp({
|
|
kind: 'write',
|
|
path: 'a.md',
|
|
contentBase64: 'YQ==',
|
|
});
|
|
expect(result).toEqual({ result: 'error', message: expect.stringMatching(/disk full/) });
|
|
});
|
|
|
|
it('replayQueuedOp refuses to run while reconnecting', async () => {
|
|
const fake = makeFakeClient({});
|
|
const adapter = makeAdapterWithQueue({ client: fake.client });
|
|
adapter.setReconnecting(true);
|
|
const result = await adapter.replayQueuedOp({ kind: 'mkdir', path: 'x' });
|
|
expect(result).toMatchObject({ result: 'error' });
|
|
});
|
|
});
|
|
|
|
describe('SftpDataAdapter: getResourcePath', () => {
|
|
let readCache: ReadCache;
|
|
let dirCache: DirCache;
|
|
beforeEach(() => {
|
|
readCache = new ReadCache({ maxBytes: 1024 });
|
|
dirCache = new DirCache({ ttlMs: 10000 });
|
|
});
|
|
|
|
it('returns a data: fallback URL when no resourceBridge is wired', () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
expect(adapter.getResourcePath('img.png')).toBe('data:application/octet-stream;base64,');
|
|
});
|
|
|
|
it('returns the bridge URL when the bridge is running', () => {
|
|
const fake = makeFakeClient();
|
|
const fakeBridge = {
|
|
isRunning: () => true,
|
|
urlFor: (p: string, _opts?: unknown) => `http://127.0.0.1:4567/r/token?p=${encodeURIComponent(p)}`,
|
|
};
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v',
|
|
null, fakeBridge as unknown as import('../src/adapter/ResourceBridge').ResourceBridge,
|
|
);
|
|
const url = adapter.getResourcePath('folder/photo.jpg');
|
|
expect(url).toContain('127.0.0.1:4567');
|
|
expect(url).toContain('photo.jpg');
|
|
});
|
|
|
|
it('returns the data: fallback when the bridge exists but is not running', () => {
|
|
const fake = makeFakeClient();
|
|
const fakeBridge = {
|
|
isRunning: () => false,
|
|
urlFor: (_p: string) => { throw new Error('should not be called'); },
|
|
};
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v',
|
|
null, fakeBridge as unknown as import('../src/adapter/ResourceBridge').ResourceBridge,
|
|
);
|
|
expect(adapter.getResourcePath('img.png')).toBe('data:application/octet-stream;base64,');
|
|
});
|
|
});
|
|
|
|
describe('SftpDataAdapter: readBuffer stat-after-read failure', () => {
|
|
let readCache: ReadCache;
|
|
let dirCache: DirCache;
|
|
beforeEach(() => {
|
|
readCache = new ReadCache({ maxBytes: 1024 });
|
|
dirCache = new DirCache({ ttlMs: 10000 });
|
|
});
|
|
|
|
it('still returns data and caches with mtime=0 when stat-after-read throws', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/note.md': { data: Buffer.from('content'), mtime: 42 } },
|
|
});
|
|
// Make stat always reject so the post-read stat path throws.
|
|
fake.spies.stat.mockRejectedValue(new Error('stat unavailable'));
|
|
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
const result = await adapter.read('note.md');
|
|
|
|
expect(result).toBe('content');
|
|
// Cache should be populated with mtime=0 because the stat failed.
|
|
const cached = readCache.peek('/v/note.md');
|
|
expect(cached).not.toBeNull();
|
|
expect(cached?.mtime).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ─── writer reflect wiring (#341) ────────────────────────────────────────────
|
|
|
|
describe('SftpDataAdapter — writer reflect (#341)', () => {
|
|
let readCache: ReadCache;
|
|
let dirCache: DirCache;
|
|
|
|
beforeEach(() => {
|
|
readCache = new ReadCache();
|
|
dirCache = new DirCache();
|
|
});
|
|
|
|
function makeReflector() {
|
|
return {
|
|
reflectWrite: vi.fn(),
|
|
reflectRename: vi.fn(),
|
|
reflectRemove: vi.fn(),
|
|
reflectMkdir: vi.fn(),
|
|
};
|
|
}
|
|
|
|
it('write / writeBinary / appendBinary call reflectWrite with the vault path', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
const r = makeReflector();
|
|
adapter.setWriterReflector(r);
|
|
|
|
await adapter.write('note.md', 'hello');
|
|
await adapter.writeBinary('blob.bin', new ArrayBuffer(2));
|
|
await adapter.appendBinary('log.bin', new ArrayBuffer(1));
|
|
|
|
expect(r.reflectWrite.mock.calls.map(c => c[0]))
|
|
.toEqual(['note.md', 'blob.bin', 'log.bin']);
|
|
});
|
|
|
|
it('mkdir → reflectMkdir, remove → reflectRemove, rmdir → reflectRemove', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
const r = makeReflector();
|
|
adapter.setWriterReflector(r);
|
|
|
|
await adapter.mkdir('dir');
|
|
await adapter.write('dir/f.md', 'x');
|
|
await adapter.remove('dir/f.md');
|
|
await adapter.rmdir('dir', true);
|
|
|
|
expect(r.reflectMkdir).toHaveBeenCalledWith('dir');
|
|
expect(r.reflectRemove.mock.calls.map(c => c[0])).toEqual(['dir/f.md', 'dir']);
|
|
});
|
|
|
|
it('rename calls reflectRename(old, new); copy reflects nothing', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
const r = makeReflector();
|
|
adapter.setWriterReflector(r);
|
|
|
|
await adapter.write('a.md', 'x');
|
|
await adapter.rename('a.md', 'b.md');
|
|
await adapter.copy('b.md', 'c.md');
|
|
|
|
expect(r.reflectRename).toHaveBeenCalledWith('a.md', 'b.md');
|
|
// copy has no writer-reflect by design — none of the hooks fire for it.
|
|
expect(r.reflectRename).toHaveBeenCalledTimes(1);
|
|
expect(r.reflectWrite).toHaveBeenCalledTimes(1); // only the seed write
|
|
});
|
|
|
|
it('a throwing reflector does not surface as a write failure', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
adapter.setWriterReflector({
|
|
reflectWrite: () => { throw new Error('listener blew up'); },
|
|
reflectRename: vi.fn(),
|
|
reflectRemove: vi.fn(),
|
|
reflectMkdir: vi.fn(),
|
|
});
|
|
|
|
await expect(adapter.write('note.md', 'hello')).resolves.toBeUndefined();
|
|
// The remote write still happened despite the reflect throw.
|
|
expect(fake.files['/v/note.md'].data.toString('utf8')).toBe('hello');
|
|
});
|
|
|
|
it('a null reflector is a safe no-op', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
adapter.setWriterReflector(null);
|
|
await expect(adapter.write('note.md', 'hi')).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('does NOT reflect a remove that was only queued while reconnecting (#C1)', async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-ssh-reflect-q-'));
|
|
const queue = await OfflineQueue.open(tmpDir);
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v',
|
|
null, null, null, new AncestorTracker(), queue,
|
|
);
|
|
const r = makeReflector();
|
|
adapter.setWriterReflector(r);
|
|
|
|
adapter.setReconnecting(true);
|
|
await adapter.remove('note.md'); // queued, NOT applied to remote
|
|
|
|
expect(r.reflectRemove).not.toHaveBeenCalled();
|
|
expect(queue.pending().map(e => e.op.kind)).toContain('remove');
|
|
});
|
|
|
|
it('does NOT reflect rmdir / rename that were only queued while reconnecting (#C1)', async () => {
|
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-ssh-reflect-q2-'));
|
|
const queue = await OfflineQueue.open(tmpDir);
|
|
const fake = makeFakeClient({ dirs: { '/v': [], '/v/d': [] } });
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/v', readCache, dirCache, 'v',
|
|
null, null, null, new AncestorTracker(), queue,
|
|
);
|
|
const r = makeReflector();
|
|
adapter.setWriterReflector(r);
|
|
|
|
adapter.setReconnecting(true);
|
|
await adapter.rmdir('d', true); // queued, not applied
|
|
await adapter.rename('a.md', 'b.md'); // queued, not applied
|
|
|
|
expect(r.reflectRemove).not.toHaveBeenCalled();
|
|
expect(r.reflectRename).not.toHaveBeenCalled();
|
|
const kinds = queue.pending().map(e => e.op.kind);
|
|
expect(kinds).toContain('rmdir');
|
|
expect(kinds).toContain('rename');
|
|
});
|
|
|
|
it('a throwing reflector on rename does not surface as a rename failure', async () => {
|
|
const fake = makeFakeClient({ dirs: { '/v': [] } });
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
await adapter.write('a.md', 'x');
|
|
adapter.setWriterReflector({
|
|
reflectWrite: vi.fn(),
|
|
reflectRename: () => { throw new Error('listener blew up'); },
|
|
reflectRemove: vi.fn(),
|
|
reflectMkdir: vi.fn(),
|
|
});
|
|
|
|
await expect(adapter.rename('a.md', 'b.md')).resolves.toBeUndefined();
|
|
// The remote rename still happened despite the reflect throw.
|
|
expect(fake.files['/v/b.md']).toBeDefined();
|
|
expect(fake.files['/v/a.md']).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── cache revalidation: mtime alone is not enough ──────────────────────────
|
|
//
|
|
// SFTP reports mtime at 1-SECOND resolution (`SftpClient`: `stats.mtime * 1000`),
|
|
// so two edits inside the same wall-clock second land on the SAME mtime. A cache
|
|
// that revalidates on mtime alone then serves a copy of a file that no longer
|
|
// exists on the server — and the user saves it back over the real one. `stat`
|
|
// already returns the size, so comparing it closes the common case for free.
|
|
// The E2E form of this lives in `e2e/cache-pressure.spec.ts`.
|
|
describe('SftpDataAdapter — cache revalidation compares size, not just mtime', () => {
|
|
let readCache: ReadCache;
|
|
let dirCache: DirCache;
|
|
|
|
beforeEach(() => {
|
|
readCache = new ReadCache({ maxBytes: 4096 });
|
|
dirCache = new DirCache({ ttlMs: 10_000 });
|
|
});
|
|
|
|
it('refetches when the remote size changed but the mtime did NOT', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/a.md': { data: Buffer.from('old'), mtime: 5000 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
|
|
expect(await adapter.read('a.md')).toBe('old'); // populates the cache
|
|
|
|
// A same-second edit: different bytes, DIFFERENT length, mtime unchanged.
|
|
fake.files['/v/a.md'] = { data: Buffer.from('a-much-longer-body'), mtime: 5000 };
|
|
|
|
expect(
|
|
await adapter.read('a.md'),
|
|
'served the STALE cached copy: the mtime matched, but the size did not',
|
|
).toBe('a-much-longer-body');
|
|
});
|
|
|
|
it('still serves from cache — no refetch — when mtime AND size both match', async () => {
|
|
const fake = makeFakeClient({
|
|
files: { '/v/a.md': { data: Buffer.from('same'), mtime: 5000 } },
|
|
});
|
|
const adapter = new SftpDataAdapter(fake.client, '/v', readCache, dirCache, 'v');
|
|
|
|
expect(await adapter.read('a.md')).toBe('same');
|
|
const fetches = fake.spies.readBinary.mock.calls.length;
|
|
|
|
expect(await adapter.read('a.md')).toBe('same');
|
|
expect(
|
|
fake.spies.readBinary.mock.calls.length,
|
|
'a matching mtime+size must still be a cache hit — the size check must not defeat caching',
|
|
).toBe(fetches);
|
|
});
|
|
});
|
|
|
|
// ─── config write-through (#342 / #429) ─────────────────────────────────────
|
|
//
|
|
// Obsidian loads community plugins at startup and each `onload()` calls
|
|
// `Plugin.loadData()` — reading `.obsidian/plugins/<id>/data.json` off the
|
|
// LOCAL shadow disk — BEFORE remote-ssh has connected over SSH and patched the
|
|
// adapter. Nothing used to write that local copy (saveData() went straight to
|
|
// the remote), so every restart booted plugins on DEFAULTS, which they then
|
|
// saved back, destroying the real settings on the remote too.
|
|
//
|
|
// The adapter must therefore mirror `<configDir>/**` writes to local disk.
|
|
describe('SftpDataAdapter — config write-through to the local shadow disk (#342/#429)', () => {
|
|
let readCache: ReadCache;
|
|
let dirCache: DirCache;
|
|
let shadow: string;
|
|
|
|
beforeEach(async () => {
|
|
readCache = new ReadCache({ maxBytes: 4096 });
|
|
dirCache = new DirCache({ ttlMs: 10_000 });
|
|
shadow = await fs.mkdtemp(path.join(os.tmpdir(), 'shadow-wt-'));
|
|
});
|
|
|
|
it('mirrors a plugin data.json write onto local disk, and sends it to the per-device remote path', async () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/srv/vault', readCache, dirCache, 'v',
|
|
new PathMapper('host-a', '.obsidian'), null, null, null, null,
|
|
shadow,
|
|
);
|
|
|
|
await adapter.write('.obsidian/plugins/claudian/data.json', '{"model":"x"}');
|
|
|
|
// Local: the exact path Obsidian reads at the next startup.
|
|
const local = path.join(shadow, '.obsidian', 'plugins', 'claudian', 'data.json');
|
|
expect(await fs.readFile(local, 'utf-8')).toBe('{"model":"x"}');
|
|
|
|
// Remote: redirected into THIS device's private subtree, so a second
|
|
// device never collides on the same file.
|
|
expect(fake.files['/srv/vault/.obsidian/user/host-a/plugins/claudian/data.json']).toBeDefined();
|
|
expect(fake.files['/srv/vault/.obsidian/plugins/claudian/data.json']).toBeUndefined();
|
|
});
|
|
|
|
it('mirrors the core per-device config files too', async () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/srv/vault', readCache, dirCache, 'v',
|
|
new PathMapper('host-a', '.obsidian'), null, null, null, null,
|
|
shadow,
|
|
);
|
|
|
|
await adapter.write('.obsidian/app.json', '{"promptDelete":false}');
|
|
|
|
expect(await fs.readFile(path.join(shadow, '.obsidian', 'app.json'), 'utf-8'))
|
|
.toBe('{"promptDelete":false}');
|
|
});
|
|
|
|
it('does NOT mirror ordinary vault notes — the note tree stays virtual', async () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/srv/vault', readCache, dirCache, 'v',
|
|
new PathMapper('host-a', '.obsidian'), null, null, null, null,
|
|
shadow,
|
|
);
|
|
|
|
await adapter.write('Notes/foo.md', '# hi');
|
|
|
|
// The remote got it...
|
|
expect(fake.files['/srv/vault/Notes/foo.md']).toBeDefined();
|
|
// ...but nothing was mirrored to the local shadow disk.
|
|
await expect(fs.stat(path.join(shadow, 'Notes', 'foo.md'))).rejects.toThrow();
|
|
});
|
|
|
|
it('a local mirror failure never fails the write that already landed remotely', async () => {
|
|
const fake = makeFakeClient();
|
|
// Shadow root is a FILE, so creating any directory beneath it fails.
|
|
const bogus = path.join(shadow, 'not-a-dir');
|
|
await fs.writeFile(bogus, 'x');
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/srv/vault', readCache, dirCache, 'v',
|
|
new PathMapper('host-a', '.obsidian'), null, null, null, null,
|
|
bogus,
|
|
);
|
|
|
|
await expect(
|
|
adapter.write('.obsidian/plugins/claudian/data.json', '{"a":1}'),
|
|
).resolves.toBeUndefined();
|
|
|
|
// The remote write still succeeded — only the warm-start cache is degraded.
|
|
expect(fake.files['/srv/vault/.obsidian/user/host-a/plugins/claudian/data.json']).toBeDefined();
|
|
});
|
|
|
|
it('is a no-op when no shadow base path is wired (unit-test default)', async () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/srv/vault', readCache, dirCache, 'v',
|
|
new PathMapper('host-a', '.obsidian'),
|
|
);
|
|
await expect(adapter.write('.obsidian/app.json', '{}')).resolves.toBeUndefined();
|
|
});
|
|
|
|
// ShadowVaultBootstrap.installPlugin symlinks remote-ssh's OWN
|
|
// main.js/manifest.json/styles.css in the shadow vault back to the SOURCE
|
|
// vault's real files. fs.writeFileSync FOLLOWS symlinks, so mirroring one
|
|
// would overwrite the source vault's plugin install and brick it — the exact
|
|
// bug class #455 fixed. The mirror must refuse.
|
|
it('never writes THROUGH a symlink (would clobber the source vault — #455 regression guard)', async () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/srv/vault', readCache, dirCache, 'v',
|
|
new PathMapper('host-a', '.obsidian'), null, null, null, null,
|
|
shadow,
|
|
);
|
|
|
|
// The "source vault" real file, living OUTSIDE the shadow root.
|
|
const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'source-vault-'));
|
|
const sourceMain = path.join(sourceDir, 'main.js');
|
|
await fs.writeFile(sourceMain, 'SOURCE-REAL-FILE');
|
|
|
|
// The shadow's plugin dir symlinks main.js at that source file.
|
|
const shadowPluginDir = path.join(shadow, '.obsidian', 'plugins', 'remote-ssh');
|
|
await fs.mkdir(shadowPluginDir, { recursive: true });
|
|
await fs.symlink(sourceMain, path.join(shadowPluginDir, 'main.js'));
|
|
|
|
await adapter.write('.obsidian/plugins/remote-ssh/main.js', 'NEW-BUNDLE-BYTES');
|
|
|
|
// The remote still got the write...
|
|
expect(fake.files['/srv/vault/.obsidian/plugins/remote-ssh/main.js']).toBeDefined();
|
|
// ...but the SOURCE vault's real file is untouched.
|
|
expect(await fs.readFile(sourceMain, 'utf-8')).toBe('SOURCE-REAL-FILE');
|
|
// ...and the symlink is still a symlink (not replaced by a real file).
|
|
expect((await fs.lstat(path.join(shadowPluginDir, 'main.js'))).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
// The `startsWith(configDir + '/')` guard runs on the RAW string, before
|
|
// `..` is resolved — so it must not be the only containment check.
|
|
it('refuses to mirror a traversal path that escapes the shadow root', async () => {
|
|
const fake = makeFakeClient();
|
|
const adapter = new SftpDataAdapter(
|
|
fake.client, '/srv/vault', readCache, dirCache, 'v',
|
|
new PathMapper('host-a', '.obsidian'), null, null, null, null,
|
|
shadow,
|
|
);
|
|
|
|
const victim = path.join(shadow, '..', `escaped-${path.basename(shadow)}.txt`);
|
|
await expect(fs.stat(victim)).rejects.toThrow(); // not there yet
|
|
|
|
// Starts with `.obsidian/` so it passes the raw prefix test, but resolves
|
|
// out of the shadow root.
|
|
await adapter.write(`.obsidian/../escaped-${path.basename(shadow)}.txt`, 'pwned');
|
|
|
|
// Nothing was written outside the shadow root.
|
|
await expect(fs.stat(victim)).rejects.toThrow();
|
|
});
|
|
});
|