* test(#3145): bound the installer/runtime cluster onto the process seam Migrates 156 unbounded sync spawn sites across 47 files. Allowlist 120 to 73. Timeouts are sized from evidence already in the tree rather than a house default, because this wave spawns installers rather than git plumbing and an undersized bound does not catch a hang -- it manufactures CI flake, which is worse, since a flake gets re-run instead of investigated. install.test.cjs records a real spawnSync ETIMEDOUT at a 60000ms cap on a loaded bench while another lane passed the same commit in 12.7s, so full installs are bound at 120000ms against that recorded incident. Also adds an auditable escape to the guard's timeout ceiling. The 600000ms cap was set in #3143 from partial evidence, but fragment-single-edit- propagation carries a documented, load-tested 900000ms bound on a run that chains a full build plus eight generators -- the guard would have rejected a correct timeout the moment that file left the allowlist. A value above the ceiling is now permitted only with an inline allow-spawn-timeout-ceiling marker carrying a non-empty reason. It raises the ceiling; it never waives the requirement for a bound, which is asserted directly. install-shared.cjs keeps its hand-rolled assert rather than routing through throwIfFailed: its message embeds both streams, and throwIfFailed carries only a trimmed stderr. The message now also names the outcome, so a bounded timeout reads as such across its 38 importers instead of as expected null to equal 0. * test(#3145): extract class-norm timeouts and correct the build-hooks sizing A pre-PR review found 52 copies of four class-norm timeout constants across this wave. These are not per-suite fixture bindings -- they are shared facts about how long a class of subprocess takes, derived from a recorded bench incident. That norm already moved once (60000 to 120000 after a real ETIMEDOUT), and 52 copies would have drifted the next time it moved. Extracts tests/helpers/timeouts.cjs, where each norm is justified once, and converts the copies. A site that genuinely differs -- a real tsc compile, or regen:derived -- keeps its own local constant with its own justification. Also corrects a misclassification: scripts/build-hooks.js was sized as a build at 120000 in twelve places and 60000 in another, but it compiles and bundles nothing. Its own header says no bundling needed; it copies pre-built files and syntax-checks them with vm. Three different values bounded one script; now there is one. * test(#3145): fix red CI — lint self-match and a Windows chunk overrun Two failures on PR 3176. lint-allow-test-rule-refs read a RuleTester fixture as a real exemption. The fixture exists to prove an unrelated marker does NOT suppress the rule, so it carries that marker's literal text as test data. Split via concatenation, the same idiom no-unbounded-spawn-allowlist.test.cjs already uses for its own self-match problem. The explanatory comment needed the same treatment. The Windows shard 3/3 chunk was killed at its 600000ms budget. Output stopped seven minutes before the kill, so this was an overrun rather than a slow chunk: regenDerivedPropagatesSingleFragmentEditWithNoSecondSourceSurface runs regen:derived bounded at 900000ms, which is larger than the whole chunk budget, so the chunk killer always fires first and it can never complete there. Both the test and that bound predate this change; modifying the file pulled it into the Windows targeted set and exposed it. Skipped on Windows with the reason recorded; the Linux lanes cover it. The 900000 bound and its ceiling marker are unchanged -- they are correct. * test(#3145): refresh the stale test-timings cost table The Windows shard was killed at its 600000ms per-chunk budget. run-tests.cjs packs chunks by measured duration from tests/test-timings.json, and an unknown file falls back to the table's median weight -- advisory by design, but it silently underweights exactly the files that matter. Four of the failing chunk's 22 files were absent from the table, including the two heaviest: fragment-single-edit-propagation.install.test.cjs at 230s (it runs regen:derived) and agent-fragments-emission.install.test.cjs at 79s. Both were weighted as average, so the chunk's total weight read 53.68 against a budget of 60 and the packer produced a single chunk. Regenerated from a passing full-suite run, per the remedy the script itself documents. 700 to 770 entries, 70 added, 0 dropped -- verified, since gen-test-timings.cjs replaces the table wholesale rather than merging. Proven against the real packer: the same 22 files now weigh 103.91 and split into two chunks. No logic, budget, or timeout was changed; raising a budget to make a red gate pass is not a fix. --------- Co-authored-by: sim <sim@local>
202 lines
9.7 KiB
JavaScript
202 lines
9.7 KiB
JavaScript
// allow-test-rule: source-text-is-the-product (see #2913)
|
|
// .github/workflows/release.yml is the deployed CI contract; the cherry-pick
|
|
// error handler is bash inside the YAML. This test has two layers:
|
|
// 1. Real-git: proves the DISCRIMINATION LOGIC (check for unmerged paths →
|
|
// skip if empty, abort if conflict) is correct by exercising it against
|
|
// a real git repo with both scenarios.
|
|
// 2. Source-text: asserts the discrimination logic AND the separate summary
|
|
// heading actually made it into release.yml — so a regression that
|
|
// removes the check from the workflow is caught even if the logic test
|
|
// still passes.
|
|
|
|
'use strict';
|
|
|
|
const { describe, test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const { runGit } = require('./helpers/process-seam.cjs');
|
|
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const RELEASE_WORKFLOW = path.join(__dirname, '..', '.github', 'workflows', 'release.yml');
|
|
|
|
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
|
|
const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
// ─── git helpers ────────────────────────────────────────────────────────────
|
|
|
|
// Migrated from a hand-rolled throw-on-non-zero over spawnSync's `-C cwd`
|
|
// argv form to gitOrThrow's `{ cwd }` option — same external behavior (throws
|
|
// on non-zero exit, returns trimmed stdout on success), no caller reads the
|
|
// old custom error message so the throw-shape swap is safe.
|
|
function git(cwd, ...args) {
|
|
return gitOrThrow(args, { cwd, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
}
|
|
|
|
function makeRepo(dir) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
gitOrThrow(['init', dir], { timeoutMs: GIT_TIMEOUT_MS });
|
|
git(dir, 'config', 'user.email', 'test@test');
|
|
git(dir, 'config', 'user.name', 'Test');
|
|
git(dir, 'config', 'commit.gpgsign', 'false');
|
|
// Ensure the default branch is 'main' regardless of the system's
|
|
// init.defaultBranch setting (older Git defaults to 'master').
|
|
gitOrThrow(['symbolic-ref', 'HEAD', 'refs/heads/main'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
|
|
return dir;
|
|
}
|
|
|
|
function commitFile(dir, file, content, message) {
|
|
fs.writeFileSync(path.join(dir, file), content);
|
|
git(dir, 'add', file);
|
|
git(dir, 'commit', '-m', message);
|
|
return git(dir, 'rev-parse', 'HEAD');
|
|
}
|
|
|
|
/**
|
|
* The discrimination logic extracted from release.yml's cherry-pick error
|
|
* handler. This mirrors what the bash does after `git cherry-pick -x "$SHA"`
|
|
* exits non-zero:
|
|
* - Check for unmerged paths (`git diff --name-only --diff-filter=U`).
|
|
* - Empty → skip (already applied by content), record, continue.
|
|
* - Non-empty → genuine conflict, abort, return conflict info.
|
|
*
|
|
* @returns {{ status: 'empty' | 'conflict', unmerged: string[] }}
|
|
*/
|
|
function discriminateCherryPick(dir) {
|
|
const unmerged = git(dir, 'diff', '--name-only', '--diff-filter=U');
|
|
const unmergedFiles = unmerged.split('\n').filter(Boolean);
|
|
if (unmergedFiles.length === 0) {
|
|
git(dir, 'cherry-pick', '--skip');
|
|
return { status: 'empty', unmerged: [] };
|
|
}
|
|
git(dir, 'cherry-pick', '--abort');
|
|
return { status: 'conflict', unmerged: unmergedFiles };
|
|
}
|
|
|
|
// ─── real-git tests: prove the discrimination logic ─────────────────────────
|
|
|
|
describe('#2913 — cherry-pick empty-vs-conflict discrimination (real git)', () => {
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2913-'));
|
|
});
|
|
|
|
after(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('an already-applied-by-content commit is detected as empty and skipped', () => {
|
|
const repo = makeRepo(path.join(tmpDir, 'empty-pick'));
|
|
// Seed: create file.txt with "v1"
|
|
commitFile(repo, 'file.txt', 'v1\n', 'init');
|
|
const baseSha = git(repo, 'rev-parse', 'HEAD');
|
|
|
|
// On "next": bump file.txt to v2, then create a chore commit that sets it back to v1 then to v2
|
|
git(repo, 'checkout', '-b', 'next');
|
|
commitFile(repo, 'file.txt', 'v2\n', 'feat: bump to v2');
|
|
// Now create a commit that is a no-op against base by content: set file.txt to "v1\n"
|
|
// then immediately set it back — but that creates a non-empty diff. Instead,
|
|
// simulate the exact scenario: a commit whose PATCH applies empty because
|
|
// the content is already present.
|
|
//
|
|
// Create the "chore" commit on a throwaway branch from base, then cherry-pick
|
|
// it onto next where the change is already present.
|
|
git(repo, 'checkout', baseSha);
|
|
git(repo, 'checkout', '-b', 'throwaway');
|
|
// Apply a change that sets file.txt to v2 (same content as next already has)
|
|
const choreSha = commitFile(repo, 'file.txt', 'v2\n', 'chore: sync version to v2');
|
|
|
|
// Go back to next and try to cherry-pick the chore commit.
|
|
// file.txt is already v2 on next → the patch applies empty.
|
|
git(repo, 'checkout', 'next');
|
|
|
|
// Attempt the cherry-pick — it exits non-zero (empty).
|
|
const r = runGit(['cherry-pick', '-x', choreSha], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
assert.notStrictEqual(r.exitCode, 0, 'cherry-pick of an already-applied commit must exit non-zero');
|
|
|
|
// Apply the discrimination logic.
|
|
const result = discriminateCherryPick(repo);
|
|
assert.strictEqual(result.status, 'empty', 'an empty pick must be classified as "empty", not "conflict"');
|
|
assert.deepStrictEqual(result.unmerged, []);
|
|
|
|
// After skip, HEAD should be back on next (no cherry-pick state).
|
|
const headAfter = git(repo, 'rev-parse', 'next');
|
|
const currentHead = git(repo, 'rev-parse', 'HEAD');
|
|
assert.strictEqual(currentHead, headAfter, 'after skip, HEAD should be unchanged (no new commit applied)');
|
|
|
|
// #2913 MINOR 1: after --skip, the sequencer must be clean so the next
|
|
// iteration's cherry-pick succeeds. A regression that switched --skip to
|
|
// --quit (which leaves sequencer state) would fail here.
|
|
git(repo, 'checkout', baseSha);
|
|
git(repo, 'checkout', '-b', 'throwaway2');
|
|
const realSha = commitFile(repo, 'other.txt', 'real change\n', 'fix: a real fix');
|
|
git(repo, 'checkout', 'next');
|
|
const realPick = runGit(['cherry-pick', '-x', realSha], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
assert.strictEqual(realPick.exitCode, 0,
|
|
`after --skip, the sequencer must be clean so the next cherry-pick succeeds; got status ${realPick.exitCode}:\n${realPick.stderr || realPick.stdout}`);
|
|
});
|
|
|
|
test('a genuine cherry-pick conflict is detected as conflict and aborted', () => {
|
|
const repo = makeRepo(path.join(tmpDir, 'conflict-pick'));
|
|
// Seed: file.txt with "base line"
|
|
commitFile(repo, 'file.txt', 'base line\n', 'init');
|
|
|
|
// On "next": change line 1 to "next version"
|
|
git(repo, 'checkout', '-b', 'next');
|
|
const conflictingSha = commitFile(repo, 'file.txt', 'next version\n', 'fix: change to next version');
|
|
|
|
// Go back to main and make a DIFFERENT change to the same line.
|
|
git(repo, 'checkout', 'main');
|
|
commitFile(repo, 'file.txt', 'main version\n', 'fix: change to main version');
|
|
|
|
// Attempt to cherry-pick the next commit — it conflicts (same line, different content).
|
|
const r = runGit(['cherry-pick', '-x', conflictingSha], { cwd: repo, timeoutMs: GIT_TIMEOUT_MS });
|
|
assert.notStrictEqual(r.exitCode, 0, 'cherry-pick of a conflicting commit must exit non-zero');
|
|
|
|
// Apply the discrimination logic.
|
|
const result = discriminateCherryPick(repo);
|
|
assert.strictEqual(result.status, 'conflict', 'a genuine conflict must be classified as "conflict"');
|
|
assert.ok(result.unmerged.length > 0, 'a genuine conflict must have unmerged files');
|
|
assert.ok(result.unmerged.includes('file.txt'), 'file.txt must be in the unmerged list');
|
|
});
|
|
});
|
|
|
|
// ─── source-text assertions: prove the logic is in release.yml ──────────────
|
|
|
|
describe('#2913 — release.yml cherry-pick error handler discriminates empty from conflict', () => {
|
|
const text = fs.existsSync(RELEASE_WORKFLOW)
|
|
? fs.readFileSync(RELEASE_WORKFLOW, 'utf8')
|
|
: '';
|
|
|
|
test('the cherry-pick error handler checks for unmerged paths before aborting', () => {
|
|
// The fix adds a `git diff --name-only --diff-filter=U` check inside the
|
|
// `if ! git cherry-pick` block. Without it, every empty pick aborts the run.
|
|
assert.ok(text.length > 0, 'release.yml must exist');
|
|
assert.ok(
|
|
text.includes('--diff-filter=U'),
|
|
'release.yml cherry-pick error handler must check for unmerged paths (git diff --diff-filter=U) before aborting — without this, an already-applied commit aborts the entire hotfix create (#2913)',
|
|
);
|
|
});
|
|
|
|
test('empty picks are skipped (git cherry-pick --skip), not aborted', () => {
|
|
assert.ok(text.length > 0, 'release.yml must exist');
|
|
assert.ok(
|
|
text.includes('cherry-pick --skip'),
|
|
'release.yml must skip (not abort) already-applied cherry-picks — git cherry-pick --skip continues the loop (#2913)',
|
|
);
|
|
});
|
|
|
|
test('the job summary distinguishes already-applied from not-fix-chore skips', () => {
|
|
assert.ok(text.length > 0, 'release.yml must exist');
|
|
// The fix introduces a separate variable/heading for already-applied commits
|
|
// so they are not silently dropped or confused with not-fix/chore skips.
|
|
assert.ok(
|
|
/already applied|applied by content|SKIPPED_EMPTY|empty pick/i.test(text),
|
|
'release.yml job summary must distinguish "skipped (already applied)" from "skipped (not fix/chore)" — a separate heading or variable for empty picks (#2913)',
|
|
);
|
|
});
|
|
});
|