sotashimozono_obsidian-remo.../plugin/tests/PerfCompare.test.ts
Souta 6476372550 ci(plugin): Phase C.M10 — perf-bench off the PR critical path; nightly + workflow_dispatch (0.4.48)
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>
2026-04-29 08:39:01 +09:00

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