test(#1977): consolidate 22 misc + repo-invariant regression tests
Final epic-#1969 batch. Fold 22 issue-named files: the 4 genuine repo-wide invariant scans (551-eslint-bin-lib-coverage, bug-3054 stale /gsd-next, bug-3810 no-gsd-sdk-runtime-refs, feat-3593 cli-negative-universal) into a NEW shared repo-invariants.test.cjs; the other 18 as singletons into their nearest module suite (model-resolver, codex-config, runtime-converters, security, state-transition, worktree-safety, roadmap-parser, etc.). Verbatim block-scoped describe wrappers; 334 subtests conserved 1:1. Host-env pre-check (B2+B6): the 6 CLI folds into GSD_TEST_MODE-setting hosts (model-resolver/ codex-config/runtime-converters) are benign — each origin independently sets GSD_TEST_MODE=1 itself (idempotent), unlike the B6 real-install case. Regenerates regression-name allowlist (222->213), ratchets file-count allowlist (state 17->16), makes 7 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes stale ids). Repoints 2 tests/ refs in docs/TESTING-SUITES.md. lint:ci green. Part of epic #1969. Closes #1977. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8252,3 +8252,228 @@ describe('enh-772: reconcileCodexHooksJsonEvent preserves user-owned entries', (
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2866-codex-strip-no-trailing-newline.test.cjs — consolidation epic #1969 (B8 #1977)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2866-codex-strip-no-trailing-newline (consolidation epic #1969 B8 #1977)", () => {
|
||||
/**
|
||||
* Bug #2866: Codex Installer (RC.7) fails to strip legacy flat hooks if
|
||||
* trailing newline is missing.
|
||||
*
|
||||
* The cleanup regexes in `bin/install.js` matched stale GSD hook blocks
|
||||
* via `\r?\n` at the end. When a stale block sat at end-of-file without
|
||||
* a trailing newline (very common — many editors strip them, and the
|
||||
* legacy installer never wrote one), no shape stripped, the installer
|
||||
* saw `gsd-check-update` already present, skipped writing the new
|
||||
* Nested-AoT block, and Codex 0.125+ refused to load with
|
||||
* "invalid type: map, expected a sequence in `hooks`"
|
||||
*
|
||||
* Fix: every shape's terminator is now `(?:\r?\n|$)` so end-of-file
|
||||
* counts as a valid terminator. The strip logic was lifted into a pure
|
||||
* helper, `stripStaleGsdHookBlocks(configContent)`, exported from
|
||||
* `bin/install.js` for direct test coverage.
|
||||
*
|
||||
* This test parses `package.json` to require `bin/install.js`
|
||||
* structurally (not by hardcoded path), then drives each historical
|
||||
* shape through the helper twice — once with a trailing newline, once
|
||||
* without — and asserts both are stripped.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf-8'));
|
||||
const installPath = path.resolve(REPO_ROOT, pkg.bin['gsd-core']);
|
||||
const { stripStaleGsdHookBlocks } = require(installPath);
|
||||
|
||||
/**
|
||||
* Parse the TOML output line-structurally so assertions check shape, not
|
||||
* substring presence in raw text. Comments are dropped, table headers are
|
||||
* recorded, and string-valued keys are captured. Sufficient for the small,
|
||||
* well-formed TOML produced by these tests.
|
||||
*/
|
||||
function parseTomlShape(text) {
|
||||
const tableHeaders = [];
|
||||
const keys = new Map(); // dotted path → string value (last-write-wins, fine for these inputs)
|
||||
let currentTable = '';
|
||||
for (const rawLine of text.split('\n')) {
|
||||
const line = rawLine.replace(/(?:^|\s)#.*$/, '').trim();
|
||||
if (!line) continue;
|
||||
const tableMatch = line.match(/^\[(\[)?([^\]]+)\]?\]$/);
|
||||
if (tableMatch) {
|
||||
currentTable = tableMatch[2];
|
||||
tableHeaders.push((tableMatch[1] ? '[[' : '[') + currentTable + (tableMatch[1] ? ']]' : ']'));
|
||||
continue;
|
||||
}
|
||||
const kvMatch = line.match(/^([A-Za-z_][\w-]*)\s*=\s*(.*)$/);
|
||||
if (kvMatch) {
|
||||
const key = currentTable ? `${currentTable}.${kvMatch[1]}` : kvMatch[1];
|
||||
const value = kvMatch[2].replace(/^"(.*)"$/, '$1');
|
||||
keys.set(key, value);
|
||||
}
|
||||
}
|
||||
return { tableHeaders, keys };
|
||||
}
|
||||
|
||||
const SHAPES = {
|
||||
'Shape 1 (legacy gsd-update-check)': [
|
||||
'# GSD Hooks',
|
||||
'[[hooks]]',
|
||||
'event = "SessionStart"',
|
||||
'command = "node /Users/USER/.codex/hooks/gsd-update-check.js"',
|
||||
].join('\n'),
|
||||
'Shape 2 (flat [[hooks]] + gsd-check-update)': [
|
||||
'# GSD Hooks',
|
||||
'[[hooks]]',
|
||||
'event = "SessionStart"',
|
||||
'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"',
|
||||
].join('\n'),
|
||||
'Shape 3 ([[hooks.SessionStart]] without nested .hooks)': [
|
||||
'# GSD Hooks',
|
||||
'[[hooks.SessionStart]]',
|
||||
'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"',
|
||||
].join('\n'),
|
||||
'Shape 4 (nested [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]])': [
|
||||
'# GSD Hooks',
|
||||
'[[hooks.SessionStart]]',
|
||||
'',
|
||||
'[[hooks.SessionStart.hooks]]',
|
||||
'type = "command"',
|
||||
'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
describe('bug-2866: stripStaleGsdHookBlocks handles end-of-file without trailing newline', () => {
|
||||
test('stripStaleGsdHookBlocks is exported from bin/install.js', () => {
|
||||
assert.strictEqual(typeof stripStaleGsdHookBlocks, 'function',
|
||||
'bin/install.js must export stripStaleGsdHookBlocks');
|
||||
});
|
||||
|
||||
function assertStripped(out, shape, scenario) {
|
||||
const shape_ = parseTomlShape(out);
|
||||
const hooksTable = shape_.tableHeaders.find((h) => /^\[\[?hooks(\.|]\])/.test(h));
|
||||
assert.strictEqual(hooksTable, undefined,
|
||||
`(${shape}, ${scenario}) no hooks table header may remain after strip, got tables: ${shape_.tableHeaders.join(', ')}`);
|
||||
const staleCmd = [...shape_.keys.entries()].find(([_, v]) =>
|
||||
/gsd-(update-check|check-update)/.test(v));
|
||||
assert.strictEqual(staleCmd, undefined,
|
||||
`(${shape}, ${scenario}) no key may carry a stale gsd-*-update command, got: ${staleCmd && staleCmd.join('=')}`);
|
||||
assert.strictEqual(shape_.keys.get('history.persistence'), 'save-all',
|
||||
`(${shape}, ${scenario}) history.persistence must be preserved as "save-all"`);
|
||||
}
|
||||
|
||||
for (const [shape, block] of Object.entries(SHAPES)) {
|
||||
test(`${shape}: stripped when terminated by trailing newline`, () => {
|
||||
const input = `[history]\npersistence = "save-all"\n${block}\n`;
|
||||
assertStripped(stripStaleGsdHookBlocks(input), shape, 'with trailing newline');
|
||||
});
|
||||
|
||||
test(`${shape}: stripped when at end-of-file without trailing newline`, () => {
|
||||
// The reporter's repro: stale block sits at the very end with no \n.
|
||||
const input = `[history]\npersistence = "save-all"\n${block}`;
|
||||
assertStripped(stripStaleGsdHookBlocks(input), shape, 'no trailing newline');
|
||||
});
|
||||
}
|
||||
|
||||
test('returns input unchanged when no GSD hook block is present', () => {
|
||||
const benign = '[history]\npersistence = "save-all"\n';
|
||||
const out = stripStaleGsdHookBlocks(benign);
|
||||
assert.strictEqual(out, benign, 'helper must be a no-op when no GSD reference exists');
|
||||
const benignShape = parseTomlShape(out);
|
||||
assert.strictEqual(benignShape.keys.get('history.persistence'), 'save-all',
|
||||
'parsed shape must preserve history.persistence');
|
||||
assert.deepStrictEqual(benignShape.tableHeaders, ['[history]'],
|
||||
'parsed shape must contain only the [history] table');
|
||||
});
|
||||
|
||||
// The structural rewrite (TOML-AST-driven, not regex-driven) must handle
|
||||
// whitespace and key-ordering variations that the previous regex missed.
|
||||
// These cases were silently leaked by the old implementation; one
|
||||
// (V3) actually corrupted the file by leaving an orphaned key=value line
|
||||
// outside any table.
|
||||
const VARIATIONS = {
|
||||
'extra blank line in Shape 4': [
|
||||
'# GSD Hooks',
|
||||
'[[hooks.SessionStart]]',
|
||||
'',
|
||||
'',
|
||||
'[[hooks.SessionStart.hooks]]',
|
||||
'type = "command"',
|
||||
'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"',
|
||||
].join('\n'),
|
||||
'keys reordered (command before event in Shape 2)': [
|
||||
'# GSD Hooks',
|
||||
'[[hooks]]',
|
||||
'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"',
|
||||
'event = "SessionStart"',
|
||||
].join('\n'),
|
||||
'extra key alongside command (Shape 3 + timeout)': [
|
||||
'# GSD Hooks',
|
||||
'[[hooks.SessionStart]]',
|
||||
'command = "node /Users/USER/.codex/hooks/gsd-check-update.js"',
|
||||
'timeout = 5000',
|
||||
].join('\n'),
|
||||
'tight whitespace (no spaces around `=`)': [
|
||||
'# GSD Hooks',
|
||||
'[[hooks]]',
|
||||
'event="SessionStart"',
|
||||
'command="node /Users/USER/.codex/hooks/gsd-check-update.js"',
|
||||
].join('\n'),
|
||||
};
|
||||
|
||||
for (const [variation, block] of Object.entries(VARIATIONS)) {
|
||||
test(`variation stripped: ${variation}`, () => {
|
||||
const input = `[history]\npersistence = "save-all"\n${block}\n`;
|
||||
assertStripped(stripStaleGsdHookBlocks(input), variation, 'with trailing newline');
|
||||
});
|
||||
test(`variation stripped at EOF without trailing newline: ${variation}`, () => {
|
||||
const input = `[history]\npersistence = "save-all"\n${block}`;
|
||||
assertStripped(stripStaleGsdHookBlocks(input), variation, 'no trailing newline');
|
||||
});
|
||||
}
|
||||
|
||||
test('user-authored [[hooks.UserPromptSubmit]] is preserved', () => {
|
||||
// The structural strip must not touch hook tables that don't carry a
|
||||
// GSD-managed `gsd-(check-update|update-check).js` command.
|
||||
const input = [
|
||||
'[history]',
|
||||
'persistence = "save-all"',
|
||||
'[[hooks.UserPromptSubmit]]',
|
||||
'command = "node /Users/USER/my-hook.js"',
|
||||
'',
|
||||
].join('\n');
|
||||
const out = stripStaleGsdHookBlocks(input);
|
||||
const shape = parseTomlShape(out);
|
||||
assert.ok(
|
||||
shape.tableHeaders.includes('[[hooks.UserPromptSubmit]]'),
|
||||
`user-authored [[hooks.UserPromptSubmit]] must survive, got: ${shape.tableHeaders.join(', ')}`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
shape.keys.get('hooks.UserPromptSubmit.command'),
|
||||
'node /Users/USER/my-hook.js',
|
||||
'user-authored command value must be preserved verbatim',
|
||||
);
|
||||
});
|
||||
|
||||
test('Shape 4 strip does not leave an orphaned [[hooks.SessionStart]] header', () => {
|
||||
// Shape 4 is stripped before Shape 3 specifically to avoid this.
|
||||
const block = SHAPES['Shape 4 (nested [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]])'];
|
||||
const out = stripStaleGsdHookBlocks(`[history]\npersistence = "save-all"\n${block}`);
|
||||
const outShape = parseTomlShape(out);
|
||||
const orphan = outShape.tableHeaders.find((h) => /hooks\.SessionStart/.test(h));
|
||||
assert.strictEqual(orphan, undefined,
|
||||
`Shape 4 strip must remove the parent [[hooks.SessionStart]] header too, got tables: ${outShape.tableHeaders.join(', ')}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user