All checks were successful
Build, test and publish / verify (push) Successful in 1m40s
246 lines
8.9 KiB
JavaScript
246 lines
8.9 KiB
JavaScript
/** Isolated hook acceptance checks and full-process timing; never stages this checkout. */
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { cpSync, mkdtempSync, readFileSync, writeFileSync, symlinkSync } from 'node:fs';
|
|
import { tmpdir, cpus, platform, release } from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { performance } from 'node:perf_hooks';
|
|
|
|
const source = fileURLToPath(new URL('../../', import.meta.url));
|
|
const root = mkdtempSync(path.join(tmpdir(), 's2-hook-acceptance-'));
|
|
const frontend = path.join(root, 'agiledancers-fe');
|
|
const excluded = new Set([
|
|
'node_modules',
|
|
'dist',
|
|
'.angular',
|
|
'coverage',
|
|
'test-results',
|
|
'unit-results',
|
|
'playwright-report',
|
|
'_',
|
|
]);
|
|
cpSync(path.join(source, 'agiledancers-fe'), frontend, {
|
|
recursive: true,
|
|
filter: (file) => !excluded.has(path.basename(file)),
|
|
});
|
|
cpSync(path.join(source, '.husky'), path.join(root, '.husky'), {
|
|
recursive: true,
|
|
filter: (file) => !excluded.has(path.basename(file)),
|
|
});
|
|
symlinkSync(
|
|
path.join(source, 'agiledancers-fe/node_modules'),
|
|
path.join(frontend, 'node_modules'),
|
|
'dir',
|
|
);
|
|
const env = { ...process.env };
|
|
delete env.CI;
|
|
delete env.HUSKY;
|
|
function run(command, args, cwd = root, environment = env) {
|
|
return execFileSync(command, args, {
|
|
cwd,
|
|
env: environment,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
}).trimEnd();
|
|
}
|
|
const git = (...args) => run('git', args);
|
|
const write = (file, content) => writeFileSync(path.join(root, file), content);
|
|
const read = (file) => readFileSync(path.join(root, file), 'utf8');
|
|
const fixture = 'agiledancers-fe/src/hook-fixture.ts';
|
|
const baseline =
|
|
'export const hookValue = { value: 1 };\n\n// Independent edits below.\n\nexport const otherValue = 1;\n';
|
|
write(fixture, baseline);
|
|
write('unrelated.txt', 'baseline\n');
|
|
git('init', '-q');
|
|
git('config', 'user.name', 'S2 validation');
|
|
git('config', 'user.email', 's2@example.invalid');
|
|
git('add', '.');
|
|
git('-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'Fixture baseline');
|
|
git('config', 'core.hooksPath', '/dev/null');
|
|
run('node', ['tools/install-hooks.mjs'], frontend, { ...env, CI: 'true' });
|
|
assert.equal(git('config', '--get', 'core.hooksPath'), '/dev/null');
|
|
run('node', ['tools/install-hooks.mjs'], frontend, { ...env, HUSKY: '0' });
|
|
assert.equal(git('config', '--get', 'core.hooksPath'), '/dev/null');
|
|
console.log('PASS CI and HUSKY=0 skip hook installation');
|
|
run('node', ['tools/install-hooks.mjs'], frontend);
|
|
assert.equal(git('config', '--get', 'core.hooksPath'), '.husky/_');
|
|
const reset = () => git('reset', '--hard', '-q', 'HEAD');
|
|
const snapshot = () =>
|
|
JSON.stringify([
|
|
git('diff', '--cached', '--binary'),
|
|
git('diff', '--binary'),
|
|
git('status', '--porcelain'),
|
|
git('stash', 'list'),
|
|
]);
|
|
function hook() {
|
|
const start = performance.now();
|
|
const result = spawnSync('sh', ['.husky/_/pre-commit'], { cwd: root, env, encoding: 'utf8' });
|
|
return {
|
|
seconds: Number(((performance.now() - start) / 1000).toFixed(3)),
|
|
exit: result.status,
|
|
output: result.stdout + result.stderr,
|
|
};
|
|
}
|
|
function passed(name) {
|
|
console.log(`PASS ${name}`);
|
|
}
|
|
|
|
// Format only the index version while independently staged/unstaged edits survive.
|
|
write(fixture, baseline.replace('{ value: 1 }', '{value:2}'));
|
|
git('add', fixture);
|
|
write(fixture, read(fixture).replace('otherValue = 1', 'otherValue = 2'));
|
|
write('unrelated.txt', 'staged\n');
|
|
git('add', 'unrelated.txt');
|
|
write('unrelated.txt', 'staged\nunstaged\n');
|
|
const result = hook();
|
|
assert.equal(result.exit, 0, result.output);
|
|
assert.match(git('show', `:${fixture}`), /hookValue = \{ value: 2 \}/);
|
|
assert.match(git('show', `:${fixture}`), /otherValue = 1/);
|
|
assert.match(read(fixture), /otherValue = 2/);
|
|
assert.equal(git('show', ':unrelated.txt'), 'staged');
|
|
assert.equal(read('unrelated.txt'), 'staged\nunstaged\n');
|
|
assert.equal(git('stash', 'list'), '');
|
|
passed('partially staged formatting preserves unrelated staged and unstaged changes');
|
|
reset();
|
|
|
|
write('unrelated.txt', 'only backend/documentation\n');
|
|
git('add', 'unrelated.txt');
|
|
let before = snapshot();
|
|
assert.equal(hook().exit, 0);
|
|
assert.equal(snapshot(), before);
|
|
passed('commits without frontend changes leave state unchanged');
|
|
reset();
|
|
|
|
write('agiledancers-fe/package-lock.json', read('agiledancers-fe/package-lock.json') + '\n\n');
|
|
git('add', 'agiledancers-fe/package-lock.json');
|
|
before = snapshot();
|
|
assert.equal(hook().exit, 0);
|
|
assert.equal(snapshot(), before);
|
|
passed('generated lockfile is excluded');
|
|
reset();
|
|
|
|
write(
|
|
'agiledancers-fe/src/app/app.ts',
|
|
read('agiledancers-fe/src/app/app.ts') + '\nexport const formatterCheck={value:2};\n',
|
|
);
|
|
write(fixture, 'export const broken = ;\n');
|
|
git('add', 'agiledancers-fe/src/app/app.ts', fixture);
|
|
write('unrelated.txt', 'unstaged survives failures\n');
|
|
before = snapshot();
|
|
const failed = hook();
|
|
assert.notEqual(failed.exit, 0);
|
|
assert.match(failed.output, /SyntaxError|Unexpected token/);
|
|
assert.equal(snapshot(), before);
|
|
passed('formatter failure restores index, worktree and stash');
|
|
reset();
|
|
|
|
write(fixture, baseline.replace('{ value: 1 }', '{value:2}'));
|
|
git('add', fixture);
|
|
write(fixture, read(fixture).replace('{value:2}', '{value:3}'));
|
|
before = snapshot();
|
|
const conflict = hook();
|
|
assert.notEqual(conflict.exit, 0, conflict.output);
|
|
assert.match(conflict.output, /conflict|Failed to restore unstaged/i);
|
|
assert.equal(snapshot(), before);
|
|
passed('unstaged reconciliation conflict restores original state');
|
|
reset();
|
|
|
|
// Benchmark the rejected combined candidate using the same hook and state protections.
|
|
const selectedConfig = read('agiledancers-fe/lint-staged.config.mjs');
|
|
write(
|
|
'agiledancers-fe/lint-staged.combined.mjs',
|
|
`import base from './lint-staged.config.mjs';
|
|
export default Object.fromEntries(Object.entries(base).map(([pattern, task]) => [pattern, files => {
|
|
const commands = task(files);
|
|
const lintable = files.filter(file => /\\.(ts|js|mjs|cjs|html)$/.test(file));
|
|
return commands.length && lintable.length
|
|
? [...commands, 'eslint --max-warnings 0 ' + lintable.map(file => JSON.stringify(file)).join(' ')] : commands;
|
|
}]));\n`,
|
|
);
|
|
const selectedHook = read('.husky/pre-commit');
|
|
write(
|
|
'.husky/pre-commit',
|
|
selectedHook.replace(
|
|
'--concurrent false',
|
|
'--concurrent false --config lint-staged.combined.mjs',
|
|
),
|
|
);
|
|
write(fixture, baseline.replace('{ value: 1 }', '{value:2}'));
|
|
write(
|
|
'agiledancers-fe/src/app/app.ts',
|
|
read('agiledancers-fe/src/app/app.ts') + '\nexport const badLint: any = 1;\n',
|
|
);
|
|
git('add', fixture, 'agiledancers-fe/src/app/app.ts');
|
|
before = snapshot();
|
|
const lintFailure = hook();
|
|
assert.notEqual(lintFailure.exit, 0);
|
|
assert.match(lintFailure.output, /no-explicit-any/);
|
|
assert.equal(snapshot(), before);
|
|
passed('combined candidate restores formatting after ESLint failure');
|
|
reset();
|
|
|
|
const all = git('ls-files', 'agiledancers-fe')
|
|
.split('\n')
|
|
.filter((file) => !file.endsWith('/package-lock.json'));
|
|
const scopes = {
|
|
single: ['agiledancers-fe/src/app/app.ts'],
|
|
mixed: [
|
|
'agiledancers-fe/src/app/app.ts',
|
|
'agiledancers-fe/src/app/app.html',
|
|
'agiledancers-fe/src/app/app.css',
|
|
'agiledancers-fe/angular.json',
|
|
'agiledancers-fe/eslint.config.mjs',
|
|
'agiledancers-fe/e2e/school-management.spec.ts',
|
|
],
|
|
all,
|
|
};
|
|
const timings = [];
|
|
for (const mode of ['combined', 'prettier-only']) {
|
|
for (const [scope, files] of Object.entries(scopes)) {
|
|
for (let repeat = 1; repeat <= 3; repeat++) {
|
|
reset();
|
|
write(
|
|
'.husky/pre-commit',
|
|
mode === 'combined'
|
|
? selectedHook.replace(
|
|
'--concurrent false',
|
|
'--concurrent false --config lint-staged.combined.mjs',
|
|
)
|
|
: selectedHook,
|
|
);
|
|
for (const file of files) {
|
|
const addition = /\.(ts|js|mjs|cjs)$/.test(file)
|
|
? '\n// Hook timing fixture.\n'
|
|
: file.endsWith('.html')
|
|
? '\n<!-- Hook timing fixture. -->\n'
|
|
: file.endsWith('.css')
|
|
? '\n/* Hook timing fixture. */\n'
|
|
: '\n';
|
|
write(file, read(file) + addition);
|
|
}
|
|
git('add', '--', ...files);
|
|
const measurement = hook();
|
|
assert.equal(measurement.exit, 0, measurement.output);
|
|
timings.push({ mode, scope, repeat, files, seconds: measurement.seconds });
|
|
console.log(`TIMING ${mode} ${scope} ${repeat}: ${measurement.seconds}s`);
|
|
}
|
|
}
|
|
}
|
|
assert.equal(read('agiledancers-fe/lint-staged.config.mjs').includes(selectedConfig.trim()), true);
|
|
const report = {
|
|
machine: cpus()[0].model,
|
|
system: `${platform()} ${release()}`,
|
|
node: process.version,
|
|
npm: run('npm', ['--version']),
|
|
root,
|
|
timings,
|
|
};
|
|
writeFileSync(path.join(root, 'report.json'), JSON.stringify(report, null, 2) + '\n');
|
|
console.log(`Evidence: ${path.join(root, 'report.json')}`);
|
|
const selected = timings.filter((result) => result.mode === 'prettier-only');
|
|
assert.ok(
|
|
selected.every((result) => result.seconds <= 3),
|
|
'Prettier-only exceeds the three-second budget; do not enable the hook.',
|
|
);
|