agentic-loop 3.1.5 → 3.2.0

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,450 +0,0 @@
1
- #!/usr/bin/env npx tsx
2
- /**
3
- * browser-verify.ts - Real browser verification for Ralph
4
- *
5
- * Uses Playwright to actually load pages and verify they work.
6
- * Much more reliable than asking Claude to "imagine" what a page looks like.
7
- *
8
- * Usage:
9
- * npx tsx browser-verify.ts <url> [options]
10
- *
11
- * Options:
12
- * --selectors '["#app", ".header"]' - Required elements (CSS selectors)
13
- * --screenshot <path> - Save screenshot to path
14
- * --timeout <ms> - Page load timeout (default: 30000)
15
- * --headless - Run headless (default: true)
16
- * --check-console - Fail on console errors (default: true)
17
- * --check-network - Fail on failed network requests (default: true)
18
- * --viewport <width>x<height> - Viewport size (default: 1280x720)
19
- * --mobile - Use mobile viewport (375x667)
20
- * --auth-cookie <json> - Set auth cookie before loading
21
- *
22
- * Output: JSON to stdout
23
- * { "pass": true/false, "errors": [], "warnings": [], "screenshot": "path" }
24
- */
25
-
26
- import { chromium, Browser, Page, ConsoleMessage } from 'playwright';
27
-
28
- interface AuthLogin {
29
- loginUrl: string;
30
- usernameSelector: string;
31
- passwordSelector: string;
32
- submitSelector: string;
33
- username: string;
34
- password: string;
35
- successIndicator?: string; // Selector or URL pattern to confirm login worked
36
- }
37
-
38
- interface VerifyOptions {
39
- url: string;
40
- selectors: string[];
41
- screenshot?: string;
42
- timeout: number;
43
- headless: boolean;
44
- checkConsole: boolean;
45
- checkNetwork: boolean;
46
- viewport: { width: number; height: number };
47
- authCookie?: { name: string; value: string; domain: string };
48
- authLogin?: AuthLogin;
49
- }
50
-
51
- interface VerifyResult {
52
- pass: boolean;
53
- errors: string[];
54
- warnings: string[];
55
- screenshot?: string;
56
- title?: string;
57
- elementsFound: string[];
58
- elementsMissing: string[];
59
- consoleErrors: string[];
60
- networkErrors: string[];
61
- loadTime: number;
62
- }
63
-
64
- async function parseArgs(): Promise<VerifyOptions> {
65
- const args = process.argv.slice(2);
66
-
67
- if (args.length === 0 || args[0] === '--help') {
68
- console.error(`
69
- Usage: npx tsx browser-verify.ts <url> [options]
70
-
71
- Options:
72
- --selectors '["#app", ".btn"]' Required elements (JSON array)
73
- --screenshot <path> Save screenshot
74
- --timeout <ms> Load timeout (default: 30000)
75
- --headless Run headless (default: true)
76
- --no-headless Show browser window
77
- --check-console Fail on console errors (default: true)
78
- --no-check-console Ignore console errors
79
- --check-network Fail on network errors (default: true)
80
- --no-check-network Ignore network errors
81
- --viewport <W>x<H> Viewport size (default: 1280x720)
82
- --mobile Mobile viewport (375x667)
83
- --auth-cookie '<json>' Set auth cookie
84
- --auth-login '<json>' Login before verifying (see below)
85
-
86
- Auth Login JSON format:
87
- {
88
- "loginUrl": "http://localhost:3000/login",
89
- "usernameSelector": "input[name='email']",
90
- "passwordSelector": "input[name='password']",
91
- "submitSelector": "button[type='submit']",
92
- "username": "test@test.local",
93
- "password": "testpass123",
94
- "successIndicator": "/dashboard" // URL pattern or selector
95
- }
96
- `);
97
- process.exit(1);
98
- }
99
-
100
- const url = args[0];
101
- const options: VerifyOptions = {
102
- url,
103
- selectors: [],
104
- timeout: 30000,
105
- headless: true,
106
- checkConsole: true,
107
- checkNetwork: true,
108
- viewport: { width: 1280, height: 720 },
109
- };
110
-
111
- for (let i = 1; i < args.length; i++) {
112
- const arg = args[i];
113
-
114
- switch (arg) {
115
- case '--selectors':
116
- try {
117
- options.selectors = JSON.parse(args[++i]);
118
- } catch {
119
- console.error('Invalid --selectors JSON');
120
- process.exit(1);
121
- }
122
- break;
123
- case '--screenshot':
124
- options.screenshot = args[++i];
125
- break;
126
- case '--timeout':
127
- options.timeout = parseInt(args[++i], 10);
128
- break;
129
- case '--headless':
130
- options.headless = true;
131
- break;
132
- case '--no-headless':
133
- options.headless = false;
134
- break;
135
- case '--check-console':
136
- options.checkConsole = true;
137
- break;
138
- case '--no-check-console':
139
- options.checkConsole = false;
140
- break;
141
- case '--check-network':
142
- options.checkNetwork = true;
143
- break;
144
- case '--no-check-network':
145
- options.checkNetwork = false;
146
- break;
147
- case '--viewport':
148
- const [w, h] = args[++i].split('x').map(Number);
149
- options.viewport = { width: w, height: h };
150
- break;
151
- case '--mobile':
152
- options.viewport = { width: 375, height: 667 };
153
- break;
154
- case '--auth-cookie':
155
- try {
156
- options.authCookie = JSON.parse(args[++i]);
157
- } catch {
158
- console.error('Invalid --auth-cookie JSON');
159
- process.exit(1);
160
- }
161
- break;
162
- case '--auth-login':
163
- try {
164
- options.authLogin = JSON.parse(args[++i]);
165
- } catch {
166
- console.error('Invalid --auth-login JSON');
167
- process.exit(1);
168
- }
169
- break;
170
- }
171
- }
172
-
173
- return options;
174
- }
175
-
176
- async function performLogin(page: Page, auth: AuthLogin, timeout: number): Promise<{ success: boolean; error?: string }> {
177
- try {
178
- // Navigate to login page
179
- await page.goto(auth.loginUrl, {
180
- timeout,
181
- waitUntil: 'networkidle',
182
- });
183
-
184
- // Wait for and fill username
185
- await page.waitForSelector(auth.usernameSelector, { timeout: 10000 });
186
- await page.fill(auth.usernameSelector, auth.username);
187
-
188
- // Fill password
189
- await page.waitForSelector(auth.passwordSelector, { timeout: 5000 });
190
- await page.fill(auth.passwordSelector, auth.password);
191
-
192
- // Click submit
193
- await page.waitForSelector(auth.submitSelector, { timeout: 5000 });
194
- await page.click(auth.submitSelector);
195
-
196
- // Wait for login to complete
197
- if (auth.successIndicator) {
198
- if (auth.successIndicator.startsWith('/') || auth.successIndicator.startsWith('http')) {
199
- // It's a URL pattern - wait for navigation
200
- await page.waitForURL(`**${auth.successIndicator}*`, { timeout: 15000 });
201
- } else {
202
- // It's a selector - wait for element
203
- await page.waitForSelector(auth.successIndicator, { timeout: 15000 });
204
- }
205
- } else {
206
- // No indicator - just wait for navigation away from login page
207
- await page.waitForFunction(
208
- (loginUrl) => !window.location.href.includes(loginUrl),
209
- auth.loginUrl,
210
- { timeout: 15000 }
211
- );
212
- }
213
-
214
- return { success: true };
215
- } catch (err: any) {
216
- return { success: false, error: `Login failed: ${err.message}` };
217
- }
218
- }
219
-
220
- async function verify(options: VerifyOptions): Promise<VerifyResult> {
221
- const result: VerifyResult = {
222
- pass: true,
223
- errors: [],
224
- warnings: [],
225
- elementsFound: [],
226
- elementsMissing: [],
227
- consoleErrors: [],
228
- networkErrors: [],
229
- loadTime: 0,
230
- };
231
-
232
- let browser: Browser | null = null;
233
- let page: Page | null = null;
234
-
235
- try {
236
- // Launch browser
237
- browser = await chromium.launch({
238
- headless: options.headless,
239
- });
240
-
241
- const context = await browser.newContext({
242
- viewport: options.viewport,
243
- });
244
-
245
- // Set auth cookie if provided
246
- if (options.authCookie) {
247
- await context.addCookies([{
248
- name: options.authCookie.name,
249
- value: options.authCookie.value,
250
- domain: options.authCookie.domain,
251
- path: '/',
252
- }]);
253
- }
254
-
255
- page = await context.newPage();
256
-
257
- // Perform login if auth credentials provided
258
- if (options.authLogin) {
259
- const loginResult = await performLogin(page, options.authLogin, options.timeout);
260
- if (!loginResult.success) {
261
- result.errors.push(loginResult.error || 'Login failed');
262
- result.pass = false;
263
- return result;
264
- }
265
- result.warnings.push('Logged in as ' + options.authLogin.username);
266
- }
267
-
268
- // Collect console errors
269
- const consoleErrors: string[] = [];
270
- page.on('console', (msg: ConsoleMessage) => {
271
- const type = msg.type();
272
- const text = msg.text();
273
-
274
- // Capture actual console.error calls
275
- if (type === 'error') {
276
- // Only ignore truly benign noise (favicons, devtools)
277
- if (!text.includes('favicon') && !text.includes('DevTools')) {
278
- consoleErrors.push(text);
279
- }
280
- }
281
-
282
- // Also capture logs/warnings that indicate errors (e.g., custom loggers)
283
- if ((type === 'log' || type === 'warning') &&
284
- (text.includes('_error') || text.includes('Error:') || text.includes('ERROR'))) {
285
- // Ignore known non-critical patterns
286
- if (!text.includes('ResizeObserver')) {
287
- consoleErrors.push(`[${type}] ${text}`);
288
- }
289
- }
290
- });
291
-
292
- // Collect network errors
293
- const networkErrors: string[] = [];
294
- page.on('requestfailed', (request) => {
295
- const failure = request.failure();
296
- if (failure) {
297
- networkErrors.push(`${request.method()} ${request.url()}: ${failure.errorText}`);
298
- }
299
- });
300
-
301
- // Track failed responses (4xx, 5xx)
302
- page.on('response', (response) => {
303
- const status = response.status();
304
- if (status >= 400) {
305
- const url = response.url();
306
- // Ignore favicon and some common 404s
307
- if (!url.includes('favicon') && !url.includes('hot-update')) {
308
- networkErrors.push(`${response.request().method()} ${url}: ${status}`);
309
- }
310
- }
311
- });
312
-
313
- // Navigate to page
314
- const startTime = Date.now();
315
-
316
- try {
317
- await page.goto(options.url, {
318
- timeout: options.timeout,
319
- waitUntil: 'networkidle',
320
- });
321
- } catch (err: any) {
322
- if (err.message.includes('net::ERR_CONNECTION_REFUSED')) {
323
- result.errors.push(`Cannot connect to ${options.url} - is the server running?`);
324
- result.pass = false;
325
- return result;
326
- }
327
- throw err;
328
- }
329
-
330
- result.loadTime = Date.now() - startTime;
331
-
332
- // Get page title
333
- result.title = await page.title();
334
- if (!result.title) {
335
- result.warnings.push('Page has no title');
336
- }
337
-
338
- // Check for error page indicators
339
- const bodyText = await page.textContent('body') || '';
340
- const errorPatterns = [
341
- /something went wrong/i,
342
- /error occurred/i,
343
- /500 internal server/i,
344
- /503 service unavailable/i,
345
- /404 not found/i,
346
- /oops!/i,
347
- /application error/i,
348
- /unhandled runtime error/i,
349
- ];
350
-
351
- for (const pattern of errorPatterns) {
352
- if (pattern.test(bodyText)) {
353
- result.errors.push(`Page contains error text: "${bodyText.match(pattern)?.[0]}"`);
354
- result.pass = false;
355
- }
356
- }
357
-
358
- // Check required selectors
359
- for (const selector of options.selectors) {
360
- try {
361
- const element = await page.$(selector);
362
- if (element) {
363
- result.elementsFound.push(selector);
364
- } else {
365
- result.elementsMissing.push(selector);
366
- result.errors.push(`Required element not found: ${selector}`);
367
- result.pass = false;
368
- }
369
- } catch (err: any) {
370
- result.elementsMissing.push(selector);
371
- result.errors.push(`Error checking selector "${selector}": ${err.message}`);
372
- result.pass = false;
373
- }
374
- }
375
-
376
- // Check console errors
377
- result.consoleErrors = consoleErrors;
378
- if (options.checkConsole && consoleErrors.length > 0) {
379
- result.errors.push(`${consoleErrors.length} console error(s) detected`);
380
- result.pass = false;
381
- }
382
-
383
- // Check network errors
384
- result.networkErrors = networkErrors;
385
- if (options.checkNetwork && networkErrors.length > 0) {
386
- // Filter out truly benign 404s (static resources)
387
- const criticalErrors = networkErrors.filter(e =>
388
- !e.includes('favicon') &&
389
- !e.includes('.map') &&
390
- !e.includes('hot-update') &&
391
- !e.includes('.woff') &&
392
- !e.includes('.ttf') &&
393
- !e.includes('.png') &&
394
- !e.includes('.jpg') &&
395
- !e.includes('.svg') &&
396
- !e.includes('.ico')
397
- );
398
- if (criticalErrors.length > 0) {
399
- // Show actual URLs so Claude knows what to fix
400
- result.errors.push(`Network errors (fix these):`);
401
- criticalErrors.slice(0, 5).forEach(e => result.errors.push(` ${e}`));
402
- result.pass = false;
403
- }
404
- }
405
-
406
- // Take screenshot
407
- if (options.screenshot) {
408
- await page.screenshot({
409
- path: options.screenshot,
410
- fullPage: true,
411
- });
412
- result.screenshot = options.screenshot;
413
- }
414
-
415
- } catch (err: any) {
416
- result.errors.push(`Browser error: ${err.message}`);
417
- result.pass = false;
418
- } finally {
419
- if (browser) {
420
- await browser.close();
421
- }
422
- }
423
-
424
- return result;
425
- }
426
-
427
- async function main() {
428
- const options = await parseArgs();
429
- const result = await verify(options);
430
-
431
- // Output JSON result
432
- console.log(JSON.stringify(result, null, 2));
433
-
434
- // Exit with appropriate code
435
- process.exit(result.pass ? 0 : 1);
436
- }
437
-
438
- main().catch((err) => {
439
- console.error(JSON.stringify({
440
- pass: false,
441
- errors: [`Fatal error: ${err.message}`],
442
- warnings: [],
443
- elementsFound: [],
444
- elementsMissing: [],
445
- consoleErrors: [],
446
- networkErrors: [],
447
- loadTime: 0,
448
- }, null, 2));
449
- process.exit(1);
450
- });
@@ -1,238 +0,0 @@
1
- #!/usr/bin/env bash
2
- # shellcheck shell=bash
3
- # playwright.sh - Playwright test integration for ralph
4
-
5
- # Ensure Playwright is installed and configured
6
- ensure_playwright() {
7
- # Check if playwright config exists
8
- if [[ ! -f "playwright.config.ts" ]] && [[ ! -f "playwright.config.js" ]]; then
9
- print_info "Playwright not configured, initializing..."
10
-
11
- # Check if npx is available
12
- if ! command -v npx &>/dev/null; then
13
- print_error "npx not found - cannot install Playwright"
14
- return 1
15
- fi
16
-
17
- # Install Playwright package and browsers
18
- npm install playwright 2>/dev/null && npx playwright install chromium 2>/dev/null || {
19
- print_warning "Could not install Playwright - run: npm install playwright && npx playwright install chromium"
20
- }
21
-
22
- # Create minimal config if it doesn't exist
23
- if [[ ! -f "playwright.config.ts" ]] && [[ ! -f "playwright.config.js" ]]; then
24
- print_info "Creating playwright.config.ts..."
25
- cat > playwright.config.ts << 'EOF'
26
- import { defineConfig, devices } from '@playwright/test';
27
-
28
- export default defineConfig({
29
- testDir: './tests/e2e',
30
- fullyParallel: true,
31
- forbidOnly: !!process.env.CI,
32
- retries: process.env.CI ? 2 : 0,
33
- workers: process.env.CI ? 1 : undefined,
34
- reporter: 'html',
35
- use: {
36
- baseURL: process.env.BASE_URL || 'http://localhost:3000',
37
- trace: 'on-first-retry',
38
- screenshot: 'only-on-failure',
39
- },
40
- projects: [
41
- {
42
- name: 'chromium',
43
- use: { ...devices['Desktop Chrome'] },
44
- },
45
- {
46
- name: 'mobile',
47
- use: { ...devices['iPhone 13'] },
48
- },
49
- ],
50
- });
51
- EOF
52
- fi
53
-
54
- # Create test directory
55
- mkdir -p tests/e2e
56
-
57
- print_success "Playwright initialized"
58
- fi
59
-
60
- return 0
61
- }
62
-
63
- # Find test file for a story - uses explicit config or story-level testFile
64
- find_story_test_file() {
65
- local story="$1"
66
- local test_dir="$2"
67
-
68
- # 1. Check story for explicit testFile path (preferred)
69
- local explicit_file
70
- explicit_file=$(jq -r --arg id "$story" '.stories[] | select(.id==$id) | .testFile // empty' "$RALPH_DIR/prd.json" 2>/dev/null)
71
- if [[ -n "$explicit_file" && -f "$explicit_file" ]]; then
72
- echo "$explicit_file"
73
- return 0
74
- fi
75
-
76
- # 2. Check config.json for e2e test directory pattern
77
- local config_test_dir
78
- config_test_dir=$(get_config '.playwright.testDir' "")
79
- if [[ -n "$config_test_dir" ]]; then
80
- test_dir="$config_test_dir"
81
- fi
82
-
83
- # 3. Standard naming: {testDir}/{story-id}.spec.ts
84
- if [[ -f "${test_dir}/${story}.spec.ts" ]]; then
85
- echo "${test_dir}/${story}.spec.ts"
86
- return 0
87
- fi
88
- if [[ -f "${test_dir}/${story}.spec.js" ]]; then
89
- echo "${test_dir}/${story}.spec.js"
90
- return 0
91
- fi
92
-
93
- # 4. Slug-based naming from story title
94
- local story_slug
95
- story_slug=$(jq -r --arg id "$story" '.stories[] | select(.id==$id) | .title // ""' "$RALPH_DIR/prd.json" 2>/dev/null | \
96
- tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//')
97
-
98
- if [[ -n "$story_slug" && -f "${test_dir}/${story_slug}.spec.ts" ]]; then
99
- echo "${test_dir}/${story_slug}.spec.ts"
100
- return 0
101
- fi
102
-
103
- return 1
104
- }
105
-
106
- # Run Playwright tests for a specific story or all tests
107
- run_playwright_tests() {
108
- local story="$1"
109
-
110
- # Check if Playwright is enabled in config
111
- local pw_enabled
112
- pw_enabled=$(get_config '.playwright.enabled' "true")
113
- if [[ "$pw_enabled" == "false" ]]; then
114
- echo " (Playwright disabled in config, skipping)"
115
- return 0
116
- fi
117
-
118
- # Ensure Playwright is set up
119
- if ! ensure_playwright; then
120
- return 1
121
- fi
122
-
123
- # Check if npx is available
124
- if ! command -v npx &>/dev/null; then
125
- print_error "npx not found - cannot run Playwright"
126
- return 1
127
- fi
128
-
129
- # Get test directory from config or use default
130
- local test_dir
131
- test_dir=$(get_config '.playwright.testDir' "tests/e2e")
132
-
133
- # Find the test file for this story
134
- local test_file
135
- test_file=$(find_story_test_file "$story" "$test_dir")
136
-
137
- local log_file
138
- log_file=$(create_temp_file ".log") || return 1
139
-
140
- echo -n " Running Playwright tests... "
141
-
142
- if [[ -n "$test_file" && -f "$test_file" ]]; then
143
- # Run story-specific test
144
- echo -n "(${test_file##*/}) "
145
- if npx playwright test "$test_file" --reporter=line > "$log_file" 2>&1; then
146
- print_success "passed"
147
- rm -f "$log_file"
148
- return 0
149
- fi
150
- else
151
- # No story-specific test - check if e2e was expected
152
- local e2e_required
153
- e2e_required=$(jq -r --arg id "$story" '.stories[] | select(.id==$id) | .e2e // false' "$RALPH_DIR/prd.json" 2>/dev/null)
154
-
155
- if [[ "$e2e_required" == "true" ]]; then
156
- # Show where we looked and how to fix
157
- print_warning "missing (e2e: true but no test file found)"
158
- echo ""
159
- echo " Looked for: ${test_dir}/${story}.spec.ts"
160
- echo ""
161
- echo " Fix options:"
162
- echo " 1. Add 'testFile' to story: \"testFile\": \"apps/web/tests/e2e/my-test.spec.ts\""
163
- echo " 2. Set testDir in .ralph/config.json: \"playwright\": {\"testDir\": \"apps/web/tests/e2e\"}"
164
- echo " 3. Name test file: ${test_dir}/${story}.spec.ts"
165
- rm -f "$log_file"
166
- return 1 # Fail if e2e was expected but not created
167
- fi
168
-
169
- # Check if any tests exist to run
170
- if [[ -z "$(find "$test_dir" -name '*.spec.ts' -o -name '*.spec.js' 2>/dev/null | head -1)" ]]; then
171
- echo "skipped (not required for this story)"
172
- rm -f "$log_file"
173
- return 0
174
- fi
175
-
176
- # Run all tests
177
- if npx playwright test --reporter=line > "$log_file" 2>&1; then
178
- print_success "passed"
179
- rm -f "$log_file"
180
- return 0
181
- fi
182
- fi
183
-
184
- # Tests failed
185
- print_error "failed"
186
- echo ""
187
- echo " Playwright output (last $MAX_LOG_LINES lines):"
188
- tail -"$MAX_LOG_LINES" "$log_file" | sed 's/^/ /'
189
-
190
- # Save failure for context
191
- cp "$log_file" "$RALPH_DIR/last_playwright_failure.log"
192
-
193
- rm -f "$log_file"
194
- return 1
195
- }
196
-
197
- # Run Playwright with accessibility checks
198
- run_playwright_a11y() {
199
- local story="$1"
200
- local url="$2"
201
-
202
- # Check if @axe-core/playwright is available
203
- if ! npm list @axe-core/playwright &>/dev/null 2>&1; then
204
- print_info "Installing @axe-core/playwright for accessibility testing..."
205
- npm install -D @axe-core/playwright 2>/dev/null || {
206
- print_warning "Could not install axe-core, skipping a11y tests"
207
- return 0
208
- }
209
- fi
210
-
211
- # a11y tests are typically part of the Playwright tests
212
- # This function can be extended to run standalone a11y audits
213
- return 0
214
- }
215
-
216
- # Run Playwright at specific viewport (for mobile testing)
217
- run_playwright_mobile() {
218
- local story="$1"
219
-
220
- local log_file
221
- log_file=$(create_temp_file ".log") || return 1
222
-
223
- echo -n " Running mobile viewport tests... "
224
-
225
- # Run tests with mobile project
226
- if npx playwright test --project=mobile --reporter=line > "$log_file" 2>&1; then
227
- print_success "passed"
228
- rm -f "$log_file"
229
- return 0
230
- else
231
- print_error "failed"
232
- echo ""
233
- echo " Mobile test output:"
234
- tail -"$MAX_OUTPUT_PREVIEW_LINES" "$log_file" | sed 's/^/ /'
235
- rm -f "$log_file"
236
- return 1
237
- fi
238
- }