sotashimozono_obsidian-remo.../plugin/tests/helpers/FakeFileExplorer.ts
Souta a14aa9b156 test(plugin): Phase C.M9 — sync-reflect E2E matrix (foundational slice) (0.4.47)
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>
2026-04-29 08:00:30 +09:00

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;
}