Files
houseplan-card/test/rebase-on-dev.test.mjs
Claude f0c8ae24ba infra: portable Windows gate, CI toolchain pins, task packet, deterministic wait, gate:small --smokes
Windows portability (the three red tests on the owner's machine at green CI):
- scripts/spawn-portable.mjs: isMainModule via pathToFileURL (the
  `file://${argv[1]}` form gives file:///C:/C:/... and the CLI stays silent);
  portableCommand — a shell only for npm/npx/.cmd, node and git run directly
  (spawn via shell dropped the quotes of `node -e "…"`). Applied to
  classify-changes, mutation-gate-report, review-doc-guard, check-inputs,
  merge-candidate, gate-small, rebase-on-dev.
- the rebase-on-dev test pins core.autocrlf=false / core.eol=lf through
  GIT_CONFIG_* for its temp repository instead of touching the user's git
  config; test/windows-portability.test.mjs forbids both anti-patterns.

Pins: scripts/toolchain-pins.mjs reads Node/Python from validate.yml, the HA
stack from tests_backend/requirements.txt, Playwright/Chromium from the
lockfile — no second dictionary; `npm run toolchain:check` compares the
machine; .nvmrc/.python-version are derived and tested equal;
scripts/wsl-setup.sh provisions WSL/Linux with those pins.

scripts/task-packet.mjs: one derived view of an issue (status, rights, owner
decisions, branch vs dev, Validate on the tip, previous verdict with recorded
tree, AC → evidence, unwitnessed). scripts/wait-verdict.mjs: polls labels,
pipeline comments and optionally Validate, prints only on state change, exit
0/3/4, writes nothing.

gate:small --smokes: after build the browser phase runs bundle-sync and then
the directly matched and registered smokes, two at a time; broad matches stay
with the reviewer. package.json changed, so the bundle is rebuilt here.

Issue: #496
User-Visible: no
2026-09-09 01:11:24 +03:00

114 lines
6.7 KiB
JavaScript
Raw Permalink 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.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { rebaseOnDev, splitConflicts } from '../scripts/rebase-on-dev.mjs';
// #479 AC5: конфликт только в бандле решается пересборкой, конфликт в src/**
// останавливает ребейз, не тронув дерево. Сценарий — настоящий git в temp.
// Личность коммитера нужна и скрипту (rebase, amend), не только тесту. Заодно
// временный репозиторий изолируется от пользовательского git config (#496): на
// Windows с глобальным `core.autocrlf=true` checkout давал `dev\r\n` вместо
// `dev\n`, и тест краснел на переводе строки, а не на ребейзе. Менять глобальный
// конфиг владельца ради теста нельзя — конфиг передаётся окружением.
Object.assign(process.env, {
GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t',
GIT_CONFIG_COUNT: '2',
GIT_CONFIG_KEY_0: 'core.autocrlf', GIT_CONFIG_VALUE_0: 'false',
GIT_CONFIG_KEY_1: 'core.eol', GIT_CONFIG_VALUE_1: 'lf',
});
const git = (cwd, ...args) => execFileSync('git', args, {
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
// Фальшивая сборка: dist/a.js = 'built:' + содержимое src/x.ts.
const SYNC = [process.execPath, '-e',
"const fs=require('fs');fs.writeFileSync('dist/a.js','built:'+fs.readFileSync('src/x.ts','utf8'))"];
function repo({ conflictInSrc }) {
const root = mkdtempSync(join(tmpdir(), 'hp-rebase-'));
const origin = join(root, 'origin.git'); const work = join(root, 'work');
git(root, 'init', '--bare', '-q', '-b', 'dev', origin);
git(root, 'clone', '-q', origin, work);
git(work, 'checkout', '-q', '-b', 'dev');
mkdirSync(join(work, 'dist')); mkdirSync(join(work, 'src'));
writeFileSync(join(work, 'src/x.ts'), 'base\n');
writeFileSync(join(work, 'src/y.ts'), 'y0\n');
writeFileSync(join(work, 'dist/a.js'), 'built:base\n');
git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'base'); git(work, 'push', '-q', '-u', 'origin', 'dev');
// Ветка задачи: правит src/x.ts (или src/y.ts) и бандл.
git(work, 'checkout', '-q', '-b', 'issue/1-x');
writeFileSync(join(work, conflictInSrc ? 'src/y.ts' : 'src/x.ts'), 'branch\n');
writeFileSync(join(work, 'dist/a.js'), 'built:branch\n');
git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'feat: branch');
// dev уходит вперёд: другой файл (или тот же y.ts) и тот же бандл.
git(work, 'checkout', '-q', 'dev');
writeFileSync(join(work, conflictInSrc ? 'src/y.ts' : 'src/z.ts'), 'dev\n');
writeFileSync(join(work, 'dist/a.js'), 'built:dev\n');
git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'dev moves'); git(work, 'push', '-q', 'origin', 'dev');
git(work, 'checkout', '-q', 'issue/1-x');
return { root, work };
}
test('splitConflicts делит пути на сгенерированные и ручные (#479)', () => {
const { generated, manual } = splitConflicts([
'dist/houseplan-card.js', 'custom_components/houseplan/frontend/houseplan-assets.json',
'src/houseplan-card.ts', 'custom_components/houseplan/const.py', '',
]);
assert.deepEqual(generated, ['dist/houseplan-card.js', 'custom_components/houseplan/frontend/houseplan-assets.json']);
assert.deepEqual(manual, ['src/houseplan-card.ts', 'custom_components/houseplan/const.py']);
});
test('конфликт только в бандле: ребейз доведён, бандл пересобран и зааменден (#479 AC5)', () => {
const { root, work } = repo({ conflictInSrc: false });
try {
const result = rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} });
assert.equal(result.rebased, true);
assert.equal(result.resolved.length, 1);
assert.equal(result.rebuilt, true);
assert.equal(git(work, 'status', '--porcelain'), '');
assert.equal(git(work, 'rev-list', '--count', 'origin/dev..HEAD'), '1', 'один коммит ветки поверх dev');
assert.equal(git(work, 'rev-list', '--count', 'HEAD..origin/dev'), '0', 'dev полностью под веткой');
assert.equal(readFileSync(join(work, 'dist/a.js'), 'utf8'), 'built:branch\n', 'бандл собран из src ветки, а не dev');
assert.equal(readFileSync(join(work, 'src/z.ts'), 'utf8'), 'dev\n', 'правка dev на месте');
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('конфликт в src/**: ребейз отменён, дерево и HEAD как были (#479 AC5)', () => {
const { root, work } = repo({ conflictInSrc: true });
try {
const before = git(work, 'rev-parse', 'HEAD');
assert.throws(() => rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} }), /src\/y\.ts/);
assert.equal(git(work, 'rev-parse', 'HEAD'), before);
assert.equal(git(work, 'status', '--porcelain'), '');
assert.equal(readFileSync(join(work, 'src/y.ts'), 'utf8'), 'branch\n');
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('--dry-run предсказывает конфликт по бандлу и не трогает дерево (#479)', () => {
const { root, work } = repo({ conflictInSrc: false });
try {
const before = git(work, 'rev-parse', 'HEAD');
const lines = [];
const result = rebaseOnDev({ cwd: work, dryRun: true, syncCommand: SYNC, log: (l) => lines.push(l) });
assert.equal(result.rebased, false);
assert.deepEqual(result.predicted.generated, ['dist/a.js']);
assert.equal(git(work, 'rev-parse', 'HEAD'), before);
assert.ok(lines.some((l) => l.includes('dry-run')));
} finally { rmSync(root, { recursive: true, force: true }); }
});
test('грязное дерево и ветка dev отвергаются до любого действия (#479)', () => {
const { root, work } = repo({ conflictInSrc: false });
try {
writeFileSync(join(work, 'src/x.ts'), 'dirty\n');
assert.throws(() => rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} }), /не чистое/);
git(work, 'checkout', '-q', '--', 'src/x.ts');
git(work, 'checkout', '-q', 'dev');
assert.throws(() => rebaseOnDev({ cwd: work, syncCommand: SYNC, log: () => {} }), /ветка dev/);
} finally { rmSync(root, { recursive: true, force: true }); }
});