sotashimozono_obsidian-remo.../plugin/scripts/replay-prev-tests.mjs
Souta 1df4a05368 ci(replay): replay previous release tag's tests against current source
Catches PRs where source and tests are modified in lockstep so a
previously-asserted invariant ships green even though it's broken.
The replay script copies the most recent published tag's
`plugin/tests/` over the PR's `plugin/src/`, runs vitest, and
fails the (advisory) check on regression.

How it works:
- `git describe --tags --abbrev=0 origin/main` resolves the
  baseline tag (e.g. `0.4.88`).
- `git worktree add` checks the tag out into a temp dir.
- The full `plugin/tests/` tree is copied into the gitignored
  scratch dir `plugin/tests-replay/` (whole tree, including
  `integration/helpers/`, because some unit tests import shared
  helpers from there).
- A dedicated `vitest.replay.config.ts` narrows discovery to
  `tests-replay/**/*.test.ts` and excludes `tests-replay/integration/**`
  so integration tests don't run.
- Failures point at `.github/replay-skip.txt`, where a deliberately-
  retired test can be added with a "# reason / PR ref" comment.

Files:
- `plugin/scripts/replay-prev-tests.mjs` — orchestrator, runnable
  locally via `npm run test:replay` (also wired into `package.json`).
- `plugin/vitest.replay.config.ts` — replay-only test config.
- `.github/replay-skip.txt` — append-only retirement list, empty.
- `.github/workflows/replay.yml` — PR-only trigger, advisory
  (`continue-on-error: true`); flip to required-check once the
  signal-to-noise has been validated over real PRs.
- `.gitignore` — exclude `plugin/tests-replay/` scratch dir.

Verified locally: replay against tag `0.4.88` runs vitest with
626 tests, all pass against current source. (One test-collection
detail surfaced during dev: `AssertSyncReflect.test.ts` and
`PerfAggregator.test.ts` import helpers from `integration/helpers/`,
which is why the copy step now mirrors the entire tests tree
rather than skipping `integration/` at the directory level — the
exclude is enforced by the vitest config's `exclude` glob.)

No plugin-version bump: this is CI-infrastructure only and ships
nothing to end users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:54:22 +09:00

214 lines
8.2 KiB
JavaScript

// Run the previous release tag's unit tests against the current
// `plugin/src/` source.
//
// **Why:** in a normal PR, source and tests are modified together.
// If the tests are updated to "fit" a behaviour change, an
// otherwise-broken-by-the-PR invariant ships green. This replay
// catches that: the tests as they existed at the previous release
// tag are taken as a frozen contract; if any of them now fails
// against current source, either the PR is a regression OR the
// invariant is being intentionally retired (in which case the test
// goes onto the skip list with a written reason).
//
// **Scope:** `plugin/tests/*.test.ts` only (the unit tier). The
// integration suite is excluded — it's heavier, has its own opt-in
// runner (`vitest.integration.config.ts`), and tends to hit
// transient infra (sshd, workspaces) that's not what we're trying
// to pin. A future variant can cover integration replay separately.
//
// **Usage:**
// - CI: `.github/workflows/replay.yml` (advisory, PR only)
// - Local: `npm run test:replay` (from `plugin/`)
//
// **Skip list:** `.github/replay-skip.txt` at repo root. One
// relative path per line (relative to `plugin/tests/`), with a
// `# reason / PR ref` comment on the same line. Empty / comment-
// only lines ignored. See the file's header for the full format.
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const pluginRoot = path.resolve(here, '..');
const repoRoot = path.resolve(pluginRoot, '..');
const REPLAY_DIR = path.join(pluginRoot, 'tests-replay');
const SKIP_LIST = path.join(repoRoot, '.github', 'replay-skip.txt');
/** Run a command, exit on non-zero. Captures stdout for `git` queries. */
function run(cmd, args, opts = {}) {
const r = spawnSync(cmd, args, {
encoding: 'utf8',
stdio: opts.capture ? ['ignore', 'pipe', 'inherit'] : 'inherit',
cwd: opts.cwd ?? repoRoot,
shell: false,
});
if (r.status !== 0) {
if (opts.softFail) return null;
process.exit(r.status ?? 1);
}
return opts.capture ? r.stdout.trim() : '';
}
/** `git describe` against `origin/main` so PR branches replay against
* the most recent **published** tag, not whatever happens to be
* reachable from the PR's HEAD (which on a PR that bumps the
* version would otherwise be the bump itself). */
function findPreviousTag() {
// Prefer origin/main; fall back to local tag list if origin isn't
// wired (e.g. shallow clone in some CI configs).
const fromOrigin = run('git', ['describe', '--tags', '--abbrev=0', 'origin/main'], {
capture: true,
softFail: true,
});
if (fromOrigin) return fromOrigin;
return run('git', ['describe', '--tags', '--abbrev=0'], { capture: true });
}
/** Read `.github/replay-skip.txt` into a Set of relative paths.
* Lines without a `#` reason comment are rejected — the format is
* meant to be self-documenting. */
function readSkipList() {
if (!fs.existsSync(SKIP_LIST)) return new Set();
const lines = fs.readFileSync(SKIP_LIST, 'utf8').split(/\r?\n/);
const skips = new Set();
for (const raw of lines) {
const line = raw.trim();
if (!line || line.startsWith('#')) continue;
const hash = line.indexOf('#');
if (hash < 0) {
console.error(
`replay-skip.txt: missing '# reason' comment on line: "${line}"\n` +
`Each entry must explain why the test is being dropped.`,
);
process.exit(1);
}
const pathPart = line.slice(0, hash).trim();
if (pathPart) skips.add(pathPart);
}
return skips;
}
/** Recursively copy a directory, dropping any paths in the skip
* set. Returns the count of `*.test.ts` files actually copied so
* a misconfigured skip glob (= "skipped everything") is visible
* in the log.
*
* We copy the **entire** tests tree — including `integration/` —
* rather than skipping integration at the dir level here, because
* some top-level unit tests import helpers physically located
* under `integration/helpers/` (e.g. `assertSyncReflect`,
* `perfAggregator`). Stripping the directory would break those
* imports at module-resolution time. The integration **tests**
* themselves are excluded from execution by
* `vitest.replay.config.ts`'s `exclude` glob, so they don't run
* even though their helpers are present. */
function copyTests(src, dest, skips) {
fs.mkdirSync(dest, { recursive: true });
let copied = 0;
const walk = (relDir) => {
const absSrc = path.join(src, relDir);
const absDest = path.join(dest, relDir);
fs.mkdirSync(absDest, { recursive: true });
for (const entry of fs.readdirSync(absSrc, { withFileTypes: true })) {
const rel = path.posix.join(relDir.replaceAll(path.sep, '/'), entry.name);
if (entry.isDirectory()) {
walk(rel);
} else if (entry.isFile()) {
if (skips.has(rel)) {
console.log(`replay: skipping ${rel} (in replay-skip.txt)`);
continue;
}
fs.copyFileSync(path.join(absSrc, entry.name), path.join(absDest, entry.name));
if (rel.endsWith('.test.ts')) copied += 1;
}
}
};
walk('');
return copied;
}
function rmrf(target) {
if (!fs.existsSync(target)) return;
// `maxRetries` + `retryDelay` smooth over Windows + cloud-sync
// (Dropbox / OneDrive) races where a recently-closed file handle
// hasn't been released yet, which surfaces as `EPERM` on `rmSync`.
// `force: true` already swallows ENOENT; the retries cover EPERM /
// EBUSY on slow filesystems.
//
// We swallow any leftover error: cleanup failure must not mask the
// test result. The replay scratch dir is gitignored, so a leftover
// tree only inconveniences the next run (which `rmrf`s upfront);
// it never escapes into the working tree's tracked state.
try {
fs.rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch (e) {
console.warn(`replay: cleanup of ${target} failed (${e.code ?? 'unknown'}); leaving in place`);
}
}
function main() {
const tag = findPreviousTag();
if (!tag) {
console.error('replay: could not resolve a previous tag — repo has no tags?');
process.exit(1);
}
console.log(`replay: baseline tag = ${tag}`);
const worktree = fs.mkdtempSync(path.join(os.tmpdir(), 'replay-'));
rmrf(REPLAY_DIR);
let exitCode = 0;
try {
run('git', ['worktree', 'add', '--detach', worktree, tag]);
const skips = readSkipList();
const srcTests = path.join(worktree, 'plugin', 'tests');
if (!fs.existsSync(srcTests)) {
console.error(`replay: tag ${tag} has no plugin/tests/ — nothing to replay`);
return;
}
const copied = copyTests(srcTests, REPLAY_DIR, skips);
console.log(`replay: copied ${copied} unit-test file(s) from ${tag}`);
if (copied === 0) {
console.error('replay: 0 test files copied — skip list mis-configured?');
exitCode = 1;
return;
}
// Run vitest with the replay-only config. The default
// `vitest.config.ts` pins `include: tests/**/*.test.ts`, which
// doesn't match `tests-replay/`; the dedicated replay config
// narrows discovery to that directory exclusively. Inherits
// stdio so the user sees vitest's normal output.
const vitestBin = path.join(pluginRoot, 'node_modules', 'vitest', 'vitest.mjs');
const result = spawnSync(process.execPath, [
vitestBin, 'run', '--config', 'vitest.replay.config.ts',
], {
cwd: pluginRoot,
stdio: 'inherit',
shell: false,
});
if (result.status !== 0) {
console.error(
`\nreplay: tests from ${tag} failed against current plugin/src/.\n` +
`Either fix the regression, or — if the invariant is being\n` +
`intentionally retired — append the failing test path(s) to\n` +
`.github/replay-skip.txt with a "# reason / PR ref" comment.`,
);
exitCode = result.status ?? 1;
} else {
console.log(`\nreplay: ${tag}'s tests pass against current source.`);
}
} finally {
// Always clean up so a re-run doesn't trip on a stale worktree.
run('git', ['worktree', 'remove', '--force', worktree], { softFail: true });
rmrf(worktree);
rmrf(REPLAY_DIR);
}
process.exit(exitCode);
}
main();