@arcadialdev/arcality 3.0.2 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,139 +1,153 @@
1
- #!/usr/bin/env node
2
- // scripts/gen-and-run.mjs
3
- // Main Arcality CLI loop — v3 Single Configuration Mode
4
- // Reads from arcality.config instead of multi-config .env approach.
5
-
6
- import 'dotenv/config';
7
- import fs from "node:fs";
8
- import path from "node:path";
9
- import { createRequire } from 'node:module';
10
- import { spawn, exec } from "node:child_process";
11
- import chalk from "chalk";
12
- import { fileURLToPath, pathToFileURL } from 'url';
13
- const require = createRequire(import.meta.url);
14
- import figlet from "figlet";
15
- import { intro, outro, select, text, spinner, note, isCancel, cancel } from '@clack/prompts';
16
- import { load as loadYaml } from 'js-yaml';
17
- import { loadGlobalConfig, getApiKey, getApiUrl, maskApiKey, setupProcessEnv } from '../src/configLoader.mjs';
18
- import {
19
- configExists,
20
- loadProjectConfig,
21
- validateConfig,
22
- injectConfigToEnv,
23
- getYamlOutputDir,
24
- ensureYamlOutputDir,
25
- } from '../src/configManager.mjs';
26
-
27
- // Load global config at startup (injects keys into process.env)
28
- setupProcessEnv();
29
-
30
- // ── WINDOWS FIX: Interceptar Ctrl+C antes que cmd.exe ──
31
- // En Windows, `arcality` se ejecuta vía arcality.cmd (creado por npm para el campo bin).
32
- // cmd.exe intercepta Ctrl+C y muestra "¿Desea terminar el trabajo por lotes (S/N)?".
33
- // Al presionar S, mata el árbol de procesos antes de que nuestro finally pueda correr.
34
- //
35
- // La solución: setRawMode(true) hace que Node capture los bytes del teclado directamente,
36
- // ANTES de que cmd.exe los procese. Al detectar Ctrl+C (byte 0x03) lo convertimos en
37
- // un SIGINT de Node cmd.exe nunca lo ve y nunca muestra el prompt.
38
- if (process.stdin.isTTY && process.stdin.setRawMode) {
39
- try {
40
- process.stdin.setRawMode(true);
41
- process.stdin.resume();
42
- process.stdin.setEncoding('utf8');
43
- process.stdin.on('data', (key) => {
44
- if (key === '\u0003') { // Ctrl+C
45
- process.emit('SIGINT');
46
- }
47
- });
48
- } catch (e) {
49
- // Si falla (ej. en CI sin TTY), continuar sin raw mode
50
- }
51
- }
52
-
53
- const __filename = fileURLToPath(import.meta.url);
54
- const __dirname = path.dirname(__filename);
55
- const PROJECT_ROOT = process.env.ARCALITY_ROOT || path.join(__dirname, "..");
56
-
57
- const ORIGINAL_CWD = process.cwd();
58
-
59
- // ── Load arcality.config if present ──
60
- let projectConfig = loadProjectConfig(ORIGINAL_CWD) || loadProjectConfig(PROJECT_ROOT);
61
- if (projectConfig) {
62
- injectConfigToEnv(projectConfig);
63
- }
64
-
65
- function parseDotEnvFile(p) {
66
- try {
67
- const raw = fs.readFileSync(p, 'utf8');
68
- const lines = raw.split(/\r?\n/);
69
- const out = {};
70
- for (const line of lines) {
71
- const trimmed = line.trim();
72
- if (!trimmed || trimmed.startsWith('#')) continue;
73
- const idx = trimmed.indexOf('=');
74
- if (idx === -1) continue;
75
- let k = trimmed.slice(0, idx).trim();
76
- let v = trimmed.slice(idx + 1).trim();
77
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
78
- out[k] = v;
79
- }
80
- return out;
81
- } catch {
82
- return {};
83
- }
84
- }
85
-
86
- function updateDotEnvFile(updates) {
87
- const p = path.join(PROJECT_ROOT, '.env');
88
- let content = '';
89
- try {
90
- content = fs.readFileSync(p, 'utf8');
91
- } catch {
92
- content = '';
93
- }
94
-
95
- let lines = content.split(/\r?\n/);
96
- const keys = Object.keys(updates);
97
- const updatedKeys = new Set();
98
-
99
- lines = lines.map(line => {
100
- const trimmed = line.trim();
101
- if (!trimmed || trimmed.startsWith('#')) return line;
102
- const idx = trimmed.indexOf('=');
103
- if (idx === -1) return line;
104
- const k = trimmed.slice(0, idx).trim();
105
- if (keys.includes(k)) {
106
- updatedKeys.add(k);
107
- return `${k}=${updates[k]}`;
108
- }
109
- return line;
110
- });
111
-
112
- keys.forEach(k => {
113
- if (!updatedKeys.has(k)) {
114
- lines.push(`${k}=${updates[k]}`);
115
- }
116
- });
117
-
118
- fs.writeFileSync(p, lines.join('\n'), 'utf8');
119
- keys.forEach(k => { process.env[k] = updates[k]; });
120
- }
121
-
1
+ #!/usr/bin/env node
2
+ // scripts/gen-and-run.mjs
3
+ // Main Arcality CLI loop — v3 Single Configuration Mode
4
+ // Reads from arcality.config instead of multi-config .env approach.
5
+
6
+ import 'dotenv/config';
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { createRequire } from 'node:module';
10
+ import { spawn } from "node:child_process";
11
+ import { createInterface } from "node:readline";
12
+ import chalk from "chalk";
13
+ import { fileURLToPath, pathToFileURL } from 'url';
14
+ const require = createRequire(import.meta.url);
15
+ import figlet from "figlet";
16
+ import { intro, outro, select, text, spinner, note, isCancel, cancel } from '@clack/prompts';
17
+ import { load as loadYaml } from 'js-yaml';
18
+ import { loadGlobalConfig, getApiKey, getApiUrl, maskApiKey, setupProcessEnv } from '../src/configLoader.mjs';
19
+ import {
20
+ configExists,
21
+ loadProjectConfig,
22
+ validateConfig,
23
+ injectConfigToEnv,
24
+ getYamlOutputDir,
25
+ ensureYamlOutputDir,
26
+ isRealProjectId,
27
+ } from '../src/configManager.mjs';
28
+
29
+ // Load global config at startup (injects keys into process.env)
30
+ setupProcessEnv();
31
+
32
+ // ── WINDOWS FIX: Interceptar Ctrl+C antes que cmd.exe ──
33
+ // En Windows, `arcality` se ejecuta vía arcality.cmd (creado por npm para el campo bin).
34
+ // cmd.exe intercepta Ctrl+C y muestra "¿Desea terminar el trabajo por lotes (S/N)?".
35
+ // Al presionar S, mata el árbol de procesos antes de que nuestro finally pueda correr.
36
+ //
37
+ // La solución: setRawMode(true) hace que Node capture los bytes del teclado directamente,
38
+ // ANTES de que cmd.exe los procese. Al detectar Ctrl+C (byte 0x03) lo convertimos en
39
+ // un SIGINT de Node — cmd.exe nunca lo ve y nunca muestra el prompt.
40
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
41
+ try {
42
+ process.stdin.setRawMode(true);
43
+ process.stdin.resume();
44
+ process.stdin.setEncoding('utf8');
45
+ process.stdin.on('data', (key) => {
46
+ if (key === '\u0003') { // Ctrl+C
47
+ process.emit('SIGINT');
48
+ }
49
+ });
50
+ } catch (e) {
51
+ // Si falla (ej. en CI sin TTY), continuar sin raw mode
52
+ }
53
+ }
54
+
55
+ const __filename = fileURLToPath(import.meta.url);
56
+ const __dirname = path.dirname(__filename);
57
+ const PROJECT_ROOT = process.env.ARCALITY_ROOT || path.join(__dirname, "..");
58
+ const RUNNER_SOURCE_FILE = path.join(PROJECT_ROOT, 'tests', '_helpers', 'agentic-runner.spec.ts');
59
+ const RUNNER_BUNDLE_FILE = path.join(PROJECT_ROOT, 'tests', '_helpers', 'agentic-runner.bundle.spec.js');
60
+
61
+ const ORIGINAL_CWD = process.cwd();
62
+ const COLLECTION_DEFAULT_CONCURRENCY = 3;
63
+ const COLLECTION_MAX_CONCURRENCY = 5;
64
+
65
+ // ── Load arcality.config if present ──
66
+ let projectConfig = loadProjectConfig(ORIGINAL_CWD) || loadProjectConfig(PROJECT_ROOT);
67
+ if (projectConfig) {
68
+ if (!isRealProjectId(projectConfig.projectId)) {
69
+ console.error(chalk.red('\n[FATAL] Proyecto Arcality no conectado.'));
70
+ console.error(chalk.yellow('El arcality.config actual no tiene un Project ID real del backend.'));
71
+ console.error(chalk.gray('Ejecuta `arcality init` para registrar el proyecto en Arcality antes de correr misiones.\n'));
72
+ process.exit(1);
73
+ }
74
+ injectConfigToEnv(projectConfig);
75
+ }
76
+
77
+ function parseDotEnvFile(p) {
78
+ try {
79
+ const raw = fs.readFileSync(p, 'utf8');
80
+ const lines = raw.split(/\r?\n/);
81
+ const out = {};
82
+ for (const line of lines) {
83
+ const trimmed = line.trim();
84
+ if (!trimmed || trimmed.startsWith('#')) continue;
85
+ const idx = trimmed.indexOf('=');
86
+ if (idx === -1) continue;
87
+ let k = trimmed.slice(0, idx).trim();
88
+ let v = trimmed.slice(idx + 1).trim();
89
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
90
+ out[k] = v;
91
+ }
92
+ return out;
93
+ } catch {
94
+ return {};
95
+ }
96
+ }
97
+
98
+ function updateDotEnvFile(updates) {
99
+ const p = path.join(PROJECT_ROOT, '.env');
100
+ let content = '';
101
+ try {
102
+ content = fs.readFileSync(p, 'utf8');
103
+ } catch {
104
+ content = '';
105
+ }
106
+
107
+ let lines = content.split(/\r?\n/);
108
+ const keys = Object.keys(updates);
109
+ const updatedKeys = new Set();
110
+
111
+ lines = lines.map(line => {
112
+ const trimmed = line.trim();
113
+ if (!trimmed || trimmed.startsWith('#')) return line;
114
+ const idx = trimmed.indexOf('=');
115
+ if (idx === -1) return line;
116
+ const k = trimmed.slice(0, idx).trim();
117
+ if (keys.includes(k)) {
118
+ updatedKeys.add(k);
119
+ return `${k}=${updates[k]}`;
120
+ }
121
+ return line;
122
+ });
123
+
124
+ keys.forEach(k => {
125
+ if (!updatedKeys.has(k)) {
126
+ lines.push(`${k}=${updates[k]}`);
127
+ }
128
+ });
129
+
130
+ fs.writeFileSync(p, lines.join('\n'), 'utf8');
131
+ keys.forEach(k => { process.env[k] = updates[k]; });
132
+ }
133
+
122
134
  function setupEnvironment() {
123
-
124
- // Use config name from arcality.config or fallback
125
- const configName = projectConfig?.project?.name || 'Default';
126
- const baseUrl = projectConfig?.project?.baseUrl || process.env.BASE_URL || 'http://localhost';
127
-
128
- const rootOutDir = path.join(ORIGINAL_CWD, '.arcality', 'out');
129
- const configDir = path.join(rootOutDir, configName.replace(/[^a-zA-Z0-9_-]/g, '_'));
130
-
131
- process.env.DOMAIN_DIR = configDir;
132
- process.env.MISSIONS_DIR = path.join(configDir, 'missions');
133
- process.env.CONTEXT_DIR = path.join(configDir, 'context');
134
- process.env.REPORTS_DIR = path.join(configDir, 'reports');
135
-
136
- [process.env.MISSIONS_DIR, process.env.CONTEXT_DIR, process.env.REPORTS_DIR].forEach(dir => {
135
+
136
+ // Use config name from arcality.config or fallback
137
+ const configName = projectConfig?.project?.name || 'Default';
138
+ const baseUrl = projectConfig?.project?.baseUrl || process.env.BASE_URL || 'http://localhost';
139
+
140
+ const rootOutDir = path.join(ORIGINAL_CWD, '.arcality', 'out');
141
+ const configDir = path.join(rootOutDir, configName.replace(/[^a-zA-Z0-9_-]/g, '_'));
142
+
143
+ const runDomainDir = process.env.ARCALITY_RUN_DOMAIN_DIR || configDir;
144
+ process.env.DOMAIN_DIR = runDomainDir;
145
+ process.env.MISSIONS_DIR = process.env.ARCALITY_RUN_MISSIONS_DIR || path.join(configDir, 'missions');
146
+ process.env.CONTEXT_DIR = process.env.ARCALITY_RUN_CONTEXT_DIR || path.join(runDomainDir, 'context');
147
+ process.env.REPORTS_DIR = process.env.ARCALITY_RUN_REPORTS_DIR || path.join(runDomainDir, 'reports');
148
+ process.env.PLAYWRIGHT_OUTPUT_DIR = process.env.ARCALITY_RUN_PLAYWRIGHT_OUTPUT_DIR || path.join(runDomainDir, 'playwright-output');
149
+
150
+ [process.env.MISSIONS_DIR, process.env.CONTEXT_DIR, process.env.REPORTS_DIR, process.env.PLAYWRIGHT_OUTPUT_DIR].forEach(dir => {
137
151
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
138
152
  });
139
153
  ensureMissionTemplates(process.env.MISSIONS_DIR);
@@ -143,22 +157,22 @@ function setupEnvironment() {
143
157
  const yamlOutputDir = ensureYamlOutputDir(projectConfig, ORIGINAL_CWD);
144
158
  ensureMissionTemplates(yamlOutputDir);
145
159
  }
146
-
147
- // Framework detection from package.json (NO .git)
148
- let techStack = 'Not detected';
149
- try {
150
- const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
151
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
152
- if (deps['next']) techStack = 'Next.js 🚀';
153
- else if (deps['vite']) techStack = 'Vite ⚡';
154
- else if (deps['react-scripts']) techStack = 'Create React App ⚛️';
155
- } catch { }
156
-
157
- // Override with arcality.config detection if available
158
- if (projectConfig?.project?.frameworkDetected) {
159
- techStack = projectConfig.project.frameworkDetected;
160
- }
161
-
160
+
161
+ // Framework detection from package.json (NO .git)
162
+ let techStack = 'Not detected';
163
+ try {
164
+ const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
165
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
166
+ if (deps['next']) techStack = 'Next.js 🚀';
167
+ else if (deps['vite']) techStack = 'Vite ⚡';
168
+ else if (deps['react-scripts']) techStack = 'Create React App ⚛️';
169
+ } catch { }
170
+
171
+ // Override with arcality.config detection if available
172
+ if (projectConfig?.project?.frameworkDetected) {
173
+ techStack = projectConfig.project.frameworkDetected;
174
+ }
175
+
162
176
  return { configName, baseUrl, techStack, configDir };
163
177
  }
164
178
 
@@ -585,33 +599,43 @@ default_page_path: "/welcome"
585
599
  return created;
586
600
  }
587
601
 
588
- async function waitForEnter(message = 'Presiona Enter para continuar...') {
602
+ async function waitForEnter(message = 'Presiona Enter para continuar...', options = {}) {
589
603
  if (!process.stdin.isTTY) return;
590
604
 
591
605
  console.log(chalk.gray(`\n ${message}`));
592
606
  await new Promise(resolve => {
593
- const onKey = (key) => {
594
- const keyStr = String(key);
595
- if (keyStr.includes('\r') || keyStr.includes('\n') || keyStr === '\u0003') {
596
- process.stdin.off('data', onKey);
607
+ const restoreRawMode = options.restoreRawMode !== false;
608
+ const pauseOnResolve = options.pauseOnResolve === true;
609
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
610
+ process.stdin.setRawMode(false);
611
+ }
612
+ process.stdin.resume();
597
613
 
598
- if (process.stdin.isTTY && process.stdin.setRawMode) {
599
- process.stdin.setRawMode(false);
600
- }
614
+ const rl = createInterface({
615
+ input: process.stdin,
616
+ output: process.stdout
617
+ });
601
618
 
602
- if (keyStr === '\u0003') {
603
- process.exit(0);
604
- } else {
605
- resolve();
606
- }
619
+ const cleanup = () => {
620
+ rl.close();
621
+ if (process.stdin.isTTY && process.stdin.setRawMode && restoreRawMode) {
622
+ process.stdin.setRawMode(true);
623
+ process.stdin.resume();
624
+ }
625
+ if (pauseOnResolve) {
626
+ process.stdin.pause();
607
627
  }
608
628
  };
609
629
 
610
- if (process.stdin.isTTY && process.stdin.setRawMode) {
611
- process.stdin.setRawMode(true);
612
- }
613
- process.stdin.resume();
614
- process.stdin.on('data', onKey);
630
+ rl.on('SIGINT', () => {
631
+ cleanup();
632
+ process.exit(0);
633
+ });
634
+
635
+ rl.question('', () => {
636
+ cleanup();
637
+ resolve();
638
+ });
615
639
  });
616
640
  }
617
641
 
@@ -646,15 +670,320 @@ function readLatestMissionLog(contextDir, knownFiles = new Set()) {
646
670
  }
647
671
  }
648
672
 
673
+ function readMissionResult(runDomainDir, contextDir, knownFiles = new Set()) {
674
+ const persistedResultPath = runDomainDir
675
+ ? path.join(runDomainDir, 'mission-result.json')
676
+ : null;
677
+
678
+ if (persistedResultPath && fs.existsSync(persistedResultPath)) {
679
+ try {
680
+ return {
681
+ file: persistedResultPath,
682
+ data: JSON.parse(fs.readFileSync(persistedResultPath, 'utf8'))
683
+ };
684
+ } catch {
685
+ // Fallback below.
686
+ }
687
+ }
688
+
689
+ return readLatestMissionLog(contextDir, knownFiles);
690
+ }
691
+
692
+ function normalizeCollectionConcurrency(value, fallback = COLLECTION_DEFAULT_CONCURRENCY) {
693
+ const n = Number(value);
694
+ if (!Number.isFinite(n) || n < 1) return fallback;
695
+ return Math.min(COLLECTION_MAX_CONCURRENCY, Math.max(1, Math.floor(n)));
696
+ }
697
+
698
+ function createCollectionRunId() {
699
+ return new Date().toISOString()
700
+ .replace(/\.\d{3}Z$/, 'Z')
701
+ .replace(/[^0-9TZ]/g, '')
702
+ .replace('T', '-')
703
+ .replace('Z', '');
704
+ }
705
+
706
+ function formatPathForHtml(value) {
707
+ return String(value || '').split(path.sep).join('/');
708
+ }
709
+
710
+ function escapeHtml(value) {
711
+ return String(value ?? '')
712
+ .replace(/&/g, '&amp;')
713
+ .replace(/</g, '&lt;')
714
+ .replace(/>/g, '&gt;')
715
+ .replace(/"/g, '&quot;')
716
+ .replace(/'/g, '&#39;');
717
+ }
718
+
719
+ function createUniqueMissionSlug(missionSpec, fileName, index, usedSlugs) {
720
+ const baseName = missionSpec.name || fileName || `mission-${index + 1}`;
721
+ const base = sanitizePathSegment(baseName) || `mission_${index + 1}`;
722
+ const prefix = String(index + 1).padStart(2, '0');
723
+ let candidate = `${prefix}-${base}`;
724
+ let suffix = 2;
725
+
726
+ while (usedSlugs.has(candidate)) {
727
+ candidate = `${prefix}-${base}-${suffix}`;
728
+ suffix += 1;
729
+ }
730
+
731
+ usedSlugs.add(candidate);
732
+ return candidate;
733
+ }
734
+
735
+ async function ensureRunnerBundle({ debug = false, spinnerRef = null, statusMessage = 'Compilando Arcality Runner...' } = {}) {
736
+ const sourceExists = fs.existsSync(RUNNER_SOURCE_FILE);
737
+ const bundleExists = fs.existsSync(RUNNER_BUNDLE_FILE);
738
+
739
+ if (!sourceExists) {
740
+ if (bundleExists) {
741
+ if (debug) {
742
+ console.log(chalk.gray('[DEBUG] Runner fuente no incluido en el paquete; usando bundle precompilado.'));
743
+ }
744
+ return true;
745
+ }
746
+
747
+ console.warn(chalk.yellow('\nAdvertencia: No se encontro el runner fuente ni el bundle precompilado.'));
748
+ return false;
749
+ }
750
+
751
+ try {
752
+ if (spinnerRef && !debug) {
753
+ spinnerRef.message(statusMessage);
754
+ } else if (debug) {
755
+ console.log(chalk.cyan(statusMessage));
756
+ }
757
+
758
+ await runNpmScript(['run', 'build:runner'], {
759
+ cwd: PROJECT_ROOT,
760
+ streamOutput: debug
761
+ });
762
+ return true;
763
+ } catch (err) {
764
+ console.warn(chalk.yellow(`\nAdvertencia: No se pudo compilar el runner: ${err.message}`));
765
+ return false;
766
+ }
767
+ }
768
+
769
+ async function buildRunnerForCollection(debug = false, spinnerRef = null) {
770
+ return ensureRunnerBundle({
771
+ debug,
772
+ spinnerRef,
773
+ statusMessage: debug
774
+ ? '[RUN-ALL] Compilando Arcality Runner una sola vez para la coleccion...'
775
+ : 'Compilando Arcality Runner una sola vez para la coleccion...'
776
+ });
777
+ }
778
+
779
+ async function runWithConcurrency(items, concurrency, worker) {
780
+ const results = new Array(items.length);
781
+ let nextIndex = 0;
782
+ const workerCount = Math.min(Math.max(1, concurrency), items.length);
783
+
784
+ await Promise.all(Array.from({ length: workerCount }, async () => {
785
+ while (nextIndex < items.length) {
786
+ const currentIndex = nextIndex;
787
+ nextIndex += 1;
788
+ results[currentIndex] = await worker(items[currentIndex], currentIndex);
789
+ }
790
+ }));
791
+
792
+ return results;
793
+ }
794
+
795
+ function writeCollectionArtifacts(runDir, summary, metadata) {
796
+ const summaryPath = path.join(runDir, 'summary.json');
797
+ const reportDir = path.join(runDir, 'collection-report');
798
+ const reportPath = path.join(reportDir, 'index.html');
799
+
800
+ if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true });
801
+
802
+ const passed = summary.filter(item => item.success).length;
803
+ const failed = summary.length - passed;
804
+ const startedAt = metadata.startedAt || new Date().toISOString();
805
+ const finishedAt = new Date().toISOString();
806
+ const durationMs = summary.reduce((total, item) => total + (item.durationMs || 0), 0);
807
+
808
+ fs.writeFileSync(summaryPath, JSON.stringify({
809
+ runId: metadata.runId,
810
+ collection: metadata.collectionLabel,
811
+ concurrency: metadata.concurrency,
812
+ startedAt,
813
+ finishedAt,
814
+ total: summary.length,
815
+ passed,
816
+ failed,
817
+ missions: summary
818
+ }, null, 2));
819
+
820
+ const rows = summary.map(item => {
821
+ const reportExists = item.reportPath && fs.existsSync(item.reportPath);
822
+ const relativeReport = reportExists
823
+ ? formatPathForHtml(path.relative(reportDir, item.reportPath))
824
+ : '';
825
+ const status = item.success ? 'Exitosa' : 'Error';
826
+ const tone = item.success ? 'passed' : 'failed';
827
+ const reportLink = reportExists
828
+ ? `<a href="${escapeHtml(relativeReport)}">Abrir reporte</a>`
829
+ : '<span class="muted">Sin reporte</span>';
830
+ const reason = item.reason ? escapeHtml(item.reason) : '<span class="muted">-</span>';
831
+
832
+ return `
833
+ <tr>
834
+ <td><span class="status ${tone}">${status}</span></td>
835
+ <td>
836
+ <strong>${escapeHtml(item.name)}</strong>
837
+ <small>${escapeHtml(item.collection || 'General')}</small>
838
+ </td>
839
+ <td>${reason}</td>
840
+ <td>${item.durationMs ? Math.round(item.durationMs / 1000) + 's' : '-'}</td>
841
+ <td>${reportLink}</td>
842
+ </tr>
843
+ `;
844
+ }).join('');
845
+
846
+ const html = `<!DOCTYPE html>
847
+ <html lang="es">
848
+ <head>
849
+ <meta charset="UTF-8">
850
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
851
+ <title>Arcality Collection Report</title>
852
+ <style>
853
+ :root {
854
+ --bg: #07080d;
855
+ --surface: #10131b;
856
+ --line: #293042;
857
+ --text: #f7f4ff;
858
+ --muted: #a9afc0;
859
+ --ok: #36d399;
860
+ --bad: #ff5d7a;
861
+ --accent: #75c7ff;
862
+ }
863
+ * { box-sizing: border-box; }
864
+ body {
865
+ margin: 0;
866
+ min-height: 100vh;
867
+ background: var(--bg);
868
+ color: var(--text);
869
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
870
+ }
871
+ main { width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 44px 0 64px; }
872
+ header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; margin-bottom: 34px; }
873
+ h1 { margin: 0 0 10px; font-size: clamp(38px, 6vw, 72px); line-height: .95; letter-spacing: 0; }
874
+ p { margin: 0; color: var(--muted); font-size: 16px; }
875
+ .badge { border: 1px solid var(--line); border-radius: 8px; padding: 16px 18px; min-width: 250px; background: rgba(255,255,255,.02); }
876
+ .badge small { color: var(--muted); display: block; text-transform: uppercase; font-size: 11px; letter-spacing: .14em; margin-bottom: 8px; }
877
+ .badge strong { display: block; font-size: 22px; }
878
+ .grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; margin-bottom: 28px; }
879
+ .metric { padding: 20px; background: var(--surface); border-right: 1px solid var(--line); }
880
+ .metric:last-child { border-right: 0; }
881
+ .metric b { display: block; font-size: 30px; margin-bottom: 8px; }
882
+ .metric span { color: var(--muted); text-transform: uppercase; font-size: 11px; letter-spacing: .14em; font-weight: 700; }
883
+ table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
884
+ th, td { text-align: left; padding: 16px; border-bottom: 1px solid var(--line); vertical-align: top; }
885
+ th { color: var(--muted); text-transform: uppercase; font-size: 11px; letter-spacing: .14em; }
886
+ tr:last-child td { border-bottom: 0; }
887
+ td small { display: block; color: var(--muted); margin-top: 4px; }
888
+ a { color: var(--accent); text-decoration: none; font-weight: 700; }
889
+ .status { display: inline-flex; border-radius: 999px; padding: 5px 9px; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
890
+ .status.passed { color: var(--ok); background: rgba(54, 211, 153, .12); }
891
+ .status.failed { color: var(--bad); background: rgba(255, 93, 122, .12); }
892
+ .muted { color: var(--muted); }
893
+ @media (max-width: 760px) {
894
+ main { width: min(100% - 28px, 1180px); padding-top: 28px; }
895
+ header { display: block; }
896
+ .badge { margin-top: 22px; }
897
+ .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
898
+ .metric:nth-child(2) { border-right: 0; }
899
+ table, thead, tbody, tr, th, td { display: block; }
900
+ thead { display: none; }
901
+ tr { border-bottom: 1px solid var(--line); }
902
+ td { border-bottom: 0; padding: 12px 14px; }
903
+ }
904
+ </style>
905
+ </head>
906
+ <body>
907
+ <main>
908
+ <header>
909
+ <div>
910
+ <h1>Reporte de coleccion</h1>
911
+ <p>${escapeHtml(metadata.collectionLabel)} ejecutada con hasta ${metadata.concurrency} misiones simultaneas.</p>
912
+ </div>
913
+ <div class="badge">
914
+ <small>Estado general</small>
915
+ <strong>${failed > 0 ? 'Ejecucion con errores' : 'Ejecucion exitosa'}</strong>
916
+ <p>${escapeHtml(startedAt)} - ${escapeHtml(finishedAt)}</p>
917
+ </div>
918
+ </header>
919
+ <section class="grid">
920
+ <div class="metric"><b>${summary.length}</b><span>Misiones</span></div>
921
+ <div class="metric"><b>${passed}</b><span>Exitosas</span></div>
922
+ <div class="metric"><b>${failed}</b><span>Errores</span></div>
923
+ <div class="metric"><b>${Math.round(durationMs / 1000)}s</b><span>Tiempo acumulado</span></div>
924
+ </section>
925
+ <table>
926
+ <thead>
927
+ <tr>
928
+ <th>Estado</th>
929
+ <th>Mision</th>
930
+ <th>Razon</th>
931
+ <th>Duracion</th>
932
+ <th>Evidencia</th>
933
+ </tr>
934
+ </thead>
935
+ <tbody>${rows}</tbody>
936
+ </table>
937
+ </main>
938
+ </body>
939
+ </html>`;
940
+
941
+ fs.writeFileSync(reportPath, html);
942
+ return { summaryPath, reportPath };
943
+ }
944
+
945
+ function openLocalFile(filePath) {
946
+ if (!filePath || !fs.existsSync(filePath)) return false;
947
+
948
+ try {
949
+ if (process.platform === 'win32') {
950
+ const child = spawn('cmd.exe', ['/d', '/s', '/c', 'start', '', filePath], {
951
+ detached: true,
952
+ stdio: 'ignore',
953
+ windowsHide: true
954
+ });
955
+ child.unref();
956
+ } else if (process.platform === 'darwin') {
957
+ const child = spawn('open', [filePath], { detached: true, stdio: 'ignore' });
958
+ child.unref();
959
+ } else {
960
+ const child = spawn('xdg-open', [filePath], { detached: true, stdio: 'ignore' });
961
+ child.unref();
962
+ }
963
+ return true;
964
+ } catch {
965
+ return false;
966
+ }
967
+ }
968
+
969
+ function openCollectionReport(reportPath) {
970
+ if (!reportPath || !fs.existsSync(reportPath) || process.env.CI === 'true') return;
971
+ if (!process.stdin.isTTY) return;
972
+
973
+ openLocalFile(reportPath);
974
+ }
975
+
649
976
  async function runCollectionBatch({
650
977
  missionsRootDir,
651
978
  selectedCollection = null,
652
- debug = false
979
+ debug = false,
980
+ concurrency = 1,
981
+ interactive = false
653
982
  }) {
654
983
  const collections = listMissionCollections(missionsRootDir);
655
984
  if (collections.length === 0) {
656
985
  note(chalk.yellow('No hay colecciones con misiones guardadas para ejecutar.'), 'Run All');
657
- return;
986
+ return { summary: [], failed: 0 };
658
987
  }
659
988
 
660
989
  let targetCollections = collections;
@@ -664,7 +993,7 @@ async function runCollectionBatch({
664
993
 
665
994
  if (targetCollections.length === 0) {
666
995
  note(chalk.yellow('La coleccion solicitada no existe o no contiene misiones.'), 'Run All');
667
- return;
996
+ return { summary: [], failed: 0 };
668
997
  }
669
998
 
670
999
  const missionsToRun = [];
@@ -681,38 +1010,114 @@ async function runCollectionBatch({
681
1010
 
682
1011
  if (missionsToRun.length === 0) {
683
1012
  note(chalk.yellow('No se encontraron misiones ejecutables en la seleccion actual.'), 'Run All');
684
- return;
1013
+ return { summary: [], failed: 0 };
685
1014
  }
686
1015
 
1016
+ const requestedConcurrency = normalizeCollectionConcurrency(
1017
+ concurrency,
1018
+ interactive ? COLLECTION_DEFAULT_CONCURRENCY : 1
1019
+ );
1020
+ const startedAt = new Date().toISOString();
1021
+ const runId = createCollectionRunId();
1022
+ const envInfo = setupEnvironment();
1023
+ const runDir = path.join(envInfo.configDir, 'runs', runId);
1024
+ const missionsRunDir = path.join(runDir, 'missions');
1025
+ const collectionLabel = selectedCollection && selectedCollection !== '__ALL__'
1026
+ ? (targetCollections[0]?.label || selectedCollection)
1027
+ : `Todas las colecciones (${targetCollections.length})`;
1028
+
1029
+ if (!fs.existsSync(missionsRunDir)) fs.mkdirSync(missionsRunDir, { recursive: true });
1030
+
687
1031
  const sBatch = spinner();
688
1032
  if (!debug) {
689
- sBatch.start(`Ejecutando ${missionsToRun.length} misiones de ${targetCollections.length} coleccion(es)...`);
1033
+ sBatch.start(`Preparando ${missionsToRun.length} misiones de ${targetCollections.length} coleccion(es)...`);
690
1034
  }
691
1035
 
692
1036
  const summary = [];
1037
+ const runnableMissions = [];
1038
+ const usedSlugs = new Set();
1039
+
693
1040
  for (let index = 0; index < missionsToRun.length; index++) {
694
1041
  const missionEntry = missionsToRun[index];
695
1042
  const rawMission = readYamlFile(missionEntry.fullPath);
696
1043
  const missionSpec = normalizeMissionSpec(rawMission, missionEntry.fullPath);
697
1044
  const collectionMeta = readCollectionMeta(missionEntry.collection.dir, missionEntry.collection.value);
698
1045
  const discoverPath = getMissionStartPath(missionSpec, collectionMeta, '/');
699
- const contextDir = process.env.CONTEXT_DIR;
700
- const knownLogFiles = new Set(listMissionLogFiles(contextDir));
1046
+ const slug = createUniqueMissionSlug(missionSpec, missionEntry.file, index, usedSlugs);
1047
+ const missionDir = path.join(missionsRunDir, slug);
1048
+ const contextDir = path.join(missionDir, 'context');
1049
+ const reportsDir = path.join(missionDir, 'reports');
1050
+ const playwrightOutputDir = path.join(missionDir, 'playwright-output');
701
1051
 
702
1052
  if (!missionSpec.prompt) {
703
1053
  summary.push({
1054
+ index,
704
1055
  name: missionSpec.name || missionEntry.file,
705
1056
  collection: missionEntry.collection.label,
706
1057
  success: false,
707
- reason: 'missing_prompt'
1058
+ reason: 'missing_prompt',
1059
+ durationMs: 0,
1060
+ reportPath: null,
1061
+ contextDir,
1062
+ reportsDir,
1063
+ playwrightOutputDir
708
1064
  });
709
1065
  continue;
710
1066
  }
711
1067
 
1068
+ runnableMissions.push({
1069
+ index,
1070
+ missionEntry,
1071
+ missionSpec,
1072
+ collectionMeta,
1073
+ discoverPath,
1074
+ slug,
1075
+ missionDir,
1076
+ contextDir,
1077
+ reportsDir,
1078
+ playwrightOutputDir,
1079
+ name: missionSpec.name || missionEntry.file,
1080
+ collectionLabel: missionEntry.collection.label
1081
+ });
1082
+ }
1083
+
1084
+ const runnerPrebuilt = runnableMissions.length > 0
1085
+ ? await buildRunnerForCollection(debug, sBatch)
1086
+ : false;
1087
+ let effectiveConcurrency = Math.min(requestedConcurrency, Math.max(1, runnableMissions.length));
1088
+
1089
+ if (!runnerPrebuilt && effectiveConcurrency > 1) {
1090
+ effectiveConcurrency = 1;
1091
+ note(
1092
+ 'No se pudo precompilar el runner antes del lote. Para evitar compilaciones simultaneas, esta coleccion se ejecutara de forma secuencial.',
1093
+ 'Concurrencia ajustada'
1094
+ );
1095
+ }
1096
+
1097
+ let completed = 0;
1098
+ const runnableResults = await runWithConcurrency(runnableMissions, effectiveConcurrency, async (mission) => {
1099
+ const startedMissionAt = Date.now();
1100
+
712
1101
  if (!debug) {
713
- sBatch.message(`[${index + 1}/${missionsToRun.length}] ${missionSpec.name} (${missionEntry.collection.label})`);
1102
+ sBatch.message(`[${completed}/${runnableMissions.length}] En curso hasta ${effectiveConcurrency}. Iniciando ${mission.name}`);
714
1103
  } else {
715
- console.log(chalk.cyan(`[RUN-ALL] ${index + 1}/${missionsToRun.length} -> ${missionSpec.name} [${missionEntry.collection.label}]`));
1104
+ console.log(chalk.cyan(`[RUN-ALL] ${mission.index + 1}/${missionsToRun.length} -> ${mission.name} [${mission.collectionLabel}]`));
1105
+ }
1106
+
1107
+ const childEnv = {
1108
+ ...process.env,
1109
+ ARCALITY_ROOT: PROJECT_ROOT,
1110
+ ARCALITY_BATCH_MODE: 'true',
1111
+ ARCALITY_RUN_DOMAIN_DIR: mission.missionDir,
1112
+ ARCALITY_RUN_CONTEXT_DIR: mission.contextDir,
1113
+ ARCALITY_RUN_REPORTS_DIR: mission.reportsDir,
1114
+ ARCALITY_RUN_PLAYWRIGHT_OUTPUT_DIR: mission.playwrightOutputDir,
1115
+ ARCALITY_COLLECTION_RUN_ID: runId,
1116
+ ARCALITY_COLLECTION_MISSION_SLUG: mission.slug
1117
+ };
1118
+
1119
+ if (runnerPrebuilt) {
1120
+ childEnv.ARCALITY_RUNNER_PREBUILT = 'true';
716
1121
  }
717
1122
 
718
1123
  try {
@@ -721,40 +1126,66 @@ async function runCollectionBatch({
721
1126
  '--agent',
722
1127
  '--batch',
723
1128
  '--skip-save-prompt',
724
- `--discover=${discoverPath}`,
725
- missionSpec.prompt
1129
+ `--discover=${mission.discoverPath}`,
1130
+ mission.missionSpec.prompt
726
1131
  ], {
727
1132
  cwd: ORIGINAL_CWD,
728
- env: {
729
- ...process.env,
730
- ARCALITY_ROOT: PROJECT_ROOT,
731
- ARCALITY_BATCH_MODE: 'true'
732
- }
1133
+ env: childEnv,
1134
+ streamOutput: debug,
1135
+ lastRunLog: path.join(mission.missionDir, 'last-run.log')
733
1136
  });
734
1137
 
735
- const latestLog = readLatestMissionLog(contextDir, knownLogFiles);
1138
+ const latestLog = readMissionResult(mission.missionDir, mission.contextDir);
736
1139
  const success = latestLog?.data?.success === true;
737
- summary.push({
738
- name: missionSpec.name || missionEntry.file,
739
- collection: missionEntry.collection.label,
1140
+ return {
1141
+ index: mission.index,
1142
+ name: mission.name,
1143
+ collection: mission.collectionLabel,
740
1144
  success,
741
1145
  reason: latestLog?.data?.fail_reason || null
742
- });
1146
+ || (!latestLog ? 'missing_mission_log' : null),
1147
+ durationMs: Date.now() - startedMissionAt,
1148
+ reportPath: path.join(mission.reportsDir, 'index.html'),
1149
+ contextDir: mission.contextDir,
1150
+ reportsDir: mission.reportsDir
1151
+ };
743
1152
  } catch (error) {
744
- summary.push({
745
- name: missionSpec.name || missionEntry.file,
746
- collection: missionEntry.collection.label,
1153
+ return {
1154
+ index: mission.index,
1155
+ name: mission.name,
1156
+ collection: mission.collectionLabel,
747
1157
  success: false,
748
- reason: error instanceof Error ? error.message : 'runner_error'
749
- });
1158
+ reason: error instanceof Error ? error.message : 'runner_error',
1159
+ durationMs: Date.now() - startedMissionAt,
1160
+ reportPath: path.join(mission.reportsDir, 'index.html'),
1161
+ contextDir: mission.contextDir,
1162
+ reportsDir: mission.reportsDir
1163
+ };
1164
+ } finally {
1165
+ completed += 1;
1166
+ if (!debug) {
1167
+ sBatch.message(`[${completed}/${runnableMissions.length}] Misiones terminadas. Concurrencia ${effectiveConcurrency}.`);
1168
+ }
750
1169
  }
751
- }
1170
+ });
1171
+
1172
+ summary.push(...runnableResults.filter(Boolean));
1173
+ summary.sort((a, b) => a.index - b.index);
752
1174
 
753
1175
  const passed = summary.filter(item => item.success).length;
754
1176
  const failed = summary.length - passed;
1177
+ const artifacts = writeCollectionArtifacts(runDir, summary, {
1178
+ runId,
1179
+ collectionLabel,
1180
+ concurrency: effectiveConcurrency,
1181
+ startedAt
1182
+ });
755
1183
 
756
1184
  if (!debug) {
757
- sBatch.stop(chalk.green(`Coleccion ejecutada. Exitosas: ${passed} | Fallidas: ${failed}`));
1185
+ const finalMessage = failed > 0
1186
+ ? chalk.yellow(`Coleccion ejecutada. Exitosas: ${passed} | Fallidas: ${failed}`)
1187
+ : chalk.green(`Coleccion ejecutada. Exitosas: ${passed} | Fallidas: ${failed}`);
1188
+ sBatch.stop(finalMessage);
758
1189
  }
759
1190
 
760
1191
  const lines = summary.map(item => {
@@ -763,288 +1194,311 @@ async function runCollectionBatch({
763
1194
  return `- [${status}] ${item.collection} -> ${item.name}${reason}`;
764
1195
  });
765
1196
  note(lines.join('\n'), 'Resumen Run All');
1197
+ note(
1198
+ `Resumen JSON: ${artifacts.summaryPath}\nReporte HTML: ${artifacts.reportPath}\nNota: la ejecucion paralela abre varias sesiones del portal. Arcality no puede validar reglas internas de sesion, bloqueo de usuario o datos compartidos del sistema probado.`,
1199
+ 'Evidencia de coleccion'
1200
+ );
1201
+
1202
+ openCollectionReport(artifacts.reportPath);
1203
+ return { summary, failed, runDir, reportPath: artifacts.reportPath };
766
1204
  }
767
1205
 
768
1206
  function showBanner() {
769
- console.clear();
770
-
771
- let version = 'unknown';
772
- try {
773
- const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
774
- version = pkg.version || 'unknown';
775
- } catch { }
776
-
777
- const sentinelAscii = `
778
- █████ ██████ ██████ █████ ██ ██ ████████ ██ ██
779
- ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
780
- ███████ ██████ ██ ███████ ██ ██ ██ ████
781
- ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
782
- ██ ██ ██ ██ ██████ ██ ██ ███████ ██ ██ ██ `;
783
-
784
- intro(chalk.gray(sentinelAscii));
785
-
786
- note(
787
- chalk.gray('◉ Motor de Observación: ') + chalk.bold.white('EN LÍNEA') + '\n' +
788
- chalk.gray('◉ Módulo de Recuperación de UI: ') + chalk.bold.white('ACTIVO') + '\n' +
789
- chalk.gray('◉ Lógica Forense: ') + chalk.bold.white('ACTIVA') + '\n' +
790
- chalk.gray('─'.repeat(50)) + '\n' +
791
- chalk.gray('Versión del Sistema: ') + chalk.white(`v${version}`),
792
- 'Estado Central de Arcality'
793
- );
794
-
795
- const { configName, baseUrl, techStack, configDir } = setupEnvironment();
796
-
797
- // Show arcality.config status instead of multi-config switching
798
- const hasConfig = !!projectConfig;
799
- const configStatus = hasConfig
800
- ? chalk.green('◉ Sincronización Establecida')
801
- : chalk.red('⚠ Desconectado — Ejecuta `arcality init`');
802
-
803
- const { key: apiKeyVal, source: apiKeySource } = getApiKey();
804
- const apiKeyDisplay = apiKeyVal
805
- ? chalk.green(maskApiKey(apiKeyVal)) + chalk.gray(` [${apiKeySource}]`)
806
- : chalk.red('NO VERIFICADO');
807
-
808
- const infoLines = [
809
- chalk.gray('Nodo Objetivo: ') + chalk.white(process.env.NODE_ENV ?? 'development'),
810
- chalk.gray('Token de Autenticación: ') + apiKeyDisplay,
811
- chalk.gray('Stack: ') + chalk.white(techStack),
812
- ];
813
-
814
- if (hasConfig) {
815
- infoLines.splice(1, 0,
816
- chalk.gray('Proyecto: ') + chalk.white(configName) + chalk.gray(` [${baseUrl}]`),
817
- chalk.gray('ID de Identidad: ') + chalk.white(projectConfig.projectId || 'N/A'),
818
- );
819
- } else {
820
- infoLines.push(chalk.gray('Configuración: ') + configStatus);
821
- }
822
-
823
- note(infoLines.join('\n'), 'Telemetría del Objetivo');
824
- }
825
-
826
- const ARCALITY_BRAIN_URL = "https://api.anthropic.com/v1/messages";
827
- const ARCALITY_MODEL = process.env.CLAUDE_MODEL || "claude-4-5-sonnet-latest";
828
- const OUT_DIR = path.join(PROJECT_ROOT, "tests");
829
- const LOGS_DIR = path.join(PROJECT_ROOT, "logs");
830
-
831
- async function arcalityChat(messages) {
832
- const internalApiUrl = getApiUrl();
833
- const isProxyMode = !!internalApiUrl;
834
-
835
- if (!process.env.ANTHROPIC_API_KEY && !isProxyMode) {
836
- throw new Error("Missing ANTHROPIC_API_KEY in .env");
837
- }
838
-
839
- const systemMessage = messages.find(m => m.role === 'system');
840
- const userMessages = messages.filter(m => m.role !== 'system');
841
-
842
- try {
843
- const endpointUrl = isProxyMode
844
- ? `${internalApiUrl}/api/v1/ai/proxy`
845
- : ARCALITY_BRAIN_URL;
846
-
847
- const headers = {
848
- "Content-Type": "application/json"
849
- };
850
-
851
- if (isProxyMode) {
852
- headers["x-api-key"] = process.env.ARCALITY_API_KEY || "";
853
- const missionId = process.env.ARCALITY_MISSION_ID || '';
854
- if (missionId) headers["x-mission-id"] = missionId;
855
- } else {
856
- headers["x-api-key"] = process.env.ANTHROPIC_API_KEY;
857
- headers["anthropic-version"] = "2023-06-01";
858
- }
859
-
860
- const res = await fetch(endpointUrl, {
861
- method: "POST",
862
- headers,
863
- body: JSON.stringify({
864
- model: ARCALITY_MODEL,
865
- system: systemMessage ? systemMessage.content : undefined,
866
- messages: userMessages,
867
- max_tokens: 4000,
868
- temperature: 0.3
869
- })
870
- });
871
-
872
- if (!res.ok) {
873
- const txt = await res.text();
874
- throw new Error(`Arcality Brain Error ${res.status}: ${txt}`);
875
- }
876
-
877
- const data = await res.json();
878
- const content = data.content?.[0]?.text || "";
879
-
880
- try {
881
- if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
882
- fs.writeFileSync(path.join(LOGS_DIR, 'last-ai-response.txt'), content);
883
- } catch (e) { /* ignore */ }
884
-
885
- return content;
886
- } catch (err) {
887
- throw new Error(`Error communicating with Arcality brain: ${err.message}`);
888
- }
889
- }
890
-
891
- function extractBlocks(text) {
892
- const cleaned = text
893
- .replace(/```[a-zA-Z]*\n?/g, "")
894
- .replace(/```/g, "")
895
- .trim();
896
-
897
- const fnMatch = cleaned.match(/^FILENAME:\s*(.+)$/m);
898
- const contentIdx = cleaned.indexOf("CONTENT:");
899
-
900
- if (!fnMatch || contentIdx === -1) {
901
- throw new Error(
902
- `Invalid format. Expected:\nFILENAME: <file.spec.ts>\nCONTENT:\n<code>\n\nResponse:\n${cleaned}`
903
- );
904
- }
905
-
906
- const filename = fnMatch[1].trim();
907
- const content = cleaned.slice(contentIdx + "CONTENT:".length).trim();
908
-
909
- return { filename, content, cleaned };
910
- }
911
-
912
- function validateSpec(ts) {
913
- const errors = [];
914
- if (!/test\s*\(.*async\s*\(\s*\{\s*page\s*\}\s*\)\s*=>/s.test(ts)) {
915
- errors.push('Missing fixture: test("...", async ({ page }) => { ... })');
916
- }
917
- if (/from\s+['"]playwright['"]/.test(ts)) {
918
- errors.push("Forbidden to import from core. Use only the Arcality SDK.");
919
- }
920
- if (!ts.includes("import { test, expect } from '@playwright/test'")) {
921
- errors.push("Must import exactly the Arcality engine.");
922
- }
923
- return errors;
924
- }
925
-
926
- function sanitizeFilename(name, fallback) {
927
- if (typeof name !== "string") return fallback;
928
- const safe = name.trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_");
929
- if (!safe.endsWith(".spec.ts")) return fallback;
930
- return safe;
931
- }
932
-
933
- function run(cmd, args, options = {}) {
934
- const isDebug = process.argv.includes('--debug');
935
- const cwd = options.cwd || process.cwd();
936
-
937
- return new Promise((resolve, reject) => {
938
- // NODE_PATH: let Node resolve modules from both the arcality package and user's project
939
- const appNodeModules = path.join(PROJECT_ROOT, 'node_modules');
940
- const userNodeModules = path.join(ORIGINAL_CWD, 'node_modules');
941
- const existingNodePath = process.env.NODE_PATH || '';
942
- const newNodePath = [appNodeModules, userNodeModules, existingNodePath].filter(Boolean).join(path.delimiter);
943
-
944
- const env = { ...options.env || process.env, NODE_PATH: newNodePath };
945
- if (isDebug) console.log(chalk.gray(`\n[DEBUG] CWD: ${cwd}`));
946
- if (isDebug) console.log(chalk.gray(`[DEBUG] Running: ${cmd} ${args.join(' ')}`));
947
-
948
- // Use standard cross-platform spawn. When shell is false, Node's child_process
949
- // properly wraps arguments with spaces in quotes natively for CreateProcessW on Windows.
950
- //
951
- // CRITICAL (Windows): stdin MUST be 'ignore' (not 'inherit') to prevent cmd.exe from
952
- // showing the "¿Desea terminar el trabajo por lotes (S/N)?" prompt on Ctrl+C.
953
- // When stdin is 'inherit', cmd.exe intercepts Ctrl+C at the console level and kills
954
- // the entire process tree before our SIGINT handler can run, bypassing the finally block.
955
- const p = spawn(cmd, args, {
956
- stdio: isDebug ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
957
- shell: false,
958
- cwd,
959
- env,
960
- });
961
-
962
- // Exponer el proceso hijo para que el cancelHandler pueda matarlo
963
- if (options.onSpawn) options.onSpawn(p);
964
-
965
- let outputBuffer = '';
966
- const handleData = (data) => {
967
- const str = data.toString();
968
- outputBuffer += str;
969
-
970
- if (str.includes('>>ARCALITY_STATUS>>')) {
971
- const lines = str.split('\n');
972
- for (const line of lines) {
973
- if (line.includes('>>ARCALITY_STATUS>>')) {
974
- const status = line.split('>>ARCALITY_STATUS>>')[1].trim();
975
- if (options.onStatus) options.onStatus(status);
976
- } else if (line.trim()) {
977
- process.stdout.write(line + '\n');
978
- }
979
- }
980
- } else {
981
- process.stdout.write(data);
982
- }
983
- };
984
-
985
- if (p.stdout) p.stdout.on('data', handleData);
986
- if (p.stderr) {
987
- p.stderr.on('data', (data) => {
988
- if (isDebug) process.stderr.write(data);
989
- else process.stderr.write(data);
990
- outputBuffer += data.toString();
991
- });
992
- }
993
-
994
- p.on('exit', (code, signal) => {
995
- const lastRunLog = path.join(LOGS_DIR, 'last-run.log');
996
- try {
997
- if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
998
- fs.writeFileSync(lastRunLog, outputBuffer);
999
- } catch (e) { }
1000
-
1001
- // code===0 → clean success
1002
- // code===null + signal killed by signal (Ctrl+C) — treat as non-fatal so finally runs
1003
- // code===1 test failed (Playwright returns 1 on test failure) — also non-fatal
1004
- if (code === 0 || signal !== null || code === 1) resolve();
1005
- else {
1006
- if (isDebug) console.error(chalk.red(`\n[DEBUG] Process exited with code ${code}`));
1007
- reject(new Error(`Process exited with code ${code}`));
1008
- }
1009
- });
1010
-
1011
- p.on('error', (err) => {
1012
- if (isDebug) console.error(chalk.red(`\n[DEBUG] Process failed to spawn: ${err.message}`));
1013
- reject(err);
1014
- });
1015
- });
1016
- }
1017
-
1018
- /**
1019
- * Ping the project to register activity.
1020
- * Uses internal API URL — not user-configurable.
1021
- */
1022
- async function pingProject(projectId, apiKey) {
1023
- if (!projectId || !apiKey) return;
1024
- try {
1025
- await fetch(`${getApiUrl()}/api/v1/projects/${projectId}/ping`, {
1026
- method: 'PATCH',
1027
- headers: { 'x-api-key': apiKey },
1028
- });
1029
- } catch { /* silent */ }
1030
- }
1031
-
1032
- async function main() {
1033
- process.chdir(PROJECT_ROOT);
1034
-
1035
- // Backward compat: initialize .env if ACTIVE_CONFIG missing (legacy)
1036
- let initialEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
1037
- if (!projectConfig && !initialEnv.ACTIVE_CONFIG) {
1038
- updateDotEnvFile({
1039
- ACTIVE_CONFIG: 'Default',
1040
- SAVED_CONFIGS: 'Default',
1041
- Default_URL: initialEnv.BASE_URL || '',
1042
- Default_USER: initialEnv.LOGIN_USER || '',
1043
- Default_PASS: initialEnv.LOGIN_PASSWORD || ''
1044
- });
1045
- initialEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
1046
- }
1047
-
1207
+ console.clear();
1208
+
1209
+ let version = 'unknown';
1210
+ try {
1211
+ const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
1212
+ version = pkg.version || 'unknown';
1213
+ } catch { }
1214
+
1215
+ const sentinelAscii = `
1216
+ █████ ██████ ██████ █████ ██ ██ ████████ ██ ██
1217
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
1218
+ ███████ ██████ ██ ███████ ██ ██ ██ ████
1219
+ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
1220
+ ██ ██ ██ ██ ██████ ██ ██ ███████ ██ ██ ██ `;
1221
+
1222
+ intro(chalk.gray(sentinelAscii));
1223
+
1224
+ note(
1225
+ chalk.gray('◉ Motor de Observación: ') + chalk.bold.white('EN LÍNEA') + '\n' +
1226
+ chalk.gray('◉ Módulo de Recuperación de UI: ') + chalk.bold.white('ACTIVO') + '\n' +
1227
+ chalk.gray('◉ Lógica Forense: ') + chalk.bold.white('ACTIVA') + '\n' +
1228
+ chalk.gray('─'.repeat(50)) + '\n' +
1229
+ chalk.gray('Versión del Sistema: ') + chalk.white(`v${version}`),
1230
+ 'Estado Central de Arcality'
1231
+ );
1232
+
1233
+ const { configName, baseUrl, techStack, configDir } = setupEnvironment();
1234
+
1235
+ // Show arcality.config status instead of multi-config switching
1236
+ const hasConfig = !!projectConfig;
1237
+ const configStatus = hasConfig
1238
+ ? chalk.green('◉ Sincronización Establecida')
1239
+ : chalk.red('⚠ Desconectado — Ejecuta `arcality init`');
1240
+
1241
+ const { key: apiKeyVal, source: apiKeySource } = getApiKey();
1242
+ const apiKeyDisplay = apiKeyVal
1243
+ ? chalk.green(maskApiKey(apiKeyVal)) + chalk.gray(` [${apiKeySource}]`)
1244
+ : chalk.red('NO VERIFICADO');
1245
+
1246
+ const infoLines = [
1247
+ chalk.gray('Nodo Objetivo: ') + chalk.white(process.env.NODE_ENV ?? 'development'),
1248
+ chalk.gray('Token de Autenticación: ') + apiKeyDisplay,
1249
+ chalk.gray('Stack: ') + chalk.white(techStack),
1250
+ ];
1251
+
1252
+ if (hasConfig) {
1253
+ infoLines.splice(1, 0,
1254
+ chalk.gray('Proyecto: ') + chalk.white(configName) + chalk.gray(` [${baseUrl}]`),
1255
+ chalk.gray('ID de Identidad: ') + chalk.white(projectConfig.projectId || 'N/A'),
1256
+ );
1257
+ } else {
1258
+ infoLines.push(chalk.gray('Configuración: ') + configStatus);
1259
+ }
1260
+
1261
+ note(infoLines.join('\n'), 'Telemetría del Objetivo');
1262
+ }
1263
+
1264
+ const ARCALITY_BRAIN_URL = "https://api.anthropic.com/v1/messages";
1265
+ const ARCALITY_MODEL = process.env.CLAUDE_MODEL || "claude-4-5-sonnet-latest";
1266
+ const OUT_DIR = path.join(PROJECT_ROOT, "tests");
1267
+ const LOGS_DIR = path.join(PROJECT_ROOT, "logs");
1268
+
1269
+ async function arcalityChat(messages) {
1270
+ const internalApiUrl = getApiUrl();
1271
+ const isProxyMode = !!internalApiUrl;
1272
+
1273
+ if (!process.env.ANTHROPIC_API_KEY && !isProxyMode) {
1274
+ throw new Error("Missing ANTHROPIC_API_KEY in .env");
1275
+ }
1276
+
1277
+ const systemMessage = messages.find(m => m.role === 'system');
1278
+ const userMessages = messages.filter(m => m.role !== 'system');
1279
+
1280
+ try {
1281
+ const endpointUrl = isProxyMode
1282
+ ? `${internalApiUrl}/api/v1/ai/proxy`
1283
+ : ARCALITY_BRAIN_URL;
1284
+
1285
+ const headers = {
1286
+ "Content-Type": "application/json"
1287
+ };
1288
+
1289
+ if (isProxyMode) {
1290
+ headers["x-api-key"] = process.env.ARCALITY_API_KEY || "";
1291
+ const missionId = process.env.ARCALITY_MISSION_ID || '';
1292
+ if (missionId) headers["x-mission-id"] = missionId;
1293
+ } else {
1294
+ headers["x-api-key"] = process.env.ANTHROPIC_API_KEY;
1295
+ headers["anthropic-version"] = "2023-06-01";
1296
+ }
1297
+
1298
+ const res = await fetch(endpointUrl, {
1299
+ method: "POST",
1300
+ headers,
1301
+ body: JSON.stringify({
1302
+ model: ARCALITY_MODEL,
1303
+ system: systemMessage ? systemMessage.content : undefined,
1304
+ messages: userMessages,
1305
+ max_tokens: 4000,
1306
+ temperature: 0.3
1307
+ })
1308
+ });
1309
+
1310
+ if (!res.ok) {
1311
+ const txt = await res.text();
1312
+ throw new Error(`Arcality Brain Error ${res.status}: ${txt}`);
1313
+ }
1314
+
1315
+ const data = await res.json();
1316
+ const content = data.content?.[0]?.text || "";
1317
+
1318
+ try {
1319
+ if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
1320
+ fs.writeFileSync(path.join(LOGS_DIR, 'last-ai-response.txt'), content);
1321
+ } catch (e) { /* ignore */ }
1322
+
1323
+ return content;
1324
+ } catch (err) {
1325
+ throw new Error(`Error communicating with Arcality brain: ${err.message}`);
1326
+ }
1327
+ }
1328
+
1329
+ function extractBlocks(text) {
1330
+ const cleaned = text
1331
+ .replace(/```[a-zA-Z]*\n?/g, "")
1332
+ .replace(/```/g, "")
1333
+ .trim();
1334
+
1335
+ const fnMatch = cleaned.match(/^FILENAME:\s*(.+)$/m);
1336
+ const contentIdx = cleaned.indexOf("CONTENT:");
1337
+
1338
+ if (!fnMatch || contentIdx === -1) {
1339
+ throw new Error(
1340
+ `Invalid format. Expected:\nFILENAME: <file.spec.ts>\nCONTENT:\n<code>\n\nResponse:\n${cleaned}`
1341
+ );
1342
+ }
1343
+
1344
+ const filename = fnMatch[1].trim();
1345
+ const content = cleaned.slice(contentIdx + "CONTENT:".length).trim();
1346
+
1347
+ return { filename, content, cleaned };
1348
+ }
1349
+
1350
+ function validateSpec(ts) {
1351
+ const errors = [];
1352
+ if (!/test\s*\(.*async\s*\(\s*\{\s*page\s*\}\s*\)\s*=>/s.test(ts)) {
1353
+ errors.push('Missing fixture: test("...", async ({ page }) => { ... })');
1354
+ }
1355
+ if (/from\s+['"]playwright['"]/.test(ts)) {
1356
+ errors.push("Forbidden to import from core. Use only the Arcality SDK.");
1357
+ }
1358
+ if (!ts.includes("import { test, expect } from '@playwright/test'")) {
1359
+ errors.push("Must import exactly the Arcality engine.");
1360
+ }
1361
+ return errors;
1362
+ }
1363
+
1364
+ function sanitizeFilename(name, fallback) {
1365
+ if (typeof name !== "string") return fallback;
1366
+ const safe = name.trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_");
1367
+ if (!safe.endsWith(".spec.ts")) return fallback;
1368
+ return safe;
1369
+ }
1370
+
1371
+ function run(cmd, args, options = {}) {
1372
+ const isDebug = process.argv.includes('--debug');
1373
+ const cwd = options.cwd || process.cwd();
1374
+ const streamOutput = options.streamOutput !== false || isDebug;
1375
+
1376
+ return new Promise((resolve, reject) => {
1377
+ // NODE_PATH: let Node resolve modules from both the arcality package and user's project
1378
+ const appNodeModules = path.join(PROJECT_ROOT, 'node_modules');
1379
+ const userNodeModules = path.join(ORIGINAL_CWD, 'node_modules');
1380
+ const existingNodePath = process.env.NODE_PATH || '';
1381
+ const newNodePath = [appNodeModules, userNodeModules, existingNodePath].filter(Boolean).join(path.delimiter);
1382
+
1383
+ const env = { ...options.env || process.env, NODE_PATH: newNodePath };
1384
+ if (isDebug) console.log(chalk.gray(`\n[DEBUG] CWD: ${cwd}`));
1385
+ if (isDebug) console.log(chalk.gray(`[DEBUG] Running: ${cmd} ${args.join(' ')}`));
1386
+
1387
+ // Use standard cross-platform spawn. When shell is false, Node's child_process
1388
+ // properly wraps arguments with spaces in quotes natively for CreateProcessW on Windows.
1389
+ //
1390
+ // CRITICAL (Windows): stdin MUST be 'ignore' (not 'inherit') to prevent cmd.exe from
1391
+ // showing the "¿Desea terminar el trabajo por lotes (S/N)?" prompt on Ctrl+C.
1392
+ // When stdin is 'inherit', cmd.exe intercepts Ctrl+C at the console level and kills
1393
+ // the entire process tree before our SIGINT handler can run, bypassing the finally block.
1394
+ const p = spawn(cmd, args, {
1395
+ stdio: isDebug ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
1396
+ shell: false,
1397
+ cwd,
1398
+ env,
1399
+ });
1400
+
1401
+ // Exponer el proceso hijo para que el cancelHandler pueda matarlo
1402
+ if (options.onSpawn) options.onSpawn(p);
1403
+
1404
+ let outputBuffer = '';
1405
+ const handleData = (data) => {
1406
+ const str = data.toString();
1407
+ outputBuffer += str;
1408
+
1409
+ if (str.includes('>>ARCALITY_STATUS>>')) {
1410
+ const lines = str.split('\n');
1411
+ for (const line of lines) {
1412
+ if (line.includes('>>ARCALITY_STATUS>>')) {
1413
+ const status = line.split('>>ARCALITY_STATUS>>')[1].trim();
1414
+ if (options.onStatus) options.onStatus(status);
1415
+ } else if (line.trim() && streamOutput) {
1416
+ process.stdout.write(line + '\n');
1417
+ }
1418
+ }
1419
+ } else if (streamOutput) {
1420
+ process.stdout.write(data);
1421
+ }
1422
+ };
1423
+
1424
+ if (p.stdout) p.stdout.on('data', handleData);
1425
+ if (p.stderr) {
1426
+ p.stderr.on('data', (data) => {
1427
+ if (streamOutput) process.stderr.write(data);
1428
+ outputBuffer += data.toString();
1429
+ });
1430
+ }
1431
+
1432
+ p.on('exit', (code, signal) => {
1433
+ const lastRunLog = options.lastRunLog || path.join(LOGS_DIR, 'last-run.log');
1434
+ try {
1435
+ const lastRunLogDir = path.dirname(lastRunLog);
1436
+ if (!fs.existsSync(lastRunLogDir)) fs.mkdirSync(lastRunLogDir, { recursive: true });
1437
+ fs.writeFileSync(lastRunLog, outputBuffer);
1438
+ } catch (e) { }
1439
+
1440
+ // code===0clean success
1441
+ // code===null + signal killed by signal (Ctrl+C) — treat as non-fatal so finally runs
1442
+ // code===1 test failed (Playwright returns 1 on test failure) — also non-fatal
1443
+ if (code === 0 || signal !== null || code === 1) resolve();
1444
+ else {
1445
+ if (isDebug) console.error(chalk.red(`\n[DEBUG] Process exited with code ${code}`));
1446
+ reject(new Error(`Process exited with code ${code}`));
1447
+ }
1448
+ });
1449
+
1450
+ p.on('error', (err) => {
1451
+ if (isDebug) console.error(chalk.red(`\n[DEBUG] Process failed to spawn: ${err.message}`));
1452
+ reject(err);
1453
+ });
1454
+ });
1455
+ }
1456
+
1457
+ function runNpmScript(args, options = {}) {
1458
+ if (process.platform === 'win32') {
1459
+ return run('cmd.exe', ['/d', '/s', '/c', 'npm.cmd', ...args], options);
1460
+ }
1461
+ return run('npm', args, options);
1462
+ }
1463
+
1464
+ /**
1465
+ * Ping the project to register activity.
1466
+ * Uses internal API URL — not user-configurable.
1467
+ */
1468
+ async function pingProject(projectId, apiKey) {
1469
+ if (!projectId || !apiKey) return;
1470
+ try {
1471
+ await fetch(`${getApiUrl()}/api/v1/projects/${projectId}/ping`, {
1472
+ method: 'PATCH',
1473
+ headers: { 'x-api-key': apiKey },
1474
+ });
1475
+ } catch { /* silent */ }
1476
+ }
1477
+
1478
+ async function main() {
1479
+ process.chdir(PROJECT_ROOT);
1480
+
1481
+ // Backward compat: initialize .env if ACTIVE_CONFIG missing (legacy)
1482
+ let initialEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
1483
+ const envProjectId = initialEnv.ARCALITY_PROJECT_ID || process.env.ARCALITY_PROJECT_ID;
1484
+ if (envProjectId && !isRealProjectId(envProjectId)) {
1485
+ console.error(chalk.red('\n[FATAL] ARCALITY_PROJECT_ID invalido.'));
1486
+ console.error(chalk.yellow('La herramienta no acepta Project IDs locales o mock.'));
1487
+ console.error(chalk.gray('Ejecuta `arcality init` para registrar el proyecto en Arcality.\n'));
1488
+ process.exit(1);
1489
+ }
1490
+
1491
+ if (!projectConfig && !initialEnv.ACTIVE_CONFIG) {
1492
+ updateDotEnvFile({
1493
+ ACTIVE_CONFIG: 'Default',
1494
+ SAVED_CONFIGS: 'Default',
1495
+ Default_URL: initialEnv.BASE_URL || '',
1496
+ Default_USER: initialEnv.LOGIN_USER || '',
1497
+ Default_PASS: initialEnv.LOGIN_PASSWORD || ''
1498
+ });
1499
+ initialEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
1500
+ }
1501
+
1048
1502
  const argv = process.argv.slice(2);
1049
1503
  let initialDiscoverPath = null;
1050
1504
  let initialSmartMode = false;
@@ -1054,6 +1508,7 @@ async function main() {
1054
1508
  let initialSkipSavePrompt = process.env.ARCALITY_SKIP_SAVE_PROMPT === 'true';
1055
1509
  let initialBatchMode = process.env.ARCALITY_BATCH_MODE === 'true';
1056
1510
  let initialCollectionPath = null;
1511
+ let initialCollectionConcurrency = null;
1057
1512
 
1058
1513
  const dIndex = argv.findIndex(a => a === '--discover' || a.startsWith('--discover='));
1059
1514
  if (dIndex !== -1) {
@@ -1084,16 +1539,26 @@ async function main() {
1084
1539
  }
1085
1540
  argv.splice(collectionIndex, 1);
1086
1541
  }
1542
+ const concurrencyIndex = argv.findIndex(a => a === '--concurrency' || a.startsWith('--concurrency=') || a === '--parallel' || a.startsWith('--parallel='));
1543
+ if (concurrencyIndex !== -1) {
1544
+ const token = argv[concurrencyIndex];
1545
+ if (token.includes('=')) initialCollectionConcurrency = normalizeCollectionConcurrency(token.split('=')[1], 1);
1546
+ else if (argv[concurrencyIndex + 1]) {
1547
+ initialCollectionConcurrency = normalizeCollectionConcurrency(argv[concurrencyIndex + 1], 1);
1548
+ argv.splice(concurrencyIndex + 1, 1);
1549
+ }
1550
+ argv.splice(concurrencyIndex, 1);
1551
+ }
1087
1552
  const debugIndex = argv.findIndex(a => a === '--debug');
1088
1553
  if (debugIndex !== -1) { argv.splice(debugIndex, 1); } // Handled by run() naturally now
1089
-
1090
- const promptArg = argv.join(" ").trim();
1091
- let firstRun = true;
1092
- let globalReportProcess = null;
1093
-
1094
- setupEnvironment();
1095
-
1096
- while (true) {
1554
+
1555
+ const promptArg = argv.join(" ").trim();
1556
+ let firstRun = true;
1557
+ let globalReportProcess = null;
1558
+
1559
+ setupEnvironment();
1560
+
1561
+ while (true) {
1097
1562
  let prompt = firstRun ? promptArg : "";
1098
1563
  let agentMode = firstRun ? initialAgentMode : false;
1099
1564
  let smartMode = firstRun ? initialSmartMode : false;
@@ -1101,6 +1566,7 @@ async function main() {
1101
1566
  let runAllMode = firstRun ? initialRunAllMode : false;
1102
1567
  let templateMode = firstRun ? initialTemplateMode : false;
1103
1568
  let collectionPath = firstRun ? initialCollectionPath : null;
1569
+ let collectionConcurrency = firstRun ? initialCollectionConcurrency : null;
1104
1570
  let skipSavePrompt = firstRun ? initialSkipSavePrompt : false;
1105
1571
  let batchMode = firstRun ? initialBatchMode : false;
1106
1572
 
@@ -1108,31 +1574,31 @@ async function main() {
1108
1574
  if (agentMode) skipMenu = true; // Hard-lock: No menu in agent mode
1109
1575
 
1110
1576
  firstRun = false;
1111
-
1577
+
1112
1578
  if (!skipMenu) {
1113
1579
  showBanner();
1114
1580
  try {
1115
- // ── Build menu options (single config mode) ──
1116
- const missionsDir = process.env.MISSIONS_DIR;
1581
+ // ── Build menu options (single config mode) ──
1582
+ const missionsDir = process.env.MISSIONS_DIR;
1117
1583
  const savedMissionCollections = listMissionCollections(missionsDir);
1118
-
1119
- // Check for YAML files in the yaml output dir from arcality.config
1584
+
1585
+ // Check for YAML files in the yaml output dir from arcality.config
1120
1586
  let projectMissionCollections = [];
1121
- if (projectConfig) {
1122
- const yamlDir = getYamlOutputDir(projectConfig, ORIGINAL_CWD);
1123
- if (fs.existsSync(yamlDir)) {
1587
+ if (projectConfig) {
1588
+ const yamlDir = getYamlOutputDir(projectConfig, ORIGINAL_CWD);
1589
+ if (fs.existsSync(yamlDir)) {
1124
1590
  projectMissionCollections = listMissionCollections(yamlDir);
1125
- }
1126
- }
1127
-
1128
- // Root-level YAML templates
1591
+ }
1592
+ }
1593
+
1594
+ // Root-level YAML templates
1129
1595
  const rootMissionFiles = fs.existsSync(ORIGINAL_CWD)
1130
1596
  ? fs.readdirSync(ORIGINAL_CWD).filter(f => isYamlFile(f))
1131
1597
  : [];
1132
1598
  const hasSavedMissionSources = savedMissionCollections.length || projectMissionCollections.length || rootMissionFiles.length;
1133
1599
  const yamlOutputCollections = projectMissionCollections;
1134
1600
  const rootYamlFiles = rootMissionFiles;
1135
-
1601
+
1136
1602
  const options = [
1137
1603
  { label: '◉ Desplegar Arcality (Prompt)', value: 'agent' },
1138
1604
  ...(savedMissionCollections.length ? [{ label: '▶ Ejecutar colección completa', value: 'run_collection' }] : []),
@@ -1143,7 +1609,7 @@ async function main() {
1143
1609
  { label: '◎ Recalibrar Sistema (init)', value: 'reconfigure' },
1144
1610
  { label: '✕ Terminar Proceso', value: 'exit' }
1145
1611
  ];
1146
-
1612
+
1147
1613
  const menuOptions = options
1148
1614
  .filter(option => !['launch_saved', 'reuse_yaml', 'yaml_list'].includes(option.value))
1149
1615
  .map(option => option.value === 'generate_templates'
@@ -1160,15 +1626,15 @@ async function main() {
1160
1626
  }
1161
1627
 
1162
1628
  const action = await select({
1163
- message: '¿Qué te gustaría hacer?',
1629
+ message: '¿Qué te gustaría hacer?',
1164
1630
  options: menuOptions
1165
1631
  });
1166
-
1167
- if (isCancel(action) || action === 'exit') {
1168
- outro(chalk.cyan('¡Hasta luego!'));
1169
- break;
1170
- }
1171
-
1632
+
1633
+ if (isCancel(action) || action === 'exit') {
1634
+ outro(chalk.cyan('¡Hasta luego!'));
1635
+ break;
1636
+ }
1637
+
1172
1638
  if (action === 'agent') {
1173
1639
  agentMode = true;
1174
1640
  } else if (action === 'run_collection') {
@@ -1204,33 +1670,33 @@ async function main() {
1204
1670
  await waitForEnter('Presiona Enter para volver al menu...');
1205
1671
  continue;
1206
1672
  } else if (action === 'reconfigure') {
1207
- // Launch arcality init in the user's project directory
1208
- const initScript = path.join(PROJECT_ROOT, 'scripts', 'init.mjs');
1209
- try {
1210
- await new Promise((resolve, reject) => {
1211
- const child = spawn('node', [initScript], {
1212
- stdio: 'inherit',
1213
- cwd: ORIGINAL_CWD,
1214
- env: { ...process.env, ARCALITY_ROOT: PROJECT_ROOT }
1215
- });
1216
- child.on('exit', code => code === 0 ? resolve() : reject(new Error(`Init exited with ${code}`)));
1217
- child.on('error', reject);
1218
- });
1219
-
1220
- // ── REFRESH CONFIG ──
1221
- // Recargar el archivo que init.mjs acaba de escribir
1222
- projectConfig = loadProjectConfig(ORIGINAL_CWD) || loadProjectConfig(PROJECT_ROOT);
1223
- if (projectConfig) {
1224
- injectConfigToEnv(projectConfig);
1225
- }
1226
- setupEnvironment();
1227
-
1228
- } catch (e) {
1229
- // Mostrar el error claramente antes de que console.clear() lo borre
1230
- console.log(chalk.red(`\n❌ Reconfigure failed: ${e.message}`));
1231
- await new Promise(r => setTimeout(r, 3000));
1232
- }
1233
- continue;
1673
+ // Launch arcality init in the user's project directory
1674
+ const initScript = path.join(PROJECT_ROOT, 'scripts', 'init.mjs');
1675
+ try {
1676
+ await new Promise((resolve, reject) => {
1677
+ const child = spawn('node', [initScript], {
1678
+ stdio: 'inherit',
1679
+ cwd: ORIGINAL_CWD,
1680
+ env: { ...process.env, ARCALITY_ROOT: PROJECT_ROOT }
1681
+ });
1682
+ child.on('exit', code => code === 0 ? resolve() : reject(new Error(`Init exited with ${code}`)));
1683
+ child.on('error', reject);
1684
+ });
1685
+
1686
+ // ── REFRESH CONFIG ──
1687
+ // Recargar el archivo que init.mjs acaba de escribir
1688
+ projectConfig = loadProjectConfig(ORIGINAL_CWD) || loadProjectConfig(PROJECT_ROOT);
1689
+ if (projectConfig) {
1690
+ injectConfigToEnv(projectConfig);
1691
+ }
1692
+ setupEnvironment();
1693
+
1694
+ } catch (e) {
1695
+ // Mostrar el error claramente antes de que console.clear() lo borre
1696
+ console.log(chalk.red(`\n❌ Reconfigure failed: ${e.message}`));
1697
+ await new Promise(r => setTimeout(r, 3000));
1698
+ }
1699
+ continue;
1234
1700
  } else if (action === 'launch_saved') {
1235
1701
  const selectedMission = await selectMissionYaml(
1236
1702
  missionsDir,
@@ -1262,8 +1728,8 @@ async function main() {
1262
1728
  const sel = await select({
1263
1729
  message: 'Elige el archivo YAML:',
1264
1730
  options: rootYamlFiles.map(f => ({ label: f, value: f }))
1265
- });
1266
- if (isCancel(sel)) continue;
1731
+ });
1732
+ if (isCancel(sel)) continue;
1267
1733
 
1268
1734
  const yamlContent = fs.readFileSync(path.join(PROJECT_ROOT, sel), 'utf8');
1269
1735
  const missionSpec = normalizeMissionSpec(loadYaml(yamlContent), path.join(PROJECT_ROOT, sel));
@@ -1271,9 +1737,9 @@ async function main() {
1271
1737
  discoverPath = getMissionStartPath(missionSpec);
1272
1738
  agentMode = true;
1273
1739
  }
1274
- } catch (e) {
1275
- console.error("Menu error:", e);
1276
- break;
1740
+ } catch (e) {
1741
+ console.error("Menu error:", e);
1742
+ break;
1277
1743
  }
1278
1744
  }
1279
1745
 
@@ -1314,10 +1780,38 @@ async function main() {
1314
1780
  batchCollection = selectedBatchCollection;
1315
1781
  }
1316
1782
 
1783
+ let batchConcurrency = normalizeCollectionConcurrency(
1784
+ collectionConcurrency,
1785
+ skipMenu ? 1 : COLLECTION_DEFAULT_CONCURRENCY
1786
+ );
1787
+
1788
+ if (!skipMenu && process.stdin.isTTY) {
1789
+ note(
1790
+ 'La ejecucion paralela abre varias sesiones del portal al mismo tiempo. Arcality no puede conocer ni validar reglas internas de sesion, bloqueo por usuario, datos compartidos o limites del sistema probado; si tu portal no permite sesiones paralelas con el mismo usuario, usa 1.',
1791
+ 'Sesiones del portal'
1792
+ );
1793
+
1794
+ const selectedConcurrency = await select({
1795
+ message: 'Cuantas misiones quieres ejecutar al mismo tiempo?',
1796
+ options: [
1797
+ { label: `3 simultaneas (recomendado)`, value: 3 },
1798
+ { label: '1 secuencial', value: 1 },
1799
+ { label: '5 simultaneas (maximo)', value: 5 }
1800
+ ]
1801
+ });
1802
+ if (isCancel(selectedConcurrency)) {
1803
+ if (skipMenu) return;
1804
+ continue;
1805
+ }
1806
+ batchConcurrency = normalizeCollectionConcurrency(selectedConcurrency, COLLECTION_DEFAULT_CONCURRENCY);
1807
+ }
1808
+
1317
1809
  await runCollectionBatch({
1318
1810
  missionsRootDir: process.env.MISSIONS_DIR,
1319
1811
  selectedCollection: batchCollection || '__ALL__',
1320
- debug: process.argv.includes('--debug')
1812
+ debug: process.argv.includes('--debug'),
1813
+ concurrency: batchConcurrency,
1814
+ interactive: !skipMenu
1321
1815
  });
1322
1816
 
1323
1817
  if (skipMenu) return;
@@ -1326,443 +1820,508 @@ async function main() {
1326
1820
 
1327
1821
  // --- VALIDATION AND CAPTURE OF PARAMETERS FOR THE AGENT ---
1328
1822
  if (agentMode) {
1329
- if (!prompt || prompt.trim().length < 4) {
1330
- const p = await text({
1331
- message: chalk.cyan('🤖 Misión para Arcality (Requerida):'),
1332
- placeholder: 'Ej., Llena el formulario de registro con datos aleatorios',
1333
- validate: v => {
1334
- if (!v || !v.trim()) return 'La misión es requerida para iniciar.';
1335
- if (v.trim().length < 4) return 'La misión debe ser más descriptiva (mínimo 4 caracteres).';
1336
- }
1337
- });
1338
-
1339
- if (isCancel(p)) {
1340
- cancel('Misión cancelada. Regresando al menú principal...');
1341
- agentMode = false;
1342
- prompt = "";
1343
- skipMenu = false;
1344
- continue;
1345
- }
1346
- prompt = p;
1347
- }
1348
-
1349
- if (!discoverPath) {
1350
- const knownBaseUrl = projectConfig?.project?.baseUrl || process.env.BASE_URL;
1351
- const promptMsg = knownBaseUrl
1352
- ? `🌐 Initial navigation path (relative to ${knownBaseUrl}):`
1353
- : '🌐 URL Completa de Navegación (ej., http://localhost:3000/):';
1354
-
1355
- const d = await text({
1356
- message: chalk.cyan(promptMsg),
1357
- placeholder: knownBaseUrl ? '/' : 'http://localhost:3000/',
1358
- validate: v => {
1359
- if (!knownBaseUrl && !v.startsWith('http')) return 'Por favor provee una URL completa con http:// o https://';
1360
- return undefined;
1361
- }
1362
- });
1363
-
1364
- if (isCancel(d)) {
1365
- cancel('Misión abortada. Regresando al menú...');
1366
- agentMode = false;
1367
- prompt = "";
1368
- skipMenu = false;
1369
- continue;
1370
- }
1371
- discoverPath = d;
1372
- }
1373
- }
1374
-
1375
- if (agentMode) {
1376
- // Load environment from BOTH the user's project and the library's root
1377
- const userDotEnv = parseDotEnvFile(path.join(ORIGINAL_CWD, '.env'));
1378
- const libDotEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
1379
- const dotEnv = { ...userDotEnv, ...libDotEnv };
1380
-
1381
- // ── API Key and Quota Validation ──
1382
- const { validateApiKey, startMission: requestMission } = await import('../src/arcalityClient.mjs');
1383
-
1384
- const validation = await validateApiKey();
1385
- if (process.env.DEBUG || process.argv.includes('--debug')) {
1386
- console.log(chalk.gray(`[DEBUG] Validation result: ${JSON.stringify(validation, null, 2)}`));
1387
- }
1388
-
1389
- const detectedOrgId = validation.organization_id || validation.organizationId || validation.OrganizationId;
1390
- if (detectedOrgId) {
1391
- process.env.ARCALITY_ORG_ID = detectedOrgId;
1392
- }
1393
- if (!validation.valid) {
1394
- const errorMessages = {
1395
- 'no_api_key': '🔑 API Key no encontrada.\n Configúrala vía `arcality init` o en .env (ARCALITY_API_KEY)',
1396
- 'invalid_format': '🔑 API Key inválida. Debe comenzar con "arc_"',
1397
- 'invalid_api_key': '🔑 API Key no reconocida por el servidor',
1398
- 'plan_expired': '💳 Tu plan ha expirado'
1399
- };
1400
- note(chalk.red(errorMessages[validation.error] || `Error: ${validation.error}`), '❌ Autenticación');
1401
- agentMode = false;
1402
- prompt = "";
1403
- skipMenu = false;
1404
- continue;
1405
- }
1406
-
1407
- // Show plan info
1408
- const modeLabel = validation.mode === 'mock' ? chalk.gray('[sandbox]') : chalk.cyanBright('[live-net]');
1409
- const dailyLimitDisplay = validation.daily_limit === -1 ? '' : (validation.daily_limit || 35);
1410
- const remainingDisplay = validation.remaining >= 999999 ? '∞' : validation.remaining;
1411
- note(
1412
- chalk.gray('Conexión: ') + chalk.cyanBright('SECURE ') + modeLabel + '\n' +
1413
- chalk.gray('Autorización: ') + chalk.white(validation.plan || 'internal') + '\n' +
1414
- chalk.gray('Misiones: ') + chalk.white(`${validation.daily_used || 0}/${dailyLimitDisplay}`) + '\n' +
1415
- chalk.gray('Reservas: ') + chalk.white(remainingDisplay),
1416
- 'Enlace Arcality'
1417
- );
1418
-
1419
- // --- RESOLVE PROJECT ID ---
1420
- let selectedProjectId = projectConfig?.projectId || dotEnv.ARCALITY_PROJECT_ID || process.env.ARCALITY_PROJECT_ID;
1421
- const apiBase = getApiUrl();
1422
-
1423
- if (!selectedProjectId) {
1424
- // If no project ID from config, try to create/select from backend
1425
- try {
1426
- const projController = new AbortController();
1427
- const projTimeout = setTimeout(() => projController.abort(), 5000);
1428
- const projRes = await fetch(`${apiBase}/api/v1/projects`, {
1429
- headers: { 'x-api-key': process.env.ARCALITY_API_KEY || '' },
1430
- signal: projController.signal
1431
- });
1432
- clearTimeout(projTimeout);
1433
-
1434
- if (projRes.ok) {
1435
- const data = await projRes.json();
1436
- const projects = data.projects || [];
1437
-
1438
- const projOptions = projects.map(p => ({ label: p.name || p.Name, value: p.id || p.Id }));
1439
- projOptions.push({ label: '➕ Crear nuevo proyecto', value: 'new' });
1440
-
1441
- const projSelection = await select({
1442
- message: chalk.cyan('Elige un proyecto para esta misión:'),
1443
- options: projOptions
1444
- });
1445
-
1446
- if (isCancel(projSelection)) {
1447
- cancel('Misión abortada.');
1448
- agentMode = false; prompt = ""; skipMenu = false; continue;
1449
- }
1450
-
1451
- if (projSelection === 'new') {
1452
- const newName = await text({ message: 'Nombre del proyecto:', validate: v => !v ? 'Requerido' : undefined });
1453
- if (isCancel(newName)) { cancel('Abortado'); agentMode = false; prompt = ""; skipMenu = false; continue; }
1454
-
1455
- const createRes = await fetch(`${apiBase}/api/v1/projects`, {
1456
- method: 'POST',
1457
- headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.ARCALITY_API_KEY || '' },
1458
- body: JSON.stringify({
1459
- name: newName,
1460
- base_url: projectConfig?.project?.baseUrl || discoverPath || '/',
1461
- arcality_version: projectConfig?.meta?.arcalityVersion || 'unknown',
1462
- config: { source: 'npm-cli', single_configuration_mode: true, yaml_reuse_enabled: true }
1463
- })
1464
- });
1465
-
1466
- if (createRes.ok) {
1467
- const created = await createRes.json();
1468
- selectedProjectId = created.id || created.Id;
1469
- note(chalk.green(`✅ Proyecto creado: ${newName}`));
1470
- } else {
1471
- note(chalk.red(`❌ Falló al crear proyecto.`));
1472
- }
1473
- } else {
1474
- selectedProjectId = projSelection;
1475
- }
1476
-
1477
- if (selectedProjectId) {
1478
- process.env.ARCALITY_PROJECT_ID = selectedProjectId;
1479
- }
1480
- }
1481
- } catch (e) {
1482
- console.log(chalk.yellow(`\n⚠️ Error connecting to project server: ${e.message}. Using default.`));
1483
- }
1484
- } else {
1485
- process.env.ARCALITY_PROJECT_ID = selectedProjectId;
1486
- }
1487
-
1488
- // ── Start Mission ──
1489
- const mission = await requestMission(prompt, discoverPath || '/');
1490
- if (!mission.allowed) {
1491
- note(
1492
- chalk.red(`❌ ${mission.error === 'mission_limit_exceeded' ? 'Límite diario de misiones alcanzado' : mission.error}`) + '\n' +
1493
- chalk.yellow(`📊 Used: ${mission.daily_used}/${mission.daily_limit}`) + '\n' +
1494
- chalk.gray(`🔄 Resets: ${mission.resets_at ? new Date(mission.resets_at).toLocaleString() : 'tomorrow'}`),
1495
- '⚠️ Límite Alcanzado'
1496
- );
1497
- agentMode = false;
1498
- prompt = "";
1499
- skipMenu = false;
1500
- continue;
1501
- }
1502
-
1503
- const s = spinner();
1504
- if (!process.argv.includes('--debug')) {
1505
- s.start(`◉ [ARCALITY INICIADO] Ejecutando directiva: "${prompt}" ${chalk.gray(`(${mission.daily_used}/${mission.daily_limit})`)}`);
1506
- } else {
1507
- console.log(chalk.cyan(`\n◉ [ARCALITY INICIADO] Ejecutando directiva: "${prompt}" ${chalk.gray(`(${mission.daily_used}/${mission.daily_limit})`)}`));
1508
- }
1509
-
1510
- const finalProjectId = process.env.ARCALITY_PROJECT_ID || selectedProjectId;
1511
-
1512
- // ── Cancelación Manual (Ctrl+C) Estrategia bulletproof ──
1513
- // El problema: @clack/prompts registra su propio SIGINT handler cada tick del spinner,
1514
- // que llama a process.exit(0) directamente. removeAllListeners() no es suficiente porque
1515
- // Clack lo re-registra en cada frame de animación.
1516
- //
1517
- // La solución: interceptar process.exit() antes de que Clack lo llame,
1518
- // y usar prependListener para que NUESTRO handler siempre se ejecute primero.
1519
-
1520
- let activeChildProcess = null;
1521
- let userCanceledMission = false;
1522
- let sigintCount = 0;
1523
- let _missionActive = true; // Mientras true, bloqueamos el process.exit de Clack
1524
-
1525
- // Guardar el process.exit original y reemplazarlo temporalmente
1526
- const _origProcessExit = process.exit.bind(process);
1527
- process.exit = (code) => {
1528
- if (_missionActive) {
1529
- // Clack (u otra lib) está intentando matar el proceso — lo bloqueamos.
1530
- // Nuestro cancelHandler ya habrá sido invocado vía prependListener.
1531
- // El proceso se cerrará naturalmente cuando el child process termine.
1532
- return;
1533
- }
1534
- _origProcessExit(code);
1535
- };
1536
-
1537
- // ── RESTORE RAW MODE FOR WINDOWS FIX ──
1538
- // @clack/prompts pausea stdin y desactiva raw mode al terminar un menú.
1539
- // Debemos reactivarlo aquí para que nuestro interceptor global de Ctrl+C
1540
- // siga funcionando durante toda la misión.
1541
- if (process.stdin.isTTY && process.stdin.setRawMode) {
1542
- try {
1543
- process.stdin.setRawMode(true);
1544
- process.stdin.resume();
1545
- } catch(e) {}
1546
- }
1547
-
1548
- const cancelHandler = () => {
1549
- sigintCount++;
1550
- if (sigintCount > 3) {
1551
- console.log(chalk.red('\n🛑 Forzando salida inmediata...'));
1552
- _missionActive = false;
1553
- _origProcessExit(1);
1554
- return;
1555
- }
1556
-
1557
- if (userCanceledMission) {
1558
- console.log(chalk.yellow('\n⏳ Cancelación en progreso, esperando reporte... (Ctrl+C x4 para forzar cierre)'));
1559
- return;
1560
- }
1561
- userCanceledMission = true;
1562
-
1563
- console.log(chalk.yellow('\n\n⚠️ [ARCALITY] Directiva abortada por el operador.'));
1564
- if (!process.argv.includes('--debug')) {
1565
- try { s.stop(chalk.yellow('⚠️ Directiva abortada.')); } catch { /* ignore */ }
1566
- }
1567
-
1568
- // Terminar el proceso hijo de Playwright directamente (Windows-safe)
1569
- if (activeChildProcess && !activeChildProcess.killed) {
1570
- try {
1571
- if (process.platform === 'win32') {
1572
- spawn('taskkill', ['/pid', String(activeChildProcess.pid), '/f', '/t'], { stdio: 'ignore', windowsHide: true });
1573
- } else {
1574
- activeChildProcess.kill('SIGTERM');
1575
- }
1576
- } catch { /* silencioso */ }
1577
- }
1578
-
1579
- try {
1580
- const ctxDir = process.env.CONTEXT_DIR;
1581
- if (ctxDir && fs.existsSync(ctxDir)) {
1582
- const cancelLog = {
1583
- prompt,
1584
- history: ['[ABORTO DEL OPERADOR] La directiva fue detenida manualmente antes de completarse.'],
1585
- steps: 0,
1586
- success: false,
1587
- error: true,
1588
- fail_reason: 'user_canceled',
1589
- canceled_by_user: true,
1590
- timestamp: new Date().toISOString()
1591
- };
1592
- fs.writeFileSync(path.join(ctxDir, `arcality-log-${Date.now()}.json`), JSON.stringify(cancelLog, null, 2));
1593
- }
1594
- } catch { /* silencioso */ }
1595
-
1596
- console.log(chalk.gray(' >> Esperando que Playwright cierre y genere el reporte...'));
1597
- // Playwright también recibe el SIGINT nativamente desde la terminal Windows
1598
- // y cerrará su proceso limpiamente, lo que resolverá la Promise del run() → finally
1599
- };
1600
-
1601
- // prependListener asegura que nuestro handler se ejecuta ANTES que el de Clack
1602
- process.prependListener('SIGINT', cancelHandler);
1603
- process.prependListener('SIGTERM', cancelHandler);
1604
-
1605
-
1606
- // Build env for the runner — merge arcality.config values.
1607
- // ARCALITY_API_URL must be explicit — it comes from the library's internal config,
1608
- // not from the user's project .env, so we inject it directly via getApiUrl().
1609
- const fallbackBaseUrl = (!projectConfig && discoverPath?.startsWith('http')) ? discoverPath : undefined;
1610
-
1611
- const mergedEnv = {
1612
- ...dotEnv,
1613
- ...process.env,
1614
- ARCALITY_API_URL: getApiUrl(), // Internal URL — always injected explicitly
1615
- ARCALITY_PROJECT_ID: finalProjectId,
1616
- ARCALITY_MISSION_ID: mission.mission_id || mission.missionId || mission.MissionId || '', // ← Propagate mission_id to every proxy call
1617
- ARCALITY_ORG_ID: validation.organization_id || validation.organizationId || validation.OrganizationId || process.env.ARCALITY_ORG_ID || '', // ← Propagate org_id for pattern persistence
1618
- BASE_URL: projectConfig?.project?.baseUrl || dotEnv.BASE_URL || process.env.BASE_URL || fallbackBaseUrl || 'http://localhost:3000',
1619
- LOGIN_USER: projectConfig?.auth?.username || dotEnv.LOGIN_USER || process.env.LOGIN_USER,
1620
- LOGIN_PASSWORD: projectConfig?.auth?.password || dotEnv.LOGIN_PASSWORD || process.env.LOGIN_PASSWORD,
1621
- DOMAIN_DIR: process.env.DOMAIN_DIR,
1622
- MISSIONS_DIR: process.env.MISSIONS_DIR,
1623
- CONTEXT_DIR: process.env.CONTEXT_DIR,
1624
- REPORTS_DIR: process.env.REPORTS_DIR,
1625
- SMART_PROMPT: prompt,
1626
- TARGET_PATH: discoverPath || '/'
1627
- };
1628
-
1629
- if (finalProjectId) {
1630
- console.log(chalk.gray(`>> ARCALITY_PROJECT_ID: ${finalProjectId}`));
1631
- console.log(chalk.gray(`>> ARCALITY_ORG_ID: ${mergedEnv.ARCALITY_ORG_ID || 'NONE'}`));
1632
- }
1633
-
1634
- // ── Resolve execution paths ──
1635
- // tsx and playwright are HOISTED to ORIGINAL_CWD/node_modules when installed via npm.
1636
- // We run from ORIGINAL_CWD so relative paths for playwright work.
1637
- // For the test file and config (inside the arcality package), we use absolute paths.
1638
- //
1639
- // KEY: tsx loader uses file:// URL (pathToFileURL) so spaces encode as %20 — no quoting needed.
1640
-
1641
- const testFile = path.join(PROJECT_ROOT, 'tests', '_helpers', 'agentic-runner.spec.ts');
1642
- const configFile = path.join(PROJECT_ROOT, 'playwright.config.js');
1643
-
1644
- // Resolve playwright CLI CRITICAL singleton alignment:
1645
- //
1646
- // @playwright/test uses `playwright` internally. When npm detects a version mismatch
1647
- // between the top-level `playwright` and what `@playwright/test` needs, it installs
1648
- // a NESTED copy at: @playwright/test/node_modules/playwright/
1649
- //
1650
- // Playwright's test runner tracks the active suite via a module-level singleton
1651
- // (currentSuite). If the CLI uses a DIFFERENT playwright instance than the one
1652
- // @playwright/test loaded, their singletons are out of sync → "did not expect test()
1653
- // to be called here".
1654
- //
1655
- // Fix: ALWAYS prefer the playwright nested INSIDE @playwright/test, because that is
1656
- // guaranteed to be the same instance that @playwright/test uses for its internals.
1657
- //
1658
- // Resolution priority:
1659
- // 1. <project>/@playwright/test/node_modules/playwright/cli.js ← nested (safe)
1660
- // 2. <project>/playwright/cli.js ← sibling top-level
1661
- // (repeated for ORIGINAL_CWD and PROJECT_ROOT)
1662
- let playwrightCli;
1663
- try {
1664
- const searchPaths = [ORIGINAL_CWD, PROJECT_ROOT];
1665
- let testRunnerPath = null;
1666
- let resolveError = null;
1667
-
1668
- for (const searchPath of searchPaths) {
1669
- try {
1670
- testRunnerPath = require.resolve('@playwright/test', { paths: [searchPath] });
1671
- break;
1672
- } catch (e) {
1673
- resolveError = e;
1674
- }
1675
- }
1676
-
1677
- if (!testRunnerPath) throw resolveError;
1678
-
1679
- const testRunnerDir = path.dirname(testRunnerPath); // → .../node_modules/@playwright/test/
1680
-
1681
- // Priority 1: playwright nested INSIDE @playwright/test — same singleton as the package uses
1682
- const nestedCli = path.join(testRunnerDir, 'node_modules', 'playwright', 'cli.js');
1683
- // Priority 2: playwright as a top-level sibling (clean installs, versions aligned)
1684
- const siblingCli = path.join(testRunnerDir, '..', '..', 'playwright', 'cli.js');
1685
-
1686
- if (fs.existsSync(nestedCli)) {
1687
- playwrightCli = nestedCli;
1688
- } else if (fs.existsSync(siblingCli)) {
1689
- playwrightCli = siblingCli;
1690
- } else {
1691
- throw new Error(
1692
- `Playwright CLI not found. Tried:\n (nested) ${nestedCli}\n (sibling) ${siblingCli}`
1693
- );
1694
- }
1695
-
1696
- if (process.argv.includes('--debug')) {
1697
- console.log(chalk.gray(`[DEBUG] Using playwright CLI: ${playwrightCli}`));
1698
- }
1699
- } catch (e) {
1700
- console.error(chalk.red('\n[FATAL] Núcleo de Playwright no encontrado. Asegúrate de que @playwright/test esté instalado.'));
1701
- console.error(chalk.red(e.message));
1702
- console.error(chalk.yellow('\nConsejo: Ejecuta `npm install @playwright/test` en tu directorio de proyecto.'));
1703
- _missionActive = false;
1704
- process.exit = _origProcessExit;
1705
- _origProcessExit(1);
1706
- }
1707
-
1708
- const postProcessSigint = () => console.log(chalk.yellow('\n⏳ Finalizando reporte de telemetría, por favor espera...'));
1709
- let missionResult = null;
1710
- let finalFailReason = null;
1711
- let finalUsageData = undefined;
1712
- let finalReportUrl = null;
1713
- let finalFailReasoning = null;
1714
-
1715
- // ── Auto-compilar el runner antes de ejecutar Playwright para asegurar que los cambios de rama/merge estén aplicados ──
1716
- try {
1717
- if (!process.argv.includes('--debug')) {
1718
- s.message('⚙️ Compilando agente de pruebas (Arcality Runner)...');
1719
- } else {
1720
- console.log(chalk.cyan('⚙️ Compilando agente de pruebas (Arcality Runner)...'));
1721
- }
1722
- const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
1723
- await run(npmCmd, ['run', 'build:runner'], { cwd: PROJECT_ROOT });
1724
- } catch (err) {
1725
- console.warn(chalk.yellow(`\n⚠️ Advertencia: No se pudo auto-compilar el runner: ${err.message}`));
1726
- }
1727
-
1728
- try {
1729
- // No test file path is passed — playwright.config.js uses testMatch:'agentic-runner.spec.ts'
1730
- // This eliminates the Windows regex path issue permanently on any user's machine.
1731
- await run('node', [
1732
- '--no-warnings',
1733
- playwrightCli,
1734
- 'test',
1735
- '--headed',
1736
- '--config', 'playwright.config.js'
1737
- ], {
1738
- cwd: PROJECT_ROOT,
1739
- env: mergedEnv,
1740
- onStatus: (msg) => { if (!process.argv.includes('--debug')) s.message(msg); else console.log(chalk.gray(`>> Status: ${msg}`)); },
1741
- onSpawn: (child) => { activeChildProcess = child; } // ← Capturar referencia para cancelHandler
1742
- });
1743
-
1744
- process.removeAllListeners('SIGINT');
1745
- process.removeAllListeners('SIGTERM');
1746
- process.on('SIGINT', postProcessSigint);
1747
- process.on('SIGTERM', postProcessSigint);
1748
-
1749
- if (userCanceledMission) {
1750
- missionResult = 'canceled';
1751
- finalFailReason = 'user_canceled';
1752
- // Detener el spinner explícitamente antes de salir
1753
- if (!process.argv.includes('--debug')) {
1754
- try { s.stop(chalk.yellow('⚠️ Misión cancelada por el usuario.')); } catch { }
1755
- }
1756
- return; // El handler ya tomó el control, finally se ejecutará
1757
- }
1758
-
1759
- missionResult = 'success';
1760
- if (!process.argv.includes('--debug')) s.stop(chalk.green('✅ Misión completada exitosamente.'));
1761
- else console.log(chalk.green(' Misión completada exitosamente.'));
1762
-
1763
- // ── Ping Project ──
1764
- await pingProject(finalProjectId, process.env.ARCALITY_API_KEY);
1765
-
1823
+ if (!prompt || prompt.trim().length < 4) {
1824
+ const p = await text({
1825
+ message: chalk.cyan('🤖 Misión para Arcality (Requerida):'),
1826
+ placeholder: 'Ej., Llena el formulario de registro con datos aleatorios',
1827
+ validate: v => {
1828
+ if (!v || !v.trim()) return 'La misión es requerida para iniciar.';
1829
+ if (v.trim().length < 4) return 'La misión debe ser más descriptiva (mínimo 4 caracteres).';
1830
+ }
1831
+ });
1832
+
1833
+ if (isCancel(p)) {
1834
+ cancel('Misión cancelada. Regresando al menú principal...');
1835
+ agentMode = false;
1836
+ prompt = "";
1837
+ skipMenu = false;
1838
+ continue;
1839
+ }
1840
+ prompt = p;
1841
+ }
1842
+
1843
+ if (!discoverPath) {
1844
+ const knownBaseUrl = projectConfig?.project?.baseUrl || process.env.BASE_URL;
1845
+ const promptMsg = knownBaseUrl
1846
+ ? `🌐 URL Completa de Navegación (relative to ${knownBaseUrl}):`
1847
+ : '🌐 URL Completa de Navegación (ej., http://localhost:3000/):';
1848
+
1849
+ const d = await text({
1850
+ message: chalk.cyan(promptMsg),
1851
+ placeholder: knownBaseUrl ? '/' : 'http://localhost:3000/',
1852
+ validate: v => {
1853
+ if (!knownBaseUrl && !v.startsWith('http')) return 'Por favor provee una URL completa con http:// o https://';
1854
+ return undefined;
1855
+ }
1856
+ });
1857
+
1858
+ if (isCancel(d)) {
1859
+ cancel('Misión abortada. Regresando al menú...');
1860
+ agentMode = false;
1861
+ prompt = "";
1862
+ skipMenu = false;
1863
+ continue;
1864
+ }
1865
+ discoverPath = d;
1866
+ }
1867
+ }
1868
+
1869
+ if (agentMode) {
1870
+ // Load environment from BOTH the user's project and the library's root
1871
+ const userDotEnv = parseDotEnvFile(path.join(ORIGINAL_CWD, '.env'));
1872
+ const libDotEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
1873
+ const dotEnv = { ...userDotEnv, ...libDotEnv };
1874
+
1875
+ // ── API Key and Quota Validation ──
1876
+ const { validateApiKey, startMission: requestMission } = await import('../src/arcalityClient.mjs');
1877
+
1878
+ const validation = await validateApiKey();
1879
+ if (process.env.DEBUG || process.argv.includes('--debug')) {
1880
+ console.log(chalk.gray(`[DEBUG] Validation result: ${JSON.stringify(validation, null, 2)}`));
1881
+ }
1882
+
1883
+ const detectedOrgId = validation.organization_id || validation.organizationId || validation.OrganizationId;
1884
+ if (detectedOrgId) {
1885
+ process.env.ARCALITY_ORG_ID = detectedOrgId;
1886
+ }
1887
+ if (!validation.valid) {
1888
+ const errorMessages = {
1889
+ 'no_api_key': '🔑 API Key no encontrada.\n Configúrala vía `arcality init` o en .env (ARCALITY_API_KEY)',
1890
+ 'invalid_format': '🔑 API Key inválida. Debe comenzar con "arc_"',
1891
+ 'invalid_api_key': '🔑 API Key no reconocida por el servidor',
1892
+ 'plan_expired': '💳 Tu plan ha expirado',
1893
+ 'backend_unavailable': 'No se pudo conectar con Arcality. La herramienta requiere conexion al backend para validar la cuenta.'
1894
+ };
1895
+ note(chalk.red(errorMessages[validation.error] || `Error: ${validation.error}`), '❌ Autenticación');
1896
+ agentMode = false;
1897
+ prompt = "";
1898
+ skipMenu = false;
1899
+ continue;
1900
+ }
1901
+
1902
+ // Show plan info
1903
+ const modeLabel = validation.mode === 'mock' ? chalk.gray('[sandbox]') : chalk.cyanBright('[live-net]');
1904
+ const dailyLimitDisplay = validation.daily_limit === -1 ? '∞' : (validation.daily_limit || 35);
1905
+ const remainingDisplay = validation.remaining >= 999999 ? '∞' : validation.remaining;
1906
+ note(
1907
+ chalk.gray('Conexión: ') + chalk.cyanBright('SECURE ') + modeLabel + '\n' +
1908
+ chalk.gray('Autorización: ') + chalk.white(validation.plan || 'internal') + '\n' +
1909
+ chalk.gray('Misiones: ') + chalk.white(`${validation.daily_used || 0}/${dailyLimitDisplay}`) + '\n' +
1910
+ chalk.gray('Reservas: ') + chalk.white(remainingDisplay),
1911
+ 'Enlace Arcality'
1912
+ );
1913
+
1914
+ // --- RESOLVE PROJECT ID ---
1915
+ let selectedProjectId = projectConfig?.projectId || dotEnv.ARCALITY_PROJECT_ID || process.env.ARCALITY_PROJECT_ID;
1916
+ const apiBase = getApiUrl();
1917
+
1918
+ if (selectedProjectId && !isRealProjectId(selectedProjectId)) {
1919
+ note(
1920
+ chalk.red('Project ID invalido: la herramienta no puede ejecutarse con IDs locales o mock.') + '\n' +
1921
+ chalk.gray('Ejecuta `arcality init` para registrar este proyecto en Arcality.'),
1922
+ 'Proyecto no conectado'
1923
+ );
1924
+ agentMode = false;
1925
+ prompt = "";
1926
+ skipMenu = false;
1927
+ continue;
1928
+ }
1929
+
1930
+ if (!selectedProjectId) {
1931
+ // If no project ID from config, try to create/select from backend
1932
+ try {
1933
+ const projController = new AbortController();
1934
+ const projTimeout = setTimeout(() => projController.abort(), 5000);
1935
+ const projRes = await fetch(`${apiBase}/api/v1/projects`, {
1936
+ headers: { 'x-api-key': process.env.ARCALITY_API_KEY || '' },
1937
+ signal: projController.signal
1938
+ });
1939
+ clearTimeout(projTimeout);
1940
+
1941
+ if (projRes.ok) {
1942
+ const data = await projRes.json();
1943
+ const projects = data.projects || [];
1944
+
1945
+ const projOptions = projects.map(p => ({ label: p.name || p.Name, value: p.id || p.Id }));
1946
+ projOptions.push({ label: ' Crear nuevo proyecto', value: 'new' });
1947
+
1948
+ const projSelection = await select({
1949
+ message: chalk.cyan('Elige un proyecto para esta misión:'),
1950
+ options: projOptions
1951
+ });
1952
+
1953
+ if (isCancel(projSelection)) {
1954
+ cancel('Misión abortada.');
1955
+ agentMode = false; prompt = ""; skipMenu = false; continue;
1956
+ }
1957
+
1958
+ if (projSelection === 'new') {
1959
+ const newName = await text({ message: 'Nombre del proyecto:', validate: v => !v ? 'Requerido' : undefined });
1960
+ if (isCancel(newName)) { cancel('Abortado'); agentMode = false; prompt = ""; skipMenu = false; continue; }
1961
+
1962
+ const createRes = await fetch(`${apiBase}/api/v1/projects`, {
1963
+ method: 'POST',
1964
+ headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.ARCALITY_API_KEY || '' },
1965
+ body: JSON.stringify({
1966
+ name: newName,
1967
+ base_url: projectConfig?.project?.baseUrl || discoverPath || '/',
1968
+ arcality_version: projectConfig?.meta?.arcalityVersion || 'unknown',
1969
+ config: { source: 'npm-cli', single_configuration_mode: true, yaml_reuse_enabled: true }
1970
+ })
1971
+ });
1972
+
1973
+ if (createRes.ok) {
1974
+ const created = await createRes.json();
1975
+ selectedProjectId = created.id || created.Id;
1976
+ if (!isRealProjectId(selectedProjectId)) {
1977
+ note(chalk.red('El backend no devolvio un Project ID valido.'), 'Proyecto no conectado');
1978
+ selectedProjectId = null;
1979
+ }
1980
+ if (selectedProjectId) note(chalk.green(`✅ Proyecto creado: ${newName}`));
1981
+ } else {
1982
+ note(chalk.red(`❌ Falló al crear proyecto.`));
1983
+ }
1984
+ } else {
1985
+ selectedProjectId = projSelection;
1986
+ }
1987
+
1988
+ if (selectedProjectId) {
1989
+ process.env.ARCALITY_PROJECT_ID = selectedProjectId;
1990
+ }
1991
+ }
1992
+ } catch (e) {
1993
+ note(
1994
+ chalk.red(`No se pudo conectar con el servidor de proyectos: ${e.message}`) + '\n' +
1995
+ chalk.gray('Arcality requiere un proyecto real conectado antes de ejecutar misiones.'),
1996
+ 'Proyecto no conectado'
1997
+ );
1998
+ }
1999
+ } else {
2000
+ process.env.ARCALITY_PROJECT_ID = selectedProjectId;
2001
+ }
2002
+
2003
+ if (!isRealProjectId(selectedProjectId)) {
2004
+ note(
2005
+ chalk.red('No hay un Project ID real asignado para esta ejecucion.') + '\n' +
2006
+ chalk.gray('Ejecuta `arcality init` y vuelve a intentar.'),
2007
+ 'Proyecto no conectado'
2008
+ );
2009
+ agentMode = false;
2010
+ prompt = "";
2011
+ skipMenu = false;
2012
+ continue;
2013
+ }
2014
+
2015
+ // ── Start Mission ──
2016
+ const mission = await requestMission(prompt, discoverPath || '/');
2017
+ if (!mission.allowed) {
2018
+ if (mission.error === 'mission_limit_exceeded') {
2019
+ note(
2020
+ chalk.red('Limite diario de misiones alcanzado') + '\n' +
2021
+ chalk.yellow(`Misiones usadas: ${mission.daily_used}/${mission.daily_limit}`) + '\n' +
2022
+ chalk.gray(`Reinicio: ${mission.resets_at ? new Date(mission.resets_at).toLocaleString() : 'manana'}`),
2023
+ 'Limite alcanzado'
2024
+ );
2025
+ } else {
2026
+ const missionErrors = {
2027
+ no_project_id: 'No hay Project ID real conectado para esta ejecucion.',
2028
+ backend_unavailable: 'No se pudo iniciar la mision porque Arcality no respondio.',
2029
+ server_error: 'Arcality no pudo registrar el inicio de la mision.',
2030
+ no_api_key: 'API Key no encontrada.'
2031
+ };
2032
+ note(
2033
+ chalk.red(missionErrors[mission.error] || `No se pudo iniciar la mision: ${mission.error}`) + '\n' +
2034
+ chalk.gray('La prueba no se ejecutara en modo local. Revisa `arcality init` y la conexion al backend.'),
2035
+ 'Mision detenida'
2036
+ );
2037
+ }
2038
+ agentMode = false;
2039
+ prompt = "";
2040
+ skipMenu = false;
2041
+ continue;
2042
+ }
2043
+
2044
+ const s = spinner();
2045
+ if (!process.argv.includes('--debug')) {
2046
+ s.start(`◉ [ARCALITY INICIADO] Ejecutando directiva: "${prompt}" ${chalk.gray(`(${mission.daily_used}/${mission.daily_limit})`)}`);
2047
+ } else {
2048
+ console.log(chalk.cyan(`\n◉ [ARCALITY INICIADO] Ejecutando directiva: "${prompt}" ${chalk.gray(`(${mission.daily_used}/${mission.daily_limit})`)}`));
2049
+ }
2050
+
2051
+ const finalProjectId = process.env.ARCALITY_PROJECT_ID || selectedProjectId;
2052
+
2053
+ // ── Cancelación Manual (Ctrl+C) — Estrategia bulletproof ──
2054
+ // El problema: @clack/prompts registra su propio SIGINT handler cada tick del spinner,
2055
+ // que llama a process.exit(0) directamente. removeAllListeners() no es suficiente porque
2056
+ // Clack lo re-registra en cada frame de animación.
2057
+ //
2058
+ // La solución: interceptar process.exit() antes de que Clack lo llame,
2059
+ // y usar prependListener para que NUESTRO handler siempre se ejecute primero.
2060
+
2061
+ let activeChildProcess = null;
2062
+ let userCanceledMission = false;
2063
+ let sigintCount = 0;
2064
+ let _missionActive = true; // Mientras true, bloqueamos el process.exit de Clack
2065
+
2066
+ // Guardar el process.exit original y reemplazarlo temporalmente
2067
+ const _origProcessExit = process.exit.bind(process);
2068
+ process.exit = (code) => {
2069
+ if (_missionActive) {
2070
+ // Clack (u otra lib) está intentando matar el proceso — lo bloqueamos.
2071
+ // Nuestro cancelHandler ya habrá sido invocado vía prependListener.
2072
+ // El proceso se cerrará naturalmente cuando el child process termine.
2073
+ return;
2074
+ }
2075
+ _origProcessExit(code);
2076
+ };
2077
+
2078
+ // ── RESTORE RAW MODE FOR WINDOWS FIX ──
2079
+ // @clack/prompts pausea stdin y desactiva raw mode al terminar un menú.
2080
+ // Debemos reactivarlo aquí para que nuestro interceptor global de Ctrl+C
2081
+ // siga funcionando durante toda la misión.
2082
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
2083
+ try {
2084
+ process.stdin.setRawMode(true);
2085
+ process.stdin.resume();
2086
+ } catch(e) {}
2087
+ }
2088
+
2089
+ const cancelHandler = () => {
2090
+ sigintCount++;
2091
+ if (sigintCount > 3) {
2092
+ console.log(chalk.red('\n🛑 Forzando salida inmediata...'));
2093
+ _missionActive = false;
2094
+ _origProcessExit(1);
2095
+ return;
2096
+ }
2097
+
2098
+ if (userCanceledMission) {
2099
+ console.log(chalk.yellow('\n⏳ Cancelación en progreso, esperando reporte... (Ctrl+C x4 para forzar cierre)'));
2100
+ return;
2101
+ }
2102
+ userCanceledMission = true;
2103
+
2104
+ console.log(chalk.yellow('\n\n⚠️ [ARCALITY] Directiva abortada por el operador.'));
2105
+ if (!process.argv.includes('--debug')) {
2106
+ try { s.stop(chalk.yellow('⚠️ Directiva abortada.')); } catch { /* ignore */ }
2107
+ }
2108
+
2109
+ // Terminar el proceso hijo de Playwright directamente (Windows-safe)
2110
+ if (activeChildProcess && !activeChildProcess.killed) {
2111
+ try {
2112
+ if (process.platform === 'win32') {
2113
+ spawn('taskkill', ['/pid', String(activeChildProcess.pid), '/f', '/t'], { stdio: 'ignore', windowsHide: true });
2114
+ } else {
2115
+ activeChildProcess.kill('SIGTERM');
2116
+ }
2117
+ } catch { /* silencioso */ }
2118
+ }
2119
+
2120
+ try {
2121
+ const ctxDir = process.env.CONTEXT_DIR;
2122
+ if (ctxDir && fs.existsSync(ctxDir)) {
2123
+ const cancelLog = {
2124
+ prompt,
2125
+ history: ['[ABORTO DEL OPERADOR] La directiva fue detenida manualmente antes de completarse.'],
2126
+ steps: 0,
2127
+ success: false,
2128
+ error: true,
2129
+ fail_reason: 'user_canceled',
2130
+ canceled_by_user: true,
2131
+ timestamp: new Date().toISOString()
2132
+ };
2133
+ fs.writeFileSync(path.join(ctxDir, `arcality-log-${Date.now()}.json`), JSON.stringify(cancelLog, null, 2));
2134
+ }
2135
+ } catch { /* silencioso */ }
2136
+
2137
+ console.log(chalk.gray(' >> Esperando que Playwright cierre y genere el reporte...'));
2138
+ // Playwright también recibe el SIGINT nativamente desde la terminal Windows
2139
+ // y cerrará su proceso limpiamente, lo que resolverá la Promise del run() → finally
2140
+ };
2141
+
2142
+ // prependListener asegura que nuestro handler se ejecuta ANTES que el de Clack
2143
+ process.prependListener('SIGINT', cancelHandler);
2144
+ process.prependListener('SIGTERM', cancelHandler);
2145
+
2146
+
2147
+ // Build env for the runner — merge arcality.config values.
2148
+ // ARCALITY_API_URL must be explicit — it comes from the library's internal config,
2149
+ // not from the user's project .env, so we inject it directly via getApiUrl().
2150
+ const fallbackBaseUrl = (!projectConfig && discoverPath?.startsWith('http')) ? discoverPath : undefined;
2151
+
2152
+ const mergedEnv = {
2153
+ ...dotEnv,
2154
+ ...process.env,
2155
+ ARCALITY_API_URL: getApiUrl(), // Internal URL always injected explicitly
2156
+ ARCALITY_PROJECT_ID: finalProjectId,
2157
+ ARCALITY_MISSION_ID: mission.mission_id || mission.missionId || mission.MissionId || '', // ← Propagate mission_id to every proxy call
2158
+ ARCALITY_ORG_ID: validation.organization_id || validation.organizationId || validation.OrganizationId || process.env.ARCALITY_ORG_ID || '', // ← Propagate org_id for pattern persistence
2159
+ BASE_URL: projectConfig?.project?.baseUrl || dotEnv.BASE_URL || process.env.BASE_URL || fallbackBaseUrl || 'http://localhost:3000',
2160
+ LOGIN_USER: projectConfig?.auth?.username || dotEnv.LOGIN_USER || process.env.LOGIN_USER,
2161
+ LOGIN_PASSWORD: projectConfig?.auth?.password || dotEnv.LOGIN_PASSWORD || process.env.LOGIN_PASSWORD,
2162
+ ARCALITY_ASSETS_FILE: projectConfig?.assets?.manifestPath || dotEnv.ARCALITY_ASSETS_FILE || process.env.ARCALITY_ASSETS_FILE || path.join(ORIGINAL_CWD, '.arcality', 'assets.json'),
2163
+ ARCALITY_ASSETS_DIR: projectConfig?.assets?.directory || dotEnv.ARCALITY_ASSETS_DIR || process.env.ARCALITY_ASSETS_DIR || path.join(ORIGINAL_CWD, '.arcality', 'assets'),
2164
+ DOMAIN_DIR: process.env.DOMAIN_DIR,
2165
+ MISSIONS_DIR: process.env.MISSIONS_DIR,
2166
+ CONTEXT_DIR: process.env.CONTEXT_DIR,
2167
+ REPORTS_DIR: process.env.REPORTS_DIR,
2168
+ SMART_PROMPT: prompt,
2169
+ TARGET_PATH: discoverPath || '/'
2170
+ };
2171
+
2172
+ if (finalProjectId) {
2173
+ console.log(chalk.gray(`>> ARCALITY_PROJECT_ID: ${finalProjectId}`));
2174
+ console.log(chalk.gray(`>> ARCALITY_ORG_ID: ${mergedEnv.ARCALITY_ORG_ID || 'NONE'}`));
2175
+ }
2176
+
2177
+ // ── Resolve execution paths ──
2178
+ // tsx and playwright are HOISTED to ORIGINAL_CWD/node_modules when installed via npm.
2179
+ // We run from ORIGINAL_CWD so relative paths for playwright work.
2180
+ // For the test file and config (inside the arcality package), we use absolute paths.
2181
+ //
2182
+ // KEY: tsx loader uses file:// URL (pathToFileURL) so spaces encode as %20 — no quoting needed.
2183
+
2184
+ const testFile = path.join(PROJECT_ROOT, 'tests', '_helpers', 'agentic-runner.spec.ts');
2185
+ const configFile = path.join(PROJECT_ROOT, 'playwright.config.js');
2186
+
2187
+ // Resolve playwright CLI — CRITICAL singleton alignment:
2188
+ //
2189
+ // @playwright/test uses `playwright` internally. When npm detects a version mismatch
2190
+ // between the top-level `playwright` and what `@playwright/test` needs, it installs
2191
+ // a NESTED copy at: @playwright/test/node_modules/playwright/
2192
+ //
2193
+ // Playwright's test runner tracks the active suite via a module-level singleton
2194
+ // (currentSuite). If the CLI uses a DIFFERENT playwright instance than the one
2195
+ // @playwright/test loaded, their singletons are out of sync → "did not expect test()
2196
+ // to be called here".
2197
+ //
2198
+ // Fix: ALWAYS prefer the playwright nested INSIDE @playwright/test, because that is
2199
+ // guaranteed to be the same instance that @playwright/test uses for its internals.
2200
+ //
2201
+ // Resolution priority:
2202
+ // 1. <project>/@playwright/test/node_modules/playwright/cli.js ← nested (safe)
2203
+ // 2. <project>/playwright/cli.js ← sibling top-level
2204
+ // (repeated for ORIGINAL_CWD and PROJECT_ROOT)
2205
+ let playwrightCli;
2206
+ try {
2207
+ const searchPaths = [ORIGINAL_CWD, PROJECT_ROOT];
2208
+ let testRunnerPath = null;
2209
+ let resolveError = null;
2210
+
2211
+ for (const searchPath of searchPaths) {
2212
+ try {
2213
+ testRunnerPath = require.resolve('@playwright/test', { paths: [searchPath] });
2214
+ break;
2215
+ } catch (e) {
2216
+ resolveError = e;
2217
+ }
2218
+ }
2219
+
2220
+ if (!testRunnerPath) throw resolveError;
2221
+
2222
+ const testRunnerDir = path.dirname(testRunnerPath); // → .../node_modules/@playwright/test/
2223
+
2224
+ // Priority 1: playwright nested INSIDE @playwright/test same singleton as the package uses
2225
+ const nestedCli = path.join(testRunnerDir, 'node_modules', 'playwright', 'cli.js');
2226
+ // Priority 2: playwright as a top-level sibling (clean installs, versions aligned)
2227
+ const siblingCli = path.join(testRunnerDir, '..', '..', 'playwright', 'cli.js');
2228
+
2229
+ if (fs.existsSync(nestedCli)) {
2230
+ playwrightCli = nestedCli;
2231
+ } else if (fs.existsSync(siblingCli)) {
2232
+ playwrightCli = siblingCli;
2233
+ } else {
2234
+ throw new Error(
2235
+ `Playwright CLI not found. Tried:\n (nested) ${nestedCli}\n (sibling) ${siblingCli}`
2236
+ );
2237
+ }
2238
+
2239
+ if (process.argv.includes('--debug')) {
2240
+ console.log(chalk.gray(`[DEBUG] Using playwright CLI: ${playwrightCli}`));
2241
+ }
2242
+ } catch (e) {
2243
+ console.error(chalk.red('\n[FATAL] Núcleo de Playwright no encontrado. Asegúrate de que @playwright/test esté instalado.'));
2244
+ console.error(chalk.red(e.message));
2245
+ console.error(chalk.yellow('\nConsejo: Ejecuta `npm install @playwright/test` en tu directorio de proyecto.'));
2246
+ _missionActive = false;
2247
+ process.exit = _origProcessExit;
2248
+ _origProcessExit(1);
2249
+ }
2250
+
2251
+ let _postProcessSigintCount = 0;
2252
+ const postProcessSigint = () => {
2253
+ _postProcessSigintCount++;
2254
+ if (_postProcessSigintCount === 1) {
2255
+ console.log(chalk.yellow('\n⏳ Finalizando reporte de telemetría, por favor espera... (Ctrl+C de nuevo para salir ya)'));
2256
+ } else {
2257
+ console.log(chalk.red('\n🛑 Saliendo forzado por el usuario.'));
2258
+ process.removeAllListeners('SIGINT');
2259
+ process.removeAllListeners('SIGTERM');
2260
+ process.exit(0);
2261
+ }
2262
+ };
2263
+ let missionResult = null;
2264
+ let finalFailReason = null;
2265
+ let finalUsageData = undefined;
2266
+ let finalReportUrl = null;
2267
+ let finalFailReasoning = null;
2268
+
2269
+ if (process.env.ARCALITY_RUNNER_PREBUILT === 'true') {
2270
+ if (process.argv.includes('--debug')) {
2271
+ console.log(chalk.gray('[DEBUG] Runner precompilado por la coleccion. Omitiendo build local.'));
2272
+ }
2273
+ } else
2274
+
2275
+ // ── Auto-compilar el runner antes de ejecutar Playwright para asegurar que los cambios de rama/merge estén aplicados ──
2276
+ try {
2277
+ if (!process.argv.includes('--debug')) {
2278
+ s.message('⚙️ Compilando agente de pruebas (Arcality Runner)...');
2279
+ } else {
2280
+ console.log(chalk.cyan('⚙️ Compilando agente de pruebas (Arcality Runner)...'));
2281
+ }
2282
+ await ensureRunnerBundle({ debug: process.argv.includes('--debug') });
2283
+ } catch (err) {
2284
+ console.warn(chalk.yellow(`\n⚠️ Advertencia: No se pudo auto-compilar el runner: ${err.message}`));
2285
+ }
2286
+
2287
+ try {
2288
+ // No test file path is passed — playwright.config.js uses testMatch:'agentic-runner.spec.ts'
2289
+ // This eliminates the Windows regex path issue permanently on any user's machine.
2290
+ await run('node', [
2291
+ '--no-warnings',
2292
+ playwrightCli,
2293
+ 'test',
2294
+ '--headed',
2295
+ '--config', 'playwright.config.js'
2296
+ ], {
2297
+ cwd: PROJECT_ROOT,
2298
+ env: mergedEnv,
2299
+ onStatus: (msg) => { if (!process.argv.includes('--debug')) s.message(msg); else console.log(chalk.gray(`>> Status: ${msg}`)); },
2300
+ onSpawn: (child) => { activeChildProcess = child; } // ← Capturar referencia para cancelHandler
2301
+ });
2302
+
2303
+ process.removeAllListeners('SIGINT');
2304
+ process.removeAllListeners('SIGTERM');
2305
+ process.on('SIGINT', postProcessSigint);
2306
+ process.on('SIGTERM', postProcessSigint);
2307
+
2308
+ if (userCanceledMission) {
2309
+ missionResult = 'canceled';
2310
+ finalFailReason = 'user_canceled';
2311
+ // Detener el spinner explícitamente antes de salir
2312
+ if (!process.argv.includes('--debug')) {
2313
+ try { s.stop(chalk.yellow('⚠️ Misión cancelada por el usuario.')); } catch { }
2314
+ }
2315
+ return; // El handler ya tomó el control, finally se ejecutará
2316
+ }
2317
+
2318
+ missionResult = 'success';
2319
+ if (!process.argv.includes('--debug')) s.stop(chalk.green('✅ Misión completada exitosamente.'));
2320
+ else console.log(chalk.green('✅ Misión completada exitosamente.'));
2321
+
2322
+ // ── Ping Project ──
2323
+ await pingProject(finalProjectId, process.env.ARCALITY_API_KEY);
2324
+
1766
2325
  // ── Save Mission YAML ──
1767
2326
  let saveDecision = 'no';
1768
2327
  if (!skipSavePrompt && !batchMode) {
@@ -1776,17 +2335,17 @@ async function main() {
1776
2335
  }
1777
2336
 
1778
2337
  if (saveDecision === 'yes') {
1779
- const name = await text({
1780
- message: 'Nombre de la misión (ej., login_valido):',
1781
- placeholder: 'my_mission',
1782
- validate: v => v.length < 3 ? 'Nombre muy corto' : undefined
1783
- });
1784
-
2338
+ const name = await text({
2339
+ message: 'Nombre de la misión (ej., login_valido):',
2340
+ placeholder: 'my_mission',
2341
+ validate: v => v.length < 3 ? 'Nombre muy corto' : undefined
2342
+ });
2343
+
1785
2344
  if (!isCancel(name)) {
1786
2345
  const safeName = name.trim().replace(/[^a-z0-9]/gi, '_').toLowerCase();
1787
-
1788
- // Save to missions dir (existing flow)
1789
- const missionsDir = process.env.MISSIONS_DIR;
2346
+
2347
+ // Save to missions dir (existing flow)
2348
+ const missionsDir = process.env.MISSIONS_DIR;
1790
2349
  const missionSaveDir = await selectMissionSaveDir(missionsDir);
1791
2350
  if (!missionSaveDir) {
1792
2351
  note(chalk.yellow('Guardado de mision cancelado.'), 'Persistencia Arcality');
@@ -1807,8 +2366,8 @@ async function main() {
1807
2366
  `priority: ${JSON.stringify('normal')}`,
1808
2367
  `created_at: ${JSON.stringify(new Date().toISOString())}`
1809
2368
  ].join('\n') + '\n';
1810
-
1811
- const smartYamlPath = path.join(process.env.CONTEXT_DIR, 'last-mission-smart.yaml');
2369
+
2370
+ const smartYamlPath = path.join(process.env.CONTEXT_DIR, 'last-mission-smart.yaml');
1812
2371
  if (fs.existsSync(smartYamlPath)) {
1813
2372
  const smartYamlContent = fs.readFileSync(smartYamlPath, 'utf8').trim();
1814
2373
  yamlData = [
@@ -1823,172 +2382,178 @@ async function main() {
1823
2382
  smartYamlContent
1824
2383
  ].join('\n') + '\n';
1825
2384
  }
1826
-
1827
- // Internal fallback backup for the agent
1828
- fs.writeFileSync(yamlPathMissions, yamlData);
1829
-
1830
- // Primary save to user's specified directory from arcality.config
1831
- if (projectConfig) {
1832
- const yamlOutputDir = ensureYamlOutputDir(projectConfig, ORIGINAL_CWD);
2385
+
2386
+ // Internal fallback backup for the agent
2387
+ fs.writeFileSync(yamlPathMissions, yamlData);
2388
+
2389
+ // Primary save to user's specified directory from arcality.config
2390
+ if (projectConfig) {
2391
+ const yamlOutputDir = ensureYamlOutputDir(projectConfig, ORIGINAL_CWD);
1833
2392
  const yamlOutputTargetDir = missionSaveDir.relDir ? path.join(yamlOutputDir, missionSaveDir.relDir) : yamlOutputDir;
1834
2393
  fs.mkdirSync(yamlOutputTargetDir, { recursive: true });
1835
2394
  const yamlPathOutput = path.join(yamlOutputTargetDir, `${safeName}.yaml`);
1836
- fs.writeFileSync(yamlPathOutput, yamlData);
1837
- note(chalk.green(`✅ Misión guardada en: ${yamlPathOutput}`), 'Persistencia Arcality');
1838
- } else {
1839
- note(chalk.green(`✅ Misión guardada en: ${yamlPathMissions}`), 'Persistencia Arcality');
1840
- }
1841
- }
1842
- }
1843
- } catch (e) {
1844
- // SIGINT ya fue manejado por cancelHandler — solo clasificamos el resultado
1845
- if (userCanceledMission) {
1846
- missionResult = 'canceled';
1847
- finalFailReason = 'user_canceled';
1848
- console.log(chalk.yellow('\n⚠️ Directiva abortada — generando reporte de evidencia...'));
1849
- } else {
1850
- missionResult = 'failed';
1851
- finalFailReason = 'timeout_or_crash';
1852
- s.stop(chalk.red(`❌ Arcality no pudo completar la directiva.`));
1853
- console.error(chalk.red(`\nDetalles del Error: ${e.message}`));
1854
- if (e.stack && process.argv.includes('--debug')) {
1855
- console.error(chalk.gray(e.stack));
1856
- }
1857
- }
1858
- } finally {
1859
- // Desactivar el bloqueo de process.exit y restaurar el original
1860
- _missionActive = false;
1861
- process.exit = _origProcessExit;
1862
- process.removeListener('SIGINT', cancelHandler);
1863
- process.removeListener('SIGTERM', cancelHandler);
1864
-
1865
- if (userCanceledMission) {
1866
- missionResult = 'canceled';
1867
- finalFailReason = 'user_canceled';
1868
- finalFailReasoning = 'La misión fue detenida manualmente por el operador (Ctrl+C). Esto no es una falla técnica del sistema.';
1869
- await new Promise(r => setTimeout(r, 800));
1870
- }
1871
-
1872
- process.on('SIGINT', postProcessSigint);
1873
- process.on('SIGTERM', postProcessSigint);
1874
-
1875
- const sRep = spinner();
1876
- if (!process.argv.includes('--debug')) {
1877
- sRep.start('📊 Generando reporte...');
1878
- } else {
1879
- console.log('📊 Generando reporte...');
1880
- }
1881
-
1882
- await run("node", [path.join(PROJECT_ROOT, "scripts/rebrand-report.mjs")]).catch(() => { });
1883
-
1884
- const reportDir = process.env.REPORTS_DIR || 'tests-report';
1885
- const arcalityPath = path.resolve(reportDir, 'index.html');
1886
-
1887
- sRep.stop();
1888
-
1889
- // Read final usage and failReason from agent-log
1890
- try {
1891
- const ctxDir = process.env.CONTEXT_DIR;
1892
- if (fs.existsSync(ctxDir)) {
2395
+ fs.writeFileSync(yamlPathOutput, yamlData);
2396
+ note(chalk.green(`✅ Misión guardada en: ${yamlPathOutput}`), 'Persistencia Arcality');
2397
+ } else {
2398
+ note(chalk.green(`✅ Misión guardada en: ${yamlPathMissions}`), 'Persistencia Arcality');
2399
+ }
2400
+ }
2401
+ }
2402
+ } catch (e) {
2403
+ // SIGINT ya fue manejado por cancelHandler — solo clasificamos el resultado
2404
+ if (userCanceledMission) {
2405
+ missionResult = 'canceled';
2406
+ finalFailReason = 'user_canceled';
2407
+ console.log(chalk.yellow('\n⚠️ Directiva abortada — generando reporte de evidencia...'));
2408
+ } else {
2409
+ missionResult = 'failed';
2410
+ finalFailReason = 'timeout_or_crash';
2411
+ s.stop(chalk.red(`❌ Arcality no pudo completar la directiva.`));
2412
+ console.error(chalk.red(`\nDetalles del Error: ${e.message}`));
2413
+ if (e.stack && process.argv.includes('--debug')) {
2414
+ console.error(chalk.gray(e.stack));
2415
+ }
2416
+ }
2417
+ } finally {
2418
+ // Desactivar el bloqueo de process.exit y restaurar el original
2419
+ _missionActive = false;
2420
+ process.exit = _origProcessExit;
2421
+ process.removeListener('SIGINT', cancelHandler);
2422
+ process.removeListener('SIGTERM', cancelHandler);
2423
+
2424
+ if (userCanceledMission) {
2425
+ missionResult = 'canceled';
2426
+ finalFailReason = 'user_canceled';
2427
+ finalFailReasoning = 'La misión fue detenida manualmente por el operador (Ctrl+C). Esto no es una falla técnica del sistema.';
2428
+ await new Promise(r => setTimeout(r, 800));
2429
+ }
2430
+
2431
+ process.on('SIGINT', postProcessSigint);
2432
+ process.on('SIGTERM', postProcessSigint);
2433
+
2434
+ const sRep = spinner();
2435
+ if (!process.argv.includes('--debug')) {
2436
+ sRep.start('📊 Generando reporte...');
2437
+ } else {
2438
+ console.log('📊 Generando reporte...');
2439
+ }
2440
+
2441
+ await run("node", [path.join(PROJECT_ROOT, "scripts/rebrand-report.mjs")]).catch(() => { });
2442
+
2443
+ const reportDir = process.env.REPORTS_DIR || 'tests-report';
2444
+ const arcalityPath = path.resolve(reportDir, 'index.html');
2445
+
2446
+ sRep.stop();
2447
+
2448
+ // Read final usage and failReason from agent-log
2449
+ try {
2450
+ const ctxDir = process.env.CONTEXT_DIR;
2451
+ if (fs.existsSync(ctxDir)) {
1893
2452
  const files = fs.readdirSync(ctxDir);
1894
2453
  const logFiles = files.filter(f =>
1895
2454
  (f.startsWith('arcality-log-') || f.startsWith('agent-log-')) &&
1896
2455
  f.endsWith('.json')
1897
2456
  ).sort();
1898
- if (logFiles.length > 0) {
1899
- const lastLog = path.join(ctxDir, logFiles[logFiles.length - 1]);
1900
- const logData = JSON.parse(fs.readFileSync(lastLog, 'utf8'));
1901
- finalUsageData = logData.usage || finalUsageData;
1902
- finalFailReasoning = logData.fail_reasoning || null;
1903
- if (missionResult === 'failed' && !finalFailReason) {
1904
- finalFailReason = logData.fail_reason || "timeout_or_crash";
1905
- }
1906
- }
1907
- }
1908
- } catch(e) {}
1909
-
1910
- // ── Upload Evidence (solo si NO fue cancelado por usuario) ──
1911
- if (missionResult !== 'canceled') {
1912
- try {
1913
- const { getEvidenceSasToken } = await import('../src/arcalityClient.mjs');
1914
- const { uploadEvidence } = await import('../src/EvidenceUploader.mjs');
1915
-
1916
- const sasData = await getEvidenceSasToken(mission.mission_id, finalProjectId);
1917
- if (process.argv.includes('--debug')) console.log(`[DEBUG] sasData received:`, sasData);
1918
-
1919
- const actualSasUrl = sasData?.sasTokenUrl || sasData?.sasUrl || sasData?.sas_url;
1920
- const actualBasePath = sasData?.basePath || sasData?.base_path;
1921
-
1922
- if (sasData && actualSasUrl && actualBasePath) {
1923
- if (!process.argv.includes('--debug')) {
1924
- sRep.start('☁️ Subiendo evidencia a Azure...');
1925
- }
1926
- process.removeAllListeners('SIGINT');
1927
- process.removeAllListeners('SIGTERM');
1928
- process.on('SIGINT', postProcessSigint);
1929
- process.on('SIGTERM', postProcessSigint);
1930
-
1931
- await uploadEvidence(reportDir, actualSasUrl, actualBasePath);
1932
-
1933
- try {
1934
- const parsedSas = new URL(actualSasUrl);
1935
- finalReportUrl = `${parsedSas.origin}${parsedSas.pathname}/${actualBasePath}/index.html`;
1936
- } catch(e) {}
1937
-
1938
- if (!process.argv.includes('--debug')) sRep.stop(chalk.green('✅ Evidencia subida exitosamente.'));
1939
- }
1940
- } catch (e) {
1941
- if (process.argv.includes('--debug')) console.log(chalk.yellow(`⚠️ Could not upload evidence: ${e.message}`));
1942
- if (!process.argv.includes('--debug')) sRep.stop(chalk.yellow('⚠️ Fallo al subir evidencia.'));
1943
- }
1944
- } else {
1945
- console.log(chalk.gray(' >> Saltando subida de evidencia (la misión fue cancelada).'));
1946
- }
1947
-
1948
- // ── End Mission — SIEMPRE se llama, independientemente de si la evidencia subió ──
1949
- try {
1950
- const { endMission } = await import('../src/arcalityClient.mjs');
1951
- await endMission(mission.mission_id, missionResult || 'failed', finalUsageData, finalFailReason, finalReportUrl, finalFailReasoning);
1952
- if (process.argv.includes('--debug')) console.log(chalk.gray(`[DEBUG] endMission called result: ${missionResult}, failReason: ${finalFailReason}`));
1953
- } catch (e) {
1954
- if (process.argv.includes('--debug')) console.log(chalk.yellow(`⚠️ Could not end mission in backend: ${e.message}`));
1955
- }
1956
-
1957
- if (globalReportProcess) {
1958
- try {
1959
- if (process.platform === "win32") {
1960
- spawn("taskkill", ["/pid", globalReportProcess.pid, "/f", "/t"], { stdio: 'ignore', windowsHide: true });
1961
- } else {
1962
- globalReportProcess.kill();
1963
- }
1964
- } catch (e) { /* ignore */ }
1965
- globalReportProcess = null;
1966
- }
1967
-
2457
+ if (logFiles.length > 0) {
2458
+ const lastLog = path.join(ctxDir, logFiles[logFiles.length - 1]);
2459
+ const logData = JSON.parse(fs.readFileSync(lastLog, 'utf8'));
2460
+ finalUsageData = logData.usage || finalUsageData;
2461
+ finalFailReasoning = logData.fail_reasoning || null;
2462
+ if (missionResult === 'failed' && !finalFailReason) {
2463
+ finalFailReason = logData.fail_reason || "timeout_or_crash";
2464
+ }
2465
+ }
2466
+ }
2467
+ } catch(e) {}
2468
+
2469
+ // ── Upload Evidence (solo si NO fue cancelado por usuario) ──
2470
+ if (missionResult !== 'canceled') {
2471
+ try {
2472
+ const { getEvidenceSasToken } = await import('../src/arcalityClient.mjs');
2473
+ const { uploadEvidence } = await import('../src/EvidenceUploader.mjs');
2474
+
2475
+ const sasData = await getEvidenceSasToken(mission.mission_id, finalProjectId);
2476
+ if (process.argv.includes('--debug')) console.log(`[DEBUG] sasData received:`, sasData);
2477
+
2478
+ const actualSasUrl = sasData?.sasTokenUrl || sasData?.sasUrl || sasData?.sas_url;
2479
+ const actualBasePath = sasData?.basePath || sasData?.base_path;
2480
+
2481
+ if (sasData && actualSasUrl && actualBasePath) {
2482
+ if (!process.argv.includes('--debug')) {
2483
+ sRep.start('☁️ Subiendo evidencia a Azure...');
2484
+ }
2485
+ process.removeAllListeners('SIGINT');
2486
+ process.removeAllListeners('SIGTERM');
2487
+ process.on('SIGINT', postProcessSigint);
2488
+ process.on('SIGTERM', postProcessSigint);
2489
+
2490
+ await uploadEvidence(reportDir, actualSasUrl, actualBasePath);
2491
+
2492
+ try {
2493
+ const parsedSas = new URL(actualSasUrl);
2494
+ finalReportUrl = `${parsedSas.origin}${parsedSas.pathname}/${actualBasePath}/index.html`;
2495
+ } catch(e) {}
2496
+
2497
+ if (!process.argv.includes('--debug')) sRep.stop(chalk.green('✅ Evidencia subida exitosamente.'));
2498
+ }
2499
+ } catch (e) {
2500
+ if (process.argv.includes('--debug')) console.log(chalk.yellow(`⚠️ Could not upload evidence: ${e.message}`));
2501
+ if (!process.argv.includes('--debug')) sRep.stop(chalk.yellow('⚠️ Fallo al subir evidencia.'));
2502
+ }
2503
+ } else {
2504
+ console.log(chalk.gray(' >> Saltando subida de evidencia (la misión fue cancelada).'));
2505
+ }
2506
+
2507
+ // ── End Mission — SIEMPRE se llama, independientemente de si la evidencia subió ──
2508
+ try {
2509
+ const { endMission } = await import('../src/arcalityClient.mjs');
2510
+ await Promise.race([
2511
+ endMission(mission.mission_id, missionResult || 'failed', finalUsageData, finalFailReason, finalReportUrl, finalFailReasoning),
2512
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 8000))
2513
+ ]);
2514
+ if (process.argv.includes('--debug')) console.log(chalk.gray(`[DEBUG] endMission called → result: ${missionResult}, failReason: ${finalFailReason}`));
2515
+ } catch (e) {
2516
+ if (process.argv.includes('--debug')) console.log(chalk.yellow(`⚠️ Could not end mission in backend: ${e.message}`));
2517
+ }
2518
+
2519
+ if (globalReportProcess) {
2520
+ try {
2521
+ if (process.platform === "win32") {
2522
+ spawn("taskkill", ["/pid", globalReportProcess.pid, "/f", "/t"], { stdio: 'ignore', windowsHide: true });
2523
+ } else {
2524
+ globalReportProcess.kill();
2525
+ }
2526
+ } catch (e) { /* ignore */ }
2527
+ globalReportProcess = null;
2528
+ }
2529
+
1968
2530
  if (!batchMode && fs.existsSync(arcalityPath)) {
1969
2531
  console.log(chalk.blue(`🌐 Abriendo Reporte de Arcality: ${arcalityPath}`));
1970
- if (process.platform === "win32") {
1971
- exec(`start "" "${arcalityPath}"`);
1972
- } else {
1973
- exec(`open "${arcalityPath}"`);
1974
- }
2532
+ openLocalFile(arcalityPath);
1975
2533
  } else if (!fs.existsSync(arcalityPath)) {
1976
2534
  console.log(chalk.yellow(`⚠️ Reporte no generado (la prueba pudo haber fallado antes de completarse).`));
1977
2535
  }
1978
-
1979
- await new Promise(resolve => setTimeout(resolve, 2000));
1980
-
1981
- // Ping project after report
1982
- await pingProject(
1983
- process.env.ARCALITY_PROJECT_ID,
1984
- process.env.ARCALITY_API_KEY
1985
- );
1986
-
1987
- // Limpiar handlers del post-proceso ANTES de esperar Enter, ya que toda la telemetría y reportes ya finalizaron
1988
- process.removeListener('SIGINT', postProcessSigint);
1989
- process.removeListener('SIGTERM', postProcessSigint);
1990
-
2536
+
2537
+ await new Promise(resolve => setTimeout(resolve, 2000));
2538
+
2539
+ // Ping project after report (with timeout so it cannot hang the exit path)
2540
+ await Promise.race([
2541
+ pingProject(process.env.ARCALITY_PROJECT_ID, process.env.ARCALITY_API_KEY),
2542
+ new Promise(resolve => setTimeout(resolve, 5000))
2543
+ ]);
2544
+
2545
+ // Limpiar handlers del post-proceso ANTES de esperar Enter, ya que toda la telemetría y reportes ya finalizaron
2546
+ process.removeListener('SIGINT', postProcessSigint);
2547
+ process.removeListener('SIGTERM', postProcessSigint);
2548
+
1991
2549
  if (!batchMode) {
2550
+ await waitForEnter('Presiona Enter para regresar al menu...', {
2551
+ pauseOnResolve: skipMenu,
2552
+ restoreRawMode: !skipMenu
2553
+ });
2554
+ }
2555
+
2556
+ if (false && !batchMode) {
1992
2557
  // Esperar Enter para regresar al menú
1993
2558
  // Usamos escucha directa de stdin (raw mode) porque @clack/text
1994
2559
  // no procesa Enter correctamente cuando setRawMode(true) está activo.
@@ -2030,22 +2595,22 @@ async function main() {
2030
2595
  process.stdin.on('data', onKey);
2031
2596
  });
2032
2597
  }
2033
-
2034
- if (skipMenu) {
2035
- outro(chalk.cyan('¡Misión terminada! Hasta luego.'));
2036
- return;
2037
- }
2038
-
2039
- agentMode = false;
2040
- prompt = "";
2041
- firstRun = false;
2042
- }
2043
- }
2044
- firstRun = false;
2045
- }
2046
- }
2047
-
2048
- main().catch(err => {
2049
- console.error(err);
2050
- process.exit(1);
2051
- });
2598
+
2599
+ if (skipMenu) {
2600
+ outro(chalk.cyan('¡Misión terminada! Hasta luego.'));
2601
+ return;
2602
+ }
2603
+
2604
+ agentMode = false;
2605
+ prompt = "";
2606
+ firstRun = false;
2607
+ }
2608
+ }
2609
+ firstRun = false;
2610
+ }
2611
+ }
2612
+
2613
+ main().catch(err => {
2614
+ console.error(err);
2615
+ process.exit(1);
2616
+ });