#!/usr/bin/env node // A/B R1 : compare la sortie de BATTERY_CLOUD_LOCKS avant/après déduplication. // AVANT = git HEAD ; APRÈS = working tree. Exigence : sorties identiques // (hors nightEvcsPreview, volontairement étendu) sur tous les scénarios. import fs from 'fs'; import vm from 'vm'; import { execSync } from 'child_process'; const OLD = JSON.parse(execSync('git show HEAD:workflows/evcs-surplus-controller-v2.6.json', { maxBuffer: 64 * 1024 * 1024 }).toString()); const NEW = JSON.parse(fs.readFileSync('workflows/evcs-surplus-controller-v2.6.json', 'utf8')); const code = wf => name => wf.nodes.find(n => n.name === name).parameters.jsCode; const oldW = code(OLD)('WINDOWS_SOURCEV2'), oldB = code(OLD)('BATTERY_CLOUD_LOCKS'); const newW = code(NEW)('WINDOWS_SOURCEV2'), newB = code(NEW)('BATTERY_CLOUD_LOCKS'); // Réutilise buildPrev + scénarios du harnais dry-run existant const harness = fs.readFileSync('tools/dryrun-20260807-night-hc.mjs', 'utf8'); const buildPrev = eval('(' + harness.slice(harness.indexOf('function buildPrev'), harness.indexOf('function run')).replace('function buildPrev', 'function') + ')'); const scenarios = eval(harness.slice(harness.indexOf('const scenarios = ') + 'const scenarios = '.length, harness.indexOf('];') + 1)); function run(jsCode, prev) { const ctx = { $input: { first: () => ({ json: prev }) }, console, Date, Intl, Math, Number, Object, String, JSON }; vm.createContext(ctx); return vm.runInContext(`(function(){\n${jsCode}\n})()`, ctx)[0].json; } // Diff récursif (chemins des différences) function diff(a, b, path = '', out = []) { if (a === b) return out; if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') { if (!(Number.isNaN(a) && Number.isNaN(b))) out.push(`${path}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`); return out; } for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) diff(a[k], b[k], path ? `${path}.${k}` : k, out); return out; } let failures = 0; for (const sc of scenarios) { // Snapshot construit UNE fois puis cloné : élimine la non-déterminisme Date.now() const basePrev = buildPrev(sc); const clone = () => JSON.parse(JSON.stringify(basePrev)); const bOld = run(oldB, run(oldW, clone())); const bNew = run(newB, run(newW, clone())); // nightEvcsPreview est volontairement étendu (champs additionnels) : exclu du diff strict const strip = o => { const c = JSON.parse(JSON.stringify(o)); delete c.nightEvcsPreview; return c; }; const diffs = diff(strip(bOld), strip(bNew)); // nightEvcs (consommé par DECISION/HA_SERVICE) doit être STRICTEMENT identique const nightDiffs = diff(bOld.nightEvcs, bNew.nightEvcs, 'nightEvcs'); const all = [...diffs, ...nightDiffs.filter(d => !diffs.includes(d))]; if (all.length) { failures++; console.log(`❌ ${sc.label}`); for (const d of all.slice(0, 10)) console.log(` ${d}`); } else { console.log(`✅ identique | ${sc.label}`); } } // Test fallback : preview absent => pas de crash, mode 'blocked' const prevNoPreview = run(newW, buildPrev(scenarios[0])); delete prevNoPreview.nightEvcsPreview; try { const out = run(newB, prevNoPreview); const n = out.nightEvcs; const ok = n.nightSessionActive === false && n.nightPreferredSourceMode === 'blocked'; console.log(`${ok ? '✅' : '❌'} fallback preview absent : sessionActive=${n.nightSessionActive}, preferred=${n.nightPreferredSourceMode}`); if (!ok) failures++; } catch (e) { failures++; console.log(`❌ fallback preview absent : CRASH ${e.message}`); } console.log(failures ? `\n${failures} ÉCHEC(S) — NE PAS POUSSER` : '\nA/B PARFAIT — comportement inchangé, prêt à pousser'); process.exit(failures ? 1 : 0);