mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
Stacked on PR #103 (M7 FakeFileExplorer). Closes the assertion vocabulary the M9 E2E matrix needs: one helper that captures the writer→reader→reflect round-trip, enforces an end-to-end latency budget, and returns the spans + e2eMs the same M4 PerfAggregator the bench (M6) already feeds. What ships: - `plugin/tests/integration/helpers/assertSyncReflect.ts` (new) Single-purpose assertion that drives Phase C E2E: 1. Subscribe to PerfTracer span stream. 2. Snapshot t0 = performance.now(). 3. Run opts.op() — writer-side mutation. The closure captures whatever clients/adapters it needs (helper intentionally doesn't take a Writer parameter — keeps it trivially unit-testable without an SSH session). 4. Await reader.fakeFE.awaitReflect(...) — M7's history-aware T5 observation point. 5. Compute e2eMs from t0 → reflect.atMs (FakeFileExplorer's atMs captures the performance.now() from when the underlying vault event fired, NOT when the await registered, so a late-await doesn't inflate the number). 6. Enforce budgetMs as the upper bound on e2eMs (catches the "reflect arrived but slow" case that awaitReflect's per-call timeout doesn't — op took most of the budget, reflect arrived just inside its per-call window but outside the e2e budget). 7. Return { spans, e2eMs, cid } for caller-side aggregation. Spec deviation from the plan: plan signature lists `writer: RpcClientHandle` and `reader: { vault: Vault; fakeFE }`. Neither is load-bearing in the helper itself (op() captures the writer; vault is only used by the FakeFileExplorer which the caller already attached). Dropping them keeps the helper unit-testable without an SSH session or an Obsidian Vault stub. M9 composes the full pipeline at the call site instead. cid is a passthrough for the eventual cross-process correlation (M3 daemon side + future TS wire-meta send). The helper doesn't thread it into perfTracer.begin/point — that's the caller's responsibility — but echoes it back in the result so M9 can stitch this assertion's spans to its writer-side spans. Error messages include the optional opts.label so a mass-test failure points at the offending case. - `plugin/tests/AssertSyncReflect.test.ts` (new, 8 tests): Happy path: - resolves with positive e2eMs and the captured spans - echoes back the optional cid passthrough - captures spans from the op only (not from before/after the helper's window) Failure modes: - rejects when op() throws, with the original message in the error (label-prefixed) - rejects when no reflect arrives within budgetMs (label- prefixed; descriptive message includes path + event) - rejects when reflect arrives but e2eMs exceeds budgetMs (op() spent most of the budget; awaitReflect's own timeout wouldn't fire, but the explicit check does) - bare prefix when no label is supplied Listener hygiene: - perfTracer.onSpan listener is unsubscribed even on failure (verified via a probe-based listener-leak detector) Tests run in the unit suite (`tests/AssertSyncReflect.test.ts`) even though the helper lives under `tests/integration/helpers/` per the plan's file layout — the helper has no integration deps (no SSH, no docker), so vitest resolves the cross-directory import and the assertion stays fast (<500ms). Verification: - `npx vitest run` — 36 files / 482 tests green (was 474 on M7 branch, +8 new) - `npx tsc --noEmit -p tsconfig.json` — clean manifest/package/versions bumped 0.4.45 → 0.4.46 (one ahead of PR #103 / M7 at 0.4.45). When M7 merges, this PR's diff stays the same; when this PR's CI re-runs against the merged main, the M7 changes are present and the imports resolve. Roadmap context: M7 (#103) → **M8 (this)** → M9 (E2E matrix). M9 will compose: SftpDataAdapter (writer) → daemon → fs.changed → notification handler → applyFsChange-equivalent → VaultModelBuilder mutators → fakeFE.observe(...) inside each E2E case's op() closure, with assertSyncReflect enforcing per-case latency budgets and contributing to the same PerfAggregator the bench writes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
163 lines
5.8 KiB
TypeScript
163 lines
5.8 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import type { TFile } from 'obsidian';
|
|
import { perfTracer } from '../src/util/PerfTracer';
|
|
import { FakeFileExplorer } from './helpers/FakeFileExplorer';
|
|
import { assertSyncReflect } from './integration/helpers/assertSyncReflect';
|
|
|
|
/**
|
|
* Pure unit coverage for the M8 helper. Doesn't require an SSH
|
|
* session — the writer-side `op()` closure is a tiny callback that
|
|
* just calls `fe.observe(...)` to simulate a reader-side vault
|
|
* event. M9 will compose the real writer (RpcRemoteFsClient via
|
|
* SftpDataAdapter) into the same op() shape.
|
|
*/
|
|
|
|
beforeEach(() => {
|
|
perfTracer.clear();
|
|
perfTracer.setEnabled(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
perfTracer.setEnabled(false);
|
|
perfTracer.clear();
|
|
});
|
|
|
|
function fakeFile(path: string, mtime = 0): TFile {
|
|
return {
|
|
path,
|
|
name: path.split('/').pop() ?? path,
|
|
stat: { ctime: 0, mtime, size: 0 },
|
|
} as unknown as TFile;
|
|
}
|
|
|
|
describe('assertSyncReflect — happy path', () => {
|
|
it('resolves with positive e2eMs and the captured spans', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
const result = await assertSyncReflect({
|
|
op: async () => {
|
|
// Simulate a writer-side span and the resulting reader event.
|
|
const t = perfTracer.begin('S.adp');
|
|
await new Promise((r) => setTimeout(r, 5));
|
|
perfTracer.end(t, { op: 'write', path: 'a.md', bytes: 4 });
|
|
fe.observe('create', fakeFile('a.md', 100));
|
|
},
|
|
reader: { fakeFE: fe },
|
|
expect: { path: 'a.md', event: 'create' },
|
|
budgetMs: 1_000,
|
|
});
|
|
expect(result.e2eMs).toBeGreaterThan(0);
|
|
expect(result.e2eMs).toBeLessThan(1_000);
|
|
expect(result.spans).toHaveLength(1);
|
|
expect(result.spans[0].name).toBe('S.adp');
|
|
expect(result.cid).toBeUndefined();
|
|
});
|
|
|
|
it('echoes back the optional cid passthrough', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
const r = await assertSyncReflect({
|
|
op: async () => { fe.observe('create', fakeFile('a.md', 1)); },
|
|
reader: { fakeFE: fe },
|
|
expect: { path: 'a.md', event: 'create' },
|
|
budgetMs: 1_000,
|
|
cid: 'feedfacedeadbeef',
|
|
});
|
|
expect(r.cid).toBe('feedfacedeadbeef');
|
|
});
|
|
|
|
it('captures spans from the op only (not from before / after the window)', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
perfTracer.end(perfTracer.begin('before-window'));
|
|
const r = await assertSyncReflect({
|
|
op: async () => {
|
|
perfTracer.end(perfTracer.begin('inside-window'));
|
|
fe.observe('create', fakeFile('a.md', 1));
|
|
},
|
|
reader: { fakeFE: fe },
|
|
expect: { path: 'a.md', event: 'create' },
|
|
budgetMs: 1_000,
|
|
});
|
|
perfTracer.end(perfTracer.begin('after-window'));
|
|
expect(r.spans.map((s) => s.name)).toEqual(['inside-window']);
|
|
});
|
|
});
|
|
|
|
describe('assertSyncReflect — failure modes', () => {
|
|
it('rejects when op() throws, with the original message in the error', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
await expect(assertSyncReflect({
|
|
op: async () => { throw new Error('write blew up'); },
|
|
reader: { fakeFE: fe },
|
|
expect: { path: 'a.md', event: 'create' },
|
|
budgetMs: 1_000,
|
|
label: 'case-1',
|
|
})).rejects.toThrow(/\[case-1\] op\(\) threw before reflect: write blew up/);
|
|
});
|
|
|
|
it('rejects when no reflect arrives within budgetMs', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
await expect(assertSyncReflect({
|
|
op: async () => { /* no observe — reader never sees anything */ },
|
|
reader: { fakeFE: fe },
|
|
expect: { path: 'a.md', event: 'create' },
|
|
budgetMs: 30,
|
|
label: 'case-2',
|
|
})).rejects.toThrow(/\[case-2\] awaitReflect failed.*no create.*"a\.md".*within 30ms/);
|
|
});
|
|
|
|
it('rejects when reflect arrives but e2eMs exceeds budgetMs', async () => {
|
|
// op() spends most of the budget (40ms), then reflects within
|
|
// awaitReflect's own per-call window (also 50ms) — but t0→atMs
|
|
// exceeds 50ms, which the explicit budget check catches.
|
|
const fe = new FakeFileExplorer();
|
|
await expect(assertSyncReflect({
|
|
op: async () => {
|
|
await new Promise((r) => setTimeout(r, 40));
|
|
fe.observe('create', fakeFile('a.md', 1));
|
|
},
|
|
reader: { fakeFE: fe },
|
|
expect: { path: 'a.md', event: 'create' },
|
|
budgetMs: 30,
|
|
label: 'tight',
|
|
})).rejects.toThrow(/\[tight\].*exceeded budget 30ms/);
|
|
});
|
|
|
|
it('uses the bare prefix when no label is supplied', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
await expect(assertSyncReflect({
|
|
op: async () => { /* no observe */ },
|
|
reader: { fakeFE: fe },
|
|
expect: { path: 'x', event: 'create' },
|
|
budgetMs: 20,
|
|
})).rejects.toThrow(/^awaitReflect failed:/);
|
|
});
|
|
});
|
|
|
|
describe('assertSyncReflect — onSpan listener hygiene', () => {
|
|
it('unsubscribes the perfTracer listener even on failure', async () => {
|
|
const fe = new FakeFileExplorer();
|
|
const beforeCount = countOnSpanListeners();
|
|
await assertSyncReflect({
|
|
op: async () => { throw new Error('boom'); },
|
|
reader: { fakeFE: fe },
|
|
expect: { path: 'x', event: 'create' },
|
|
budgetMs: 50,
|
|
}).catch(() => { /* expected */ });
|
|
expect(countOnSpanListeners()).toBe(beforeCount);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* PerfTracer doesn't expose a listener-count method; we infer it by
|
|
* checking whether a span fired *outside* a tracked window leaks into
|
|
* any leftover capture array. Indirect, but enough to detect a leaked
|
|
* subscription that would accumulate across many test runs.
|
|
*/
|
|
function countOnSpanListeners(): number {
|
|
let observed = 0;
|
|
const off = perfTracer.onSpan(() => { observed++; });
|
|
perfTracer.end(perfTracer.begin('probe'));
|
|
off();
|
|
// We only care that our own probe fired exactly once — meaning no
|
|
// mystery listener double-counted it.
|
|
return observed === 1 ? 0 : observed - 1;
|
|
}
|