mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
Stacked on PRs #103 (M7) + #104 (M8). Composes the full Phase C pipeline end-to-end in a single Node process — first time every piece (writer adapter / RPC / daemon / fsnotify / reader handler / VaultModelBuilder / FakeFileExplorer / assertSyncReflect) gets exercised together against an integration assertion. Pipeline under test: writerAdapter.write* (M2 spans) → RpcRemoteFsClient.write* (M2 spans) → daemon (M3 cid correlator + atomicWriteFile) → fsnotify → fs.changed notification (M3 envelope meta) → reader RpcClient.onNotification → applyFsChange-equivalent (T4a + S.app spans) → VaultModelBuilder mutator (T5a span) → fakeVault.trigger(create|modify|delete|rename) → FakeFileExplorer (M7) — the T5 surface → assertSyncReflect (M8) — single assertion What ships: - `plugin/tests/integration/sync.e2e.test.ts` (new, 4 cases) - **create** — never-seen path; writer create reflects on reader's FakeFileExplorer. - **delete** — pre-create through the same E2E pipeline so the FE knows the path; writer remove reflects as a `delete` event. - **rename** — pre-create then move; assert `create` on the new path AND verify old path is gone (separate awaitReflect for the trailing `delete` since rename fires both events). - **nested-folder** — write into a deep new sub-tree; daemon mkdirs intermediate parents, reader receives multiple `created` events in order, builder inserts each (parent before child works because fsnotify reports CREATE for the parent before any child write fires). Per-suite plumbing: - Two RPC clients on a shared deployed daemon (writer + reader) via the M5 multiclientRpc helper. - Both wrap RpcClient in SftpDataAdapter so writes flow through the production code paths M2 instrumented; the reader's adapter is used for stat-after-notification (mirrors `main.ts.applyFsChange`'s patched-adapter stat). - `HarnessVault` (~50 lines) satisfies BOTH the slice of `obsidian.Vault` VaultModelBuilder needs (`fileMap`, `getRoot`, `getAbstractFileByPath`, `trigger`) AND FakeFileExplorer's `VaultLike` (`on` / `offref`). Built inline rather than reused from VaultModelBuilder.test.ts / FakeFileExplorer.test.ts — those each cover only half the surface and unifying at this stage would invite churn. - Reader-side `handleFsChangedForReader` mirrors main.ts: T4a point on notification, then `S.app` span around the `applyFsChange`-equivalent that dispatches by event into builder.{insertOne,modifyOne,removeOne,renameOne}. - Bootstrap: writer creates the per-suite subdir first; reader handler observes the `created` notification → builder registers the folder in HarnessVault → subsequent per-case writes can resolve their parent. - `beforeAll` enables perfTracer; `afterAll` disables + clears. No `fakeFE.reset()` between cases — the synthetic vault should accumulate state the same way Obsidian's real vault does, so a delete-after-create case can look up the previously-created file. Per-suite stamp prevents path collisions. Intentionally deferred (will land in M9b/M9c follow-ups): - **modify** — same fsnotify "no IN_MOVED_TO across- watcher atomic-rename" quirk that M6's bench skipped on; needs per-iter re-subscribe or a different observation strategy. - **large 10 MB / binary content-hash** — bandwidth scenarios. - **3-way conflict** — needs ThreeWayMergeModal stub or real conflict path; PreconditionFailed wiring crosses three modules. - **disconnect/reconnect** — needs OfflineQueue + ReconnectManager orchestration. - `plugin/tests/helpers/FakeFileExplorer.ts` — two latent bugs surfaced by the M9 typecheck pass and fixed here: 1. **`VaultLike = Pick<Vault,'on'|'offref'>` was unsound** because TypeScript's `Pick` only carries the *last* overload of a method declaration. With `Pick`, every `vault.on('create', …)` / `vault.on('modify', …)` call would have been resolved against the `'rename'` overload's callback signature (TAbstractFile + oldPath) and rejected at compile time. M7's unit suite never caught it because vitest's transpiler is more forgiving and the project tsconfig only includes `src/**`. Replaced with an explicit structural interface that spells out all four event overloads — and widened the `'modify'` callback param to TAbstractFile to match `obsidian.Vault.on`'s real signature (every event delivers TAbstractFile, even modify). 2. **JSDoc block comment containing `src/**` parsed as a premature comment terminator** — esbuild saw the `*/` inside the glob pattern and bailed with "Unexpected '*'". Surfaced when M9's E2E test imported the helper through vitest's transpiler. Rephrased the comment to avoid the literal sequence; functional behaviour unchanged. These fixes accidentally landed here rather than back-ported onto the M7 PR (#103) because the PR is already in review; rebasing M7 + M8 to incorporate them would have churned both stacked PRs. The diff is contained and the commit message explains the dependency direction. Verification: - `npx vitest run` — 36 files / **482 tests green** (no unit test count change; the E2E test is integration-only). - `npx tsc --noEmit -p tsconfig.json` (production typecheck) — clean. - `npx tsc --noEmit … tests/integration/**/*.ts + tests/helpers/FakeFileExplorer.ts` — clean. Catches the cross-test-file integration with strict-null-checks. - Actual E2E run: requires Docker (`npm run sshd:start` + `npm run build:server`); not exercisable on this dev box. Integration CI workflow will run it as part of `npm run test:integration`. manifest/package/versions bumped 0.4.46 → 0.4.47 (one ahead of PR #104 / M8 at 0.4.46). Roadmap context: PRs land in dependency order (#103 M7 → #104 M8 → **this M9**). After all three merge, Phase C has the assertion vocabulary the M10 CI gate needs (perf.yml + perf-baseline orphan branch + PR commenter). Subsequent M9b/M9c add the deferred cases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
import type { EventRef, TAbstractFile, TFile } from 'obsidian';
|
|
|
|
/**
|
|
* `FakeFileExplorer` — Phase C M7.
|
|
*
|
|
* A test double that listens to the same `vault.trigger(...)` event
|
|
* stream Obsidian's real File Explorer + MetadataCache + plugins
|
|
* subscribe to, and maintains a small synthetic model of "what does
|
|
* this vault look like after the events that have fired so far". The
|
|
* Phase C E2E suite (M9) uses it as the **T5 observation point**:
|
|
* once `awaitReflect(path, event)` resolves, an Obsidian-faithful
|
|
* downstream consumer would also have observed the change.
|
|
*
|
|
* Design notes:
|
|
*
|
|
* - **Decoupled from a real Vault.** `attach(vault)` works against
|
|
* anything that conforms to `VaultLike` (just `on`/`offref`), and
|
|
* `observe(event, ...args)` lets tests with no Vault at all drive
|
|
* the model directly.
|
|
*
|
|
* - **History-aware.** A reflect that arrives before
|
|
* `awaitReflect(...)` is called is still consumed by the next
|
|
* matching call — eliminates a class of timing flakes where the
|
|
* bench races the listener registration.
|
|
*
|
|
* - **Fast and deterministic.** Pure JS, no DOM, no real Obsidian
|
|
* binary; suitable for the unit suite and for M9's per-iter calls
|
|
* (< 1 s per case is the design target the plan calls out).
|
|
*
|
|
* - **Timing captured at observation, not at await.** `atMs` on the
|
|
* resolved `awaitReflect` payload is `performance.now()` from the
|
|
* moment the underlying event fired — that's the value M9 will
|
|
* feed into PerfAggregator's `S.paint` bucket. Late-arriving
|
|
* awaits don't inflate the latency.
|
|
*/
|
|
|
|
export type VaultEvent = 'create' | 'modify' | 'delete' | 'rename';
|
|
|
|
/**
|
|
* Minimal shape FakeFileExplorer needs from a Vault. Real
|
|
* `obsidian.Vault` satisfies this; per-test stubs only need to
|
|
* expose `on(name, cb): EventRef` and `offref(ref)`.
|
|
*
|
|
* Declared as a structural interface with all four event overloads
|
|
* spelled out (rather than `Pick<Vault,'on'|'offref'>`) because
|
|
* TypeScript's `Pick` only carries the *last* overload of a method
|
|
* declaration — and `Vault.on` is overloaded per event name. With
|
|
* `Pick`, every `vault.on('create', …)` / `vault.on('modify', …)`
|
|
* call would have been resolved against the `'rename'` overload's
|
|
* callback signature and rejected at compile time. Surfaced when
|
|
* the M9 sync-reflect E2E suite typechecked under the integration-
|
|
* tests-included tsc invocation; `tsconfig.json -p` only includes
|
|
* source under `src/` so production typecheck never saw it.
|
|
*/
|
|
export interface VaultLike {
|
|
// Matches obsidian.Vault: every event delivers a TAbstractFile (not
|
|
// TFile) — even `modify` does, despite folders never receiving it.
|
|
// Internal mutators duck-type for the optional `stat` field rather
|
|
// than assuming a narrower type.
|
|
on(name: 'create', callback: (file: TAbstractFile) => unknown): EventRef;
|
|
on(name: 'modify', callback: (file: TAbstractFile) => unknown): EventRef;
|
|
on(name: 'delete', callback: (file: TAbstractFile) => unknown): EventRef;
|
|
on(name: 'rename', callback: (file: TAbstractFile, oldPath: string) => unknown): EventRef;
|
|
offref(ref: EventRef): void;
|
|
}
|
|
|
|
export interface ReflectInfo {
|
|
/** `performance.now()` at the moment the event was observed by the FE. */
|
|
atMs: number;
|
|
}
|
|
|
|
export interface ExplorerSnapshot {
|
|
/** Vault-relative paths currently in the model, sorted ascending. */
|
|
paths: string[];
|
|
/** Map of path → mtime for files that carried a stat at create/modify time. */
|
|
mtimes: Record<string, number>;
|
|
}
|
|
|
|
interface PendingWaiter {
|
|
path: string;
|
|
event: VaultEvent;
|
|
resolve: (info: ReflectInfo) => void;
|
|
reject: (err: Error) => void;
|
|
timer?: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
interface HistoryEntry {
|
|
path: string;
|
|
event: VaultEvent;
|
|
atMs: number;
|
|
}
|
|
|
|
const HISTORY_CAP = 1024;
|
|
|
|
export class FakeFileExplorer {
|
|
private readonly paths = new Set<string>();
|
|
private readonly mtimes = new Map<string, number>();
|
|
private readonly waiters: PendingWaiter[] = [];
|
|
private history: HistoryEntry[] = [];
|
|
|
|
/**
|
|
* Subscribe to a real (or real-shaped) Vault's create/modify/delete/
|
|
* rename events. Returns a disposer that calls `vault.offref(...)`
|
|
* for each registration. Idempotent under best-effort error handling.
|
|
*/
|
|
attach(vault: VaultLike): () => void {
|
|
// Each `on(...)` call returns its own EventRef; collect them so
|
|
// the disposer can `offref` precisely the listeners it added.
|
|
const refs: EventRef[] = [
|
|
vault.on('create', (file: TAbstractFile) => this.onCreate(file)),
|
|
vault.on('modify', (file: TAbstractFile) => this.onModify(file)),
|
|
vault.on('delete', (file: TAbstractFile) => this.onDelete(file)),
|
|
vault.on('rename', (file: TAbstractFile, oldPath: string) => this.onRename(file, oldPath)),
|
|
];
|
|
return () => {
|
|
for (const ref of refs) {
|
|
try { vault.offref(ref); } catch { /* best effort */ }
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Direct event injection — for tests that don't want to stand up a
|
|
* Vault at all. Mirrors the `vault.trigger(event, ...args)` arg
|
|
* shape so a FakeVault's `trigger` can forward straight here.
|
|
*/
|
|
observe(event: 'create', file: TAbstractFile): void;
|
|
observe(event: 'modify', file: TAbstractFile): void;
|
|
observe(event: 'delete', file: TAbstractFile): void;
|
|
observe(event: 'rename', file: TAbstractFile, oldPath: string): void;
|
|
observe(event: VaultEvent, ...args: unknown[]): void {
|
|
switch (event) {
|
|
case 'create': this.onCreate(args[0] as TAbstractFile); return;
|
|
case 'modify': this.onModify(args[0] as TAbstractFile); return;
|
|
case 'delete': this.onDelete(args[0] as TAbstractFile); return;
|
|
case 'rename': this.onRename(args[0] as TAbstractFile, args[1] as string); return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Snapshot of the current synthetic model. Sorted paths give
|
|
* deterministic output for assertions; `mtimes` only carries entries
|
|
* whose source event included a `stat`.
|
|
*/
|
|
snapshot(): ExplorerSnapshot {
|
|
return {
|
|
paths: [...this.paths].sort(),
|
|
mtimes: Object.fromEntries(this.mtimes),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve when the next event of `event` for `path` is observed.
|
|
*
|
|
* If a matching event already fired (and hasn't been consumed by an
|
|
* earlier `awaitReflect`), resolves synchronously on the next
|
|
* microtask — eliminates the listener-registration race that would
|
|
* otherwise drop the bench's first iteration.
|
|
*
|
|
* Rejects with a descriptive Error when `timeoutMs` elapses without
|
|
* a match. Default timeout 5 s.
|
|
*/
|
|
awaitReflect(path: string, event: VaultEvent, timeoutMs = 5_000): Promise<ReflectInfo> {
|
|
// Walk the history newest → oldest so a recent matching event is
|
|
// preferred over a stale one. Splice on hit so the same event
|
|
// never resolves two awaits.
|
|
for (let i = this.history.length - 1; i >= 0; i--) {
|
|
const h = this.history[i];
|
|
if (h.path === path && h.event === event) {
|
|
this.history.splice(i, 1);
|
|
return Promise.resolve({ atMs: h.atMs });
|
|
}
|
|
}
|
|
|
|
return new Promise<ReflectInfo>((resolve, reject) => {
|
|
const w: PendingWaiter = {
|
|
path,
|
|
event,
|
|
resolve: (info) => {
|
|
if (w.timer) clearTimeout(w.timer);
|
|
resolve(info);
|
|
},
|
|
reject,
|
|
};
|
|
w.timer = setTimeout(() => {
|
|
this.dropWaiter(w);
|
|
reject(new Error(
|
|
`FakeFileExplorer: no ${event} for "${path}" within ${timeoutMs}ms; ` +
|
|
`history=${JSON.stringify(this.history.slice(-8))}`,
|
|
));
|
|
}, timeoutMs);
|
|
this.waiters.push(w);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Drop all state and reject any pending awaits. Use in `afterEach`
|
|
* to keep tests hermetic; mid-suite reset is otherwise rare.
|
|
*/
|
|
reset(): void {
|
|
this.paths.clear();
|
|
this.mtimes.clear();
|
|
this.history = [];
|
|
for (const w of this.waiters.splice(0)) {
|
|
if (w.timer) clearTimeout(w.timer);
|
|
w.reject(new Error('FakeFileExplorer: reset() while awaiting reflect'));
|
|
}
|
|
}
|
|
|
|
// ── internals ───────────────────────────────────────────────────────
|
|
|
|
private onCreate(file: TAbstractFile): void {
|
|
this.paths.add(file.path);
|
|
const mtime = readMtime(file);
|
|
if (mtime !== undefined) this.mtimes.set(file.path, mtime);
|
|
this.recordAndFire('create', file.path);
|
|
}
|
|
|
|
private onModify(file: TAbstractFile): void {
|
|
// Modify on a path we never saw create for: still record it.
|
|
// VaultModelBuilder's modifyOne only fires when the file existed
|
|
// in fileMap, but the FE shouldn't crash if that invariant slips.
|
|
this.paths.add(file.path);
|
|
const mtime = readMtime(file);
|
|
if (mtime !== undefined) this.mtimes.set(file.path, mtime);
|
|
this.recordAndFire('modify', file.path);
|
|
}
|
|
|
|
private onDelete(file: TAbstractFile): void {
|
|
this.paths.delete(file.path);
|
|
this.mtimes.delete(file.path);
|
|
// Folders: vault.trigger('delete', folder) fires once for the
|
|
// folder itself; descendants are silently orphaned in fileMap and
|
|
// VaultModelBuilder.removeOne drops them too. Mirror that here so
|
|
// snapshot stays consistent with what File Explorer would show.
|
|
const prefix = file.path + '/';
|
|
for (const p of [...this.paths]) {
|
|
if (p.startsWith(prefix)) {
|
|
this.paths.delete(p);
|
|
this.mtimes.delete(p);
|
|
}
|
|
}
|
|
this.recordAndFire('delete', file.path);
|
|
}
|
|
|
|
private onRename(file: TAbstractFile, oldPath: string): void {
|
|
// Move the principal entry.
|
|
const oldMtime = this.mtimes.get(oldPath);
|
|
this.paths.delete(oldPath);
|
|
this.mtimes.delete(oldPath);
|
|
this.paths.add(file.path);
|
|
const newMtime = readMtime(file) ?? oldMtime;
|
|
if (newMtime !== undefined) this.mtimes.set(file.path, newMtime);
|
|
|
|
// Folders carry descendants — mirror VaultModelBuilder.renameOne's
|
|
// recursive path rewrite.
|
|
const oldPrefix = oldPath + '/';
|
|
const newPrefix = file.path + '/';
|
|
for (const p of [...this.paths]) {
|
|
if (p === file.path) continue;
|
|
if (!p.startsWith(oldPrefix)) continue;
|
|
const moved = newPrefix + p.slice(oldPrefix.length);
|
|
const m = this.mtimes.get(p);
|
|
this.paths.delete(p);
|
|
this.mtimes.delete(p);
|
|
this.paths.add(moved);
|
|
if (m !== undefined) this.mtimes.set(moved, m);
|
|
}
|
|
|
|
this.recordAndFire('rename', file.path);
|
|
}
|
|
|
|
private recordAndFire(event: VaultEvent, path: string): void {
|
|
const atMs = performance.now();
|
|
|
|
// Resolve a matching waiter first (one-shot). If we resolved here
|
|
// we deliberately don't push to history so a *later* awaitReflect
|
|
// can't double-consume the same observation.
|
|
for (let i = 0; i < this.waiters.length; i++) {
|
|
const w = this.waiters[i];
|
|
if (w.path === path && w.event === event) {
|
|
this.waiters.splice(i, 1);
|
|
w.resolve({ atMs });
|
|
return;
|
|
}
|
|
}
|
|
|
|
// No waiter — record so an awaitReflect that arrives *after* the
|
|
// event can still consume it (eliminates the listener-race flake).
|
|
this.history.push({ path, event, atMs });
|
|
if (this.history.length > HISTORY_CAP) {
|
|
this.history.splice(0, this.history.length - HISTORY_CAP);
|
|
}
|
|
}
|
|
|
|
private dropWaiter(target: PendingWaiter): void {
|
|
const i = this.waiters.indexOf(target);
|
|
if (i >= 0) this.waiters.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
function readMtime(file: TAbstractFile): number | undefined {
|
|
// TFile carries a `stat`; TFolder doesn't. Duck-type so we don't
|
|
// depend on instanceof checks against the obsidian d.ts (which has
|
|
// no runtime under vitest's node environment).
|
|
const maybeFile = file as Partial<TFile>;
|
|
return maybeFile.stat?.mtime;
|
|
}
|