@arcadialdev/arcality 3.0.4 → 4.0.1

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,4 +1,5 @@
1
1
  import fs from 'fs';
2
+ import os from 'os';
2
3
  import path from 'path';
3
4
  import { fileURLToPath } from 'url';
4
5
 
@@ -9,11 +10,11 @@ const ARCALITY_ROOT = process.env.ARCALITY_ROOT || path.resolve(__dirname, '..')
9
10
 
10
11
  async function rebrandReport() {
11
12
  // Definir directorios objetivo: El reporte personalizado, el nativo anidado y el reporte interno
12
- const reportDir = process.env.REPORTS_DIR || 'tests-report';
13
+ const reportDir = process.env.REPORTS_DIR || path.join(os.tmpdir(), 'arcality', 'reports');
13
14
  const targetDirs = [
14
15
  reportDir,
15
16
  path.join(reportDir, 'native'),
16
- 'playwright-internal-report'
17
+ path.join(os.tmpdir(), 'arcality', 'playwright-internal-report')
17
18
  ];
18
19
 
19
20
  const logoPngPath = path.join(ARCALITY_ROOT, 'public', 'logo.png');
@@ -52,22 +53,22 @@ function processDirectory(reportDir, logoBase64) {
52
53
  html = html.replace(/at\s+.*?node_modules.*?(\n|\r)/g, '');
53
54
  html = html.replace(/at\s+.*?agentic-runner\.spec\.ts:\d+/g, '');
54
55
 
55
- // 3. Preserve custom Arcality reports before native Playwright styling.
56
- // The custom Arcality report owns its visual system. Keep the
57
- // aggressive Playwright rebrand injection for native reports only.
58
- const isArcalityMissionConsole = html.includes('class="mission-brief"') || html.includes('Arcality QA Engine');
59
- if (isArcalityMissionConsole) {
60
- if (logoBase64) {
61
- html = html.replace(/rel="shortcut icon" href=".*?"/g, `rel="shortcut icon" href="${logoBase64}"`);
62
- html = html.replace(/rel="icon" href=".*?"/g, `rel="icon" href="${logoBase64}"`);
63
- }
64
- fs.writeFileSync(indexPath, html, 'utf8');
65
- processTraceViewer(reportDir, logoBase64);
66
- return;
67
- }
68
-
69
- // 4. Inyección de Estilos y Scripts (MODO AGRESIVO)
70
- const customStyles = `
56
+ // 3. Preserve custom Arcality reports before native Playwright styling.
57
+ // The custom Arcality report owns its visual system. Keep the
58
+ // aggressive Playwright rebrand injection for native reports only.
59
+ const isArcalityMissionConsole = html.includes('class="mission-brief"') || html.includes('Arcality QA Engine');
60
+ if (isArcalityMissionConsole) {
61
+ if (logoBase64) {
62
+ html = html.replace(/rel="shortcut icon" href=".*?"/g, `rel="shortcut icon" href="${logoBase64}"`);
63
+ html = html.replace(/rel="icon" href=".*?"/g, `rel="icon" href="${logoBase64}"`);
64
+ }
65
+ fs.writeFileSync(indexPath, html, 'utf8');
66
+ processTraceViewer(reportDir, logoBase64);
67
+ return;
68
+ }
69
+
70
+ // 4. Inyección de Estilos y Scripts (MODO AGRESIVO)
71
+ const customStyles = `
71
72
  <script id="arcality-branding-script">
72
73
  /* ARCALITY_BRANDING_SCRIPT */
73
74
  // 1. Limpieza de Caché y Service Worker
@@ -6,15 +6,17 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
8
  const CONFIG_FILENAME = 'arcality.config';
9
-
10
- export function isRealProjectId(projectId) {
11
- const id = String(projectId || '').trim();
12
- if (!id) return false;
13
- if (id === 'undefined' || id === 'null') return false;
14
- if (/^(local|mock)_/i.test(id)) return false;
15
- if (/^0{8}-0{4}-0{4}-0{4}-0{12}$/i.test(id)) return false;
16
- return true;
17
- }
9
+ const LEGACY_OUTPUT_DIRS = new Set(['arcality', './arcality', '.\\arcality']);
10
+ const DEFAULT_YAML_OUTPUT_DIR = './.arcality';
11
+
12
+ export function isRealProjectId(projectId) {
13
+ const id = String(projectId || '').trim();
14
+ if (!id) return false;
15
+ if (id === 'undefined' || id === 'null') return false;
16
+ if (/^(local|mock)_/i.test(id)) return false;
17
+ if (/^0{8}-0{4}-0{4}-0{4}-0{12}$/i.test(id)) return false;
18
+ return true;
19
+ }
18
20
 
19
21
  /**
20
22
  * Resolves the path to arcality.config in the project root.
@@ -68,55 +70,55 @@ export function saveProjectConfig(config, projectRoot) {
68
70
  * @param {ArcalityConfig} config
69
71
  * @returns {{ valid: boolean, missing: string[] }}
70
72
  */
71
- export function validateConfig(config) {
72
- const missing = [];
73
+ export function validateConfig(config) {
74
+ const missing = [];
73
75
 
74
76
  if (!config) {
75
77
  return { valid: false, missing: ['arcality.config file'] };
76
78
  }
77
79
 
78
- if (!config.apiKey) missing.push('apiKey');
79
- if (!isRealProjectId(config.projectId)) missing.push('projectId');
80
- if (!config.project?.baseUrl) missing.push('project.baseUrl');
81
- if (!config.auth?.username) missing.push('auth.username');
82
- if (!config.auth?.password) missing.push('auth.password');
83
-
84
- return { valid: missing.length === 0, missing };
85
- }
80
+ if (!config.apiKey) missing.push('apiKey');
81
+ if (!isRealProjectId(config.projectId)) missing.push('projectId');
82
+ if (!config.project?.baseUrl) missing.push('project.baseUrl');
83
+ if (!config.auth?.username) missing.push('auth.username');
84
+ if (!config.auth?.password) missing.push('auth.password');
85
+
86
+ return { valid: missing.length === 0, missing };
87
+ }
86
88
 
87
89
  /**
88
90
  * Creates a fresh arcality.config structure.
89
91
  * @param {object} params
90
92
  * @returns {ArcalityConfig}
91
93
  */
92
- export function createConfig({
94
+ export function createConfig({
93
95
  apiKey,
94
96
  organizationId,
95
97
  projectId,
96
- projectName,
97
- baseUrl,
98
- frameworkDetected,
99
- username,
100
- password,
101
- arcalityVersion,
102
- yamlOutputDir = './arcality',
98
+ projectName,
99
+ baseUrl,
100
+ frameworkDetected,
101
+ username,
102
+ password,
103
+ arcalityVersion,
104
+ yamlOutputDir = DEFAULT_YAML_OUTPUT_DIR,
103
105
  }) {
104
- return {
106
+ return {
105
107
  version: '1',
106
108
  apiKey,
107
109
  organizationId: organizationId || null,
108
110
  projectId,
109
111
  project: {
110
112
  name: projectName,
111
- baseUrl,
112
- frameworkDetected: frameworkDetected || null,
113
- },
114
- auth: {
115
- username,
116
- password,
117
- },
118
- runtime: {
119
- yamlOutputDir,
113
+ baseUrl,
114
+ frameworkDetected: frameworkDetected || null,
115
+ },
116
+ auth: {
117
+ username,
118
+ password,
119
+ },
120
+ runtime: {
121
+ yamlOutputDir,
120
122
  reuseSuccessfulYamls: true,
121
123
  singleConfigurationMode: true,
122
124
  },
@@ -131,17 +133,18 @@ export function createConfig({
131
133
  * with existing modules that read from process.env.
132
134
  * @param {ArcalityConfig} config
133
135
  */
134
- export function injectConfigToEnv(config) {
135
- if (!config) return;
136
+ export function injectConfigToEnv(config) {
137
+ if (!config) return;
136
138
 
137
139
  if (config.apiKey) process.env.ARCALITY_API_KEY = config.apiKey;
138
- if (isRealProjectId(config.projectId)) process.env.ARCALITY_PROJECT_ID = config.projectId;
139
- if (config.project?.baseUrl && !process.env.BASE_URL) {
140
- process.env.BASE_URL = config.project.baseUrl;
141
- }
142
- if (config.auth?.username) process.env.LOGIN_USER = config.auth.username;
143
- if (config.auth?.password) process.env.LOGIN_PASSWORD = config.auth.password;
144
- }
140
+ if (config.organizationId) process.env.ARCALITY_ORG_ID = config.organizationId;
141
+ if (isRealProjectId(config.projectId)) process.env.ARCALITY_PROJECT_ID = config.projectId;
142
+ if (config.project?.baseUrl && !process.env.BASE_URL) {
143
+ process.env.BASE_URL = config.project.baseUrl;
144
+ }
145
+ if (config.auth?.username) process.env.LOGIN_USER = config.auth.username;
146
+ if (config.auth?.password) process.env.LOGIN_PASSWORD = config.auth.password;
147
+ }
145
148
 
146
149
  /**
147
150
  * Gets the YAML output directory from config, falling back to default.
@@ -149,10 +152,13 @@ export function injectConfigToEnv(config) {
149
152
  * @param {string} [projectRoot]
150
153
  * @returns {string} absolute path
151
154
  */
152
- export function getYamlOutputDir(config, projectRoot) {
153
- const dir = config?.runtime?.yamlOutputDir || './arcality';
154
- return path.resolve(projectRoot || process.cwd(), dir);
155
- }
155
+ export function getYamlOutputDir(config, projectRoot) {
156
+ const configuredDir = String(config?.runtime?.yamlOutputDir || '').trim();
157
+ const dir = LEGACY_OUTPUT_DIRS.has(configuredDir) || !configuredDir
158
+ ? DEFAULT_YAML_OUTPUT_DIR
159
+ : configuredDir;
160
+ return path.resolve(projectRoot || process.cwd(), dir);
161
+ }
156
162
 
157
163
  /**
158
164
  * Ensures the YAML output directory exists.
@@ -175,7 +181,7 @@ export function ensureYamlOutputDir(config, projectRoot) {
175
181
  * @property {string|null} organizationId
176
182
  * @property {string} projectId
177
183
  * @property {{ name: string, baseUrl: string, frameworkDetected: string|null }} project
178
- * @property {{ username: string, password: string }} auth
184
+ * @property {{ username: string, password: string }} auth
179
185
  * @property {{ yamlOutputDir: string, reuseSuccessfulYamls: boolean, singleConfigurationMode: boolean }} runtime
180
186
  * @property {{ arcalityVersion: string }} meta
181
187
  */
@@ -0,0 +1,59 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { discoverProjectRoutes } from './routeDiscovery.mjs';
4
+
5
+ function readJson(filePath) {
6
+ const raw = fs.readFileSync(filePath, 'utf8');
7
+ return JSON.parse(raw);
8
+ }
9
+
10
+ function detectFramework(pkg) {
11
+ const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
12
+ if (deps.next) return 'next';
13
+ if (deps.vite) return 'vite';
14
+ if (deps['react-scripts']) return 'cra';
15
+ return 'unknown';
16
+ }
17
+
18
+ export function analyzeCodebase(projectRoot = process.cwd(), options = {}) {
19
+ const pkgPath = path.join(projectRoot, 'package.json');
20
+ const result = {
21
+ projectRoot,
22
+ packageJsonPath: pkgPath,
23
+ projectName: path.basename(projectRoot),
24
+ framework: 'unknown',
25
+ scripts: [],
26
+ dependencies: [],
27
+ routeCount: 0,
28
+ routes: [],
29
+ warnings: []
30
+ };
31
+
32
+ if (!fs.existsSync(pkgPath)) {
33
+ result.warnings.push('No se encontro package.json en el proyecto destino.');
34
+ return result;
35
+ }
36
+
37
+ try {
38
+ const pkg = readJson(pkgPath);
39
+ result.projectName = pkg.name || result.projectName;
40
+ result.framework = detectFramework(pkg);
41
+ result.scripts = Object.keys(pkg.scripts || {});
42
+ result.dependencies = Object.keys({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) });
43
+ result.routes = discoverProjectRoutes(projectRoot, result.framework, {
44
+ baseUrl: options.baseUrl || ''
45
+ });
46
+ result.routeCount = result.routes.length;
47
+
48
+ if (result.routeCount === 0) {
49
+ result.warnings.push(`No se detectaron rutas automaticamente para el framework "${result.framework}".`);
50
+ }
51
+ if (result.framework === 'unknown') {
52
+ result.warnings.push('Framework no reconocido automaticamente. Se usaron heuristicas de fallback.');
53
+ }
54
+ } catch (error) {
55
+ result.warnings.push(`No se pudo analizar el proyecto: ${error.message}`);
56
+ }
57
+
58
+ return result;
59
+ }
@@ -0,0 +1,76 @@
1
+ function isNonEmptyString(value) {
2
+ return typeof value === 'string' && value.trim().length > 0;
3
+ }
4
+
5
+ function isPriority(value) {
6
+ return value === 'high' || value === 'normal' || value === 'low';
7
+ }
8
+
9
+ export function validateGeneratedMissionSchema(mission) {
10
+ const issues = [];
11
+
12
+ if (!mission || typeof mission !== 'object' || Array.isArray(mission)) {
13
+ return {
14
+ valid: false,
15
+ issues: ['Mission must be a plain object.']
16
+ };
17
+ }
18
+
19
+ if (!isNonEmptyString(mission.name)) issues.push('name is required.');
20
+ if (!isNonEmptyString(mission.prompt)) issues.push('prompt is required.');
21
+ if (!isNonEmptyString(mission.page)) issues.push('page is required.');
22
+ if (!isNonEmptyString(mission.page_path)) issues.push('page_path is required.');
23
+ if (!isNonEmptyString(mission.expected_result)) issues.push('expected_result is required.');
24
+ if (!Array.isArray(mission.tags) || mission.tags.length === 0) issues.push('tags must be a non-empty array.');
25
+ if (!isPriority(mission.priority)) issues.push('priority must be one of: high, normal, low.');
26
+ if (!isNonEmptyString(mission.collection)) issues.push('collection is required.');
27
+ if (!isNonEmptyString(mission.created_at) || Number.isNaN(Date.parse(mission.created_at))) {
28
+ issues.push('created_at must be a valid ISO date string.');
29
+ }
30
+
31
+ if (!mission.generation || typeof mission.generation !== 'object' || Array.isArray(mission.generation)) {
32
+ issues.push('generation metadata is required.');
33
+ } else {
34
+ if (!isNonEmptyString(mission.generation.source)) issues.push('generation.source is required.');
35
+ if (!isNonEmptyString(mission.generation.framework)) issues.push('generation.framework is required.');
36
+ if (!isNonEmptyString(mission.generation.router_kind)) issues.push('generation.router_kind is required.');
37
+ if (!isNonEmptyString(mission.generation.variant)) issues.push('generation.variant is required.');
38
+ if (!isNonEmptyString(mission.generation.route_hash)) issues.push('generation.route_hash is required.');
39
+ if (!isNonEmptyString(mission.generation.prompt_hash)) issues.push('generation.prompt_hash is required.');
40
+ if (!isNonEmptyString(mission.generation.route_example_path)) {
41
+ issues.push('generation.route_example_path is required.');
42
+ }
43
+ if (!Array.isArray(mission.generation.route_params)) {
44
+ issues.push('generation.route_params must be an array.');
45
+ }
46
+
47
+ const status = mission.generation.context_status;
48
+ if (!status || typeof status !== 'object' || Array.isArray(status)) {
49
+ issues.push('generation.context_status is required.');
50
+ } else {
51
+ if (!isNonEmptyString(status.qaContext)) issues.push('generation.context_status.qaContext is required.');
52
+ if (!isNonEmptyString(status.feasibility)) issues.push('generation.context_status.feasibility is required.');
53
+ }
54
+ }
55
+
56
+ if (Array.isArray(mission.tags)) {
57
+ for (const tag of mission.tags) {
58
+ if (!isNonEmptyString(tag)) {
59
+ issues.push('tags must contain only non-empty strings.');
60
+ break;
61
+ }
62
+ }
63
+ }
64
+
65
+ return {
66
+ valid: issues.length === 0,
67
+ issues
68
+ };
69
+ }
70
+
71
+ export function assertGeneratedMissionSchema(mission) {
72
+ const result = validateGeneratedMissionSchema(mission);
73
+ if (!result.valid) {
74
+ throw new Error(`Generated mission schema validation failed: ${result.issues.join(' ')}`);
75
+ }
76
+ }
@@ -0,0 +1,117 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { dump, load as loadYaml } from 'js-yaml';
4
+ import { buildMissionIdentity } from './missionGenerator.mjs';
5
+ import { assertGeneratedMissionSchema } from './generatedMissionSchema.mjs';
6
+
7
+ const YAML_RE = /\.(yaml|yml)$/i;
8
+
9
+ function toPosix(value) {
10
+ return String(value || '').split(path.sep).join('/');
11
+ }
12
+
13
+ function walk(dir, out = []) {
14
+ if (!fs.existsSync(dir)) return out;
15
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
16
+ for (const entry of entries) {
17
+ const full = path.join(dir, entry.name);
18
+ if (entry.isDirectory()) {
19
+ if (entry.name === '_templates') continue;
20
+ walk(full, out);
21
+ } else {
22
+ out.push(full);
23
+ }
24
+ }
25
+ return out;
26
+ }
27
+
28
+ function sanitizeName(value) {
29
+ return String(value || 'generated_mission')
30
+ .trim()
31
+ .replace(/[^\w.-]+/g, '_')
32
+ .replace(/_+/g, '_')
33
+ .replace(/^_+|_+$/g, '') || 'generated_mission';
34
+ }
35
+
36
+ function ensureDir(dir) {
37
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
38
+ }
39
+
40
+ function loadExistingMissionIdentities(targetDir) {
41
+ const identities = new Set();
42
+ if (!fs.existsSync(targetDir)) return identities;
43
+
44
+ const files = walk(targetDir).filter(file => YAML_RE.test(file)).filter(file => !/[\\/]_collection\.(yaml|yml)$/i.test(file));
45
+ for (const file of files) {
46
+ try {
47
+ const parsed = loadYaml(fs.readFileSync(file, 'utf8'));
48
+ if (!parsed || typeof parsed !== 'object') continue;
49
+ const identity = buildMissionIdentity(parsed);
50
+ if (identity) identities.add(identity);
51
+ } catch {
52
+ // Silencioso: si un YAML existente no se puede leer, no bloqueamos la generación.
53
+ }
54
+ }
55
+
56
+ return identities;
57
+ }
58
+
59
+ function writeCache(projectRoot, payload) {
60
+ const cacheDir = path.join(projectRoot, '.arcality', 'cache');
61
+ ensureDir(cacheDir);
62
+ fs.writeFileSync(path.join(cacheDir, 'generate-state.json'), JSON.stringify(payload, null, 2), 'utf8');
63
+ }
64
+
65
+ export function saveGeneratedMissions({ projectRoot, targetDir, missions, dryRun = false }) {
66
+ const existingIdentities = loadExistingMissionIdentities(targetDir);
67
+ const created = [];
68
+ const skipped = [];
69
+
70
+ if (!dryRun) ensureDir(targetDir);
71
+
72
+ for (const mission of missions) {
73
+ assertGeneratedMissionSchema(mission);
74
+ const identity = buildMissionIdentity(mission);
75
+ if (!identity || existingIdentities.has(identity)) {
76
+ skipped.push({
77
+ name: mission.name,
78
+ page_path: mission.page_path,
79
+ reason: 'duplicate'
80
+ });
81
+ continue;
82
+ }
83
+
84
+ const collectionRel = String(mission.collection || 'generated/general').replace(/^\/+/, '');
85
+ const targetCollectionDir = path.join(targetDir, collectionRel);
86
+ const fileName = `${sanitizeName(mission.name)}.yaml`;
87
+ const filePath = path.join(targetCollectionDir, fileName);
88
+
89
+ if (!dryRun) {
90
+ ensureDir(targetCollectionDir);
91
+ fs.writeFileSync(filePath, dump(mission, { lineWidth: 120, noRefs: true }), 'utf8');
92
+ }
93
+
94
+ existingIdentities.add(identity);
95
+ created.push({
96
+ name: mission.name,
97
+ page_path: mission.page_path,
98
+ filePath: toPosix(path.relative(projectRoot, filePath))
99
+ });
100
+ }
101
+
102
+ const summary = {
103
+ generated_at: new Date().toISOString(),
104
+ target_dir: toPosix(path.relative(projectRoot, targetDir) || '.'),
105
+ total_requested: missions.length,
106
+ created_count: created.length,
107
+ skipped_count: skipped.length,
108
+ created,
109
+ skipped
110
+ };
111
+
112
+ if (!dryRun) {
113
+ writeCache(projectRoot, summary);
114
+ }
115
+
116
+ return summary;
117
+ }