Files
houseplan-card/test/workflow-jobs.test.mjs
T
Claudeandclaude[bot] 1514899746 ci: имена job validate.yml — контракт, сверяемый с файлом; шарды из матрицы (#622)
- scripts/workflow-jobs.mjs: разбор job-уровня validate.yml (id, name,
  inline matrix) без зависимостей; незнакомые формы — громкая ошибка.
- ci-proof: JOB_RULES по id job с именем-контрактом; число и точные имена
  экземпляров — из матрицы YAML (константы count: 6 нет); resolveJobRules
  на каждом evaluateCiProof — переименование даёт failed с названной job;
  jobContractProblems — сверка в обе стороны, UNCONSUMED_JOBS для proof.
- validate-gate: MUTANT_JOB_PREFIX — из контракта ci-proof.
- e2e-gate: E2E_JOB_NAME — зеркало name: из houseplan-e2e e2e.yml
  (43899da5), распознавание выводится из шаблона; missing называет
  завершённые прогоны без job по контракту.
- тесты: фикстуры имён выводятся из validate.yml; новый
  test/workflow-jobs.test.mjs; 5 мутантов в реестре.

Issue: #622
User-Visible: no
2026-09-24 01:35:25 +00:00

107 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #622: контракт имён job validate.yml с потребителями (ci-proof, validate-gate).
// Имена job — единственное, по чему ревью, слияние и релиз узнают исполненную
// проверку; расхождение с файлом давало «claimed execution is absent» без
// единого красного теста. Здесь файл читается как есть и сверяется в обе
// стороны.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import {
jobInstanceNames, parseWorkflowJobs, staticNamePrefix, validateJobs, VALIDATE_WORKFLOW_PATH,
} from '../scripts/workflow-jobs.mjs';
import { JOB_RULES, MUTANT_JOB_PREFIX, UNCONSUMED_JOBS, jobContractProblems, resolveJobRules } from '../scripts/ci-proof.mjs';
import { MUTANT_JOB_PREFIX as GATE_MUTANT_PREFIX } from '../scripts/validate-gate.mjs';
const TEXT = readFileSync(VALIDATE_WORKFLOW_PATH, 'utf8').replace(/\r\n/g, '\n');
const JOBS = validateJobs();
test('#622: parser reads job ids, names and inline matrices of the structure validate.yml uses', () => {
const jobs = parseWorkflowJobs([
'name: top', 'on: push', 'jobs:',
' # comment', ' plain:', ' name: Просто имя # хвост', ' runs-on: ubuntu-latest',
' steps:', ' - name: не имя job', ' run: |', ' name: и не это',
' quoted:', ' needs: plain', ' name: "Шард ${{ matrix.shard }}/${{ matrix.os }} #1"',
' strategy:', ' fail-fast: false', ' matrix:', ' shard: [1, 2, 3]', " os: ['a', b]",
' runs-on: x',
'env:', ' name: not-a-job',
].join('\n'));
assert.deepEqual([...jobs.keys()], ['plain', 'quoted']);
assert.equal(jobs.get('plain').name, 'Просто имя');
assert.equal(jobs.get('plain').size, 1);
assert.equal(jobs.get('quoted').name, 'Шард ${{ matrix.shard }}/${{ matrix.os }} #1');
assert.equal(jobs.get('quoted').size, 6);
assert.equal(staticNamePrefix(jobs.get('quoted').name), 'Шард ');
assert.deepEqual(jobInstanceNames(jobs.get('quoted')).slice(0, 3), ["Шард 1/'a' #1", 'Шард 1/b #1', "Шард 2/'a' #1"]);
});
test('#622: forms the parser does not understand fail loudly instead of guessing', () => {
const job = (...lines) => ['jobs:', ' j:', ' name: J', ...lines].join('\n');
assert.throws(() => parseWorkflowJobs(job(' strategy:', ' matrix:', ' include:', ' - a: 1')), /include is not supported/);
assert.throws(() => parseWorkflowJobs(job(' strategy:', ' matrix: ${{ fromJSON(x) }}')), /matrix expressions are not supported/);
assert.throws(() => parseWorkflowJobs(job(' strategy:', ' matrix:', ' shard:', ' - 1')), /only inline lists/);
assert.throws(() => parseWorkflowJobs('jobs:\n j:\n runs-on: x\n'), /job j has no name/);
assert.throws(() => parseWorkflowJobs('jobs:\n j:\n name: A\n j:\n name: B\n'), /duplicate job id j/);
assert.throws(() => parseWorkflowJobs('on: push\n'), /no top-level "jobs:"/);
assert.throws(() => jobInstanceNames(parseWorkflowJobs('jobs:\n j:\n name: "A ${{ inputs.x }}"\n').get('j')), /not a declared matrix axis/);
});
test('#622 AC1: every job of validate.yml is declared in ci-proof with its current name, and every declaration exists', () => {
assert.deepEqual(jobContractProblems(JOBS), []);
assert.doesNotThrow(() => resolveJobRules(JOBS));
const declared = [...Object.values(JOB_RULES).flat().map((rule) => rule.job), ...Object.keys(UNCONSUMED_JOBS)];
assert.deepEqual([...declared].sort(), [...JOBS.keys()].sort());
});
test('#622 AC1: renaming ANY job name in validate.yml without touching the rules turns the contract red', () => {
const nameLines = [...TEXT.matchAll(/^ {4}name: .+$/gm)].map((m) => m[0]);
assert.equal(nameLines.length, JOBS.size, 'one job-level name line per job');
for (const line of nameLines) {
const renamed = TEXT.replace(line, line.replace(/name: ("?)/, 'name: $1Переименовано '));
const problems = jobContractProblems(parseWorkflowJobs(renamed, 'validate.yml (renamed)'));
assert.equal(problems.length, 1, `${line.trim()} → ${JSON.stringify(problems)}`);
assert.match(problems[0], /validate\.yml names it/);
}
const extra = TEXT.replace('\n proof:\n', '\n extra_job:\n name: Новая job\n runs-on: ubuntu-latest\n proof:\n');
assert.match(jobContractProblems(parseWorkflowJobs(extra)).join('\n'), /extra_job: validate\.yml job is in neither/);
});
test('#622: consumed rules resolve to the exact instance names GitHub reports, within the 100-byte API limit', () => {
const rules = resolveJobRules(JOBS);
for (const [id, list] of Object.entries(rules)) {
for (const rule of list) {
assert.equal(rule.names.length, JOBS.get(rule.job).size, `${id}/${rule.job}: one name per matrix instance`);
for (const name of rule.names) {
assert.ok(Buffer.byteLength(name, 'utf8') <= 100, `${name}: GitHub Jobs API truncates names longer than 100 UTF-8 bytes`);
assert.doesNotMatch(name, /\$\{\{/, `${name}: unexpanded expression`);
}
}
}
const all = [...JOBS.values()].flatMap(jobInstanceNames);
assert.equal(new Set(all).size, all.length, 'job instance names are unique across the workflow');
});
test('#622: the mutant prefix of validate-gate is the ci-proof contract and matches only changed_mutants', () => {
assert.equal(GATE_MUTANT_PREFIX, MUTANT_JOB_PREFIX);
for (const job of JOBS.values()) {
const hits = jobInstanceNames(job).filter((name) => name.startsWith(MUTANT_JOB_PREFIX));
assert.equal(hits.length, job.id === 'changed_mutants' ? job.size : 0, job.id);
}
});
test('#622 AC2: shard totals written in names and scripts of a sharded job equal its matrix size', () => {
for (const job of JOBS.values()) {
if (!job.matrix?.shard) continue;
const start = TEXT.indexOf(`\n ${job.id}:\n`);
const next = TEXT.slice(start + 1).search(/\n {2}[A-Za-z0-9_-]+:\n/);
const body = TEXT.slice(start, next < 0 ? undefined : start + 1 + next);
const totals = [
...body.matchAll(/matrix\.shard \}\}(?:\/| из )(\d+)/g),
...body.matchAll(/\$SHARD\/(\d+)/g),
...body.matchAll(/SHARDS: '?(\d+)'?/g),
].map((m) => Number(m[1]));
assert.ok(totals.length > 0, `${job.id}: shard total is written somewhere`);
for (const total of totals) assert.equal(total, job.size, `${job.id}: literal shard total ${total} vs matrix of ${job.size}`);
}
});