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.
- package/README.md +38 -4
- package/dist/ai/interpreter.js +1 -331
- package/dist/browser/agent.js +1 -485
- package/dist/cli.js +1 -1553
- package/dist/config/loader.js +1 -305
- package/dist/index.js +1 -262
- package/dist/parser/flow.js +1 -117
- package/dist/perf/audit.js +1 -635
- package/dist/report/generator.js +1 -641
- package/dist/runner/index.js +1 -744
- package/dist/task/index.js +1 -4
- package/dist/task/report.js +1 -740
- package/dist/task/runner.js +1 -1362
- package/dist/task/session.js +1 -153
- package/dist/task/tools.d.ts +12 -0
- package/dist/task/tools.js +1 -258
- package/dist/task/types.d.ts +18 -0
- package/dist/task/types.js +1 -2
- package/dist/types.js +1 -2
- package/package.json +6 -3
- package/dist/ai/interpreter.d.ts.map +0 -1
- package/dist/ai/interpreter.js.map +0 -1
- package/dist/browser/agent.d.ts.map +0 -1
- package/dist/browser/agent.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/config/loader.d.ts.map +0 -1
- package/dist/config/loader.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/parser/flow.d.ts.map +0 -1
- package/dist/parser/flow.js.map +0 -1
- package/dist/perf/audit.d.ts.map +0 -1
- package/dist/perf/audit.js.map +0 -1
- package/dist/report/generator.d.ts.map +0 -1
- package/dist/report/generator.js.map +0 -1
- package/dist/runner/index.d.ts.map +0 -1
- package/dist/runner/index.js.map +0 -1
- package/dist/task/index.d.ts.map +0 -1
- package/dist/task/index.js.map +0 -1
- package/dist/task/report.d.ts.map +0 -1
- package/dist/task/report.js.map +0 -1
- package/dist/task/runner.d.ts.map +0 -1
- package/dist/task/runner.js.map +0 -1
- package/dist/task/session.d.ts.map +0 -1
- package/dist/task/session.js.map +0 -1
- package/dist/task/tools.d.ts.map +0 -1
- package/dist/task/tools.js.map +0 -1
- package/dist/task/types.d.ts.map +0 -1
- package/dist/task/types.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
package/dist/runner/index.js
CHANGED
|
@@ -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"}}}
|