slapify 0.0.16 → 0.0.18

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.
Files changed (52) hide show
  1. package/README.md +38 -4
  2. package/dist/ai/interpreter.js +1 -331
  3. package/dist/browser/agent.js +1 -485
  4. package/dist/cli.js +1 -1553
  5. package/dist/config/loader.js +1 -305
  6. package/dist/index.js +1 -262
  7. package/dist/parser/flow.js +1 -117
  8. package/dist/perf/audit.js +1 -635
  9. package/dist/report/generator.js +1 -641
  10. package/dist/runner/index.js +1 -744
  11. package/dist/task/index.js +1 -4
  12. package/dist/task/report.js +1 -740
  13. package/dist/task/runner.js +1 -1362
  14. package/dist/task/session.js +1 -153
  15. package/dist/task/tools.d.ts +12 -0
  16. package/dist/task/tools.js +1 -258
  17. package/dist/task/types.d.ts +18 -0
  18. package/dist/task/types.js +1 -2
  19. package/dist/types.js +1 -2
  20. package/package.json +6 -3
  21. package/dist/ai/interpreter.d.ts.map +0 -1
  22. package/dist/ai/interpreter.js.map +0 -1
  23. package/dist/browser/agent.d.ts.map +0 -1
  24. package/dist/browser/agent.js.map +0 -1
  25. package/dist/cli.d.ts.map +0 -1
  26. package/dist/cli.js.map +0 -1
  27. package/dist/config/loader.d.ts.map +0 -1
  28. package/dist/config/loader.js.map +0 -1
  29. package/dist/index.d.ts.map +0 -1
  30. package/dist/index.js.map +0 -1
  31. package/dist/parser/flow.d.ts.map +0 -1
  32. package/dist/parser/flow.js.map +0 -1
  33. package/dist/perf/audit.d.ts.map +0 -1
  34. package/dist/perf/audit.js.map +0 -1
  35. package/dist/report/generator.d.ts.map +0 -1
  36. package/dist/report/generator.js.map +0 -1
  37. package/dist/runner/index.d.ts.map +0 -1
  38. package/dist/runner/index.js.map +0 -1
  39. package/dist/task/index.d.ts.map +0 -1
  40. package/dist/task/index.js.map +0 -1
  41. package/dist/task/report.d.ts.map +0 -1
  42. package/dist/task/report.js.map +0 -1
  43. package/dist/task/runner.d.ts.map +0 -1
  44. package/dist/task/runner.js.map +0 -1
  45. package/dist/task/session.d.ts.map +0 -1
  46. package/dist/task/session.js.map +0 -1
  47. package/dist/task/tools.d.ts.map +0 -1
  48. package/dist/task/tools.js.map +0 -1
  49. package/dist/task/types.d.ts.map +0 -1
  50. package/dist/task/types.js.map +0 -1
  51. package/dist/types.d.ts.map +0 -1
  52. package/dist/types.js.map +0 -1
@@ -1,744 +1 @@
1
- import { BrowserAgent } from "../browser/agent.js";
2
- import { AIInterpreter } from "../ai/interpreter.js";
3
- import * as fs from "fs";
4
- import * as path from "path";
5
- import * as yaml from "yaml";
6
- /**
7
- * Test runner that executes flow files
8
- */
9
- export class TestRunner {
10
- config;
11
- credentials;
12
- browser;
13
- ai;
14
- autoHandled = [];
15
- allAssumptions = [];
16
- constructor(config, credentials) {
17
- this.config = config;
18
- this.credentials = credentials;
19
- this.browser = new BrowserAgent(config.browser);
20
- this.ai = new AIInterpreter(config.llm);
21
- }
22
- /**
23
- * Run a flow file
24
- * @param flow The flow file to run
25
- * @param onStep Optional callback for real-time step progress
26
- * @param runPerformanceAudit If true, collect perf metrics before closing the browser
27
- */
28
- async runFlow(flow, onStep, runPerformanceAudit) {
29
- const startTime = new Date();
30
- const stepResults = [];
31
- let perfAudit;
32
- try {
33
- for (const step of flow.steps) {
34
- let result = await this.executeStep(step);
35
- // Retry once on failure (for non-optional steps, but not @debug_wait)
36
- const isDebugWait = step.text.match(/@debug_wait/i);
37
- if (result.status === "failed" && !step.optional && !isDebugWait) {
38
- // Wait a bit before retry
39
- await this.browser.wait(1000);
40
- // Try again
41
- const retryResult = await this.executeStep(step);
42
- retryResult.retried = true;
43
- // Combine durations
44
- retryResult.duration += result.duration;
45
- // If retry succeeded or failed, use retry result
46
- result = retryResult;
47
- }
48
- stepResults.push(result);
49
- // Call progress callback
50
- if (onStep) {
51
- onStep(result);
52
- }
53
- // Stop on required step failure (after retry)
54
- if (result.status === "failed" && !step.optional) {
55
- break;
56
- }
57
- }
58
- // Collect Core Web Vitals and React Scan BEFORE closing the browser
59
- // (they read from the live DOM), then close, then run Lighthouse
60
- // sequentially so only one Chrome is alive at a time.
61
- if (runPerformanceAudit) {
62
- try {
63
- const finalUrl = await this.browser.getUrl();
64
- const { collectCoreWebVitals, collectReactScanResults } = await import("../perf/audit.js");
65
- const [vitals, react] = await Promise.all([
66
- collectCoreWebVitals(this.browser),
67
- collectReactScanResults(this.browser),
68
- ]);
69
- perfAudit = {
70
- url: finalUrl,
71
- auditedAt: new Date().toISOString(),
72
- vitals,
73
- react,
74
- scores: null, // filled in after browser closes
75
- };
76
- }
77
- catch {
78
- // Non-fatal
79
- }
80
- }
81
- }
82
- finally {
83
- await this.browser.close(); // ← browser fully closed here
84
- }
85
- // Run Lighthouse AFTER agent-browser is closed — only one Chrome at a time
86
- if (runPerformanceAudit && perfAudit) {
87
- try {
88
- const { runLighthouseAudit } = await import("../perf/audit.js");
89
- const reportDir = this.config.report?.output_dir || "./test-reports";
90
- const lhResult = await runLighthouseAudit(perfAudit.url, reportDir);
91
- if (lhResult) {
92
- perfAudit.scores = lhResult.scores;
93
- perfAudit.lighthouse = lhResult.scores; // backwards compat
94
- if (lhResult.reportPath)
95
- perfAudit.lighthouseReportPath = lhResult.reportPath;
96
- }
97
- }
98
- catch {
99
- // Non-fatal — Lighthouse failure doesn't break the test result
100
- }
101
- }
102
- const endTime = new Date();
103
- const passed = stepResults.filter((r) => r.status === "passed").length;
104
- const failed = stepResults.filter((r) => r.status === "failed").length;
105
- const skipped = stepResults.filter((r) => r.status === "skipped").length;
106
- return {
107
- flowFile: flow.path || flow.name,
108
- status: failed === 0 ||
109
- stepResults.every((r) => r.status !== "failed" ||
110
- flow.steps[stepResults.indexOf(r)]?.optional)
111
- ? "passed"
112
- : "failed",
113
- steps: stepResults,
114
- totalSteps: flow.steps.length,
115
- passedSteps: passed,
116
- failedSteps: failed,
117
- skippedSteps: skipped,
118
- duration: endTime.getTime() - startTime.getTime(),
119
- startTime,
120
- endTime,
121
- autoHandled: this.autoHandled,
122
- assumptions: this.allAssumptions,
123
- ...(perfAudit ? { perfAudit } : {}),
124
- };
125
- }
126
- /**
127
- * Execute a single step
128
- */
129
- async executeStep(step) {
130
- const startTime = Date.now();
131
- const actions = [];
132
- const assumptions = [];
133
- try {
134
- // Get current browser state
135
- const state = await this.browser.getState();
136
- // Check for auto-handle opportunities
137
- await this.handleInterruptions(actions);
138
- // Check if this is a credential-related step
139
- // Supports multiple patterns:
140
- // - "Login with <profile> credentials" → explicit profile
141
- // - "@inject <profile>" or "@inject:<profile>"
142
- // - "Inject <profile> credentials"
143
- // - "Use <profile> credentials"
144
- // - "Login" / "Sign in" / "Log in" (no profile) → auto-pick best match
145
- const credentialPatterns = [
146
- /login\s+with\s+([\w-]+)\s+credentials/i,
147
- /@inject[:\s]+([\w-]+)/i,
148
- /inject\s+([\w-]+)\s+credentials/i,
149
- /use\s+([\w-]+)\s+credentials/i,
150
- ];
151
- // Generic login intent patterns (no explicit profile name)
152
- const genericLoginPatterns = [
153
- /^(log\s?in|sign\s?in|authenticate|login)(\s+to\s+\S+)?(\s+using\s+credentials?)?$/i,
154
- /^(log\s?in|sign\s?in)\s+with\s+credentials?$/i,
155
- ];
156
- let profileName = null;
157
- for (const pattern of credentialPatterns) {
158
- const match = step.text.match(pattern);
159
- if (match) {
160
- profileName = match[1];
161
- break;
162
- }
163
- }
164
- // Auto-pick a credential profile when none was named
165
- if (!profileName) {
166
- const isGenericLogin = genericLoginPatterns.some((p) => p.test(step.text.trim()));
167
- if (isGenericLogin) {
168
- profileName = this.pickBestCredentialProfile(state.url);
169
- }
170
- }
171
- // Check for @debug_wait command
172
- const debugWaitMatch = step.text.match(/@debug_wait(?:[:\s]+(.+))?/i);
173
- if (debugWaitMatch) {
174
- const profileName = debugWaitMatch[1]?.trim() || "captured";
175
- await this.handleDebugWait(profileName, actions);
176
- return {
177
- step,
178
- status: "passed",
179
- duration: Date.now() - startTime,
180
- actions,
181
- };
182
- }
183
- if (profileName) {
184
- const profile = this.credentials.profiles[profileName];
185
- if (!profile) {
186
- throw new Error(`Credential profile not found: ${profileName}`);
187
- }
188
- await this.handleLogin(profile, actions);
189
- }
190
- else {
191
- // Interpret step with AI
192
- const interpreted = await this.ai.interpretStep(step, state, this.credentials.profiles);
193
- // If AI detected a login intent and returned a profile suggestion, use it
194
- if (interpreted.needsCredentials &&
195
- !profileName) {
196
- const suggested = interpreted.credentialProfile;
197
- const profiles = this.credentials?.profiles || {};
198
- if (suggested && profiles[suggested]) {
199
- profileName = suggested;
200
- }
201
- else {
202
- // Fall back to best domain match
203
- profileName = this.pickBestCredentialProfile(state.url);
204
- }
205
- if (profileName) {
206
- await this.handleLogin(profiles[profileName], actions);
207
- // Skip remaining command execution for this step
208
- const screenshot2 = this.config.report?.screenshots
209
- ? await this.browser.screenshot(`step-${step.line}.png`).catch(() => undefined)
210
- : undefined;
211
- return {
212
- step,
213
- status: "passed",
214
- duration: Date.now() - startTime,
215
- actions,
216
- assumptions: [`Auto-selected credential profile: ${profileName}`],
217
- screenshot: screenshot2,
218
- };
219
- }
220
- }
221
- // Handle skip reason
222
- if (interpreted.skipReason) {
223
- if (step.optional || step.conditional) {
224
- return {
225
- step,
226
- status: "skipped",
227
- duration: Date.now() - startTime,
228
- actions: [
229
- {
230
- type: "info",
231
- description: interpreted.skipReason,
232
- timestamp: Date.now(),
233
- },
234
- ],
235
- };
236
- }
237
- else {
238
- throw new Error(interpreted.skipReason);
239
- }
240
- }
241
- // Record assumptions
242
- if (interpreted.assumptions.length > 0) {
243
- assumptions.push(...interpreted.assumptions);
244
- this.allAssumptions.push(...interpreted.assumptions);
245
- }
246
- // Execute browser commands
247
- for (const cmd of interpreted.actions) {
248
- await this.executeCommand(cmd, actions);
249
- }
250
- }
251
- // After every step: silently solve captchas and dismiss interruptions
252
- await this.handleCaptcha(actions);
253
- await this.handleInterruptions(actions);
254
- // Take screenshot if enabled
255
- let screenshot;
256
- if (this.config.report?.screenshots) {
257
- const screenshotPath = `step-${step.line}.png`;
258
- await this.browser.screenshot(screenshotPath);
259
- screenshot = screenshotPath;
260
- }
261
- return {
262
- step,
263
- status: "passed",
264
- duration: Date.now() - startTime,
265
- actions,
266
- assumptions: assumptions.length > 0 ? assumptions : undefined,
267
- screenshot,
268
- };
269
- }
270
- catch (error) {
271
- // Take failure screenshot
272
- let screenshot;
273
- try {
274
- if (this.config.report?.screenshots) {
275
- const screenshotPath = `step-${step.line}-failed.png`;
276
- await this.browser.screenshot(screenshotPath);
277
- screenshot = screenshotPath;
278
- }
279
- }
280
- catch {
281
- // Ignore screenshot errors
282
- }
283
- if (step.optional) {
284
- return {
285
- step,
286
- status: "skipped",
287
- duration: Date.now() - startTime,
288
- actions,
289
- error: error.message,
290
- screenshot,
291
- };
292
- }
293
- return {
294
- step,
295
- status: "failed",
296
- duration: Date.now() - startTime,
297
- actions,
298
- error: error.message,
299
- screenshot,
300
- };
301
- }
302
- }
303
- /**
304
- * Execute a browser command
305
- */
306
- async executeCommand(cmd, actions) {
307
- actions.push({
308
- type: this.getActionType(cmd.command),
309
- description: cmd.description,
310
- selector: cmd.args[0],
311
- value: cmd.args[1],
312
- timestamp: Date.now(),
313
- });
314
- switch (cmd.command) {
315
- case "navigate":
316
- await this.browser.navigate(cmd.args[0]);
317
- // Wait for page to stabilize after navigation
318
- await this.browser.waitForStable();
319
- break;
320
- case "click":
321
- await this.browser.click(cmd.args[0]);
322
- // Brief wait after click in case it triggers navigation
323
- await this.browser.wait(300);
324
- break;
325
- case "fill":
326
- await this.browser.fill(cmd.args[0], cmd.args[1]);
327
- break;
328
- case "type":
329
- await this.browser.type(cmd.args[0], cmd.args[1]);
330
- break;
331
- case "press":
332
- await this.browser.press(cmd.args[0]);
333
- break;
334
- case "hover":
335
- await this.browser.hover(cmd.args[0]);
336
- break;
337
- case "select":
338
- await this.browser.select(cmd.args[0], cmd.args[1]);
339
- break;
340
- case "scroll":
341
- await this.browser.scroll(cmd.args[0], parseInt(cmd.args[1]));
342
- break;
343
- case "wait":
344
- await this.browser.wait(parseInt(cmd.args[0]));
345
- break;
346
- case "waitForText":
347
- await this.browser.wait(`text=${cmd.args[0]}`);
348
- break;
349
- case "getText":
350
- await this.browser.getText(cmd.args[0]);
351
- break;
352
- case "screenshot":
353
- await this.browser.screenshot(cmd.args[0]);
354
- break;
355
- case "goBack":
356
- await this.browser.goBack();
357
- break;
358
- case "reload":
359
- await this.browser.reload();
360
- break;
361
- default:
362
- throw new Error(`Unknown command: ${cmd.command}`);
363
- }
364
- // Small delay between actions for stability
365
- await this.browser.wait(100);
366
- }
367
- /**
368
- * Pick the best credential profile for a given URL.
369
- * Prefers profiles whose name matches the current domain, falls back to "default".
370
- * Returns null when no profiles exist.
371
- */
372
- pickBestCredentialProfile(url) {
373
- const profiles = this.credentials?.profiles;
374
- if (!profiles || Object.keys(profiles).length === 0)
375
- return null;
376
- // Try to match profile name against the current domain
377
- try {
378
- const hostname = new URL(url).hostname.replace(/^www\./, "");
379
- // e.g. hostname = "github.com" → try "github", "github.com"
380
- const domainBase = hostname.split(".")[0];
381
- for (const name of Object.keys(profiles)) {
382
- if (name.toLowerCase() === domainBase.toLowerCase() ||
383
- name.toLowerCase() === hostname.toLowerCase()) {
384
- return name;
385
- }
386
- }
387
- }
388
- catch {
389
- // Invalid URL — fall through to "default"
390
- }
391
- // Fall back to "default" profile if it exists
392
- if (profiles["default"])
393
- return "default";
394
- // Last resort: first available profile
395
- return Object.keys(profiles)[0];
396
- }
397
- /**
398
- * Detect and solve captchas on the current page.
399
- * Handles common captcha patterns — checkbox reCAPTCHA, hCaptcha, Cloudflare Turnstile.
400
- * Runs silently; any failure is swallowed so the main flow continues.
401
- */
402
- async handleCaptcha(actions) {
403
- try {
404
- const state = await this.browser.getState();
405
- const snap = (state.snapshot || "").toLowerCase();
406
- const title = (state.title || "").toLowerCase();
407
- const url = (state.url || "").toLowerCase();
408
- // Detect captcha presence via page content signals
409
- const captchaSignals = [
410
- snap.includes("recaptcha"),
411
- snap.includes("hcaptcha"),
412
- snap.includes("turnstile"),
413
- snap.includes("i'm not a robot"),
414
- snap.includes("i am not a robot"),
415
- snap.includes("verify you are human"),
416
- snap.includes("verify you're human"),
417
- title.includes("just a moment"), // Cloudflare waiting room
418
- url.includes("challenge"),
419
- ];
420
- if (!captchaSignals.some(Boolean))
421
- return;
422
- // Ask AI to find the captcha checkbox / button to click
423
- const result = await this.ai.findCaptchaAction(state);
424
- if (!result)
425
- return;
426
- await this.browser.click(result.ref);
427
- await this.browser.wait(2000); // wait for challenge to process
428
- const description = `Auto-solved captcha: ${result.description}`;
429
- this.autoHandled.push(description);
430
- actions.push({
431
- type: "auto-handle",
432
- description,
433
- selector: result.ref,
434
- timestamp: Date.now(),
435
- });
436
- }
437
- catch {
438
- // Never let captcha handling crash the flow
439
- }
440
- }
441
- /**
442
- * Handle automatic dismissal of interruptions
443
- */
444
- async handleInterruptions(actions) {
445
- try {
446
- const state = await this.browser.getState();
447
- const interruptions = await this.ai.checkAutoHandle(state);
448
- for (const int of interruptions) {
449
- try {
450
- await this.browser.click(int.ref);
451
- const description = `Auto-handled: ${int.description}`;
452
- this.autoHandled.push(description);
453
- actions.push({
454
- type: "auto-handle",
455
- description,
456
- selector: int.ref,
457
- timestamp: Date.now(),
458
- });
459
- // Wait for any animations
460
- await this.browser.wait(500);
461
- }
462
- catch {
463
- // Ignore failed auto-handles
464
- }
465
- }
466
- }
467
- catch {
468
- // Ignore auto-handle errors
469
- }
470
- }
471
- /**
472
- * Handle login with credentials
473
- */
474
- async handleLogin(profile, actions) {
475
- // Handle inject type (cookies/localStorage)
476
- if (profile.type === "inject") {
477
- let successCount = 0;
478
- let failCount = 0;
479
- if (profile.cookies) {
480
- for (const cookie of profile.cookies) {
481
- try {
482
- await this.browser.setCookie(cookie.name, cookie.value);
483
- actions.push({
484
- type: "fill",
485
- description: `Set cookie: ${cookie.name}`,
486
- timestamp: Date.now(),
487
- });
488
- successCount++;
489
- }
490
- catch (error) {
491
- // Log but continue - some cookies may fail due to domain restrictions
492
- actions.push({
493
- type: "info",
494
- description: `⚠ Failed to set cookie: ${cookie.name} (${error.message?.split("\n")[0] || "unknown error"})`,
495
- timestamp: Date.now(),
496
- });
497
- failCount++;
498
- }
499
- }
500
- }
501
- if (profile.localStorage) {
502
- for (const [key, value] of Object.entries(profile.localStorage)) {
503
- try {
504
- await this.browser.setLocalStorage(key, value);
505
- actions.push({
506
- type: "fill",
507
- description: `Set localStorage: ${key}`,
508
- timestamp: Date.now(),
509
- });
510
- successCount++;
511
- }
512
- catch (error) {
513
- actions.push({
514
- type: "info",
515
- description: `⚠ Failed to set localStorage: ${key} (${error.message?.split("\n")[0] || "unknown error"})`,
516
- timestamp: Date.now(),
517
- });
518
- failCount++;
519
- }
520
- }
521
- }
522
- if (profile.sessionStorage) {
523
- for (const [key, value] of Object.entries(profile.sessionStorage)) {
524
- try {
525
- await this.browser.setSessionStorage(key, value);
526
- actions.push({
527
- type: "fill",
528
- description: `Set sessionStorage: ${key}`,
529
- timestamp: Date.now(),
530
- });
531
- successCount++;
532
- }
533
- catch (error) {
534
- actions.push({
535
- type: "info",
536
- description: `⚠ Failed to set sessionStorage: ${key} (${error.message?.split("\n")[0] || "unknown error"})`,
537
- timestamp: Date.now(),
538
- });
539
- failCount++;
540
- }
541
- }
542
- }
543
- if (failCount > 0) {
544
- console.log(` ⚠ ${failCount} item(s) failed to inject (continuing with ${successCount} successful)`);
545
- }
546
- // Reload so the page picks up the injected cookies/storage
547
- await this.browser.wait(300);
548
- await this.browser.reload();
549
- return;
550
- }
551
- // Handle login form type
552
- if (profile.type === "login-form" && profile.username && profile.password) {
553
- const state = await this.browser.getState();
554
- const loginForm = await this.ai.findLoginForm(state);
555
- if (!loginForm) {
556
- throw new Error("Could not find login form on page");
557
- }
558
- // Fill username
559
- await this.browser.fill(loginForm.usernameRef, profile.username);
560
- actions.push({
561
- type: "fill",
562
- description: "Filled username field",
563
- selector: loginForm.usernameRef,
564
- timestamp: Date.now(),
565
- });
566
- // Fill password
567
- await this.browser.fill(loginForm.passwordRef, profile.password);
568
- actions.push({
569
- type: "fill",
570
- description: "Filled password field",
571
- selector: loginForm.passwordRef,
572
- timestamp: Date.now(),
573
- });
574
- // Click submit
575
- await this.browser.click(loginForm.submitRef);
576
- actions.push({
577
- type: "click",
578
- description: "Clicked login button",
579
- selector: loginForm.submitRef,
580
- timestamp: Date.now(),
581
- });
582
- // Wait for navigation
583
- await this.browser.wait(2000);
584
- // Handle TOTP if configured
585
- if (profile.totp_secret) {
586
- const totp = this.generateTOTP(profile.totp_secret);
587
- // TODO: Find OTP input and fill
588
- actions.push({
589
- type: "fill",
590
- description: `Entered TOTP code`,
591
- timestamp: Date.now(),
592
- });
593
- }
594
- // Handle fixed OTP
595
- if (profile.fixed_otp) {
596
- // TODO: Find OTP input and fill
597
- actions.push({
598
- type: "fill",
599
- description: `Entered fixed OTP`,
600
- timestamp: Date.now(),
601
- });
602
- }
603
- }
604
- }
605
- /**
606
- * Generate TOTP code from secret
607
- */
608
- generateTOTP(secret) {
609
- // Basic TOTP implementation
610
- // In production, use a proper TOTP library
611
- const epoch = Math.floor(Date.now() / 1000 / 30);
612
- // Simplified - would need proper HMAC-SHA1 implementation
613
- return "000000"; // Placeholder
614
- }
615
- /**
616
- * Handle @debug_wait - pause for user interaction, then capture credentials
617
- */
618
- async handleDebugWait(profileName, actions) {
619
- console.log("\n" + "=".repeat(60));
620
- console.log("šŸ”“ DEBUG WAIT - Browser paused for manual interaction");
621
- console.log("=".repeat(60));
622
- console.log("\nYou can now interact with the browser manually.");
623
- console.log("(e.g., log in, complete 2FA, accept cookies, etc.)\n");
624
- console.log("Press ENTER when done to capture cookies & localStorage...\n");
625
- actions.push({
626
- type: "info",
627
- description: "Paused for manual interaction (@debug_wait)",
628
- timestamp: Date.now(),
629
- });
630
- // Wait for user input using raw stdin
631
- await new Promise((resolve) => {
632
- const onData = () => {
633
- process.stdin.removeListener("data", onData);
634
- process.stdin.pause();
635
- resolve();
636
- };
637
- process.stdin.resume();
638
- process.stdin.once("data", onData);
639
- });
640
- console.log("\nšŸ“ø Capturing browser state...\n");
641
- // Capture cookies
642
- const cookies = await this.browser.getCookies();
643
- let localStorage = await this.browser.getLocalStorage();
644
- let sessionStorage = await this.browser.getSessionStorage();
645
- // Normalize to plain Record<string, string> (in case we got a string or nested structure)
646
- const toStorageObject = (v) => {
647
- if (typeof v === "string") {
648
- try {
649
- v = JSON.parse(v);
650
- }
651
- catch {
652
- return {};
653
- }
654
- }
655
- if (!v || typeof v !== "object" || Array.isArray(v))
656
- return {};
657
- const out = {};
658
- for (const [k, val] of Object.entries(v)) {
659
- out[String(k)] = typeof val === "string" ? val : JSON.stringify(val);
660
- }
661
- return out;
662
- };
663
- localStorage = toStorageObject(localStorage);
664
- sessionStorage = toStorageObject(sessionStorage);
665
- // Build credential profile with correct shape for .slapify/credentials.yaml
666
- const capturedProfile = {
667
- type: "inject",
668
- };
669
- if (cookies.length > 0) {
670
- capturedProfile.cookies = cookies.map((c) => ({
671
- name: c.name,
672
- value: c.value,
673
- }));
674
- }
675
- if (Object.keys(localStorage).length > 0) {
676
- capturedProfile.localStorage = localStorage;
677
- }
678
- if (Object.keys(sessionStorage).length > 0) {
679
- capturedProfile.sessionStorage = sessionStorage;
680
- }
681
- // Save to temp_credentials.yaml (format compatible with .slapify/credentials.yaml)
682
- const outputPath = path.join(process.cwd(), "temp_credentials.yaml");
683
- let existingData = {
684
- profiles: {},
685
- };
686
- if (fs.existsSync(outputPath)) {
687
- try {
688
- const parsed = yaml.parse(fs.readFileSync(outputPath, "utf-8"));
689
- if (parsed && parsed.profiles && typeof parsed.profiles === "object") {
690
- existingData = { profiles: parsed.profiles };
691
- }
692
- }
693
- catch {
694
- existingData = { profiles: {} };
695
- }
696
- }
697
- existingData.profiles[profileName] = capturedProfile;
698
- // Stringify with block style so localStorage/sessionStorage are key: value per line
699
- const yamlContent = `# Captured credentials from @debug_wait
700
- # Generated: ${new Date().toISOString()}
701
- #
702
- # To use: copy the profile you need to .slapify/credentials.yaml
703
- # Then use: @inject ${profileName}
704
-
705
- ${yaml.stringify(existingData, { indent: 2, lineWidth: 0 })}`;
706
- fs.writeFileSync(outputPath, yamlContent);
707
- // Summary
708
- console.log("āœ… Captured:");
709
- console.log(` - ${cookies.length} cookie(s)`);
710
- console.log(` - ${Object.keys(localStorage).length} localStorage item(s)`);
711
- console.log(` - ${Object.keys(sessionStorage).length} sessionStorage item(s)`);
712
- console.log(`\nšŸ“ Saved to: ${outputPath}`);
713
- console.log(` Profile name: "${profileName}"`);
714
- console.log("\n" + "=".repeat(60) + "\n");
715
- actions.push({
716
- type: "info",
717
- description: `Captured ${cookies.length} cookies, ${Object.keys(localStorage).length} localStorage, ${Object.keys(sessionStorage).length} sessionStorage to temp_credentials.yaml`,
718
- timestamp: Date.now(),
719
- });
720
- }
721
- /**
722
- * Get action type from command
723
- */
724
- getActionType(command) {
725
- switch (command) {
726
- case "navigate":
727
- return "navigate";
728
- case "click":
729
- case "hover":
730
- case "press":
731
- return "click";
732
- case "fill":
733
- case "type":
734
- case "select":
735
- return "fill";
736
- case "wait":
737
- case "waitForText":
738
- return "wait";
739
- default:
740
- return "info";
741
- }
742
- }
743
- }
744
- //# sourceMappingURL=index.js.map
1
+ import{BrowserAgent as e}from"../browser/agent.js";import{AIInterpreter as t}from"../ai/interpreter.js";import*as s from"fs";import*as a from"path";import*as i from"yaml";export class TestRunner{config;credentials;browser;ai;autoHandled=[];allAssumptions=[];constructor(s,a){this.config=s,this.credentials=a,this.browser=new e(s.browser),this.ai=new t(s.llm)}async runFlow(e,t,s){const a=new Date,i=[];let o;try{for(const s of e.steps){let e=await this.executeStep(s);const a=s.text.match(/@debug_wait/i);if("failed"===e.status&&!s.optional&&!a){await this.browser.wait(1e3);const t=await this.executeStep(s);t.retried=!0,t.duration+=e.duration,e=t}if(i.push(e),t&&t(e),"failed"===e.status&&!s.optional)break}if(s)try{const e=await this.browser.getUrl(),{collectCoreWebVitals:t,collectReactScanResults:s}=await import("../perf/audit.js"),[a,i]=await Promise.all([t(this.browser),s(this.browser)]);o={url:e,auditedAt:(new Date).toISOString(),vitals:a,react:i,scores:null}}catch{}}finally{await this.browser.close()}if(s&&o)try{const{runLighthouseAudit:e}=await import("../perf/audit.js"),t=this.config.report?.output_dir||"./test-reports",s=await e(o.url,t);s&&(o.scores=s.scores,o.lighthouse=s.scores,s.reportPath&&(o.lighthouseReportPath=s.reportPath))}catch{}const r=new Date,n=i.filter(e=>"passed"===e.status).length,c=i.filter(e=>"failed"===e.status).length,l=i.filter(e=>"skipped"===e.status).length;return{flowFile:e.path||e.name,status:0===c||i.every(t=>"failed"!==t.status||e.steps[i.indexOf(t)]?.optional)?"passed":"failed",steps:i,totalSteps:e.steps.length,passedSteps:n,failedSteps:c,skippedSteps:l,duration:r.getTime()-a.getTime(),startTime:a,endTime:r,autoHandled:this.autoHandled,assumptions:this.allAssumptions,...o?{perfAudit:o}:{}}}async executeStep(e){const t=Date.now(),s=[],a=[];try{const i=await this.browser.getState();await this.handleInterruptions(s);const o=[/login\s+with\s+([\w-]+)\s+credentials/i,/@inject[:\s]+([\w-]+)/i,/inject\s+([\w-]+)\s+credentials/i,/use\s+([\w-]+)\s+credentials/i],r=[/^(log\s?in|sign\s?in|authenticate|login)(\s+to\s+\S+)?(\s+using\s+credentials?)?$/i,/^(log\s?in|sign\s?in)\s+with\s+credentials?$/i];let n=null;for(const t of o){const s=e.text.match(t);if(s){n=s[1];break}}if(!n){r.some(t=>t.test(e.text.trim()))&&(n=this.pickBestCredentialProfile(i.url))}const c=e.text.match(/@debug_wait(?:[:\s]+(.+))?/i);if(c){const a=c[1]?.trim()||"captured";return await this.handleDebugWait(a,s),{step:e,status:"passed",duration:Date.now()-t,actions:s}}if(n){const e=this.credentials.profiles[n];if(!e)throw new Error(`Credential profile not found: ${n}`);await this.handleLogin(e,s)}else{const o=await this.ai.interpretStep(e,i,this.credentials.profiles);if(o.needsCredentials&&!n){const a=o.credentialProfile,r=this.credentials?.profiles||{};if(n=a&&r[a]?a:this.pickBestCredentialProfile(i.url),n){await this.handleLogin(r[n],s);const a=this.config.report?.screenshots?await this.browser.screenshot(`step-${e.line}.png`).catch(()=>{}):void 0;return{step:e,status:"passed",duration:Date.now()-t,actions:s,assumptions:[`Auto-selected credential profile: ${n}`],screenshot:a}}}if(o.skipReason){if(e.optional||e.conditional)return{step:e,status:"skipped",duration:Date.now()-t,actions:[{type:"info",description:o.skipReason,timestamp:Date.now()}]};throw new Error(o.skipReason)}o.assumptions.length>0&&(a.push(...o.assumptions),this.allAssumptions.push(...o.assumptions));for(const e of o.actions)await this.executeCommand(e,s)}let l;if(await this.handleCaptcha(s),await this.handleInterruptions(s),this.config.report?.screenshots){const t=`step-${e.line}.png`;await this.browser.screenshot(t),l=t}return{step:e,status:"passed",duration:Date.now()-t,actions:s,assumptions:a.length>0?a:void 0,screenshot:l}}catch(a){let i;try{if(this.config.report?.screenshots){const t=`step-${e.line}-failed.png`;await this.browser.screenshot(t),i=t}}catch{}return e.optional?{step:e,status:"skipped",duration:Date.now()-t,actions:s,error:a.message,screenshot:i}:{step:e,status:"failed",duration:Date.now()-t,actions:s,error:a.message,screenshot:i}}}async executeCommand(e,t){switch(t.push({type:this.getActionType(e.command),description:e.description,selector:e.args[0],value:e.args[1],timestamp:Date.now()}),e.command){case"navigate":await this.browser.navigate(e.args[0]),await this.browser.waitForStable();break;case"click":await this.browser.click(e.args[0]),await this.browser.wait(300);break;case"fill":await this.browser.fill(e.args[0],e.args[1]);break;case"type":await this.browser.type(e.args[0],e.args[1]);break;case"press":await this.browser.press(e.args[0]);break;case"hover":await this.browser.hover(e.args[0]);break;case"select":await this.browser.select(e.args[0],e.args[1]);break;case"scroll":await this.browser.scroll(e.args[0],parseInt(e.args[1]));break;case"wait":await this.browser.wait(parseInt(e.args[0]));break;case"waitForText":await this.browser.wait(`text=${e.args[0]}`);break;case"getText":await this.browser.getText(e.args[0]);break;case"screenshot":await this.browser.screenshot(e.args[0]);break;case"goBack":await this.browser.goBack();break;case"reload":await this.browser.reload();break;default:throw new Error(`Unknown command: ${e.command}`)}await this.browser.wait(100)}pickBestCredentialProfile(e){const t=this.credentials?.profiles;if(!t||0===Object.keys(t).length)return null;try{const s=new URL(e).hostname.replace(/^www\./,""),a=s.split(".")[0];for(const e of Object.keys(t))if(e.toLowerCase()===a.toLowerCase()||e.toLowerCase()===s.toLowerCase())return e}catch{}return t.default?"default":Object.keys(t)[0]}async handleCaptcha(e){try{const t=await this.browser.getState(),s=(t.snapshot||"").toLowerCase(),a=(t.title||"").toLowerCase(),i=(t.url||"").toLowerCase();if(![s.includes("recaptcha"),s.includes("hcaptcha"),s.includes("turnstile"),s.includes("i'm not a robot"),s.includes("i am not a robot"),s.includes("verify you are human"),s.includes("verify you're human"),a.includes("just a moment"),i.includes("challenge")].some(Boolean))return;const o=await this.ai.findCaptchaAction(t);if(!o)return;await this.browser.click(o.ref),await this.browser.wait(2e3);const r=`Auto-solved captcha: ${o.description}`;this.autoHandled.push(r),e.push({type:"auto-handle",description:r,selector:o.ref,timestamp:Date.now()})}catch{}}async handleInterruptions(e){try{const t=await this.browser.getState(),s=await this.ai.checkAutoHandle(t);for(const t of s)try{await this.browser.click(t.ref);const s=`Auto-handled: ${t.description}`;this.autoHandled.push(s),e.push({type:"auto-handle",description:s,selector:t.ref,timestamp:Date.now()}),await this.browser.wait(500)}catch{}}catch{}}async handleLogin(e,t){if("inject"===e.type){let s=0,a=0;if(e.cookies)for(const i of e.cookies)try{await this.browser.setCookie(i.name,i.value),t.push({type:"fill",description:`Set cookie: ${i.name}`,timestamp:Date.now()}),s++}catch(e){t.push({type:"info",description:`⚠ Failed to set cookie: ${i.name} (${e.message?.split("\n")[0]||"unknown error"})`,timestamp:Date.now()}),a++}if(e.localStorage)for(const[i,o]of Object.entries(e.localStorage))try{await this.browser.setLocalStorage(i,o),t.push({type:"fill",description:`Set localStorage: ${i}`,timestamp:Date.now()}),s++}catch(e){t.push({type:"info",description:`⚠ Failed to set localStorage: ${i} (${e.message?.split("\n")[0]||"unknown error"})`,timestamp:Date.now()}),a++}if(e.sessionStorage)for(const[i,o]of Object.entries(e.sessionStorage))try{await this.browser.setSessionStorage(i,o),t.push({type:"fill",description:`Set sessionStorage: ${i}`,timestamp:Date.now()}),s++}catch(e){t.push({type:"info",description:`⚠ Failed to set sessionStorage: ${i} (${e.message?.split("\n")[0]||"unknown error"})`,timestamp:Date.now()}),a++}return a>0&&console.log(` ⚠ ${a} item(s) failed to inject (continuing with ${s} successful)`),await this.browser.wait(300),void await this.browser.reload()}if("login-form"===e.type&&e.username&&e.password){const s=await this.browser.getState(),a=await this.ai.findLoginForm(s);if(!a)throw new Error("Could not find login form on page");if(await this.browser.fill(a.usernameRef,e.username),t.push({type:"fill",description:"Filled username field",selector:a.usernameRef,timestamp:Date.now()}),await this.browser.fill(a.passwordRef,e.password),t.push({type:"fill",description:"Filled password field",selector:a.passwordRef,timestamp:Date.now()}),await this.browser.click(a.submitRef),t.push({type:"click",description:"Clicked login button",selector:a.submitRef,timestamp:Date.now()}),await this.browser.wait(2e3),e.totp_secret){this.generateTOTP(e.totp_secret);t.push({type:"fill",description:"Entered TOTP code",timestamp:Date.now()})}e.fixed_otp&&t.push({type:"fill",description:"Entered fixed OTP",timestamp:Date.now()})}}generateTOTP(e){Math.floor(Date.now()/1e3/30);return"000000"}async handleDebugWait(e,t){console.log("\n"+"=".repeat(60)),console.log("šŸ”“ DEBUG WAIT - Browser paused for manual interaction"),console.log("=".repeat(60)),console.log("\nYou can now interact with the browser manually."),console.log("(e.g., log in, complete 2FA, accept cookies, etc.)\n"),console.log("Press ENTER when done to capture cookies & localStorage...\n"),t.push({type:"info",description:"Paused for manual interaction (@debug_wait)",timestamp:Date.now()}),await new Promise(e=>{const t=()=>{process.stdin.removeListener("data",t),process.stdin.pause(),e()};process.stdin.resume(),process.stdin.once("data",t)}),console.log("\nšŸ“ø Capturing browser state...\n");const o=await this.browser.getCookies();let r=await this.browser.getLocalStorage(),n=await this.browser.getSessionStorage();const c=e=>{if("string"==typeof e)try{e=JSON.parse(e)}catch{return{}}if(!e||"object"!=typeof e||Array.isArray(e))return{};const t={};for(const[s,a]of Object.entries(e))t[String(s)]="string"==typeof a?a:JSON.stringify(a);return t};r=c(r),n=c(n);const l={type:"inject"};o.length>0&&(l.cookies=o.map(e=>({name:e.name,value:e.value}))),Object.keys(r).length>0&&(l.localStorage=r),Object.keys(n).length>0&&(l.sessionStorage=n);const p=a.join(process.cwd(),"temp_credentials.yaml");let w={profiles:{}};if(s.existsSync(p))try{const e=i.parse(s.readFileSync(p,"utf-8"));e&&e.profiles&&"object"==typeof e.profiles&&(w={profiles:e.profiles})}catch{w={profiles:{}}}w.profiles[e]=l;const h=`# Captured credentials from @debug_wait\n# Generated: ${(new Date).toISOString()}\n#\n# To use: copy the profile you need to .slapify/credentials.yaml\n# Then use: @inject ${e}\n\n${i.stringify(w,{indent:2,lineWidth:0})}`;s.writeFileSync(p,h),console.log("āœ… Captured:"),console.log(` - ${o.length} cookie(s)`),console.log(` - ${Object.keys(r).length} localStorage item(s)`),console.log(` - ${Object.keys(n).length} sessionStorage item(s)`),console.log(`\nšŸ“ Saved to: ${p}`),console.log(` Profile name: "${e}"`),console.log("\n"+"=".repeat(60)+"\n"),t.push({type:"info",description:`Captured ${o.length} cookies, ${Object.keys(r).length} localStorage, ${Object.keys(n).length} sessionStorage to temp_credentials.yaml`,timestamp:Date.now()})}getActionType(e){switch(e){case"navigate":return"navigate";case"click":case"hover":case"press":return"click";case"fill":case"type":case"select":return"fill";case"wait":case"waitForText":return"wait";default:return"info"}}}