mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
Closes the second half of the Phase C MVP, but with a structurally different cadence than the original M10 design (PR #106 v1). The bench (`plugin/tests/integration/perf.sync.bench.test.ts`) takes ~6 minutes per run — 96 % of the integration job's wall time, all of it in the SSH-tunneled RPC RTT (~41 ms × ~3000 calls). Running it on every PR was wrong on two counts: 1. **Feedback latency**: every PR adds 6 minutes to CI for a comment-only gate. Souta correctly called out that the bench is overkill for the per-PR feedback loop. 2. **Bench purpose**: the bench is a *trend tracker* and an *on-demand perf microscope*. Trends want a stable cadence (nightly), not noise from every PR; a microscope wants to be pulled out when there's a hypothesis to test, not run on every keystroke. So this PR splits the workflow: - **integration.yml** (PR + push to main): runs everything EXCEPT the bench. Drops back to ~30 s wall time. - **bench.yml** (new, nightly cron + workflow_dispatch + push-to-main): runs ONLY the bench. Updates `perf-baseline` branch on nightly + main-push. workflow_dispatch with an optional `pr_number` input posts the diff comment to the named PR — preserves the on-demand "hey what does this branch look like perf-wise" capability without paying the 6-minute tax on every push. What ships: - `plugin/scripts/perf/compare.mjs` (new, pure) Comparison core. Reads two NDJSON datasets, joins by `(span, transport, sizeBytes)`, computes p95 deltas, classifies each bucket as regressed / improved / unchanged / new / removed against per-span tolerance (default 25 %; S.fs widened to 40 % because shared-CI disk variance is high; S.note to 50 % for fsnotify debounce noise). Renders a Markdown table for the PR comment with arrows (🔴↑/🟢↓/−/🆕/➖), bolds regressed rows, appends a pass/fail summary + a footer with the baseline / head SHA pair + run URL. Status-ordered output (regressions first) so reviewers see the bad news without scrolling. - `plugin/scripts/perf/run-compare.mjs` (new, CLI) Picks the latest NDJSON in `plugin/perf-results/`, fetches the baseline at `<workspace>/perf-baseline/baseline.ndjson`, calls `compare()`, posts via `gh pr comment`. **Upserts** the comment by sentinel header so a long-running PR with multiple workflow_dispatch invocations gets one rolling comment instead of a per-trigger spam stack. Exits 0 unless `PERF_GATE=1` AND there's a regression past tolerance. - `plugin/scripts/perf/run-update-baseline.mjs` (new, CLI) Bootstraps the orphan `perf-baseline` branch on first run; on subsequent triggers, copies the latest NDJSON onto it as `baseline.ndjson` + writes a sidecar `baseline.sha`. Skips the commit when the baseline bytes haven't changed (no-op pushes on idle main). Uses `git worktree` to isolate the baseline checkout from the main checkout. - `.github/workflows/bench.yml` (new) Triggers: nightly cron (UTC 19:00 = JST 04:00) + manual `workflow_dispatch` + push to main. Manual triggers can pass an optional `pr_number` input to comment the diff to a PR (the on-demand spot-check path). Permissions: `contents: write` (baseline branch push) + `pull-requests: write` (PR comment). - `plugin/package.json` - `test:integration` — now excludes the bench file (`--exclude **/perf.sync.bench.test.ts`) so PR runs of the integration job stay fast. - `test:integration:bench` (new) — runs ONLY the bench file (used by bench.yml). - `plugin/tests/PerfCompare.test.ts` (new, 19 tests) Pure-module unit coverage for the comparison logic — runs in the fast unit suite via vitest's ESM loader (no .mjs build step needed; ~280 ms). Covered: parseNDJSON: empty, well-formed, malformed (line number in error), missing required fields. compare status semantics: new / removed / unchanged (within ±5 % band) / regressed (past tolerance) / improved (clear drop). Per-span tolerance override (S.fs 40 %). Drops to 'new' when baseline p95 is 0 (can't compute %). ordering: regressions surface first (status-grouped), then improvements, unchanged, new, removed, each lex-sorted. isolation: buckets across (span, transport, sizeBytes) tuples stay separate. formatMarkdown: header / table / pass-summary on no- regressions, ❌ + bolded rows on regressions, "no buckets" message on empty input, gate-enabled hint surface. round-trip: NDJSON in → table out, end-to-end smoke. - `.github/workflows/integration.yml` Unchanged from main — this PR deliberately does NOT add perf- related steps to the integration workflow. integration.yml stays the home of functional integration tests; bench.yml is the home of perf bench + baseline. The split keeps each workflow's purpose discoverable from its name. Architecture summary: PR push → integration.yml (no bench, ~30 s) push to main → integration.yml (no bench) → bench.yml (writes perf-baseline) nightly cron → bench.yml (refreshes perf-baseline) workflow_dispatch→ bench.yml (no baseline write; optional PR comment) Verification: - `npx vitest run` — 37 files / 501 tests green (was 482, +19 new) - `npx tsc --noEmit -p tsconfig.json` — clean - `npm run test:integration` (locally if Docker present) — bench excluded via `--exclude` flag; only the functional integration tests run. - workflow YAML: validated against the existing repo's structural patterns. manifest/package/versions bumped 0.4.46 → 0.4.48 (one ahead of the merged PR #105 / M9 at 0.4.47). Roadmap context: - M0 → M9 (Phase C MVP) ✓ - **M10 (this)** wires the gate but off the PR critical path. - Issue #110 (PERF_GATE flip) needs an update to track "flip on schedule + workflow_dispatch in bench.yml" rather than the old "flip in integration.yml" wording. - Issue #109 (E2E expansion) and #108 (modify case revival) are unaffected; they unlock more bench cells, not bench cadence. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
9.2 KiB
TypeScript
238 lines
9.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
// .mjs ESM import — vitest resolves via the same loader as production code.
|
|
import { compare, formatMarkdown, parseNDJSON } from '../scripts/perf/compare.mjs';
|
|
|
|
/**
|
|
* Pure-module unit coverage for the M10 perf-gate comparison logic.
|
|
* Drives `compare()` + `parseNDJSON()` + `formatMarkdown()` against
|
|
* hand-crafted records so the gate's pass/fail behaviour is pinned
|
|
* before the workflow ever runs against real bench output.
|
|
*/
|
|
|
|
interface PerfRecord {
|
|
span: string;
|
|
transport: string;
|
|
sizeBytes: number;
|
|
p50: number;
|
|
p95: number;
|
|
p99: number;
|
|
mean: number;
|
|
stddev: number;
|
|
n: number;
|
|
filtered?: number;
|
|
}
|
|
|
|
function rec(span: string, transport: string, sizeBytes: number, p95: number, extras: Partial<PerfRecord> = {}): PerfRecord {
|
|
return {
|
|
span, transport, sizeBytes,
|
|
p50: extras.p50 ?? p95 * 0.6,
|
|
p95,
|
|
p99: extras.p99 ?? p95 * 1.05,
|
|
mean: extras.mean ?? p95 * 0.55,
|
|
stddev: extras.stddev ?? 1,
|
|
n: extras.n ?? 100,
|
|
filtered: extras.filtered ?? 0,
|
|
};
|
|
}
|
|
|
|
// ── parseNDJSON ──────────────────────────────────────────────────────────
|
|
|
|
describe('parseNDJSON', () => {
|
|
it('returns [] for an empty document', () => {
|
|
expect(parseNDJSON('')).toEqual([]);
|
|
expect(parseNDJSON('\n\n')).toEqual([]);
|
|
});
|
|
|
|
it('parses well-formed records and ignores blank lines', () => {
|
|
const text = JSON.stringify(rec('S.adp', 'rpc', 1024, 5)) + '\n\n'
|
|
+ JSON.stringify(rec('S.rpc', 'rpc', 1024, 10)) + '\n';
|
|
const out = parseNDJSON(text);
|
|
expect(out.map((r: PerfRecord) => r.span)).toEqual(['S.adp', 'S.rpc']);
|
|
});
|
|
|
|
it('throws on the first malformed JSON line, with the line number', () => {
|
|
const bad = JSON.stringify(rec('S.adp', 'rpc', 1024, 5)) + '\n{not json\n';
|
|
expect(() => parseNDJSON(bad)).toThrow(/line 2/);
|
|
});
|
|
|
|
it('throws on a record missing required fields', () => {
|
|
const partial = JSON.stringify({ span: 'S.adp', transport: 'rpc' });
|
|
expect(() => parseNDJSON(partial)).toThrow(/not a PerfRecord/);
|
|
});
|
|
});
|
|
|
|
// ── compare: status semantics ────────────────────────────────────────────
|
|
|
|
describe('compare — status semantics', () => {
|
|
it('marks a head bucket with no baseline as `new`', () => {
|
|
const { rows, regressions } = compare([], [rec('S.adp', 'rpc', 1024, 5)]);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].status).toBe('new');
|
|
expect(rows[0].p95DeltaPct).toBeNull();
|
|
expect(regressions).toEqual([]);
|
|
});
|
|
|
|
it('marks a baseline bucket with no head as `removed`', () => {
|
|
const { rows } = compare([rec('S.adp', 'rpc', 1024, 5)], []);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].status).toBe('removed');
|
|
});
|
|
|
|
it('marks a stable bucket as `unchanged` (delta within ±5%)', () => {
|
|
const base = rec('S.adp', 'rpc', 1024, 5);
|
|
const head = rec('S.adp', 'rpc', 1024, 5.1); // +2% — within band
|
|
const { rows, regressions } = compare([base], [head]);
|
|
expect(rows[0].status).toBe('unchanged');
|
|
expect(regressions).toEqual([]);
|
|
});
|
|
|
|
it('marks a regression past per-span tolerance as `regressed`', () => {
|
|
const base = rec('S.adp', 'rpc', 1024, 5);
|
|
const head = rec('S.adp', 'rpc', 1024, 7); // +40% — well past 25% default
|
|
const { rows, regressions } = compare([base], [head]);
|
|
expect(rows[0].status).toBe('regressed');
|
|
expect(regressions).toHaveLength(1);
|
|
});
|
|
|
|
it('marks a clear improvement as `improved`', () => {
|
|
const base = rec('S.adp', 'rpc', 1024, 10);
|
|
const head = rec('S.adp', 'rpc', 1024, 6); // -40%
|
|
const { rows } = compare([base], [head]);
|
|
expect(rows[0].status).toBe('improved');
|
|
});
|
|
|
|
it('respects the per-span tolerance override (S.fs gets 40%)', () => {
|
|
const base = rec('S.fs', 'rpc', 1024, 5);
|
|
const head = rec('S.fs', 'rpc', 1024, 6.5); // +30% — over default 25%, under S.fs 40%
|
|
const { rows, regressions } = compare([base], [head]);
|
|
expect(rows[0].status).toBe('unchanged'); // wait — actually +30% is past the 5% band, so 'regressed' if above tolerance
|
|
// Re-check: +30% < 40% (S.fs tolerance), so NOT regressed. But +30% > 5% UNCHANGED_BAND, so what is it?
|
|
// Per the makeRow logic: if !regressed && !improved (sign), and outside ±5% band → 'unchanged'.
|
|
// Confirmed: tolerance 40% means "regressed" only fires above 40%; +30% is above the band but
|
|
// below tolerance → labelled 'unchanged'. Caller can still see Δ% in the table.
|
|
expect(regressions).toEqual([]);
|
|
});
|
|
|
|
it('drops to `new` when baseline p95 is 0 (can\'t compute a percentage)', () => {
|
|
const base = rec('S.adp', 'rpc', 1024, 0);
|
|
const head = rec('S.adp', 'rpc', 1024, 5);
|
|
const { rows } = compare([base], [head]);
|
|
expect(rows[0].status).toBe('new');
|
|
expect(rows[0].p95DeltaPct).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── compare: ordering + per-bucket isolation ─────────────────────────────
|
|
|
|
describe('compare — ordering + isolation', () => {
|
|
it('groups regressions first, then improvements, then the rest', () => {
|
|
const out = compare(
|
|
[
|
|
rec('S.adp', 'rpc', 1024, 10), // base for "improved"
|
|
rec('S.rpc', 'rpc', 1024, 5), // base for "regressed"
|
|
rec('S.app', 'rpc', 1024, 5), // base for "unchanged"
|
|
],
|
|
[
|
|
rec('S.adp', 'rpc', 1024, 6), // -40% improved
|
|
rec('S.rpc', 'rpc', 1024, 8), // +60% regressed
|
|
rec('S.app', 'rpc', 1024, 5.05),// unchanged
|
|
rec('S.e2e', 'rpc', 1024, 50), // new
|
|
],
|
|
);
|
|
expect(out.rows.map((r) => `${r.span}:${r.status}`)).toEqual([
|
|
'S.rpc:regressed',
|
|
'S.adp:improved',
|
|
'S.app:unchanged',
|
|
'S.e2e:new',
|
|
]);
|
|
});
|
|
|
|
it('isolates buckets across (span, transport, sizeBytes) tuples', () => {
|
|
const base = [
|
|
rec('S.adp', 'rpc', 1024, 5),
|
|
rec('S.adp', 'rpc', 100_000, 10), // different size → different bucket
|
|
rec('S.adp', 'sftp', 1024, 5), // different transport → different bucket
|
|
];
|
|
const head = [
|
|
rec('S.adp', 'rpc', 1024, 5.05), // unchanged
|
|
rec('S.adp', 'rpc', 100_000, 25), // regressed 150%
|
|
rec('S.adp', 'sftp', 1024, 5), // unchanged
|
|
];
|
|
const { regressions } = compare(base, head);
|
|
expect(regressions).toHaveLength(1);
|
|
expect(regressions[0].sizeBytes).toBe(100_000);
|
|
expect(regressions[0].transport).toBe('rpc');
|
|
});
|
|
});
|
|
|
|
// ── formatMarkdown ───────────────────────────────────────────────────────
|
|
|
|
describe('formatMarkdown', () => {
|
|
it('renders a header, table, and pass summary when no regressions', () => {
|
|
const { rows } = compare(
|
|
[rec('S.adp', 'rpc', 1024, 5)],
|
|
[rec('S.adp', 'rpc', 1024, 5.05)],
|
|
);
|
|
const md = formatMarkdown(rows, { commitSha: 'abc1234', baselineSha: 'def5678' });
|
|
expect(md).toMatch(/## Phase C perf-bench diff/);
|
|
expect(md).toMatch(/\| span \| transport/);
|
|
expect(md).toMatch(/✅ no regressions/);
|
|
expect(md).toMatch(/baseline @ `def5678`/);
|
|
expect(md).toMatch(/head @ `abc1234`/);
|
|
});
|
|
|
|
it('bolds regressed rows and emits a ❌ summary line', () => {
|
|
const { rows } = compare(
|
|
[rec('S.adp', 'rpc', 1024, 5)],
|
|
[rec('S.adp', 'rpc', 1024, 10)], // +100%
|
|
);
|
|
const md = formatMarkdown(rows);
|
|
expect(md).toMatch(/\*\*S\.adp\*\*/);
|
|
expect(md).toMatch(/❌ \*\*1 bucket\(s\) regressed/);
|
|
});
|
|
|
|
it('emits a "no buckets" message when both inputs are empty', () => {
|
|
const md = formatMarkdown([]);
|
|
expect(md).toMatch(/_No bench buckets/);
|
|
});
|
|
|
|
it('includes a gate-enabled hint when the gate is on', () => {
|
|
const { rows } = compare(
|
|
[rec('S.adp', 'rpc', 1024, 5)],
|
|
[rec('S.adp', 'rpc', 1024, 10)],
|
|
);
|
|
const md = formatMarkdown(rows, { gateEnabled: true });
|
|
expect(md).toMatch(/Gate is \*\*enabled\*\*/);
|
|
});
|
|
|
|
it('omits the gate hint when not enabled', () => {
|
|
const { rows } = compare(
|
|
[rec('S.adp', 'rpc', 1024, 5)],
|
|
[rec('S.adp', 'rpc', 1024, 10)],
|
|
);
|
|
const md = formatMarkdown(rows, { gateEnabled: false });
|
|
expect(md).not.toMatch(/Gate is/);
|
|
});
|
|
});
|
|
|
|
// ── round-trip: NDJSON in → table out ────────────────────────────────────
|
|
|
|
describe('round-trip', () => {
|
|
it('parses two NDJSON inputs, compares, and emits a Markdown summary', () => {
|
|
const baseText = [rec('S.adp', 'rpc', 1024, 5), rec('S.rpc', 'rpc', 1024, 30)]
|
|
.map((r) => JSON.stringify(r)).join('\n') + '\n';
|
|
const headText = [rec('S.adp', 'rpc', 1024, 5.1), rec('S.rpc', 'rpc', 1024, 50)]
|
|
.map((r) => JSON.stringify(r)).join('\n') + '\n';
|
|
|
|
const base = parseNDJSON(baseText);
|
|
const head = parseNDJSON(headText);
|
|
const { rows, regressions } = compare(base, head);
|
|
|
|
expect(regressions).toHaveLength(1);
|
|
expect(regressions[0].span).toBe('S.rpc');
|
|
|
|
const md = formatMarkdown(rows, { commitSha: 'cafebabe', gateEnabled: true });
|
|
expect(md).toMatch(/❌ \*\*1 bucket\(s\) regressed/);
|
|
expect(md).toMatch(/cafebab/);
|
|
});
|
|
});
|