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.
- package/package.json +1 -1
- package/ralph/loop.sh +6 -27
- package/ralph/setup.sh +76 -11
- package/ralph/verify.sh +16 -284
- package/templates/PROMPT.md +26 -43
- package/ralph/api.sh +0 -216
- package/ralph/browser-verify/README.md +0 -135
- package/ralph/browser-verify/verify.ts +0 -450
- package/ralph/playwright.sh +0 -238
- package/ralph/verify/browser.sh +0 -324
- package/ralph/verify/review.sh +0 -152
|
@@ -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
|
-
});
|
package/ralph/playwright.sh
DELETED
|
@@ -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
|
-
}
|