Files
EVCS/tools/n8n-push.mjs
Tallagrand Thierry d1efeac44c R3: suite de tests golden — 16 scénarios, bloquante avant push
- tests/golden/run.mjs : chaîne complète des 5 nodes (code réel) en sandbox vm,
  horloge simulée par scénario, snapshot HA réel comme fixture.
- 16 scénarios attendus figés après revue : nuit HC (P7), solaire, abondance P6,
  provocation P3, gardes SOC, force_grid/force_charge, stop, overload.
- n8n-push.mjs exécute la suite avant tout PUT du workflow EVCS :
  golden rouge = push refusé (--skip-golden en échappatoire consciente).
- Validé par mutation : P7 cassé volontairement -> 2 échecs détectés + push bloqué.
- tools/fetch-snapshot-fixture.mjs : recapture d'un snapshot production.
2026-08-07 19:16:03 +02:00

122 lines
4.6 KiB
JavaScript

#!/usr/bin/env node
// Usage: node tools/n8n-push.mjs <inputFile> [workflowId]
// PUT un workflow n8n depuis un fichier JSON local (méthode validée 04/08/2026 :
// body minimal {name, nodes, connections, settings, staticData}, settings épuré).
// Fait automatiquement un backup GET avant le PUT dans modified/.
// Clé API lue depuis ~/.config/opencode/opencode.json (mcp.n8n-mcp.environment).
import fs from 'fs';
import os from 'os';
import path from 'path';
const INPUT = process.argv[2];
if (!INPUT) {
console.error('Usage: node tools/n8n-push.mjs <inputFile> [workflowId]');
process.exit(1);
}
const local = JSON.parse(fs.readFileSync(INPUT, 'utf8'));
const WORKFLOW_ID = process.argv[3] || local.id || 'ATswvGfG4KJUls0K';
function loadApiConfig() {
if (process.env.N8N_API_KEY) {
return {
url: process.env.N8N_URL || 'https://n8n.airtria.pro',
key: process.env.N8N_API_KEY
};
}
const cfgPath = path.join(os.homedir(), '.config/opencode/opencode.json');
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
const env = cfg?.mcp?.['n8n-mcp']?.environment || {};
if (!env.N8N_API_KEY) {
console.error('Clé API n8n introuvable (env N8N_API_KEY ou opencode.json)');
process.exit(1);
}
return {
url: (env.N8N_API_URL || 'https://n8n.airtria.pro/api/v1').replace(/\/api\/v1\/?$/, ''),
key: env.N8N_API_KEY
};
}
const { url, key } = loadApiConfig();
const headers = { 'X-N8N-API-KEY': key, 'Content-Type': 'application/json' };
// 1. Backup automatique de l'état serveur AVANT push
const getRes = await fetch(`${url}/api/v1/workflows/${WORKFLOW_ID}`, { headers });
if (!getRes.ok) {
console.error(`GET backup échoué: ${getRes.status} ${getRes.statusText}`);
process.exit(1);
}
const remote = await getRes.json();
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = `modified/prepush-${WORKFLOW_ID}-${stamp}.json`;
fs.mkdirSync('modified', { recursive: true });
fs.writeFileSync(backupPath, JSON.stringify(remote, null, 2) + '\n');
console.log(`Backup serveur avant push: ${backupPath}`);
// 2. Body minimal (n8n API v1 rejette les propriétés additionnelles — erreur 1003)
const body = {
name: local.name,
nodes: local.nodes,
connections: local.connections,
settings: {
executionOrder: local.settings?.executionOrder || 'v1',
callerPolicy: local.settings?.callerPolicy || 'workflowsFromSameOwner'
},
staticData: local.staticData ?? null
};
// 3. Sanity check : node --check sur chaque jsCode
let syntaxErrors = 0;
for (const node of body.nodes || []) {
const js = node.parameters?.jsCode;
if (!js) continue;
const tmp = path.join(os.tmpdir(), `n8n-check-${node.id || node.name}.js`.replace(/[^\w.-]/g, '_'));
fs.writeFileSync(tmp, js);
const { spawnSync } = await import('child_process');
const r = spawnSync('node', ['--check', tmp]);
if (r.status !== 0) {
syntaxErrors++;
console.error(`Syntaxe KO dans node "${node.name}":\n${r.stderr.toString().slice(0, 500)}`);
}
fs.unlinkSync(tmp);
}
if (syntaxErrors) {
console.error(`ABANDON: ${syntaxErrors} node(s) avec erreur de syntaxe. Rien n'a été poussé.`);
process.exit(1);
}
console.log('Syntaxe OK sur tous les nodes jsCode.');
// 3bis. Suite golden (R3) : obligatoire pour le workflow EVCS, sauf --skip-golden
const isEvcsWorkflow = path.basename(INPUT) === 'evcs-surplus-controller-v2.6.json';
const skipGolden = process.argv.includes('--skip-golden');
if (isEvcsWorkflow && fs.existsSync('tests/golden/run.mjs')) {
if (skipGolden) {
console.log('⚠️ Suite golden SAUTÉE (--skip-golden) — à n\'utiliser qu\'en connaissance de cause.');
} else {
console.log('Exécution de la suite golden (tests/golden/run.mjs)...');
const { spawnSync } = await import('child_process');
const g = spawnSync('node', ['tests/golden/run.mjs'], { stdio: 'inherit' });
if (g.status !== 0) {
console.error('ABANDON: la suite golden échoue. Rien n\'a été poussé.');
console.error('(Évolution volontaire du comportement ? Mettre à jour les attendus');
console.error(' dans tests/golden/scenarios.mjs dans le même commit, puis re-pousser.)');
process.exit(1);
}
}
}
// 4. PUT
const putRes = await fetch(`${url}/api/v1/workflows/${WORKFLOW_ID}`, {
method: 'PUT',
headers,
body: JSON.stringify(body)
});
const text = await putRes.text();
if (!putRes.ok) {
console.error(`PUT REFUSÉ: ${putRes.status} ${putRes.statusText}`);
console.error(text.slice(0, 1000));
process.exit(1);
}
const updated = JSON.parse(text);
console.log(`PUT OK: "${updated.name}" (updatedAt: ${updated.updatedAt}, active=${updated.active})`);