mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
ci: журнал пойманных свидетелей для changed_mutants
witnessFingerprint (файлы патча и гарда + объявление, без строки версии), readLedger/recordCaught/splitByLedger, флаг --ledger только с --changed; журнал пишется после каждого пойманного мутанта, в CI — cache restore по префиксу шарда и save при любом исходе. Три свидетеля. Issue: #481 User-Visible: no
This commit is contained in:
@@ -494,6 +494,17 @@ jobs:
|
||||
- name: Установить Chromium
|
||||
if: steps.pw.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
# Журнал пойманных свидетелей (#481). Отменённый или упавший по таймауту
|
||||
# прогон не пропадает: журнал пишется после каждого пойманного мутанта и
|
||||
# сохраняется при любом исходе шага, а следующий пуш начинает с того
|
||||
# места, где предыдущий остановился. Мутант с тем же отпечатком входов
|
||||
# (файлы патча и гарда, без строки версии) повторно не гоняется.
|
||||
- name: Журнал свидетелей (последний по шарду)
|
||||
uses: actions/cache/restore@v6
|
||||
with:
|
||||
path: artifacts/mutation-ledger
|
||||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
|
||||
restore-keys: mutation-ledger-${{ matrix.shard }}-
|
||||
- name: Затронутые мутанты ловятся
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
@@ -520,7 +531,18 @@ jobs:
|
||||
fi
|
||||
echo "диапазон: $base..$HEAD_SHA"
|
||||
npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs
|
||||
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3"
|
||||
node scripts/mutation-gate.mjs --changed="$base..$HEAD_SHA" --shard="$SHARD/3" \
|
||||
--ledger="artifacts/mutation-ledger/shard-$SHARD.json"
|
||||
# Сохраняется всегда: и после красного шага (пойманные до отказа уже
|
||||
# записаны), и при отмене прогона — post-шаг cache/save исполняется,
|
||||
# пока job не убита целиком. Ключ уникален на прогон: cache не
|
||||
# перезаписывает существующий ключ, а restore-keys берёт самый новый.
|
||||
- name: Сохранить журнал свидетелей
|
||||
if: always()
|
||||
uses: actions/cache/save@v6
|
||||
with:
|
||||
path: artifacts/mutation-ledger
|
||||
key: mutation-ledger-${{ matrix.shard }}-${{ github.run_id }}
|
||||
|
||||
frontend:
|
||||
name: "Фронтенд: типы, юниты, мутанты, синхрон бандла"
|
||||
|
||||
+151
-5
@@ -34,8 +34,11 @@ import {
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { withoutProductVersion } from './source-fingerprint.mjs';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
|
||||
@@ -2873,6 +2876,46 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " if (asEscaped) { escaped.add(asEscaped[1]); continue; }\n if (/^FAIL (\\S+)/.test(line)) { escaped.add(line.split(' ')[1].replace(/:$/, '')); continue; }",
|
||||
}],
|
||||
},
|
||||
// #481: журнал пойманных свидетелей — каждый защитный контракт под свидетелем.
|
||||
{
|
||||
id: 'ledger-records-escaped',
|
||||
guard: 'node --test --test-name-pattern="#481 AC3|#481 AC2" test/mutation-gate.test.mjs',
|
||||
because: 'the ledger may only remember a CAUGHT witness; recording an escaped one would '
|
||||
+ 'skip it on every later push and hide the exact rot the gate exists for (#481)',
|
||||
patches: [{
|
||||
file: 'scripts/mutation-gate.mjs',
|
||||
find: ' if (!runMutant(entry.mutant)) ' + 'continue;\n caught++;',
|
||||
replace: ' if (!runMutant(entry.mutant)) { if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint); continue; }\n caught++;',
|
||||
}, {
|
||||
// Unit-наблюдаемая половина того же контракта: пустой отпечаток в
|
||||
// записи считался бы «совпавшим» с любым — журнал перестаёт сравнивать.
|
||||
file: 'scripts/mutation-gate.mjs',
|
||||
find: " if (ledger.caught[mutant.id] === fingerprint) " + "skipped.push(mutant);",
|
||||
replace: " if (ledger.caught[mutant.id] === fingerprint || mutant.id in ledger.caught) skipped.push(mutant);",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ledger-version-sensitive',
|
||||
guard: 'node --test --test-name-pattern="#481 AC1" test/mutation-gate.test.mjs',
|
||||
because: 'a release bump touches houseplan-card.ts and houseplan-editor-runtime.ts; without '
|
||||
+ 'version normalisation every witness patching them re-runs on every candidate — the #480 timeout (#481)',
|
||||
patches: [{
|
||||
file: 'scripts/mutation-gate.mjs',
|
||||
find: " hash.update(normalize(String(read(file))" + ".replace(/\\r\\n?/g, '\\n')));",
|
||||
replace: " hash.update(String(read(file)).replace(/\\r\\n?/g, '\\n'));",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ledger-written-at-end-only',
|
||||
guard: 'node --test --test-name-pattern="#481 AC3" test/mutation-gate.test.mjs',
|
||||
because: 'the ledger must be written after EACH caught witness: a shard cancelled by the next push '
|
||||
+ 'or killed by the timeout must keep what it proved, or the snowball returns (#481)',
|
||||
patches: [{
|
||||
file: 'scripts/mutation-gate.mjs',
|
||||
find: " ledger.caught[mutant.id] = fingerprint;\n mkdirSync(dirname(file), " + "{ recursive: true });\n writeFileSync(file,",
|
||||
replace: " ledger.caught[mutant.id] = fingerprint;\n mkdirSync(dirname(file), { recursive: true });\n if (Object.keys(ledger.caught).length > 1) writeFileSync(file,",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'changed-selection-ignores-guard-files',
|
||||
guard: 'node --test --test-name-pattern="#475 AC2" test/mutation-gate.test.mjs',
|
||||
@@ -7170,6 +7213,82 @@ export function guardFiles(guard, exists = (file) => existsSync(join(repoRoot, f
|
||||
return [...files];
|
||||
}
|
||||
|
||||
/**
|
||||
* Отпечаток свидетеля (#481): содержимое файлов патча и гарда плюс само
|
||||
* объявление мутанта. Строка версии продукта нормализуется, как в
|
||||
* `visualFingerprint` (#245): релизный бамп трогает `houseplan-card.ts` и
|
||||
* `houseplan-editor-runtime.ts`, по которым отбираются десятки мутантов, но
|
||||
* ни одного свидетеля не меняет. Приближение то же, что у отбора по диффу:
|
||||
* непрямые зависимости гарда не учитываются — полный прогон остаётся
|
||||
* контрактом, журнал его не заменяет.
|
||||
*/
|
||||
export function witnessFingerprint(mutant, {
|
||||
root = repoRoot,
|
||||
read = (file) => (existsSync(join(root, file)) ? readFileSync(join(root, file), 'utf8') : ''),
|
||||
exists = (file) => existsSync(join(root, file)),
|
||||
normalize = withoutProductVersion(root),
|
||||
} = {}) {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(JSON.stringify({ id: mutant.id, guard: mutant.guard, patches: mutant.patches }));
|
||||
hash.update('\0');
|
||||
const files = new Set([
|
||||
...mutant.patches.map((patch) => patch.file),
|
||||
...guardFiles(mutant.guard, exists),
|
||||
]);
|
||||
for (const file of [...files].sort()) {
|
||||
hash.update(file);
|
||||
hash.update('\0');
|
||||
hash.update(normalize(String(read(file)).replace(/\r\n?/g, '\n')));
|
||||
hash.update('\0');
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export const LEDGER_SCHEMA = 1;
|
||||
|
||||
/** Журнал пойманных свидетелей: `{ schema, caught: { [id]: fingerprint } }`. */
|
||||
export function readLedger(file) {
|
||||
if (!file || !existsSync(file)) return { schema: LEDGER_SCHEMA, caught: {} };
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
||||
if (parsed?.schema !== LEDGER_SCHEMA || typeof parsed.caught !== 'object' || !parsed.caught) {
|
||||
return { schema: LEDGER_SCHEMA, caught: {} };
|
||||
}
|
||||
return { schema: LEDGER_SCHEMA, caught: { ...parsed.caught } };
|
||||
} catch {
|
||||
// Битый журнал — не отказ гейта: он лишь сужает работу, и пустой журнал
|
||||
// означает «гонять всё отобранное», то есть прежнее поведение.
|
||||
return { schema: LEDGER_SCHEMA, caught: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Записать пойманного свидетеля — сразу, а не в конце прогона: отменённый
|
||||
* или упавший по таймауту шард обязан сохранить уже сделанное, иначе
|
||||
* следующий пуш начинает с нуля (снежный ком #481).
|
||||
*/
|
||||
export function recordCaught(file, ledger, mutant, fingerprint) {
|
||||
ledger.caught[mutant.id] = fingerprint;
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, `${JSON.stringify({ schema: LEDGER_SCHEMA, caught: ledger.caught }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Мутанты, чей отпечаток совпадает с журналом, уже доказали, что ловятся на
|
||||
* этих же входах — их пропуск ничего не ослабляет. Сравнивается ОТПЕЧАТОК,
|
||||
* не факт присутствия id: запись от другого содержимого файлов не считается.
|
||||
*/
|
||||
export function splitByLedger(mutants, ledger, fingerprintOf = (m) => witnessFingerprint(m)) {
|
||||
const run = [];
|
||||
const skipped = [];
|
||||
for (const mutant of mutants) {
|
||||
const fingerprint = fingerprintOf(mutant);
|
||||
if (ledger.caught[mutant.id] === fingerprint) skipped.push(mutant);
|
||||
else run.push({ mutant, fingerprint });
|
||||
}
|
||||
return { run, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Мутанты, затронутые диффом (#332, расширено в #475).
|
||||
*
|
||||
@@ -7205,6 +7324,13 @@ function main(argv) {
|
||||
}
|
||||
|
||||
const changedArg = argv.find((a) => a === '--changed' || a.startsWith('--changed='));
|
||||
const ledgerArg = argv.find((a) => a.startsWith('--ledger='))?.slice(9);
|
||||
if (ledgerArg && !changedArg) {
|
||||
// Полный прогон журнал не читает — он его пишет по расписанию целиком;
|
||||
// читать журнал в полном прогоне значило бы никогда не перепроверять.
|
||||
console.error('--ledger работает только вместе с --changed: полный прогон журнал не читает');
|
||||
return 2;
|
||||
}
|
||||
if (changedArg) {
|
||||
const range = changedArg.includes('=') ? changedArg.split('=')[1] : 'origin/dev..HEAD';
|
||||
const diff = spawnSync('git', ['-C', repoRoot, 'diff', '--name-only', range],
|
||||
@@ -7277,11 +7403,31 @@ function main(argv) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!runCleanGuards(selected)) return 2;
|
||||
// Журнал (#481): отобранные по диффу мутанты, чьи входы не менялись с
|
||||
// последнего пойманного прогона, не гоняются повторно.
|
||||
let plan = selected.map((mutant) => ({ mutant, fingerprint: null }));
|
||||
let ledger = null;
|
||||
if (ledgerArg) {
|
||||
ledger = readLedger(ledgerArg);
|
||||
const split = splitByLedger(selected, ledger);
|
||||
console.log(`журнал ${ledgerArg}: по журналу пропущено ${split.skipped.length} `
|
||||
+ `(отпечатки совпали), к прогону ${split.run.length}`);
|
||||
plan = split.run;
|
||||
if (!plan.length) {
|
||||
console.log('все отобранные свидетели уже пойманы на этих же входах — гонять нечего');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
const toRun = plan.map((entry) => entry.mutant);
|
||||
if (!runCleanGuards(toRun)) return 2;
|
||||
let caught = 0;
|
||||
for (const m of selected) if (runMutant(m)) caught++;
|
||||
console.log(`\nпоймано ${caught} из ${selected.length}`);
|
||||
return caught === selected.length ? 0 : 1;
|
||||
for (const entry of plan) {
|
||||
if (!runMutant(entry.mutant)) continue;
|
||||
caught++;
|
||||
if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint);
|
||||
}
|
||||
console.log(`\nпоймано ${caught} из ${toRun.length}`);
|
||||
return caught === toRun.length ? 0 : 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
|
||||
@@ -125,7 +125,7 @@ const visualPackageProjection = (text) => {
|
||||
};
|
||||
|
||||
/** Номер версии продукта, как его знает package.json. */
|
||||
const productVersion = (root) => {
|
||||
export const productVersion = (root) => {
|
||||
const path = resolve(root, 'package.json');
|
||||
if (!existsSync(path)) return '';
|
||||
try {
|
||||
@@ -153,11 +153,20 @@ const productVersion = (root) => {
|
||||
* версия зависимости в `package-lock.json` остаётся частью отпечатка, иначе
|
||||
* обновление зависимости перестало бы требовать пересъёмки.
|
||||
*/
|
||||
export const visualFingerprint = (root = process.cwd()) => {
|
||||
/**
|
||||
* Та же нормализация версии для чужих корпусов (#481): журнал пойманных
|
||||
* свидетелей считает отпечаток по файлам патча и гарда, и бамп версии
|
||||
* не должен делать ни один свидетель «изменённым».
|
||||
*/
|
||||
export const withoutProductVersion = (root = process.cwd()) => {
|
||||
const version = productVersion(root);
|
||||
const withoutVersion = version
|
||||
return version
|
||||
? (text) => text.split(version).join('0.0.0-product-version')
|
||||
: (text) => text;
|
||||
};
|
||||
|
||||
export const visualFingerprint = (root = process.cwd()) => {
|
||||
const withoutVersion = withoutProductVersion(root);
|
||||
return digest(root, fingerprintFiles(root), (text, name) => (
|
||||
name === 'package.json'
|
||||
? visualPackageProjection(withoutVersion(text))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -7,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
MUTANTS, applyPatches, guardNeedsBundle, guardNeedsTestBuild, selectChangedMutants, shardMutants, guardFiles,
|
||||
witnessFingerprint, readLedger, recordCaught, splitByLedger, LEDGER_SCHEMA,
|
||||
} from '../scripts/mutation-gate.mjs';
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -330,3 +332,77 @@ test('#475 AC7: воспроизведение находки ревью — б
|
||||
assert.ok(byPatch.includes(id), `${id} не отобран по патчу`);
|
||||
}
|
||||
});
|
||||
|
||||
// #481. Журнал пойманных свидетелей: отобранный по диффу мутант, чьи входы
|
||||
// не менялись с последнего пойманного прогона, не гоняется повторно. Так
|
||||
// отменённый прогон не пропадает даром, а релизный бамп версии не
|
||||
// превращает 131 мутанта в 20 минут на шард.
|
||||
|
||||
const LEDGER_MUTANT = { id: 'x', guard: 'node --test test/x.test.mjs', patches: [{ file: 'src/x.ts', find: 'a', replace: 'b' }] };
|
||||
const fakeFs = (files) => ({
|
||||
root: '/repo',
|
||||
read: (file) => files[file] ?? '',
|
||||
exists: (file) => file in files,
|
||||
normalize: (text) => text.split('1.2.3').join('0.0.0-product-version'),
|
||||
});
|
||||
|
||||
test('#481 AC1: отпечаток свидетеля не меняется от бампа версии, но меняется от правки патч-файла, гарда и объявления', () => {
|
||||
const base = fakeFs({ 'src/x.ts': "const CARD_VERSION = '1.2.3';\nlet a = 1;", 'test/x.test.mjs': 'assert(a)' });
|
||||
const fp = witnessFingerprint(LEDGER_MUTANT, base);
|
||||
const bumped = fakeFs({ ...{ 'src/x.ts': "const CARD_VERSION = '1.2.3';\nlet a = 1;", 'test/x.test.mjs': 'assert(a)' } });
|
||||
bumped.read = (file) => (file === 'src/x.ts' ? "const CARD_VERSION = '1.2.4';\nlet a = 1;" : 'assert(a)');
|
||||
bumped.normalize = (text) => text.split('1.2.4').join('0.0.0-product-version');
|
||||
assert.equal(witnessFingerprint(LEDGER_MUTANT, bumped), fp, 'бамп версии — не изменение свидетеля');
|
||||
const crlf = { ...base, read: (file) => base.read(file).replace(/\n/g, '\r\n') };
|
||||
assert.equal(witnessFingerprint(LEDGER_MUTANT, crlf), fp, 'CRLF канонизируется');
|
||||
const patchChanged = { ...base, read: (file) => (file === 'src/x.ts' ? 'let a = 2;' : base.read(file)) };
|
||||
assert.notEqual(witnessFingerprint(LEDGER_MUTANT, patchChanged), fp, 'правка патч-файла меняет отпечаток');
|
||||
const guardChanged = { ...base, read: (file) => (file === 'test/x.test.mjs' ? 'assert(b)' : base.read(file)) };
|
||||
assert.notEqual(witnessFingerprint(LEDGER_MUTANT, guardChanged), fp, 'правка файла гарда меняет отпечаток');
|
||||
assert.notEqual(witnessFingerprint({ ...LEDGER_MUTANT, guard: 'node --test test/y.test.mjs' }, base), fp, 'объявление гарда');
|
||||
assert.notEqual(witnessFingerprint({ ...LEDGER_MUTANT, patches: [{ file: 'src/x.ts', find: 'a', replace: 'c' }] }, base), fp, 'объявление патча');
|
||||
});
|
||||
|
||||
test('#481 AC2: по журналу пропускается только совпавший отпечаток; чужой или отсутствующий — к прогону', () => {
|
||||
const a = { id: 'a', guard: 'g', patches: [] };
|
||||
const b = { id: 'b', guard: 'g', patches: [] };
|
||||
const c = { id: 'c', guard: 'g', patches: [] };
|
||||
const ledger = { schema: LEDGER_SCHEMA, caught: { a: 'fp-a', b: 'fp-old' } };
|
||||
const split = splitByLedger([a, b, c], ledger, (m) => `fp-${m.id}`);
|
||||
assert.deepEqual(split.skipped.map((m) => m.id), ['a']);
|
||||
assert.deepEqual(split.run.map((entry) => `${entry.mutant.id}:${entry.fingerprint}`), ['b:fp-b', 'c:fp-c']);
|
||||
});
|
||||
|
||||
test('#481 AC3: журнал пишется сразу при поимке и переживает битый/чужой файл', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'houseplan-ledger-'));
|
||||
try {
|
||||
const file = join(dir, 'nested', 'ledger.json');
|
||||
assert.deepEqual(readLedger(file), { schema: LEDGER_SCHEMA, caught: {} }, 'нет файла — пустой журнал');
|
||||
const ledger = readLedger(file);
|
||||
recordCaught(file, ledger, { id: 'a' }, 'fp-a');
|
||||
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')), { schema: LEDGER_SCHEMA, caught: { a: 'fp-a' } },
|
||||
'запись появляется в файле немедленно, не в конце прогона');
|
||||
recordCaught(file, ledger, { id: 'b' }, 'fp-b');
|
||||
assert.deepEqual(readLedger(file).caught, { a: 'fp-a', b: 'fp-b' });
|
||||
writeFileSync(file, '{ not json');
|
||||
assert.deepEqual(readLedger(file).caught, {}, 'битый журнал — пустой, не отказ');
|
||||
writeFileSync(file, JSON.stringify({ schema: 99, caught: { a: 'x' } }));
|
||||
assert.deepEqual(readLedger(file).caught, {}, 'чужая схема — пустой');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('#481 AC4: --ledger без --changed — отказ с кодом 2', () => {
|
||||
const script = join(repoRoot, 'scripts/mutation-gate.mjs');
|
||||
const run = spawnSync(process.execPath, [script, '--ledger=/tmp/none.json', '--id=budget-warning-never-fires'], { encoding: 'utf8' });
|
||||
assert.equal(run.status, 2);
|
||||
assert.match(run.stderr, /--ledger работает только вместе с --changed/);
|
||||
});
|
||||
|
||||
test('#481: отпечатки реестра детерминированы и различают мутантов', () => {
|
||||
const first = witnessFingerprint(MUTANTS[0]);
|
||||
assert.equal(witnessFingerprint(MUTANTS[0]), first);
|
||||
assert.notEqual(witnessFingerprint(MUTANTS[1]), first);
|
||||
assert.match(first, /^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
@@ -394,3 +394,18 @@ test('ручной/ночной полный прогон не делит concur
|
||||
const text = read('validate.yml');
|
||||
assert.match(text, /group: validate-\$\{\{ github\.event_name == 'workflow_dispatch' && 'dispatch-' \|\| '' \}\}/);
|
||||
});
|
||||
|
||||
test('журнал свидетелей changed_mutants: restore по шарду, ledger в команде, save при любом исходе (#481 AC5)', () => {
|
||||
const workflow = read('validate.yml');
|
||||
const start = workflow.indexOf('\n changed_mutants:\n');
|
||||
const job = workflow.slice(start, workflow.indexOf('\n frontend:\n', start));
|
||||
const restore = job.slice(job.indexOf('actions/cache/restore@v6'), job.indexOf('name: Затронутые мутанты ловятся'));
|
||||
assert.match(restore, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}/);
|
||||
assert.match(restore, /restore-keys: mutation-ledger-\$\{\{ matrix\.shard \}\}-/, 'без префикса журнал прошлого прогона не найдётся');
|
||||
assert.match(job, /--changed="\$base\.\.\$HEAD_SHA" --shard="\$SHARD\/3" \\\n\s+--ledger="artifacts\/mutation-ledger\/shard-\$SHARD\.json"/);
|
||||
const save = job.slice(job.indexOf('name: Сохранить журнал свидетелей'));
|
||||
assert.match(save, /if: always\(\)/, 'красный или отменённый шард обязан сохранить уже пойманное');
|
||||
assert.match(save, /actions\/cache\/save@v6/);
|
||||
assert.match(save, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}/);
|
||||
assert.ok(job.indexOf('name: Сохранить журнал свидетелей') > job.indexOf('--ledger='), 'save идёт после шага прогона');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user