ai-control-center 1.15.2
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/LICENSE +21 -0
- package/README.md +584 -0
- package/bin/aicc.js +772 -0
- package/lib/actions/approve.js +71 -0
- package/lib/actions/assign-project.js +132 -0
- package/lib/actions/browser-test.js +64 -0
- package/lib/actions/cleanup.js +174 -0
- package/lib/actions/debug.js +298 -0
- package/lib/actions/deploy.js +1229 -0
- package/lib/actions/fix-bug.js +134 -0
- package/lib/actions/new-feature.js +255 -0
- package/lib/actions/reject.js +307 -0
- package/lib/actions/review.js +706 -0
- package/lib/actions/status.js +47 -0
- package/lib/agents/browser-qa-agent.js +611 -0
- package/lib/agents/payment-agent.js +116 -0
- package/lib/agents/suggestion-agent.js +88 -0
- package/lib/cli.js +303 -0
- package/lib/config.js +243 -0
- package/lib/hub/hub-server.js +440 -0
- package/lib/hub/project-poller.js +75 -0
- package/lib/hub/skill-registry.js +89 -0
- package/lib/hub/state-aggregator.js +204 -0
- package/lib/index.js +471 -0
- package/lib/init/doctor.js +523 -0
- package/lib/init/presets.js +222 -0
- package/lib/init/skill-fetcher.js +77 -0
- package/lib/init/wizard.js +973 -0
- package/lib/integrations/codex-runner.js +128 -0
- package/lib/integrations/github-actions.js +248 -0
- package/lib/integrations/github-reporter.js +229 -0
- package/lib/integrations/screenshot-store.js +102 -0
- package/lib/openclaw/bridge.js +650 -0
- package/lib/openclaw/generate-skill.js +235 -0
- package/lib/openclaw/openclaw.json +64 -0
- package/lib/orchestrator/autonomous-loop.js +429 -0
- package/lib/orchestrator/thread-triggers.js +63 -0
- package/lib/roleplay/agent-messenger.js +75 -0
- package/lib/roleplay/discussion-threads.js +303 -0
- package/lib/roleplay/health-monitor.js +121 -0
- package/lib/roleplay/pm-agent.js +513 -0
- package/lib/roleplay/roleplay-config.js +25 -0
- package/lib/roleplay/room.js +164 -0
- package/lib/shared/action-runner.js +2330 -0
- package/lib/shared/event-bus.js +185 -0
- package/lib/slack/bot.js +378 -0
- package/lib/telegram/bot.js +416 -0
- package/lib/telegram/commands.js +1267 -0
- package/lib/telegram/keyboards.js +113 -0
- package/lib/telegram/notifications.js +247 -0
- package/lib/twitch/bot.js +354 -0
- package/lib/twitch/commands.js +302 -0
- package/lib/twitch/notifications.js +63 -0
- package/lib/utils/achievements.js +191 -0
- package/lib/utils/activity-log.js +182 -0
- package/lib/utils/agent-leaderboard.js +119 -0
- package/lib/utils/audit-logger.js +232 -0
- package/lib/utils/codebase-context.js +288 -0
- package/lib/utils/codebase-indexer.js +381 -0
- package/lib/utils/config-schema.js +230 -0
- package/lib/utils/context-compressor.js +172 -0
- package/lib/utils/correlation.js +63 -0
- package/lib/utils/cost-tracker.js +423 -0
- package/lib/utils/cron-scheduler.js +53 -0
- package/lib/utils/db-adapter.js +293 -0
- package/lib/utils/display.js +272 -0
- package/lib/utils/errors.js +116 -0
- package/lib/utils/format.js +134 -0
- package/lib/utils/intent-engine.js +464 -0
- package/lib/utils/mcp-client.js +238 -0
- package/lib/utils/model-ab-test.js +164 -0
- package/lib/utils/notify.js +122 -0
- package/lib/utils/persona-loader.js +80 -0
- package/lib/utils/pipeline-lock.js +73 -0
- package/lib/utils/pipeline.js +214 -0
- package/lib/utils/plugin-runner.js +234 -0
- package/lib/utils/rate-limiter.js +84 -0
- package/lib/utils/rbac.js +74 -0
- package/lib/utils/runner.js +1809 -0
- package/lib/utils/security.js +191 -0
- package/lib/utils/self-healer.js +144 -0
- package/lib/utils/skill-loader.js +255 -0
- package/lib/utils/spinner.js +132 -0
- package/lib/utils/stage-queue.js +50 -0
- package/lib/utils/state-machine.js +89 -0
- package/lib/utils/status-bar.js +327 -0
- package/lib/utils/token-estimator.js +101 -0
- package/lib/utils/ux-analyzer.js +101 -0
- package/lib/utils/webhook-emitter.js +83 -0
- package/lib/web/public/css/styles.css +417 -0
- package/lib/web/public/dark-mode.js +44 -0
- package/lib/web/public/hub/kanban.html +206 -0
- package/lib/web/public/index.html +45 -0
- package/lib/web/public/js/app.js +71 -0
- package/lib/web/public/js/ask.js +110 -0
- package/lib/web/public/js/dashboard.js +165 -0
- package/lib/web/public/js/deploy.js +72 -0
- package/lib/web/public/js/feature.js +79 -0
- package/lib/web/public/js/health.js +65 -0
- package/lib/web/public/js/logs.js +93 -0
- package/lib/web/public/js/review.js +123 -0
- package/lib/web/public/js/ws-client.js +82 -0
- package/lib/web/public/office/css/office.css +678 -0
- package/lib/web/public/office/index.html +148 -0
- package/lib/web/public/office/js/achievements-ui.js +117 -0
- package/lib/web/public/office/js/character.js +1056 -0
- package/lib/web/public/office/js/chat-bubbles.js +177 -0
- package/lib/web/public/office/js/cost-overlay.js +123 -0
- package/lib/web/public/office/js/day-night.js +68 -0
- package/lib/web/public/office/js/effects.js +632 -0
- package/lib/web/public/office/js/engine.js +146 -0
- package/lib/web/public/office/js/feature-ticket.js +216 -0
- package/lib/web/public/office/js/hub-client.js +60 -0
- package/lib/web/public/office/js/main.js +1757 -0
- package/lib/web/public/office/js/office-layout.js +1524 -0
- package/lib/web/public/office/js/pathfinding.js +144 -0
- package/lib/web/public/office/js/pixel-sprites.js +1454 -0
- package/lib/web/public/office/js/progress-bars.js +117 -0
- package/lib/web/public/office/js/replay.js +191 -0
- package/lib/web/public/office/js/sound-effects.js +91 -0
- package/lib/web/public/office/js/sprite-renderer.js +211 -0
- package/lib/web/public/office/js/stamina-system.js +89 -0
- package/lib/web/public/office/js/ui.js +107 -0
- package/lib/web/public/onboarding/index.html +243 -0
- package/lib/web/public/timeline/index.html +195 -0
- package/lib/web/routes/api.js +499 -0
- package/lib/web/routes/logs.js +20 -0
- package/lib/web/routes/metrics.js +99 -0
- package/lib/web/server.js +183 -0
- package/lib/web/ws/handler.js +65 -0
- package/package.json +67 -0
- package/templates/agent-architect.md +69 -0
- package/templates/agent-gemini-pm.md +49 -0
- package/templates/agent-gemini-reviewer.md +52 -0
- package/templates/copilot-instructions.md +36 -0
- package/templates/pipelines/mobile.json +27 -0
- package/templates/pipelines/nodejs-api.json +27 -0
- package/templates/pipelines/python.json +27 -0
- package/templates/pipelines/react.json +27 -0
- package/templates/pipelines/salesforce.json +27 -0
- package/templates/role-gemini.md +97 -0
- package/templates/skill-architect.md +114 -0
- package/templates/skill-browser-qa.md +50 -0
- package/templates/skill-bug-from-qa.md +58 -0
- package/templates/skill-chatbot.md +93 -0
- package/templates/skill-implement.md +78 -0
- package/templates/skill-openclaw.md +174 -0
- package/templates/skill-payment.md +110 -0
- package/templates/skill-pm-spec.md +77 -0
- package/templates/skill-requirement-capture.md +97 -0
- package/templates/skill-review.md +108 -0
- package/templates/skill-reviewer-qa.md +44 -0
- package/templates/skill-suggestion.md +45 -0
- package/templates/skill-template.md +142 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { getStatus, getWorkflowDir } from '../utils/pipeline.js';
|
|
5
|
+
import { printDivider } from '../utils/display.js';
|
|
6
|
+
|
|
7
|
+
export function showStatusAction() {
|
|
8
|
+
const status = getStatus();
|
|
9
|
+
const workflowDir = getWorkflowDir();
|
|
10
|
+
|
|
11
|
+
console.log('');
|
|
12
|
+
console.log(chalk.bold(' Pipeline Status'));
|
|
13
|
+
printDivider();
|
|
14
|
+
console.log(` Stage: ${chalk.bold(status.stage || 'idle')}`);
|
|
15
|
+
console.log(` Feature: ${chalk.cyan(status.current_feature || 'none')}`);
|
|
16
|
+
console.log(` Next Step: ${chalk.yellow(status.next || '—')}`);
|
|
17
|
+
|
|
18
|
+
if (status.latest_spec) console.log(` Latest Spec: ${chalk.dim(status.latest_spec)}`);
|
|
19
|
+
if (status.latest_arch) console.log(` Latest Arch: ${chalk.dim(status.latest_arch)}`);
|
|
20
|
+
if (status.latest_tasks) console.log(` Latest Tasks: ${chalk.dim(status.latest_tasks)}`);
|
|
21
|
+
if (status.latest_review) console.log(` Latest Review: ${chalk.dim(status.latest_review)}`);
|
|
22
|
+
|
|
23
|
+
printDivider();
|
|
24
|
+
|
|
25
|
+
// Show recent files in each directory
|
|
26
|
+
const dirs = ['inbox', 'specs', 'architecture', 'tasks', 'reviews', 'approved', 'rejected'];
|
|
27
|
+
dirs.forEach(dir => {
|
|
28
|
+
const fullDir = resolve(workflowDir, dir);
|
|
29
|
+
if (!existsSync(fullDir)) return;
|
|
30
|
+
const files = readdirSync(fullDir).filter(f => f.endsWith('.md')).sort().reverse().slice(0, 1);
|
|
31
|
+
if (files.length) {
|
|
32
|
+
console.log(` ${dir.padEnd(15)} ${chalk.dim(files[0])}`);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// History
|
|
37
|
+
if (status.history && status.history.length) {
|
|
38
|
+
console.log('');
|
|
39
|
+
console.log(chalk.bold(' History'));
|
|
40
|
+
printDivider();
|
|
41
|
+
status.history.slice(-5).reverse().forEach(h => {
|
|
42
|
+
console.log(` ${chalk.dim(h.time || '')} ${chalk.cyan(h.id || '')} → ${h.stage}`);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
console.log('');
|
|
47
|
+
}
|
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser QA Agent — Playwright-based autonomous web tester.
|
|
3
|
+
*
|
|
4
|
+
* Logs into target website as a test user, crawls all discoverable routes,
|
|
5
|
+
* tests interactive elements, captures screenshots, and generates a
|
|
6
|
+
* structured JSON report for the Bug-Fixer Agent to consume.
|
|
7
|
+
*
|
|
8
|
+
* Called by: lib/actions/browser-test.js (pipeline stage wrapper)
|
|
9
|
+
* Output: .ai-workflow/qa-reports/QA-{timestamp}.json
|
|
10
|
+
* .ai-workflow/screenshots/{slug}.png
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { chromium } from 'playwright';
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
|
|
15
|
+
import { resolve } from 'path';
|
|
16
|
+
import { getWorkflowDir, updateStatus } from '../utils/pipeline.js';
|
|
17
|
+
import { logActivity } from '../utils/activity-log.js';
|
|
18
|
+
import { ScreenshotStore } from '../integrations/screenshot-store.js';
|
|
19
|
+
import { isExcluded as _isExcluded, isFormSafe, isSessionExpired, resolveCredentials } from '../utils/security.js';
|
|
20
|
+
|
|
21
|
+
// Local alias + re-export for testing convenience
|
|
22
|
+
const isExcluded = _isExcluded;
|
|
23
|
+
export { isExcluded };
|
|
24
|
+
|
|
25
|
+
// ─── Config defaults ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const DEFAULT_TIMEOUT = 15_000;
|
|
28
|
+
const DEFAULT_NAV_TIMEOUT = 30_000;
|
|
29
|
+
const MAX_ROUTES = 80;
|
|
30
|
+
const INTERACTIVE_TAGS = ['button', 'a[href]', 'input', 'select', 'textarea', '[role=button]', '[role=link]', '[role=tab]'];
|
|
31
|
+
|
|
32
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function slugify(url) {
|
|
35
|
+
return url.replace(/https?:\/\//, '').replace(/[^a-z0-9]/gi, '_').slice(0, 80);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function ensureDir(dir) {
|
|
39
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Page Health Checker ──────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
async function checkPageHealth(page, url) {
|
|
45
|
+
const errors = [];
|
|
46
|
+
const warnings = [];
|
|
47
|
+
const consoleErrors = [];
|
|
48
|
+
|
|
49
|
+
page.on('console', msg => {
|
|
50
|
+
if (msg.type() === 'error') consoleErrors.push(msg.text().slice(0, 200));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Check for broken images
|
|
54
|
+
const brokenImages = await page.$$eval('img', imgs =>
|
|
55
|
+
imgs
|
|
56
|
+
.filter(i => !i.complete || i.naturalWidth === 0)
|
|
57
|
+
.map(i => i.src)
|
|
58
|
+
).catch(() => []);
|
|
59
|
+
|
|
60
|
+
if (brokenImages.length > 0) {
|
|
61
|
+
errors.push(`Broken images (${brokenImages.length}): ${brokenImages.slice(0, 3).join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check for missing alt text on images
|
|
65
|
+
const imagesWithoutAlt = await page.$$eval('img:not([alt])', imgs => imgs.length).catch(() => 0);
|
|
66
|
+
if (imagesWithoutAlt > 0) {
|
|
67
|
+
warnings.push(`${imagesWithoutAlt} images missing alt text (accessibility)`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check for 404 text in body
|
|
71
|
+
const bodyText = await page.textContent('body').catch(() => '');
|
|
72
|
+
if (/404|not found|page not found/i.test(bodyText.slice(0, 500))) {
|
|
73
|
+
errors.push('Page appears to be a 404 error');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check for unhandled error overlays (common in React dev)
|
|
77
|
+
const errorOverlay = await page.$('#webpack-dev-server-client-overlay, [data-reactroot] pre').catch(() => null);
|
|
78
|
+
if (errorOverlay) {
|
|
79
|
+
const overlayText = await errorOverlay.textContent().catch(() => '');
|
|
80
|
+
if (overlayText.trim()) errors.push(`Runtime error overlay: ${overlayText.slice(0, 200)}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Add captured console errors
|
|
84
|
+
if (consoleErrors.length > 0) {
|
|
85
|
+
errors.push(...consoleErrors.slice(0, 5));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { errors, warnings };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Login Handler ────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
async function loginToWebsite(page, credentials) {
|
|
94
|
+
const { loginUrl, email, password, loginSelectors } = credentials;
|
|
95
|
+
|
|
96
|
+
logActivity('QA', `Navigating to login: ${loginUrl}`, 'info');
|
|
97
|
+
await page.goto(loginUrl, { waitUntil: 'domcontentloaded', timeout: DEFAULT_NAV_TIMEOUT });
|
|
98
|
+
|
|
99
|
+
// Normalize selectors — config may provide a single CSS string or an array
|
|
100
|
+
const toArray = (val, defaults) => {
|
|
101
|
+
if (!val) return defaults;
|
|
102
|
+
return Array.isArray(val) ? val : [val];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const emailSels = toArray(loginSelectors?.email, [
|
|
106
|
+
'[type=email]', '[name=email]', '[name=username]',
|
|
107
|
+
'#email', '#username', 'input[placeholder*="email" i]', 'input[placeholder*="username" i]',
|
|
108
|
+
]);
|
|
109
|
+
const passwordSels = toArray(loginSelectors?.password, [
|
|
110
|
+
'[type=password]', '[name=password]', '#password',
|
|
111
|
+
'input[placeholder*="password" i]',
|
|
112
|
+
]);
|
|
113
|
+
const submitSels = toArray(loginSelectors?.submit, [
|
|
114
|
+
'button:has-text("Log In")', 'button:has-text("Login")', 'button:has-text("Sign in")',
|
|
115
|
+
'[type=submit]', '[data-testid="login-button"]',
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
// ── Step 0: If email input isn't visible yet, click the login/signin nav button
|
|
119
|
+
// (handles modal-based login flows where a nav button opens the form)
|
|
120
|
+
let emailEl = null;
|
|
121
|
+
for (const sel of emailSels) {
|
|
122
|
+
emailEl = await page.$(sel).catch(() => null);
|
|
123
|
+
if (emailEl && await emailEl.isVisible().catch(() => false)) break;
|
|
124
|
+
emailEl = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!emailEl) {
|
|
128
|
+
logActivity('QA', 'Email input not visible — looking for login trigger button', 'info');
|
|
129
|
+
const triggerSelectors = toArray(loginSelectors?.trigger, [
|
|
130
|
+
'button:has-text("Log In")', 'button:has-text("Login")', 'button:has-text("Sign in")',
|
|
131
|
+
'a:has-text("Log In")', 'a:has-text("Login")', 'a:has-text("Sign in")',
|
|
132
|
+
'[data-testid="login-trigger"]', '.login-btn', '#login-btn',
|
|
133
|
+
]);
|
|
134
|
+
for (const sel of triggerSelectors) {
|
|
135
|
+
const trigger = await page.$(sel).catch(() => null);
|
|
136
|
+
if (trigger && await trigger.isVisible().catch(() => false)) {
|
|
137
|
+
logActivity('QA', `Clicking login trigger: ${sel}`, 'info');
|
|
138
|
+
await trigger.click();
|
|
139
|
+
await page.waitForTimeout(1500); // Wait for modal/form to appear
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Re-check for email input after clicking trigger
|
|
145
|
+
for (const sel of emailSels) {
|
|
146
|
+
emailEl = await page.$(sel).catch(() => null);
|
|
147
|
+
if (emailEl && await emailEl.isVisible().catch(() => false)) break;
|
|
148
|
+
emailEl = null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!emailEl) {
|
|
152
|
+
throw new Error('Login failed — email input not found even after clicking login trigger');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Step 1: Fill email
|
|
157
|
+
await emailEl.fill(email);
|
|
158
|
+
|
|
159
|
+
// ── Step 2: Fill password
|
|
160
|
+
for (const sel of passwordSels) {
|
|
161
|
+
const el = await page.$(sel).catch(() => null);
|
|
162
|
+
if (el && await el.isVisible().catch(() => false)) {
|
|
163
|
+
await el.fill(password);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Step 3: Submit (click the form submit button, not the nav trigger)
|
|
169
|
+
// Prefer the wider/form submit button over the nav button
|
|
170
|
+
const allSubmitBtns = [];
|
|
171
|
+
for (const sel of submitSels) {
|
|
172
|
+
const els = await page.$$(sel).catch(() => []);
|
|
173
|
+
allSubmitBtns.push(...els);
|
|
174
|
+
}
|
|
175
|
+
// Pick the last matching visible submit button (usually the form one, not the nav one)
|
|
176
|
+
let submitted = false;
|
|
177
|
+
for (const btn of allSubmitBtns.reverse()) {
|
|
178
|
+
if (await btn.isVisible().catch(() => false)) {
|
|
179
|
+
await btn.click();
|
|
180
|
+
submitted = true;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (!submitted) {
|
|
185
|
+
// Fallback: press Enter on the password field
|
|
186
|
+
for (const sel of passwordSels) {
|
|
187
|
+
const el = await page.$(sel).catch(() => null);
|
|
188
|
+
if (el) { await el.press('Enter'); break; }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Step 4: Wait for login to complete
|
|
193
|
+
await page.waitForTimeout(3000);
|
|
194
|
+
await page.waitForLoadState('domcontentloaded', { timeout: DEFAULT_NAV_TIMEOUT }).catch(() => {});
|
|
195
|
+
|
|
196
|
+
const currentUrl = page.url();
|
|
197
|
+
|
|
198
|
+
// Check if login succeeded by multiple signals:
|
|
199
|
+
// 1. URL changed away from login page
|
|
200
|
+
// 2. Login modal/form is no longer visible (SPA may not change URL)
|
|
201
|
+
// 3. An auth token cookie or localStorage was set
|
|
202
|
+
const isLoginUrl = currentUrl.includes('login') || currentUrl.includes('signin');
|
|
203
|
+
|
|
204
|
+
if (isLoginUrl) {
|
|
205
|
+
// SPA might stay on /login but close the modal after successful auth.
|
|
206
|
+
// Check if the email input is still visible — if not, login likely succeeded.
|
|
207
|
+
let emailStillVisible = false;
|
|
208
|
+
for (const sel of emailSels) {
|
|
209
|
+
const el = await page.$(sel).catch(() => null);
|
|
210
|
+
if (el && await el.isVisible().catch(() => false)) {
|
|
211
|
+
emailStillVisible = true;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (emailStillVisible) {
|
|
217
|
+
// Check for error messages in the page
|
|
218
|
+
const errorTexts = await page.$$eval(
|
|
219
|
+
'[class*="error"], [class*="toast"], [role="alert"], .text-red-500, .text-red-400',
|
|
220
|
+
els => els.map(e => e.textContent.trim()).filter(t => t.length > 0 && t.length < 200)
|
|
221
|
+
).catch(() => []);
|
|
222
|
+
|
|
223
|
+
const errMsg = errorTexts.length > 0
|
|
224
|
+
? `Errors: ${errorTexts.join('; ')}`
|
|
225
|
+
: 'No error message found';
|
|
226
|
+
throw new Error(`Login failed — form still visible. URL: ${currentUrl}. ${errMsg}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Modal closed but URL unchanged — SPA login success
|
|
230
|
+
logActivity('QA', `Login successful (SPA — URL unchanged). Navigating to target...`, 'success');
|
|
231
|
+
// Navigate to the main page since the SPA didn't redirect
|
|
232
|
+
await page.goto(loginUrl.replace(/\/login.*$/, '/'), { waitUntil: 'domcontentloaded', timeout: DEFAULT_NAV_TIMEOUT }).catch(() => {});
|
|
233
|
+
await page.waitForTimeout(1000);
|
|
234
|
+
logActivity('QA', `Landed on: ${page.url()}`, 'info');
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
logActivity('QA', `Login successful. Landed on: ${currentUrl}`, 'success');
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ─── Route Discoverer ─────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
async function discoverRoutes(page, baseUrl, excludePatterns = []) {
|
|
245
|
+
const visited = new Set();
|
|
246
|
+
const toVisit = [baseUrl];
|
|
247
|
+
const routes = [];
|
|
248
|
+
const origin = new URL(baseUrl).origin;
|
|
249
|
+
|
|
250
|
+
while (toVisit.length > 0 && routes.length < MAX_ROUTES) {
|
|
251
|
+
const url = toVisit.shift();
|
|
252
|
+
if (visited.has(url)) continue;
|
|
253
|
+
visited.add(url);
|
|
254
|
+
|
|
255
|
+
// V2: Route exclusion check
|
|
256
|
+
if (isExcluded(url, excludePatterns)) {
|
|
257
|
+
logActivity('QA', `Excluded route: ${url}`, 'info');
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: DEFAULT_NAV_TIMEOUT });
|
|
263
|
+
await page.waitForTimeout(500);
|
|
264
|
+
|
|
265
|
+
routes.push(url);
|
|
266
|
+
|
|
267
|
+
const links = await page.$$eval('a[href]', (els, orig) =>
|
|
268
|
+
els
|
|
269
|
+
.map(el => {
|
|
270
|
+
try { return new URL(el.href, orig).href; } catch { return null; }
|
|
271
|
+
})
|
|
272
|
+
.filter(href =>
|
|
273
|
+
href &&
|
|
274
|
+
href.startsWith(orig) &&
|
|
275
|
+
!href.includes('#') &&
|
|
276
|
+
!href.match(/\.(pdf|zip|png|jpg|gif|svg|ico|css|js|woff|ttf)$/)
|
|
277
|
+
),
|
|
278
|
+
origin
|
|
279
|
+
).catch(() => []);
|
|
280
|
+
|
|
281
|
+
for (const link of links) {
|
|
282
|
+
if (!visited.has(link) && !toVisit.includes(link)) {
|
|
283
|
+
if (!isExcluded(link, excludePatterns)) {
|
|
284
|
+
toVisit.push(link);
|
|
285
|
+
} else {
|
|
286
|
+
logActivity('QA', `Excluded route: ${link}`, 'info');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch (err) {
|
|
291
|
+
logActivity('QA', `Skipping ${url}: ${err.message.slice(0, 80)}`, 'warn');
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
logActivity('QA', `Discovered ${routes.length} routes`, 'info');
|
|
296
|
+
return routes;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── Interactive Element Tester ───────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
async function testInteractiveElements(page, url, maxActions = 10) {
|
|
302
|
+
const issues = [];
|
|
303
|
+
let actionCount = 0;
|
|
304
|
+
|
|
305
|
+
for (const tag of INTERACTIVE_TAGS) {
|
|
306
|
+
if (actionCount >= maxActions) break;
|
|
307
|
+
const elements = await page.$$(tag).catch(() => []);
|
|
308
|
+
|
|
309
|
+
for (const el of elements.slice(0, maxActions - actionCount)) {
|
|
310
|
+
actionCount++;
|
|
311
|
+
try {
|
|
312
|
+
const isVisible = await el.isVisible().catch(() => false);
|
|
313
|
+
if (!isVisible) continue;
|
|
314
|
+
|
|
315
|
+
const tagName = await el.evaluate(e => e.tagName.toLowerCase()).catch(() => '');
|
|
316
|
+
const text = await el.textContent().catch(() => '');
|
|
317
|
+
const ariaLabel = await el.getAttribute('aria-label').catch(() => '');
|
|
318
|
+
const label = (text || ariaLabel || '').trim().slice(0, 40);
|
|
319
|
+
|
|
320
|
+
if (tagName === 'button') {
|
|
321
|
+
const hasHandler = await el.evaluate(e =>
|
|
322
|
+
e.onclick !== null || e.getAttribute('data-action') !== null
|
|
323
|
+
).catch(() => false);
|
|
324
|
+
|
|
325
|
+
if (!label && !hasHandler) {
|
|
326
|
+
issues.push({ type: 'warning', element: 'button', message: 'Button with no label or handler found' });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (tagName === 'a') {
|
|
331
|
+
const href = await el.getAttribute('href').catch(() => '');
|
|
332
|
+
if (!href || href === '#' || href.startsWith('javascript:void')) {
|
|
333
|
+
issues.push({ type: 'warning', element: 'a', message: `Dead link: "${label || 'no text'}"` });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (['input', 'select', 'textarea'].includes(tagName)) {
|
|
338
|
+
const inputId = await el.getAttribute('id').catch(() => '');
|
|
339
|
+
const hasLabel = inputId
|
|
340
|
+
? await page.$(`label[for="${inputId}"]`).then(l => !!l).catch(() => false)
|
|
341
|
+
: false;
|
|
342
|
+
const hasAria = await el.getAttribute('aria-label').catch(() => '');
|
|
343
|
+
if (!hasLabel && !hasAria) {
|
|
344
|
+
issues.push({ type: 'warning', element: tagName, message: `Form field without label (id: ${inputId || 'none'})` });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
} catch { /* element might be stale */ }
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return issues;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ─── Main QA Runner ───────────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
export async function runBrowserQA(config) {
|
|
357
|
+
const qaConfig = config.browserQA;
|
|
358
|
+
if (!qaConfig?.enabled) {
|
|
359
|
+
return { skipped: true, reason: 'browserQA.enabled is false' };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const workflowDir = getWorkflowDir();
|
|
363
|
+
const screenshotDir = resolve(workflowDir, 'screenshots');
|
|
364
|
+
const qaReportDir = resolve(workflowDir, 'qa-reports');
|
|
365
|
+
ensureDir(screenshotDir);
|
|
366
|
+
ensureDir(qaReportDir);
|
|
367
|
+
|
|
368
|
+
const store = new ScreenshotStore(screenshotDir);
|
|
369
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
370
|
+
const report = {
|
|
371
|
+
timestamp,
|
|
372
|
+
targetUrl: qaConfig.targetUrl,
|
|
373
|
+
passed: [],
|
|
374
|
+
failed: [],
|
|
375
|
+
warnings: [],
|
|
376
|
+
screenshots: [],
|
|
377
|
+
summary: null,
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
logActivity('QA', `Starting browser QA: ${qaConfig.targetUrl}`, 'info');
|
|
381
|
+
updateStatus({ stage: 'browser-qa', qa_started_at: new Date().toISOString() });
|
|
382
|
+
|
|
383
|
+
const browser = await chromium.launch({
|
|
384
|
+
headless: true,
|
|
385
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const context = await browser.newContext({
|
|
390
|
+
viewport: { width: 1280, height: 800 },
|
|
391
|
+
userAgent: 'AICC-QA-Agent/1.0',
|
|
392
|
+
});
|
|
393
|
+
const page = await context.newPage();
|
|
394
|
+
|
|
395
|
+
// ── 1. Login ──────────────────────────────────────────────────────────────
|
|
396
|
+
if (qaConfig.credentials) {
|
|
397
|
+
await loginToWebsite(page, {
|
|
398
|
+
loginUrl: qaConfig.credentials.loginUrl || qaConfig.targetUrl + '/login',
|
|
399
|
+
email: qaConfig.credentials.email,
|
|
400
|
+
password: qaConfig.credentials.password,
|
|
401
|
+
loginSelectors: qaConfig.credentials.loginSelectors,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const loginShot = await store.capture(page, 'after_login', { baseline: true });
|
|
405
|
+
report.screenshots.push(loginShot);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── 2. Discover routes (with V2 exclusion patterns) ─────────────────────
|
|
409
|
+
const excludePatterns = qaConfig.excludeRoutes || [];
|
|
410
|
+
const routes = await discoverRoutes(page, qaConfig.targetUrl, excludePatterns);
|
|
411
|
+
|
|
412
|
+
// ── 3. Test each route (with session expiry + OOM prevention) ────────────
|
|
413
|
+
let activeBrowser = browser;
|
|
414
|
+
let activeContext = context;
|
|
415
|
+
let activePage = page;
|
|
416
|
+
let routesSinceBrowserRestart = 0;
|
|
417
|
+
const browserRestartEvery = qaConfig.browserRestartEvery || 30;
|
|
418
|
+
const maxActionsPerPage = qaConfig.maxActionsPerPage || 10;
|
|
419
|
+
|
|
420
|
+
for (const url of routes) {
|
|
421
|
+
try {
|
|
422
|
+
// V2: OOM prevention — restart browser every N routes
|
|
423
|
+
routesSinceBrowserRestart++;
|
|
424
|
+
if (routesSinceBrowserRestart >= browserRestartEvery) {
|
|
425
|
+
logActivity('QA', 'Restarting browser to prevent memory leak', 'info');
|
|
426
|
+
await activeBrowser.close().catch(() => {});
|
|
427
|
+
activeBrowser = await chromium.launch({
|
|
428
|
+
headless: true,
|
|
429
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
|
430
|
+
});
|
|
431
|
+
activeContext = await activeBrowser.newContext({
|
|
432
|
+
viewport: { width: 1280, height: 800 },
|
|
433
|
+
userAgent: 'AICC-QA-Agent/1.0',
|
|
434
|
+
});
|
|
435
|
+
activePage = await activeContext.newPage();
|
|
436
|
+
// Re-login if needed
|
|
437
|
+
if (qaConfig.credentials) {
|
|
438
|
+
await loginToWebsite(activePage, {
|
|
439
|
+
loginUrl: qaConfig.credentials.loginUrl || qaConfig.targetUrl + '/login',
|
|
440
|
+
email: qaConfig.credentials.email,
|
|
441
|
+
password: qaConfig.credentials.password,
|
|
442
|
+
loginSelectors: qaConfig.credentials.loginSelectors,
|
|
443
|
+
}).catch(() => {});
|
|
444
|
+
}
|
|
445
|
+
routesSinceBrowserRestart = 0;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// V3: Pre-flight session check — re-login if current page is already on login
|
|
449
|
+
const _loginCreds = {
|
|
450
|
+
loginUrl: qaConfig.credentials?.loginUrl || qaConfig.targetUrl + '/login',
|
|
451
|
+
email: qaConfig.credentials?.email,
|
|
452
|
+
password: qaConfig.credentials?.password,
|
|
453
|
+
loginSelectors: qaConfig.credentials?.loginSelectors,
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
if (qaConfig.credentials && isSessionExpired(activePage.url())) {
|
|
457
|
+
logActivity('QA', 'Session expired — re-authenticating', 'warn');
|
|
458
|
+
await loginToWebsite(activePage, _loginCreds).catch(() => {});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
logActivity('QA', `Testing: ${url}`, 'info');
|
|
462
|
+
await activePage.goto(url, { waitUntil: 'domcontentloaded', timeout: DEFAULT_NAV_TIMEOUT });
|
|
463
|
+
await activePage.waitForTimeout(800);
|
|
464
|
+
|
|
465
|
+
// V3: Auth-gated route detection — if redirected to login, re-login + retry once.
|
|
466
|
+
// If STILL redirected after fresh login, mark as "auth_required" (not a bug).
|
|
467
|
+
let authGated = false;
|
|
468
|
+
if (qaConfig.credentials && isSessionExpired(activePage.url())) {
|
|
469
|
+
logActivity('QA', `Redirected to login on ${url} — re-authenticating and retrying`, 'warn');
|
|
470
|
+
await loginToWebsite(activePage, _loginCreds).catch(() => {});
|
|
471
|
+
await activePage.goto(url, { waitUntil: 'domcontentloaded', timeout: DEFAULT_NAV_TIMEOUT });
|
|
472
|
+
await activePage.waitForTimeout(800);
|
|
473
|
+
|
|
474
|
+
if (isSessionExpired(activePage.url())) {
|
|
475
|
+
// Still on login after fresh auth — this route needs a different role/permission
|
|
476
|
+
authGated = true;
|
|
477
|
+
logActivity('QA', `AUTH_REQUIRED: ${url} — needs elevated permissions`, 'warn');
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (authGated) {
|
|
482
|
+
report.warnings.push({
|
|
483
|
+
url,
|
|
484
|
+
warnings: [`Route requires elevated permissions (redirects to login even with valid session)`],
|
|
485
|
+
category: 'auth_required',
|
|
486
|
+
});
|
|
487
|
+
logActivity('QA', `SKIP: ${url} — auth-gated`, 'warn');
|
|
488
|
+
continue; // Don't count as pass or fail — it's not a bug
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const { errors, warnings } = await checkPageHealth(activePage, url);
|
|
492
|
+
|
|
493
|
+
// V2: Respect readOnlyMode — skip interactive tests
|
|
494
|
+
if (!qaConfig.readOnlyMode) {
|
|
495
|
+
const interactiveIssues = await testInteractiveElements(activePage, url, maxActionsPerPage);
|
|
496
|
+
warnings.push(...interactiveIssues.filter(i => i.type === 'warning').map(i => i.message));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const shotPath = await store.capture(activePage, slugify(url));
|
|
500
|
+
report.screenshots.push(shotPath);
|
|
501
|
+
|
|
502
|
+
const hasRegression = await store.compareWithBaseline(shotPath, slugify(url));
|
|
503
|
+
|
|
504
|
+
if (errors.length > 0 || hasRegression) {
|
|
505
|
+
report.failed.push({
|
|
506
|
+
url,
|
|
507
|
+
errors,
|
|
508
|
+
warnings,
|
|
509
|
+
screenshot: shotPath,
|
|
510
|
+
visualRegression: hasRegression,
|
|
511
|
+
timestamp: new Date().toISOString(),
|
|
512
|
+
});
|
|
513
|
+
logActivity('QA', `FAIL: ${url} — ${errors.length} error(s)`, 'error');
|
|
514
|
+
} else {
|
|
515
|
+
report.passed.push({ url, warnings, screenshot: shotPath });
|
|
516
|
+
if (warnings.length > 0) {
|
|
517
|
+
report.warnings.push({ url, warnings });
|
|
518
|
+
}
|
|
519
|
+
logActivity('QA', `PASS: ${url}`, 'success');
|
|
520
|
+
}
|
|
521
|
+
} catch (err) {
|
|
522
|
+
// V2: Handle browser crash mid-crawl
|
|
523
|
+
if (err.message.includes('Target closed') || err.message.includes('browser has been closed')) {
|
|
524
|
+
logActivity('QA', 'Browser crashed — restarting', 'warn');
|
|
525
|
+
try {
|
|
526
|
+
await activeBrowser.close().catch(() => {});
|
|
527
|
+
activeBrowser = await chromium.launch({
|
|
528
|
+
headless: true,
|
|
529
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
|
530
|
+
});
|
|
531
|
+
activeContext = await activeBrowser.newContext({
|
|
532
|
+
viewport: { width: 1280, height: 800 },
|
|
533
|
+
userAgent: 'AICC-QA-Agent/1.0',
|
|
534
|
+
});
|
|
535
|
+
activePage = await activeContext.newPage();
|
|
536
|
+
if (qaConfig.credentials) {
|
|
537
|
+
await loginToWebsite(activePage, {
|
|
538
|
+
loginUrl: qaConfig.credentials.loginUrl || qaConfig.targetUrl + '/login',
|
|
539
|
+
email: qaConfig.credentials.email,
|
|
540
|
+
password: qaConfig.credentials.password,
|
|
541
|
+
loginSelectors: qaConfig.credentials.loginSelectors,
|
|
542
|
+
}).catch(() => {});
|
|
543
|
+
}
|
|
544
|
+
routesSinceBrowserRestart = 0;
|
|
545
|
+
} catch { /* can't recover */ }
|
|
546
|
+
continue; // Skip this route, continue with next
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
report.failed.push({
|
|
550
|
+
url,
|
|
551
|
+
errors: [`Navigation/test error: ${err.message.slice(0, 200)}`],
|
|
552
|
+
warnings: [],
|
|
553
|
+
screenshot: null,
|
|
554
|
+
timestamp: new Date().toISOString(),
|
|
555
|
+
});
|
|
556
|
+
logActivity('QA', `ERROR: ${url} — ${err.message.slice(0, 80)}`, 'error');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ── 4. Summary ────────────────────────────────────────────────────────────
|
|
561
|
+
const authGatedCount = report.warnings.filter(w => w.category === 'auth_required').length;
|
|
562
|
+
const testedRoutes = routes.length - authGatedCount; // Don't count auth-gated in pass rate
|
|
563
|
+
|
|
564
|
+
report.summary = {
|
|
565
|
+
totalRoutes: routes.length,
|
|
566
|
+
testedRoutes,
|
|
567
|
+
passed: report.passed.length,
|
|
568
|
+
failed: report.failed.length,
|
|
569
|
+
authGated: authGatedCount,
|
|
570
|
+
warningsOnly: report.warnings.filter(w => w.category !== 'auth_required').length,
|
|
571
|
+
passRate: testedRoutes > 0
|
|
572
|
+
? Math.round((report.passed.length / testedRoutes) * 100)
|
|
573
|
+
: 0,
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const authNote = authGatedCount > 0 ? ` (${authGatedCount} auth-gated, skipped)` : '';
|
|
577
|
+
logActivity('QA', `QA complete: ${report.summary.passed}/${testedRoutes} tested passed${authNote}`, 'info');
|
|
578
|
+
|
|
579
|
+
} finally {
|
|
580
|
+
await browser.close().catch(() => {});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ── 5. Write report ───────────────────────────────────────────────────────
|
|
584
|
+
const reportPath = resolve(qaReportDir, `QA-${timestamp}.json`);
|
|
585
|
+
writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
|
586
|
+
logActivity('QA', `Report written: ${reportPath}`, 'info');
|
|
587
|
+
|
|
588
|
+
updateStatus({
|
|
589
|
+
qa_last_report: reportPath,
|
|
590
|
+
qa_last_run: new Date().toISOString(),
|
|
591
|
+
qa_pass_rate: report.summary.passRate,
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
return report;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Read the most recent QA report from disk.
|
|
599
|
+
*/
|
|
600
|
+
export function getLatestQAReport() {
|
|
601
|
+
const qaReportDir = resolve(getWorkflowDir(), 'qa-reports');
|
|
602
|
+
if (!existsSync(qaReportDir)) return null;
|
|
603
|
+
|
|
604
|
+
const files = readdirSync(qaReportDir)
|
|
605
|
+
.filter(f => f.startsWith('QA-') && f.endsWith('.json'))
|
|
606
|
+
.sort()
|
|
607
|
+
.reverse();
|
|
608
|
+
|
|
609
|
+
if (files.length === 0) return null;
|
|
610
|
+
return JSON.parse(readFileSync(resolve(qaReportDir, files[0]), 'utf8'));
|
|
611
|
+
}
|