@arcadialdev/arcality 2.4.27 → 2.4.29
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.
- package/README.md +2 -2
- package/package.json +4 -7
- package/src/KnowledgeService.ts +256 -0
- package/src/consoleBanner.ts +32 -0
- package/src/envSetup.ts +205 -0
- package/src/index.ts +25 -0
- package/src/projectInspector.ts +42 -0
- package/src/services/collectiveMemoryService.ts +178 -0
- package/src/services/securityScanner.ts +128 -0
- package/src/testRunner.ts +201 -0
- package/tests/_helpers/ArcalityReporter.ts +733 -0
- package/tests/_helpers/agentic-runner.bundle.spec.js +2912 -83
- package/tests/_helpers/agentic-runner.spec.ts +783 -0
- package/tests/_helpers/ai-agent-helper.ts +1660 -0
- package/tests/_helpers/discover-view.spec.ts +238 -0
- package/tests/_helpers/extract-view.spec.ts +118 -0
- package/tests/_helpers/qa-security-tools.ts +265 -0
- package/tests/_helpers/qa-tools.ts +333 -0
- package/tests/_helpers/smart-action.spec.ts +1458 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* collectiveMemoryService.ts
|
|
3
|
+
* Servicio de escritura hacia la Memoria Colectiva del backend de Arcality.
|
|
4
|
+
*
|
|
5
|
+
* REGLAS CRÍTICAS:
|
|
6
|
+
* - NUNCA interrumpe el flujo principal (todo en try/catch silencioso)
|
|
7
|
+
* - NUNCA envía datos sensibles (passwords, tokens, PII)
|
|
8
|
+
* - NUNCA duplica fields (valida contra /portal/context primero)
|
|
9
|
+
* - SIEMPRE usa snake_case según JsonNamingPolicy.SnakeCaseLower del servidor
|
|
10
|
+
* - NUNCA envía project_id vacío o con GUID cero
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const getBase = () => process.env.ARCALITY_API_URL ? `${process.env.ARCALITY_API_URL}/api/v1` : null;
|
|
14
|
+
const getKey = () => process.env.ARCALITY_API_KEY || '';
|
|
15
|
+
const getPid = () => process.env.ARCALITY_PROJECT_ID || '';
|
|
16
|
+
|
|
17
|
+
const EMPTY_GUID = '00000000-0000-0000-0000-000000000000';
|
|
18
|
+
|
|
19
|
+
function isConfigured(): boolean {
|
|
20
|
+
const base = getBase();
|
|
21
|
+
const key = getKey();
|
|
22
|
+
const pid = getPid();
|
|
23
|
+
|
|
24
|
+
if (!base || !key || !pid || pid === EMPTY_GUID) {
|
|
25
|
+
// Silencioso — no loguear en cada percepción para no hacer ruido
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── 1. REGLAS DE UI / NEGOCIO ──────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export type RuleType = 'UI_UX' | 'VALIDATION' | 'NAVIGATION' | 'BUSINESS' | 'SECURITY';
|
|
34
|
+
export type Severity = 'critical' | 'important' | 'suggestion';
|
|
35
|
+
|
|
36
|
+
export async function pushRule(rule: {
|
|
37
|
+
rule_type: RuleType;
|
|
38
|
+
title: string;
|
|
39
|
+
description: string;
|
|
40
|
+
severity?: Severity;
|
|
41
|
+
}): Promise<string | null> {
|
|
42
|
+
if (!isConfigured()) return null;
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(`${getBase()}/portal/rules`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
'x-api-key': getKey()
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify({
|
|
51
|
+
project_id: getPid(),
|
|
52
|
+
rule_type: rule.rule_type,
|
|
53
|
+
title: rule.title.substring(0, 100),
|
|
54
|
+
description: rule.description.substring(0, 500),
|
|
55
|
+
severity: rule.severity ?? 'important',
|
|
56
|
+
source: 'agent'
|
|
57
|
+
})
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
console.warn(`[CollectiveMemory] pushRule HTTP ${res.status}: ${await res.text().catch(() => '')}`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
console.log(`\x1b[32m[CollectiveMemory] ✅ Regla guardada: "${rule.title}"\x1b[0m`);
|
|
67
|
+
return data.id ?? null;
|
|
68
|
+
} catch (err: any) {
|
|
69
|
+
console.warn(`[CollectiveMemory] pushRule error: ${err?.message}`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── 2. CATÁLOGO DE CAMPOS ──────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export type FieldType = 'email' | 'password' | 'text' | 'number' | 'select' | 'checkbox' | 'textarea' | 'date';
|
|
77
|
+
|
|
78
|
+
export async function pushField(field: {
|
|
79
|
+
field_identifier: string;
|
|
80
|
+
field_type?: FieldType | string;
|
|
81
|
+
is_required?: boolean;
|
|
82
|
+
validation_rule?: string;
|
|
83
|
+
page_id?: string | null;
|
|
84
|
+
existingFieldIds?: string[]; // Lista de ids ya guardados para deduplicación
|
|
85
|
+
}): Promise<string | null> {
|
|
86
|
+
if (!isConfigured()) return null;
|
|
87
|
+
|
|
88
|
+
// Deduplicación: no insertar si ya existe en el contexto recuperado
|
|
89
|
+
if (field.existingFieldIds?.includes(field.field_identifier)) {
|
|
90
|
+
return null; // Ya catalogado, no duplicar
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const res = await fetch(`${getBase()}/portal/fields`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: {
|
|
97
|
+
'Content-Type': 'application/json',
|
|
98
|
+
'x-api-key': getKey()
|
|
99
|
+
},
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
project_id: getPid(),
|
|
102
|
+
page_id: field.page_id ?? null,
|
|
103
|
+
field_identifier: field.field_identifier.substring(0, 100),
|
|
104
|
+
field_type: field.field_type ?? 'text',
|
|
105
|
+
is_required: field.is_required ?? false,
|
|
106
|
+
validation_rule: field.validation_rule?.substring(0, 300) ?? null,
|
|
107
|
+
source: 'agent'
|
|
108
|
+
})
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const data = await res.json();
|
|
116
|
+
return data.id ?? null;
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
// Silencioso — fire-and-forget, 'terminated' es esperado al finalizar el proceso
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ─── 3. CONOCIMIENTO / DOCUMENTACIÓN ────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
export type DocType = 'PROCESS' | 'VALIDATION' | 'NAVIGATION' | 'COMPONENT' | 'GENERAL';
|
|
126
|
+
|
|
127
|
+
export async function pushKnowledge(knowledge: {
|
|
128
|
+
doc_type: DocType;
|
|
129
|
+
title: string;
|
|
130
|
+
content: string;
|
|
131
|
+
}): Promise<string | null> {
|
|
132
|
+
if (!isConfigured()) return null;
|
|
133
|
+
try {
|
|
134
|
+
const res = await fetch(`${getBase()}/portal/knowledge`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: {
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
'x-api-key': getKey()
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
project_id: getPid(),
|
|
142
|
+
doc_type: knowledge.doc_type,
|
|
143
|
+
title: knowledge.title.substring(0, 150),
|
|
144
|
+
content: knowledge.content.substring(0, 500),
|
|
145
|
+
source: 'agent'
|
|
146
|
+
})
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
console.warn(`[CollectiveMemory] pushKnowledge HTTP ${res.status}: ${await res.text().catch(() => '')}`);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const data = await res.json();
|
|
155
|
+
console.log(`\x1b[32m[CollectiveMemory] ✅ Conocimiento guardado: "${knowledge.title}"\x1b[0m`);
|
|
156
|
+
return data.id ?? null;
|
|
157
|
+
} catch (err: any) {
|
|
158
|
+
console.warn(`[CollectiveMemory] pushKnowledge error: ${err?.message}`);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── HELPER: Obtener campos ya conocidos para deduplicación ─────────────────
|
|
164
|
+
|
|
165
|
+
export async function getKnownFieldIdentifiers(pathUrl: string): Promise<string[]> {
|
|
166
|
+
if (!isConfigured()) return [];
|
|
167
|
+
try {
|
|
168
|
+
const res = await fetch(
|
|
169
|
+
`${getBase()}/portal/context?project_id=${getPid()}&path=${encodeURIComponent(pathUrl)}`,
|
|
170
|
+
{ headers: { 'x-api-key': getKey() } }
|
|
171
|
+
);
|
|
172
|
+
if (!res.ok) return [];
|
|
173
|
+
const data = await res.json();
|
|
174
|
+
return (data?.fields ?? []).map((f: any) => f.field_identifier ?? '').filter(Boolean);
|
|
175
|
+
} catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { Page } from '@playwright/test';
|
|
2
|
+
import {
|
|
3
|
+
SecurityFinding,
|
|
4
|
+
audit_http_headers,
|
|
5
|
+
scan_sensitive_data_exposure,
|
|
6
|
+
// test_auth_bypass can be added here if there are common protected routes to check.
|
|
7
|
+
} from '../../tests/_helpers/qa-security-tools';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The main service for running automated security scans at different points in the test lifecycle.
|
|
11
|
+
*/
|
|
12
|
+
export class SecurityScanner {
|
|
13
|
+
private page: Page;
|
|
14
|
+
private report: SecurityFinding[] = [];
|
|
15
|
+
|
|
16
|
+
constructor(page: Page) {
|
|
17
|
+
this.page = page;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Runs a series of automated scans against the current state of the page.
|
|
22
|
+
* This is intended to be called at the end of a test run to aggregate findings.
|
|
23
|
+
* @returns A promise that resolves to an array of found vulnerabilities.
|
|
24
|
+
*/
|
|
25
|
+
async runAllScans(): Promise<SecurityFinding[]> {
|
|
26
|
+
console.log('[QA-SEC] Starting comprehensive security scan...');
|
|
27
|
+
|
|
28
|
+
// Scan 1: Audit HTTP Security Headers
|
|
29
|
+
const headerVulnerabilities = await audit_http_headers(this.page);
|
|
30
|
+
this.report.push(...headerVulnerabilities);
|
|
31
|
+
|
|
32
|
+
// Scan 2: Scan for sensitive data in browser storage
|
|
33
|
+
const storageVulnerabilities = await scan_sensitive_data_exposure(this.page);
|
|
34
|
+
this.report.push(...storageVulnerabilities);
|
|
35
|
+
|
|
36
|
+
// Scan 3: Check for open redirect vulnerabilities (basic check)
|
|
37
|
+
await this.testOpenRedirect();
|
|
38
|
+
|
|
39
|
+
// More scans like auth bypass for known admin URLs could be added here.
|
|
40
|
+
|
|
41
|
+
console.log(`[QA-SEC] Comprehensive scan finished. Found ${this.report.length} potential vulnerabilities.`);
|
|
42
|
+
return this.getReport();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A basic test for open redirect vulnerabilities on the current URL.
|
|
47
|
+
*/
|
|
48
|
+
async testOpenRedirect(): Promise<void> {
|
|
49
|
+
const url = new URL(this.page.url());
|
|
50
|
+
const redirectParams = ['redirect', 'next', 'url', 'returnTo', 'dest'];
|
|
51
|
+
|
|
52
|
+
for (const param of redirectParams) {
|
|
53
|
+
if (url.searchParams.has(param)) {
|
|
54
|
+
const originalValue = url.searchParams.get(param);
|
|
55
|
+
const payload = 'https://www.evil-redirect.com';
|
|
56
|
+
url.searchParams.set(param, payload);
|
|
57
|
+
|
|
58
|
+
console.log(`[QA-SEC] Testing Open Redirect on param: ${param}`);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await this.page.goto(url.toString(), { waitUntil: 'networkidle' });
|
|
62
|
+
|
|
63
|
+
if (this.page.url().startsWith(payload)) {
|
|
64
|
+
const vulnerability: SecurityFinding = {
|
|
65
|
+
type: 'confirmed_vulnerability',
|
|
66
|
+
category: 'Open Redirect',
|
|
67
|
+
severity: 'Medium',
|
|
68
|
+
confidence: 'High',
|
|
69
|
+
exploitability: 'confirmed',
|
|
70
|
+
status: 'detected',
|
|
71
|
+
description: `The application is vulnerable to Open Redirect. The '${param}' parameter was manipulated to redirect the user to an external, malicious site.`,
|
|
72
|
+
impact: 'Attackers can use this to craft phishing links that appear to come from the trusted domain but redirect to malicious sites.',
|
|
73
|
+
evidence: {
|
|
74
|
+
vulnerableUrl: url.toString(),
|
|
75
|
+
parameter: param,
|
|
76
|
+
payload: payload,
|
|
77
|
+
},
|
|
78
|
+
reproductionSteps: [
|
|
79
|
+
`Navigate to: ${url.toString()}`,
|
|
80
|
+
"Observe that the page redirects to the payload domain."
|
|
81
|
+
],
|
|
82
|
+
remediation: 'Validate all redirection URLs against a whitelist of allowed domains. Do not blindly redirect to user-supplied URLs.',
|
|
83
|
+
classification: {
|
|
84
|
+
cwe: 'CWE-601'
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
this.report.push(vulnerability);
|
|
88
|
+
// Go back to the original page to continue other tests
|
|
89
|
+
await this.page.goBack();
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.log(`[QA-SEC] Error during open redirect test, this might be expected if the navigation was blocked. Continuing scan.`);
|
|
93
|
+
// It's possible the navigation to the evil site fails, which is good.
|
|
94
|
+
// We should ensure we can recover and continue.
|
|
95
|
+
await this.page.goBack(); // Try to recover state
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Adds a vulnerability to the report. Can be used by other parts of the system
|
|
104
|
+
* to feed into this scanner's report.
|
|
105
|
+
* @param vuln - The vulnerability to add.
|
|
106
|
+
*/
|
|
107
|
+
addVulnerability(vuln: SecurityFinding) {
|
|
108
|
+
this.report.push(vuln);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns the final list of vulnerabilities.
|
|
113
|
+
*/
|
|
114
|
+
getReport(): SecurityFinding[] {
|
|
115
|
+
// Deduplicate vulnerabilities to avoid noise in the report
|
|
116
|
+
const uniqueReport: SecurityFinding[] = [];
|
|
117
|
+
const seen = new Set<string>();
|
|
118
|
+
|
|
119
|
+
for (const vuln of this.report) {
|
|
120
|
+
const key = `${vuln.category}|${vuln.description}|${vuln.evidence.selector || ''}`;
|
|
121
|
+
if (!seen.has(key)) {
|
|
122
|
+
uniqueReport.push(vuln);
|
|
123
|
+
seen.add(key);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return uniqueReport;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { select, isCancel, outro, spinner, text } from '@clack/prompts';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { ensureEnvInteractive, readExistingEnv } from './envSetup';
|
|
8
|
+
import { KnowledgeService } from './KnowledgeService';
|
|
9
|
+
import { ArcalityClient } from './arcalityClient';
|
|
10
|
+
|
|
11
|
+
type Choice = { title: string; value: string };
|
|
12
|
+
|
|
13
|
+
class UserCancelledError extends Error {
|
|
14
|
+
constructor(message = 'User cancelled the operation') {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'UserCancelledError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function walk(dir: string): string[] {
|
|
21
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
22
|
+
const files: string[] = [];
|
|
23
|
+
for (const e of entries) {
|
|
24
|
+
const full = path.join(dir, e.name);
|
|
25
|
+
if (e.isDirectory()) files.push(...walk(full));
|
|
26
|
+
else files.push(full);
|
|
27
|
+
}
|
|
28
|
+
return files;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPlaywrightTestFile(file: string): boolean {
|
|
32
|
+
return (
|
|
33
|
+
file.endsWith('.spec.ts') ||
|
|
34
|
+
file.endsWith('.spec.js') ||
|
|
35
|
+
file.endsWith('.test.ts') ||
|
|
36
|
+
file.endsWith('.test.js')
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getChoices(testDir: string): { label: string; value: string }[] {
|
|
41
|
+
if (!fs.existsSync(testDir)) return [];
|
|
42
|
+
|
|
43
|
+
const files = walk(testDir).filter(isPlaywrightTestFile).sort();
|
|
44
|
+
const rels = files.map((abs) => path.relative(process.cwd(), abs).split(path.sep).join('/'));
|
|
45
|
+
|
|
46
|
+
return [
|
|
47
|
+
...rels.map((r) => ({ label: r, value: r })),
|
|
48
|
+
{ label: '❌ Exit', value: '__EXIT__' },
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureInteractiveStdin(): void {
|
|
53
|
+
// If NO TTY, arrows will not work. Better to fail with a clear message.
|
|
54
|
+
if (!process.stdin.isTTY) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
'The terminal is not interactive (stdin is not TTY). Run this command in a real terminal (not a pipe), and avoid ts-node-dev/watch.'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runPlaywright(selection: string): Promise<void> {
|
|
62
|
+
const isWin = process.platform === 'win32';
|
|
63
|
+
const pwBin = isWin
|
|
64
|
+
? path.join(process.cwd(), 'node_modules', '.bin', 'playwright.cmd')
|
|
65
|
+
: path.join(process.cwd(), 'node_modules', '.bin', 'playwright');
|
|
66
|
+
|
|
67
|
+
const baseArgs = ['test', '--headed', '--project=chromium'];
|
|
68
|
+
const args =
|
|
69
|
+
selection === '__ALL__'
|
|
70
|
+
? baseArgs
|
|
71
|
+
: [...baseArgs, selection];
|
|
72
|
+
|
|
73
|
+
const s = spinner();
|
|
74
|
+
s.start(`Running test: ${selection}`);
|
|
75
|
+
|
|
76
|
+
let triedNpx = false;
|
|
77
|
+
let lastError;
|
|
78
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
79
|
+
let bin = pwBin;
|
|
80
|
+
let binArgs = args;
|
|
81
|
+
let useShell = isWin;
|
|
82
|
+
if (triedNpx) {
|
|
83
|
+
bin = isWin ? 'npx.cmd' : 'npx';
|
|
84
|
+
binArgs = ['playwright', ...args];
|
|
85
|
+
useShell = isWin;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const spawnBin = (isWin && bin.includes(' ')) ? `"${bin}"` : bin;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await new Promise<void>((resolve, reject) => {
|
|
92
|
+
const child = spawn(spawnBin, binArgs, {
|
|
93
|
+
stdio: 'inherit',
|
|
94
|
+
shell: useShell,
|
|
95
|
+
windowsHide: true,
|
|
96
|
+
env: process.env,
|
|
97
|
+
cwd: process.cwd(),
|
|
98
|
+
});
|
|
99
|
+
process.removeAllListeners('SIGINT');
|
|
100
|
+
const onSigint = () => {
|
|
101
|
+
child.kill('SIGINT');
|
|
102
|
+
reject(new UserCancelledError());
|
|
103
|
+
};
|
|
104
|
+
process.once('SIGINT', onSigint);
|
|
105
|
+
child.on('close', (code: any) => {
|
|
106
|
+
process.removeListener('SIGINT', onSigint);
|
|
107
|
+
if (code === 0) resolve();
|
|
108
|
+
else reject(new Error(`Arcality engine finished with code ${code}`));
|
|
109
|
+
});
|
|
110
|
+
child.on('error', (err: any) => {
|
|
111
|
+
process.removeListener('SIGINT', onSigint);
|
|
112
|
+
reject(err);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
s.message('Performing report rebranding...');
|
|
117
|
+
try {
|
|
118
|
+
const isWin = process.platform === 'win32';
|
|
119
|
+
const nodeCmd = isWin ? 'node.exe' : 'node';
|
|
120
|
+
const { spawnSync } = require('node:child_process');
|
|
121
|
+
spawnSync(nodeCmd, [path.join(process.cwd(), 'scripts', 'rebrand-report.mjs')], { stdio: 'ignore', windowsHide: true });
|
|
122
|
+
} catch (e) { /* ignore */ }
|
|
123
|
+
|
|
124
|
+
s.stop(chalk.green('✅ Test completed.'));
|
|
125
|
+
|
|
126
|
+
const reportPath = path.join(process.cwd(), 'tests-report', 'index.html');
|
|
127
|
+
if (fs.existsSync(reportPath)) {
|
|
128
|
+
if (isWin) {
|
|
129
|
+
spawn('cmd', ['/c', 'start', '""', `"${reportPath}"`], {
|
|
130
|
+
stdio: 'ignore',
|
|
131
|
+
shell: true,
|
|
132
|
+
detached: true,
|
|
133
|
+
windowsHide: true
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
137
|
+
spawn(openCmd, [reportPath], {
|
|
138
|
+
stdio: 'ignore',
|
|
139
|
+
shell: true,
|
|
140
|
+
detached: true
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
} catch (err) {
|
|
146
|
+
lastError = err;
|
|
147
|
+
if (!triedNpx) {
|
|
148
|
+
triedNpx = true;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
s.stop(chalk.red('❌ The test failed.'));
|
|
152
|
+
if (err instanceof Error) {
|
|
153
|
+
throw new Error(`Arcality Engine Error:\n${err.message}`);
|
|
154
|
+
} else {
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
throw lastError;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function promptAndRunPlaywrightTests(arcalityClient: ArcalityClient, knowledgeService: KnowledgeService): Promise<void> {
|
|
163
|
+
while (true) {
|
|
164
|
+
try {
|
|
165
|
+
await ensureEnvInteractive();
|
|
166
|
+
|
|
167
|
+
const testDir = path.join(process.cwd(), 'tests');
|
|
168
|
+
const options = getChoices(testDir);
|
|
169
|
+
|
|
170
|
+
if (options.length === 0) {
|
|
171
|
+
console.log('No tests found in:', testDir);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const selection = await select({
|
|
176
|
+
message: 'Select the test to run:',
|
|
177
|
+
options,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (isCancel(selection) || selection === '__EXIT__') {
|
|
181
|
+
outro(chalk.cyan('See you later!'));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await runPlaywright(selection as string);
|
|
186
|
+
|
|
187
|
+
await text({
|
|
188
|
+
message: 'Press Enter to return to menu...',
|
|
189
|
+
placeholder: 'Enter'
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err instanceof UserCancelledError) {
|
|
194
|
+
outro(chalk.cyan('Operation cancelled.'));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
console.error('Error:', err instanceof Error ? err.message : err);
|
|
198
|
+
await new Promise(res => setTimeout(res, 1000));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|