mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
Pure-helper module; production code: untouched. Lays the foundation
for M8 (`assertSyncReflect`) and M9 (E2E matrix) — the slice of
Phase C that brings UI-reflect (the T5 wedge of the sync-latency
sequence) under automated assertion.
What ships:
- `plugin/tests/helpers/FakeFileExplorer.ts` (new)
A test double that listens to the same `vault.trigger(...)` event
stream Obsidian's File Explorer + MetadataCache + plugins
subscribe to, and maintains a small synthetic "what does this
vault look like after the events that have fired so far" model.
M9 will use it as the **T5 observation point**: once
`awaitReflect(path, event)` resolves, an Obsidian-faithful
downstream consumer would also have observed the change.
Surface:
- `attach(vault: VaultLike) → dispose`: subscribes to all four
vault events (create/modify/delete/rename) via `vault.on(...)`,
returns a disposer that calls `vault.offref(...)` for each
registration. `VaultLike` is a structural type
(`Pick<Vault,'on'|'offref'>`) so per-test stubs only have to
expose those two methods — no real `obsidian.Vault` runtime
required.
- `observe(event, ...args)`: direct event injection for tests
that don't even want a Vault stub. Mirrors the
`vault.trigger(event, ...args)` arg shape so a FakeVault's
`trigger` can forward straight here.
- `snapshot() → { paths[], mtimes }`: deterministic
sorted-paths view of the current model. Folders are present
in `paths` but absent from `mtimes` (mirroring the real
vault, where TFolder has no `stat`).
- `awaitReflect(path, event, timeoutMs?)`: resolves with
`{ atMs }` (the `performance.now()` from when the underlying
event fired — that's what M9 will feed into PerfAggregator's
S.paint bucket; late-arriving awaits don't inflate latency).
History-walks first so an event observed BEFORE
`awaitReflect` was called still resolves it (eliminates a
class of listener-race flakes); times out with a descriptive
error including the recent history tail.
- `reset()`: drop state and reject pending awaits — for
`afterEach`-style hygiene.
Folder semantics intentionally mirror VaultModelBuilder:
- delete on a folder also drops descendants from the snapshot
(vault.trigger fires once for the folder itself; descendants
are silently orphaned in the production fileMap and we don't
want the FE to lie about what File Explorer would show).
- rename on a folder rewrites every descendant path under the
new prefix.
history is bounded (cap 1024 entries) so a long-running suite
doesn't grow it unbounded; the cap matters in M9 where each test
may observe dozens of events.
- `plugin/tests/FakeFileExplorer.test.ts` (new, 20 tests):
observe / snapshot:
- empty start
- create adds path + records mtime
- create on a folder records path with no mtime
- modify updates mtime
- delete removes path + mtime
- folder delete removes descendants (depth ≥ 2)
- rename moves path, preserves mtime when no new stat
- rename uses the new file's stat when present
- folder rename rewrites descendants
awaitReflect:
- resolves from history when event already fired
- second await for same event doesn't double-consume
- resolves a pending waiter when event arrives later
- waiter matches its event even when others fire first
- times out with descriptive error
- timeout leaves no leaked waiter (later observe doesn't crash)
attach():
- subscribes to all four events (verified via FakeVault's
listenerCount)
- disposer detaches all four
- post-dispose, vault events no longer reflected
reset():
- drops state
- rejects pending waiters
Inline `FakeVault` helper (~30 lines) covers `on`/`offref`/
`trigger`/`listenerCount`. Not extracted — it's the only consumer
in the unit suite; M9 will likely pull a similar shape into its
own integration helper.
Verification:
- `npx vitest run` — 35 files / 474 tests green (was 454, +20 new)
- `npx tsc --noEmit -p tsconfig.json` — clean
manifest/package/versions bumped 0.4.44 → 0.4.45.
Roadmap context: M7 is the prerequisite for M8 (assertSyncReflect)
and M9 (E2E matrix). After M8 lands, the assertion vocabulary
is "writer mutation → reader notification → FakeFileExplorer
reflects path/event"; M9 fills out the case matrix
(create / modify / delete / rename / large / binary / nested /
conflict / disconnect-reconnect).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
240 lines
8.9 KiB
TypeScript
240 lines
8.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import type { EventRef, TAbstractFile, TFile } from 'obsidian';
|
|
import { FakeFileExplorer, type VaultLike } from './helpers/FakeFileExplorer';
|
|
|
|
// ── tiny FakeVault: enough surface for FakeFileExplorer.attach ──────────
|
|
|
|
interface Ref { name: string; cb: (...args: unknown[]) => void }
|
|
|
|
class FakeVault implements VaultLike {
|
|
private readonly listeners = new Map<string, Set<(...args: unknown[]) => void>>();
|
|
private readonly refs = new Map<symbol, Ref>();
|
|
|
|
on(name: string, cb: (...args: unknown[]) => unknown): EventRef {
|
|
const set = this.listeners.get(name) ?? new Set();
|
|
set.add(cb as (...args: unknown[]) => void);
|
|
this.listeners.set(name, set);
|
|
const sym = Symbol(name);
|
|
this.refs.set(sym, { name, cb: cb as (...args: unknown[]) => void });
|
|
return sym as unknown as EventRef;
|
|
}
|
|
|
|
offref(ref: EventRef): void {
|
|
const sym = ref as unknown as symbol;
|
|
const r = this.refs.get(sym);
|
|
if (!r) return;
|
|
this.listeners.get(r.name)?.delete(r.cb);
|
|
this.refs.delete(sym);
|
|
}
|
|
|
|
trigger(name: string, ...args: unknown[]): void {
|
|
const set = this.listeners.get(name);
|
|
if (!set) return;
|
|
for (const cb of [...set]) cb(...args);
|
|
}
|
|
|
|
listenerCount(name: string): number {
|
|
return this.listeners.get(name)?.size ?? 0;
|
|
}
|
|
}
|
|
|
|
function fakeFile(path: string, mtime = 0): TFile {
|
|
// Cast through unknown — we only need the structural fields the
|
|
// FakeFileExplorer reads (`path`, `stat?.mtime`).
|
|
return {
|
|
path,
|
|
name: basename(path),
|
|
stat: { ctime: 0, mtime, size: 0 },
|
|
} as unknown as TFile;
|
|
}
|
|
|
|
function fakeFolder(path: string): TAbstractFile {
|
|
return {
|
|
path,
|
|
name: basename(path),
|
|
} as unknown as TAbstractFile;
|
|
}
|
|
|
|
function basename(p: string): string {
|
|
const i = p.lastIndexOf('/');
|
|
return i < 0 ? p : p.slice(i + 1);
|
|
}
|
|
|
|
// ── observe(): direct event injection ───────────────────────────────────
|
|
|
|
describe('FakeFileExplorer — observe / snapshot', () => {
|
|
it('starts empty', () => {
|
|
const fe = new FakeFileExplorer();
|
|
expect(fe.snapshot()).toEqual({ paths: [], mtimes: {} });
|
|
});
|
|
|
|
it('create adds path and records mtime when present', () => {
|
|
const fe = new FakeFileExplorer();
|
|
fe.observe('create', fakeFile('a.md', 100));
|
|
expect(fe.snapshot()).toEqual({ paths: ['a.md'], mtimes: { 'a.md': 100 } });
|
|
});
|
|
|
|
it('create on a folder records the path with no mtime', () => {
|
|
const fe = new FakeFileExplorer();
|
|
fe.observe('create', fakeFolder('docs'));
|
|
expect(fe.snapshot()).toEqual({ paths: ['docs'], mtimes: {} });
|
|
});
|
|
|
|
it('modify updates the mtime', () => {
|
|
const fe = new FakeFileExplorer();
|
|
fe.observe('create', fakeFile('a.md', 100));
|
|
fe.observe('modify', fakeFile('a.md', 200));
|
|
expect(fe.snapshot().mtimes['a.md']).toBe(200);
|
|
});
|
|
|
|
it('delete removes the path and any mtime', () => {
|
|
const fe = new FakeFileExplorer();
|
|
fe.observe('create', fakeFile('a.md', 100));
|
|
fe.observe('delete', fakeFile('a.md'));
|
|
expect(fe.snapshot()).toEqual({ paths: [], mtimes: {} });
|
|
});
|
|
|
|
it('folder delete also removes descendants', () => {
|
|
const fe = new FakeFileExplorer();
|
|
fe.observe('create', fakeFolder('docs'));
|
|
fe.observe('create', fakeFile('docs/a.md', 100));
|
|
fe.observe('create', fakeFile('docs/sub/b.md', 200));
|
|
fe.observe('create', fakeFile('outside.md', 300));
|
|
fe.observe('delete', fakeFolder('docs'));
|
|
expect(fe.snapshot()).toEqual({ paths: ['outside.md'], mtimes: { 'outside.md': 300 } });
|
|
});
|
|
|
|
it('rename moves the path and preserves mtime when no new stat is available', () => {
|
|
const fe = new FakeFileExplorer();
|
|
fe.observe('create', fakeFile('old.md', 100));
|
|
fe.observe('rename', fakeFolder('new.md'), 'old.md'); // no stat on the rename arg
|
|
expect(fe.snapshot()).toEqual({ paths: ['new.md'], mtimes: { 'new.md': 100 } });
|
|
});
|
|
|
|
it('rename uses the new file\'s stat when present', () => {
|
|
const fe = new FakeFileExplorer();
|
|
fe.observe('create', fakeFile('old.md', 100));
|
|
fe.observe('rename', fakeFile('new.md', 999), 'old.md');
|
|
expect(fe.snapshot()).toEqual({ paths: ['new.md'], mtimes: { 'new.md': 999 } });
|
|
});
|
|
|
|
it('folder rename rewrites descendant paths', () => {
|
|
const fe = new FakeFileExplorer();
|
|
fe.observe('create', fakeFolder('docs'));
|
|
fe.observe('create', fakeFile('docs/a.md', 100));
|
|
fe.observe('create', fakeFile('docs/sub/b.md', 200));
|
|
fe.observe('rename', fakeFolder('archive'), 'docs');
|
|
expect(fe.snapshot()).toEqual({
|
|
paths: ['archive', 'archive/a.md', 'archive/sub/b.md'],
|
|
mtimes: { 'archive/a.md': 100, 'archive/sub/b.md': 200 },
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── awaitReflect: history walk + late waiter + timeout ──────────────────
|
|
|
|
describe('FakeFileExplorer — awaitReflect', () => {
|
|
it('resolves from history when the event already fired', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
fe.observe('create', fakeFile('a.md', 100));
|
|
const info = await fe.awaitReflect('a.md', 'create');
|
|
expect(typeof info.atMs).toBe('number');
|
|
expect(info.atMs).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('a later awaitReflect for the same event does not double-consume', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
fe.observe('create', fakeFile('a.md', 100));
|
|
await fe.awaitReflect('a.md', 'create'); // consumes the history entry
|
|
await expect(fe.awaitReflect('a.md', 'create', 50)).rejects.toThrow(/no create.*within 50ms/);
|
|
});
|
|
|
|
it('resolves a pending waiter when the event arrives later', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
const p = fe.awaitReflect('a.md', 'create');
|
|
queueMicrotask(() => fe.observe('create', fakeFile('a.md', 100)));
|
|
const info = await p;
|
|
expect(info.atMs).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('the right event matches its pending waiter even when others fire', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
const p = fe.awaitReflect('a.md', 'modify');
|
|
fe.observe('create', fakeFile('a.md', 100)); // wrong event for waiter
|
|
fe.observe('create', fakeFile('b.md', 100)); // different path
|
|
fe.observe('modify', fakeFile('a.md', 200)); // ← match
|
|
const info = await p;
|
|
expect(info.atMs).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('times out with a descriptive error when no event arrives', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
await expect(fe.awaitReflect('a.md', 'create', 30))
|
|
.rejects.toThrow(/no create.*"a\.md".*within 30ms/);
|
|
});
|
|
|
|
it('timeout leaves no leaked waiter (a later observe does not crash)', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
await fe.awaitReflect('a.md', 'create', 20).catch(() => {});
|
|
expect(() => fe.observe('create', fakeFile('a.md', 100))).not.toThrow();
|
|
});
|
|
});
|
|
|
|
// ── attach(vault) round-trip ────────────────────────────────────────────
|
|
|
|
describe('FakeFileExplorer — attach()', () => {
|
|
it('subscribes to all four events and tracks them', () => {
|
|
const fe = new FakeFileExplorer();
|
|
const v = new FakeVault();
|
|
fe.attach(v);
|
|
|
|
expect(v.listenerCount('create')).toBe(1);
|
|
expect(v.listenerCount('modify')).toBe(1);
|
|
expect(v.listenerCount('delete')).toBe(1);
|
|
expect(v.listenerCount('rename')).toBe(1);
|
|
|
|
v.trigger('create', fakeFile('a.md', 100));
|
|
v.trigger('modify', fakeFile('a.md', 200));
|
|
expect(fe.snapshot().mtimes['a.md']).toBe(200);
|
|
});
|
|
|
|
it('disposer detaches all four listeners', () => {
|
|
const fe = new FakeFileExplorer();
|
|
const v = new FakeVault();
|
|
const dispose = fe.attach(v);
|
|
dispose();
|
|
|
|
expect(v.listenerCount('create')).toBe(0);
|
|
expect(v.listenerCount('modify')).toBe(0);
|
|
expect(v.listenerCount('delete')).toBe(0);
|
|
expect(v.listenerCount('rename')).toBe(0);
|
|
});
|
|
|
|
it('after dispose, vault events are no longer reflected', () => {
|
|
const fe = new FakeFileExplorer();
|
|
const v = new FakeVault();
|
|
const dispose = fe.attach(v);
|
|
v.trigger('create', fakeFile('a.md', 100));
|
|
dispose();
|
|
v.trigger('create', fakeFile('b.md', 200));
|
|
expect(fe.snapshot()).toEqual({ paths: ['a.md'], mtimes: { 'a.md': 100 } });
|
|
});
|
|
});
|
|
|
|
// ── reset() ──────────────────────────────────────────────────────────────
|
|
|
|
describe('FakeFileExplorer — reset', () => {
|
|
it('drops all state', () => {
|
|
const fe = new FakeFileExplorer();
|
|
fe.observe('create', fakeFile('a.md', 100));
|
|
fe.reset();
|
|
expect(fe.snapshot()).toEqual({ paths: [], mtimes: {} });
|
|
});
|
|
|
|
it('rejects any pending waiter', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
const p = fe.awaitReflect('a.md', 'create');
|
|
fe.reset();
|
|
await expect(p).rejects.toThrow(/reset\(\) while awaiting/);
|
|
});
|
|
});
|