vision-navigator 1.0.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.
@@ -0,0 +1,897 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Navigator = void 0;
7
+ require("dotenv/config");
8
+ const axios_1 = __importDefault(require("axios"));
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const cdp_driver_1 = require("./cdp-driver");
12
+ const storage_1 = require("./storage");
13
+ class Navigator {
14
+ constructor(ollamaUrl = "http://localhost:11434", model = "qwen2.5:3b", opts) {
15
+ this.ollamaUrl = process.env.OLLAMA_HOST || process.env.OLLAMA_URL || ollamaUrl;
16
+ this.model = process.env.OLLAMA_MODEL || process.env.MODEL || model;
17
+ this.groqKey = opts?.groqApiKey || process.env.GROQ_API_KEY || process.env.GROQ_KEY;
18
+ this.groqModel = opts?.groqModel || process.env.GROQ_MODEL || "llama-3.1-8b-instant";
19
+ this.openrouterKey = opts?.openrouterApiKey || process.env.OPENROUTER_API_KEY;
20
+ this.openrouterModel = opts?.openrouterModel || process.env.OPENROUTER_MODEL || "google/gemini-2.0-flash-exp:free";
21
+ this.storage = new storage_1.Storage();
22
+ this.fastMode = String(process.env.FAST_MODE || '').toLowerCase() === 'true' || process.env.FAST_MODE === '1';
23
+ this.skipAnalysis = String(process.env.SKIP_ANALYSIS || '') === '1' || String(process.env.SKIP_ANALYSIS || '').toLowerCase() === 'true';
24
+ const analysisAsyncEnv = String(process.env.ANALYSIS_ASYNC || '').toLowerCase();
25
+ this.analysisAsync = analysisAsyncEnv === '1' || analysisAsyncEnv === 'true' || (this.fastMode && analysisAsyncEnv !== '0' && analysisAsyncEnv !== 'false');
26
+ const verifyEnv = String(process.env.VERIFY_WITH_AI || '').toLowerCase();
27
+ this.verifyWithAi = verifyEnv === '1' || verifyEnv === 'true' || (this.fastMode && verifyEnv !== '0' && verifyEnv !== 'false');
28
+ if (!this.groqKey && this.openrouterKey && String(this.openrouterKey).startsWith('gsk_')) {
29
+ this.groqKey = this.openrouterKey;
30
+ this.openrouterKey = undefined;
31
+ }
32
+ if (opts?.provider) {
33
+ this.provider = opts.provider;
34
+ }
35
+ else if (this.groqKey) {
36
+ this.provider = 'groq';
37
+ }
38
+ else if (this.openrouterKey) {
39
+ this.provider = 'openrouter';
40
+ }
41
+ else {
42
+ this.provider = 'ollama';
43
+ }
44
+ if (this.provider === 'groq' && !this.groqKey) {
45
+ if (this.openrouterKey) {
46
+ this.provider = 'openrouter';
47
+ }
48
+ else {
49
+ this.provider = 'ollama';
50
+ }
51
+ }
52
+ if (this.provider === 'groq') {
53
+ console.log(`Using Groq with model: ${this.groqModel}`);
54
+ this.model = this.groqModel;
55
+ }
56
+ else if (this.provider === 'openrouter') {
57
+ console.log(`Using OpenRouter with model: ${this.openrouterModel}`);
58
+ this.model = this.openrouterModel;
59
+ }
60
+ else {
61
+ console.log(`Using Local Ollama with model: ${this.model}`);
62
+ }
63
+ }
64
+ async ensureModelReady() {
65
+ if (this.provider === 'groq') {
66
+ console.log(`Skipping local model check. Using Groq model: ${this.groqModel}`);
67
+ return;
68
+ }
69
+ if (this.provider === 'openrouter') {
70
+ console.log(`Skipping local model check. Using OpenRouter model: ${this.openrouterModel}`);
71
+ return;
72
+ }
73
+ try {
74
+ console.log(`Checking for model ${this.model} at ${this.ollamaUrl}...`);
75
+ const resp = await axios_1.default.get(`${this.ollamaUrl}/api/tags`, { timeout: 5000 });
76
+ if (resp.status === 200) {
77
+ const models = resp.data.models.map((m) => m.name);
78
+ if (!models.includes(this.model) && !models.includes(`${this.model}:latest`)) {
79
+ console.log(`Model ${this.model} not found. Pulling...`);
80
+ await axios_1.default.post(`${this.ollamaUrl}/api/pull`, { name: this.model, stream: false });
81
+ }
82
+ else {
83
+ console.log(`Model ${this.model} is available.`);
84
+ }
85
+ }
86
+ }
87
+ catch (e) {
88
+ console.log(`Warning: Could not check/pull model: ${e}`);
89
+ }
90
+ }
91
+ async callOpenRouter(prompt, systemPrompt) {
92
+ const headers = {
93
+ "Authorization": `Bearer ${this.openrouterKey}`,
94
+ "HTTP-Referer": "http://localhost:8000",
95
+ "X-Title": "VisionNavigator",
96
+ "Content-Type": "application/json"
97
+ };
98
+ const messages = [];
99
+ if (systemPrompt) {
100
+ messages.push({ role: "system", content: systemPrompt });
101
+ }
102
+ messages.push({ role: "user", content: prompt });
103
+ const data = {
104
+ model: this.openrouterModel,
105
+ messages: messages,
106
+ temperature: 0.0
107
+ };
108
+ try {
109
+ const resp = await axios_1.default.post("https://openrouter.ai/api/v1/chat/completions", data, { headers, timeout: this.fastMode ? 8000 : 30000 });
110
+ if (resp.status !== 200) {
111
+ console.log(`OpenRouter Error ${resp.status}: ${JSON.stringify(resp.data)}`);
112
+ return null;
113
+ }
114
+ if (resp.data.choices && resp.data.choices.length > 0) {
115
+ return resp.data.choices[0].message.content;
116
+ }
117
+ return null;
118
+ }
119
+ catch (e) {
120
+ console.log(`OpenRouter Exception: ${e}`);
121
+ return null;
122
+ }
123
+ }
124
+ async callGroq(prompt, systemPrompt) {
125
+ const headers = {
126
+ "Authorization": `Bearer ${this.groqKey}`,
127
+ "Content-Type": "application/json"
128
+ };
129
+ const messages = [];
130
+ if (systemPrompt) {
131
+ messages.push({ role: "system", content: systemPrompt });
132
+ }
133
+ messages.push({ role: "user", content: prompt });
134
+ const data = {
135
+ model: this.groqModel,
136
+ messages,
137
+ temperature: 0.0
138
+ };
139
+ try {
140
+ const resp = await axios_1.default.post("https://api.groq.com/openai/v1/chat/completions", data, { headers, timeout: this.fastMode ? 8000 : 30000 });
141
+ if (resp.status !== 200) {
142
+ console.log(`Groq Error ${resp.status}: ${JSON.stringify(resp.data)}`);
143
+ return null;
144
+ }
145
+ if (resp.data.choices && resp.data.choices.length > 0) {
146
+ return resp.data.choices[0].message.content;
147
+ }
148
+ return null;
149
+ }
150
+ catch (e) {
151
+ console.log(`Groq Exception: ${e}`);
152
+ return null;
153
+ }
154
+ }
155
+ async queryModel(modelName, prompt, isAnalysis = false) {
156
+ const systemPrompt = isAnalysis
157
+ ? (this.fastMode ? "Return only: Verdict: PASS | FAIL | FLAKY — One short reason." : "You are a web performance and usability expert. Analyze the data and provide concise, actionable feedback.")
158
+ : "";
159
+ if (this.provider === 'groq') {
160
+ const resp = await this.callGroq(prompt, systemPrompt);
161
+ return resp || "Failed to generate analysis via Groq.";
162
+ }
163
+ if (this.provider === 'openrouter') {
164
+ const resp = await this.callOpenRouter(prompt, systemPrompt);
165
+ return resp || "Failed to generate analysis via OpenRouter.";
166
+ }
167
+ else {
168
+ try {
169
+ const messages = [];
170
+ if (systemPrompt) {
171
+ messages.push({ role: "system", content: systemPrompt });
172
+ }
173
+ messages.push({ role: "user", content: prompt });
174
+ const resp = await axios_1.default.post(`${this.ollamaUrl}/api/chat`, {
175
+ model: this.model,
176
+ messages: messages,
177
+ stream: false,
178
+ options: { temperature: 0.2 }
179
+ });
180
+ if (resp.data && resp.data.message) {
181
+ return resp.data.message.content;
182
+ }
183
+ return "Failed to generate analysis via Ollama.";
184
+ }
185
+ catch (e) {
186
+ console.log(`Ollama Query Error: ${e.message}`);
187
+ return "Error generating analysis.";
188
+ }
189
+ }
190
+ }
191
+ shouldVerifyStep(instruction, actionText) {
192
+ if (!this.verifyWithAi)
193
+ return false;
194
+ const instr = String(instruction || '').toLowerCase();
195
+ const action = String(actionText || '').toLowerCase();
196
+ const critical = /log\s?in|login|sign\s?in|submit|continue|next|checkout|pay|confirm|create|save|delete|remove|register|sign\s?up/.test(instr);
197
+ const likelyNoop = /Action:\s*(done)/i.test(actionText || '');
198
+ return critical || likelyNoop;
199
+ }
200
+ async verifyStepOutcome(input) {
201
+ const systemPrompt = "You are a strict QA verifier. Decide if the instruction was successfully completed based only on the provided before/after URL/title and simplified HTML.\n" +
202
+ "Output exactly two lines:\n" +
203
+ "Verdict: PASS | FAIL | UNCERTAIN\n" +
204
+ "Reason: <short>\n" +
205
+ "Do not output markdown.";
206
+ const domLimit = this.fastMode ? 2500 : 6000;
207
+ const prompt = `Instruction:\n${input.instruction}\n\n` +
208
+ `Action Taken:\n${input.actionTaken}\n\n` +
209
+ `Before Page:\n${JSON.stringify(input.before || {}, null, 2)}\n\n` +
210
+ `After Page:\n${JSON.stringify(input.after || {}, null, 2)}\n\n` +
211
+ `Before DOM (truncated):\n${String(input.domBefore || '').slice(0, domLimit)}\n\n` +
212
+ `After DOM (truncated):\n${String(input.domAfter || '').slice(0, domLimit)}\n`;
213
+ if (this.provider === 'groq')
214
+ return await this.callGroq(prompt, systemPrompt);
215
+ if (this.provider === 'openrouter')
216
+ return await this.callOpenRouter(prompt, systemPrompt);
217
+ try {
218
+ const messages = [{ role: "system", content: systemPrompt }, { role: "user", content: prompt }];
219
+ const resp = await axios_1.default.post(`${this.ollamaUrl}/api/chat`, {
220
+ model: this.model,
221
+ messages,
222
+ stream: false,
223
+ options: { temperature: 0.0 }
224
+ }, { timeout: this.fastMode ? 8000 : 30000 });
225
+ if (resp.data && resp.data.message)
226
+ return resp.data.message.content;
227
+ return null;
228
+ }
229
+ catch (e) {
230
+ return null;
231
+ }
232
+ }
233
+ async getActionFromModel(modelName, instruction, pageContent, history = []) {
234
+ // Heuristic for Navigation
235
+ const urlMatch = instruction.match(/http[s]?:\/\/(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\\(\\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+/);
236
+ if (instruction.toLowerCase().match(/navigate to|go to|open url|visit/) && urlMatch) {
237
+ console.log(`Heuristic: Found navigation URL ${urlMatch[0]}`);
238
+ return JSON.stringify({ action: "navigate", url: urlMatch[0], reasoning: "Heuristic navigation" });
239
+ }
240
+ const systemPrompt = this.fastMode
241
+ ? `You are an end-to-end web testing agent controlling a real browser.
242
+ Output Format:
243
+ Thought: <short>
244
+ Action: <click|type|scroll|navigate|done> <target> <optional_text_or_url>
245
+ Action: <...> (optional, up to 3 actions total)
246
+ Rules:
247
+ - Output 1 to 3 actions max.
248
+ - Prefer deterministic targets.
249
+ - If unsure: scroll down (once) or done.
250
+ - Do not output JSON or markdown.`
251
+ : `You are an end-to-end web testing agent executing QA steps in a real browser.
252
+ You will be given:
253
+ 1. An instruction of what to do.
254
+ 2. A Simplified HTML representation of the current page.
255
+ - Interactive elements have an id attribute (e.g. <button id="42">).
256
+ 3. A history of actions taken so far.
257
+
258
+ Your goal is to pick the ONE best next action to fulfill the instruction in a stable, test-friendly way.
259
+
260
+ Output Format:
261
+ Thought: <Reasoning>
262
+ Action: <action_type> <element_id> <optional_text_or_url>
263
+ Action: <...> (optional, up to 3 actions total)
264
+
265
+ Supported Actions:
266
+ - click <element_id>
267
+ - type <element_id> <text>
268
+ - scroll <direction> (up or down)
269
+ - navigate <url>
270
+ - done
271
+
272
+ EXAMPLES:
273
+
274
+ Instruction: "Click the search button"
275
+ HTML: <button id="42">Search</button>
276
+ Output:
277
+ Thought: Found search button with id 42
278
+ Action: click 42
279
+
280
+ Instruction: "Type 'hello' into the input"
281
+ HTML: <input id="15" placeholder="Enter text" />
282
+ Output:
283
+ Thought: Found input 15, typing 'hello'
284
+ Action: type 15 hello
285
+
286
+ Instruction: "Verify that the heading says 'Welcome'"
287
+ HTML: <h1>Welcome to our site</h1>
288
+ Output:
289
+ Thought: The heading says 'Welcome', so the verification is complete.
290
+ Action: done
291
+
292
+ RULES:
293
+ - Do NOT output JSON.
294
+ - Follow the format exactly.
295
+ - Output 1 to 3 actions max.
296
+ - If the exact wording in the instruction does not match the HTML, make a smart guess based on context. For example, "Risk Register" might be represented by a "Risks" button with a shield icon, or "Login" might be "Sign in".
297
+ - Do NOT click random elements. If you cannot confidently identify the correct element, prefer: scroll down (once) or done.
298
+ - Prefer deterministic targets: explicit labels/placeholder text, primary CTA buttons, and clearly matching inputs.
299
+ - When the instruction is a verification/assertion and the condition is already visible in the HTML, respond with Action: done.
300
+ `;
301
+ const historyText = history.length > 0 ? `History:\n${history.join('\n')}\n` : '';
302
+ const prompt = `Instruction: ${instruction}\n\n${historyText}\nCurrent Page HTML:\n${pageContent}\n\nResponse:`;
303
+ if (!this.fastMode) {
304
+ const fs = require('fs');
305
+ fs.writeFileSync('last_dom.txt', pageContent);
306
+ }
307
+ let responseText = null;
308
+ if (this.provider === 'groq') {
309
+ responseText = await this.callGroq(prompt, systemPrompt);
310
+ }
311
+ else if (this.provider === 'openrouter') {
312
+ responseText = await this.callOpenRouter(prompt, systemPrompt);
313
+ }
314
+ else {
315
+ // Ollama fallback
316
+ try {
317
+ // Use /api/chat for better instruction following with System prompt
318
+ const messages = [];
319
+ if (systemPrompt) {
320
+ messages.push({ role: "system", content: systemPrompt });
321
+ }
322
+ // One-shot example to guide the model
323
+ messages.push({
324
+ role: "user",
325
+ content: `Instruction: Click the 'Search' button\nCurrent Page HTML:\n<button id="42">Search</button>\n\nResponse:`
326
+ });
327
+ messages.push({
328
+ role: "assistant",
329
+ content: `Thought: Found search button with id 42\nAction: click 42`
330
+ });
331
+ messages.push({ role: "user", content: prompt });
332
+ const resp = await axios_1.default.post(`${this.ollamaUrl}/api/chat`, {
333
+ model: this.model, // Use the instance model property
334
+ messages: messages,
335
+ stream: false,
336
+ options: {
337
+ temperature: 0.0 // Deterministic
338
+ }
339
+ });
340
+ if (resp.data && resp.data.message) {
341
+ responseText = resp.data.message.content;
342
+ }
343
+ }
344
+ catch (e) {
345
+ console.log(`Ollama Error: ${e.message}`);
346
+ if (e.response && e.response.data) {
347
+ console.log(`Ollama Response Data: ${JSON.stringify(e.response.data)}`);
348
+ }
349
+ else if (e.request) {
350
+ console.log(`Ollama Request Error (No response): ${e.request}`);
351
+ }
352
+ else {
353
+ console.log(`Ollama Unknown Error:`, e);
354
+ }
355
+ return null;
356
+ }
357
+ }
358
+ if (responseText) {
359
+ console.log(`Raw Model Response: ${responseText}`);
360
+ // Clean up markdown code blocks if present
361
+ responseText = responseText.replace(/```json/g, "").replace(/```/g, "").trim();
362
+ }
363
+ return responseText;
364
+ }
365
+ async runWorkflow(workflow, onProgress, meta, control) {
366
+ const results = [];
367
+ const driver = new cdp_driver_1.CDPDriver();
368
+ const runId = meta?.runId || Date.now().toString();
369
+ const collectedImages = [];
370
+ const screenshotsBaseDir = path_1.default.join(process.cwd(), 'screenshots');
371
+ const runDir = path_1.default.join(screenshotsBaseDir, `run_${runId}`);
372
+ const actionHistory = [];
373
+ let finalStatus = "failed";
374
+ let issues = [];
375
+ let reportObjName = undefined;
376
+ let pbRecordId = undefined;
377
+ const analysisPromises = [];
378
+ if (!fs_1.default.existsSync(runDir)) {
379
+ fs_1.default.mkdirSync(runDir, { recursive: true });
380
+ }
381
+ console.log(`Saving screenshots to ${runDir}`);
382
+ try {
383
+ const record = await this.storage.createRun(runId, "running", [], undefined, [], meta);
384
+ if (record?.id)
385
+ pbRecordId = record.id;
386
+ }
387
+ catch (e) {
388
+ console.log(`Failed to create running record: ${e}`);
389
+ }
390
+ try {
391
+ await driver.start();
392
+ const steps = workflow.steps || [];
393
+ if (pbRecordId) {
394
+ await this.storage.updateRun(pbRecordId, {
395
+ status: "running",
396
+ currentStep: "",
397
+ currentStepIndex: 0,
398
+ currentStepTotal: steps.length,
399
+ currentScreenshots: {}
400
+ });
401
+ }
402
+ for (let i = 0; i < steps.length; i++) {
403
+ if (control?.aborted) {
404
+ finalStatus = "stopped";
405
+ break;
406
+ }
407
+ const step = steps[i];
408
+ const stepStartMs = Date.now();
409
+ await new Promise(resolve => setTimeout(resolve, this.fastMode ? 300 : 2000));
410
+ const instruction = step.instruction;
411
+ console.log(`Step ${i + 1}: ${instruction}`);
412
+ if (pbRecordId) {
413
+ await this.storage.updateRun(pbRecordId, {
414
+ status: "running",
415
+ currentStep: instruction,
416
+ currentStepIndex: i + 1,
417
+ currentStepTotal: steps.length,
418
+ currentScreenshots: { before: null, after: null },
419
+ images: collectedImages
420
+ });
421
+ }
422
+ const stepResult = {
423
+ step: instruction,
424
+ status: "pending",
425
+ screenshots: {},
426
+ logs: []
427
+ };
428
+ let beforeObjName = null;
429
+ let afterObjName = null;
430
+ try {
431
+ stepResult.page = {
432
+ url_before: await driver.executeScript(`location.href`),
433
+ title_before: await driver.executeScript(`document.title`)
434
+ };
435
+ }
436
+ catch (e) {
437
+ stepResult.page = {};
438
+ }
439
+ if (onProgress) {
440
+ onProgress({ ...stepResult });
441
+ }
442
+ // Before Screenshot (Record only, not used for logic)
443
+ try {
444
+ const screenshotData = await driver.getScreenshot();
445
+ if (screenshotData) {
446
+ const fileName = `step_${i + 1}_before.png`;
447
+ const filePath = path_1.default.join(runDir, fileName);
448
+ fs_1.default.writeFileSync(filePath, Buffer.from(screenshotData, 'base64'));
449
+ stepResult.screenshots.before = `screenshots/run_${runId}/${fileName}`;
450
+ beforeObjName = `run_${runId}/${fileName}`;
451
+ collectedImages.push(beforeObjName);
452
+ try {
453
+ await this.storage.uploadFile(filePath, beforeObjName);
454
+ }
455
+ catch (uploadErr) {
456
+ console.log(`Failed to upload before screenshot: ${uploadErr}`);
457
+ }
458
+ if (pbRecordId) {
459
+ await this.storage.updateRun(pbRecordId, {
460
+ images: collectedImages,
461
+ currentScreenshots: { before: beforeObjName, after: afterObjName }
462
+ });
463
+ }
464
+ }
465
+ }
466
+ catch (e) {
467
+ console.log(`Failed to save before screenshot: ${e}`);
468
+ }
469
+ // Get Simplified DOM instead of JSON list
470
+ try {
471
+ const cookieHit = await driver.dismissCookieBanners();
472
+ if (cookieHit?.clicked) {
473
+ await new Promise(resolve => setTimeout(resolve, this.fastMode ? 200 : 800));
474
+ }
475
+ }
476
+ catch (e) { }
477
+ let pageContent = await driver.getSimplifiedDOM();
478
+ console.log(`Page Content Length: ${pageContent.length}`);
479
+ console.log("Page Content (First 500 chars):", pageContent.substring(0, 500));
480
+ // Truncate to avoid context window limits
481
+ const domLimit = this.fastMode ? 6000 : 20000;
482
+ if (pageContent.length > domLimit) {
483
+ pageContent = pageContent.substring(0, domLimit) + "\n... [DOM Truncated]";
484
+ }
485
+ // Save DOM for debugging
486
+ if (!this.fastMode) {
487
+ const domDebugPath = path_1.default.join(runDir, `step_${i + 1}_dom.html`);
488
+ fs_1.default.writeFileSync(domDebugPath, pageContent);
489
+ }
490
+ if (!pageContent || pageContent.trim().length === 0) {
491
+ console.log("Warning: Empty DOM content received from driver.");
492
+ }
493
+ console.log(`Consulting ${this.model}...`);
494
+ const isCookieStep = /cookie|cookies|consent|gdpr|kaka|kakor|integritet/i.test(instruction);
495
+ let modelResponse = null;
496
+ if (isCookieStep) {
497
+ const cookieHit = await driver.dismissCookieBanners({ aggressive: true });
498
+ if (cookieHit?.clicked) {
499
+ modelResponse = `Thought: Cookie consent handled\nAction: done`;
500
+ stepResult.action = `cookie:clicked:${cookieHit.label || 'accept'}`;
501
+ actionHistory.push(`Cookie consent clicked: ${cookieHit.label || 'accept'}`);
502
+ }
503
+ else {
504
+ modelResponse = await this.getActionFromModel(this.model, instruction, pageContent, actionHistory);
505
+ }
506
+ }
507
+ else {
508
+ modelResponse = await this.getActionFromModel(this.model, instruction, pageContent, actionHistory);
509
+ }
510
+ console.log(`Model Response: ${modelResponse}`);
511
+ if (!stepResult.action)
512
+ stepResult.action = modelResponse;
513
+ let executed = false;
514
+ if (modelResponse) {
515
+ try {
516
+ const thoughtMatch = modelResponse.match(/Thought:\s*(.+)/i);
517
+ if (thoughtMatch)
518
+ console.log(`Thought: ${thoughtMatch[1]}`);
519
+ const actionRegex = /Action:\s*(\w+)(?:\s+([^\s]+))?(?:\s+(.+))?/ig;
520
+ const actions = [];
521
+ let m = null;
522
+ while ((m = actionRegex.exec(modelResponse)) !== null) {
523
+ actions.push({ action: String(m[1] || '').toLowerCase(), target: m[2], extra: m[3] });
524
+ if (actions.length >= 3)
525
+ break;
526
+ }
527
+ let actionObj = {};
528
+ if (actions.length > 0) {
529
+ for (const a of actions) {
530
+ if (control?.aborted) {
531
+ stepResult.status = "stopped";
532
+ results.push(stepResult);
533
+ finalStatus = "stopped";
534
+ break;
535
+ }
536
+ actionObj = { action: a.action };
537
+ if (a.action === 'click')
538
+ actionObj.element_id = a.target;
539
+ if (a.action === 'type') {
540
+ actionObj.element_id = a.target;
541
+ actionObj.text = a.extra;
542
+ }
543
+ if (a.action === 'navigate')
544
+ actionObj.url = a.target;
545
+ if (a.action === 'scroll')
546
+ actionObj.direction = a.target;
547
+ let actionType = String(actionObj.action || '').toLowerCase();
548
+ if (actionType === "visit" || actionType === "goto")
549
+ actionType = "navigate";
550
+ if (actionType === "edit" || actionType === "fill" || actionType === "input")
551
+ actionType = "type";
552
+ if (actionType === "navigate") {
553
+ const url = actionObj.url || actionObj.link;
554
+ if (url) {
555
+ await driver.navigate(url);
556
+ executed = true;
557
+ actionHistory.push(`Navigated to ${url}`);
558
+ }
559
+ }
560
+ else if (actionType === "click") {
561
+ const id = actionObj.element_id;
562
+ if (id) {
563
+ await driver.clickById(id);
564
+ executed = true;
565
+ actionHistory.push(`Clicked element ${id}`);
566
+ }
567
+ }
568
+ else if (actionType === "type") {
569
+ const id = actionObj.element_id;
570
+ const text = actionObj.text || actionObj.content || actionObj.value;
571
+ if (id && text) {
572
+ await driver.typeById(id, text);
573
+ executed = true;
574
+ actionHistory.push(`Typed into element ${id}`);
575
+ }
576
+ }
577
+ else if (actionType === "scroll") {
578
+ const direction = actionObj.direction || "down";
579
+ await driver.scroll(direction);
580
+ executed = true;
581
+ actionHistory.push(`Scrolled ${direction}`);
582
+ }
583
+ else if (actionType === "done") {
584
+ stepResult.status = "completed";
585
+ executed = true;
586
+ actionHistory.push(`Task done`);
587
+ }
588
+ await new Promise(resolve => setTimeout(resolve, this.fastMode ? 150 : 600));
589
+ }
590
+ }
591
+ else {
592
+ try {
593
+ const jsonMatch = modelResponse.match(/\{[\s\S]*\}/);
594
+ const jsonStr = jsonMatch ? jsonMatch[0] : modelResponse;
595
+ actionObj = JSON.parse(jsonStr);
596
+ }
597
+ catch (e) {
598
+ console.log("Failed to parse response as JSON or Action format.");
599
+ }
600
+ }
601
+ if (!executed && actionObj && actionObj.action) {
602
+ if (control?.aborted) {
603
+ stepResult.status = "stopped";
604
+ results.push(stepResult);
605
+ finalStatus = "stopped";
606
+ break;
607
+ }
608
+ if (!actionObj.element_id) {
609
+ actionObj.element_id = actionObj.id || actionObj.item || actionObj.target || actionObj.element;
610
+ }
611
+ let actionType = actionObj.action?.toLowerCase();
612
+ if (actionType === "visit" || actionType === "goto")
613
+ actionType = "navigate";
614
+ if (actionType === "edit" || actionType === "fill" || actionType === "input")
615
+ actionType = "type";
616
+ if (actionType === "navigate") {
617
+ const url = actionObj.url || actionObj.link;
618
+ if (url) {
619
+ await driver.navigate(url);
620
+ executed = true;
621
+ actionHistory.push(`Navigated to ${url}`);
622
+ }
623
+ }
624
+ else if (actionType === "click") {
625
+ const id = actionObj.element_id;
626
+ if (id) {
627
+ await driver.clickById(id);
628
+ executed = true;
629
+ actionHistory.push(`Clicked element ${id}`);
630
+ }
631
+ }
632
+ else if (actionType === "type") {
633
+ const id = actionObj.element_id;
634
+ const text = actionObj.text || actionObj.content || actionObj.value;
635
+ if (id && text) {
636
+ await driver.typeById(id, text);
637
+ executed = true;
638
+ actionHistory.push(`Typed into element ${id}`);
639
+ }
640
+ }
641
+ else if (actionType === "scroll") {
642
+ const direction = actionObj.direction || "down";
643
+ await driver.scroll(direction);
644
+ executed = true;
645
+ actionHistory.push(`Scrolled ${direction}`);
646
+ }
647
+ else if (actionType === "done") {
648
+ stepResult.status = "completed";
649
+ executed = true;
650
+ actionHistory.push(`Task done`);
651
+ }
652
+ }
653
+ if (executed) {
654
+ stepResult.status = "executed";
655
+ await new Promise(resolve => setTimeout(resolve, this.fastMode ? 300 : 2000));
656
+ // After Screenshot
657
+ try {
658
+ const screenshotData = await driver.getScreenshot();
659
+ if (screenshotData) {
660
+ const fileName = `step_${i + 1}_after.png`;
661
+ const filePath = path_1.default.join(runDir, fileName);
662
+ fs_1.default.writeFileSync(filePath, Buffer.from(screenshotData, 'base64'));
663
+ stepResult.screenshots.after = `screenshots/run_${runId}/${fileName}`;
664
+ afterObjName = `run_${runId}/${fileName}`;
665
+ collectedImages.push(afterObjName);
666
+ try {
667
+ await this.storage.uploadFile(filePath, afterObjName);
668
+ }
669
+ catch (uploadErr) {
670
+ console.log(`Failed to upload after screenshot: ${uploadErr}`);
671
+ }
672
+ if (pbRecordId) {
673
+ await this.storage.updateRun(pbRecordId, {
674
+ images: collectedImages,
675
+ currentScreenshots: { before: beforeObjName, after: afterObjName }
676
+ });
677
+ }
678
+ }
679
+ }
680
+ catch (e) {
681
+ console.log(`Failed to save after screenshot: ${e}`);
682
+ }
683
+ }
684
+ else {
685
+ stepResult.status = "failed_parse";
686
+ }
687
+ }
688
+ catch (e) {
689
+ console.log(`Execution error: ${e}`);
690
+ stepResult.status = "failed_exec";
691
+ }
692
+ }
693
+ try {
694
+ stepResult.page = {
695
+ ...(stepResult.page || {}),
696
+ url_after: await driver.executeScript(`location.href`),
697
+ title_after: await driver.executeScript(`document.title`)
698
+ };
699
+ }
700
+ catch (e) { }
701
+ let pageAfterContent = '';
702
+ try {
703
+ pageAfterContent = await driver.getSimplifiedDOM();
704
+ const domLimit = this.fastMode ? 6000 : 20000;
705
+ if (pageAfterContent.length > domLimit) {
706
+ pageAfterContent = pageAfterContent.substring(0, domLimit) + "\n... [DOM Truncated]";
707
+ }
708
+ }
709
+ catch (e) {
710
+ pageAfterContent = '';
711
+ }
712
+ stepResult.logs = driver.getLogs();
713
+ stepResult.network_issues = driver.getNetworkIssues();
714
+ stepResult.performance_metrics = await driver.getPerformanceMetrics();
715
+ stepResult.duration_ms = Date.now() - stepStartMs;
716
+ if (this.shouldVerifyStep(instruction, stepResult.action)) {
717
+ try {
718
+ const verdict = await this.verifyStepOutcome({
719
+ instruction,
720
+ actionTaken: String(stepResult.action || ''),
721
+ before: stepResult.page ? { url: stepResult.page.url_before, title: stepResult.page.title_before } : {},
722
+ after: stepResult.page ? { url: stepResult.page.url_after, title: stepResult.page.title_after } : {},
723
+ domBefore: pageContent,
724
+ domAfter: pageAfterContent
725
+ });
726
+ stepResult.ai_verdict = verdict || undefined;
727
+ if (verdict && /Verdict:\s*FAIL/i.test(verdict)) {
728
+ stepResult.status = "failed_verify";
729
+ }
730
+ }
731
+ catch (e) { }
732
+ }
733
+ // Add AI Analysis on gathered data
734
+ const analysisPrompt = this.fastMode
735
+ ? `Verdict only (PASS/FAIL/FLAKY) and one sentence reason.\nInstruction:\n${instruction}\nAction:\n${stepResult.action || 'None'}\nConsole:\n${(stepResult.logs || []).slice(0, 10).join('\n') || 'None'}\nNetwork:\n${(stepResult.network_issues || []).slice(0, 5).join('\n') || 'None'}`
736
+ : `You are a senior web QA engineer analyzing an end-to-end browser test step.\n\nConsole and network issues matter: treat console ERROR/EXCEPTION/WARNING and HTTP failures (>=400) as potential test failures or flakiness.\n\nYour job:\n1) Summarize whether this step should be considered PASS / FAIL / FLAKY and why.\n2) Call out console errors/warnings and likely root cause.\n3) Call out network failures/statuses and likely root cause.\n4) Call out performance timing/metrics concerns that could impact UX or cause flaky tests.\n5) Provide specific, actionable next steps (what to fix or what assertions to add).\n\nBe concise but concrete. Use headings:\n- Verdict:\n- Console:\n- Network:\n- Performance:\n- Suggested Assertions:\n- Next Steps:\n\nInstruction:\n${instruction}\n\nAction Taken:\n${stepResult.action || 'None'}\n\nPage:\n${JSON.stringify(stepResult.page || {}, null, 2)}\n\nConsole Logs:\n${stepResult.logs.join('\n') || 'None'}\n\nNetwork Issues:\n${stepResult.network_issues.join('\n') || 'None'}\n\nPerformance Metrics:\n${JSON.stringify(stepResult.performance_metrics, null, 2)}\n `;
737
+ if (this.skipAnalysis) {
738
+ stepResult.ai_suggestions = this.fastMode ? "Skipped (FAST_MODE)." : "Skipped.";
739
+ }
740
+ else if (this.analysisAsync) {
741
+ const p = (async () => {
742
+ try {
743
+ const analysis = await this.queryModel(this.model, analysisPrompt, true);
744
+ stepResult.ai_suggestions = analysis;
745
+ }
746
+ catch (e) {
747
+ stepResult.ai_suggestions = "Could not generate suggestions.";
748
+ }
749
+ })();
750
+ analysisPromises.push(p);
751
+ }
752
+ else {
753
+ try {
754
+ console.log(`Getting AI analysis for step ${i + 1}...`);
755
+ const analysis = await this.queryModel(this.model, analysisPrompt, true);
756
+ stepResult.ai_suggestions = analysis;
757
+ }
758
+ catch (e) {
759
+ console.log(`Failed to get AI analysis: ${e}`);
760
+ stepResult.ai_suggestions = "Could not generate suggestions.";
761
+ }
762
+ }
763
+ const hasErrors = stepResult.logs.some((log) => log.toLowerCase().includes("error") ||
764
+ log.toLowerCase().includes("exception") ||
765
+ log.toLowerCase().includes("failed"));
766
+ if (hasErrors) {
767
+ stepResult.has_console_errors = true;
768
+ console.log(`Step ${i + 1} has console errors.`);
769
+ }
770
+ results.push(stepResult);
771
+ if (onProgress) {
772
+ onProgress({ ...stepResult });
773
+ }
774
+ }
775
+ }
776
+ catch (e) {
777
+ console.error(`Workflow error: ${e}`);
778
+ }
779
+ finally {
780
+ await driver.stop();
781
+ if (analysisPromises.length) {
782
+ await Promise.allSettled(analysisPromises);
783
+ }
784
+ // Create run record
785
+ if (control?.aborted) {
786
+ finalStatus = "stopped";
787
+ }
788
+ else if (finalStatus !== "stopped") {
789
+ finalStatus = "completed";
790
+ for (const r of results) {
791
+ if (String(r.status || '').startsWith("failed")) {
792
+ finalStatus = "failed";
793
+ break;
794
+ }
795
+ // Check logs for errors
796
+ if (r.logs) {
797
+ for (const log of r.logs) {
798
+ if (log.toLowerCase().includes("error") || log.toLowerCase().includes("exception")) {
799
+ console.log(`Found error in logs: ${log}`);
800
+ }
801
+ }
802
+ }
803
+ }
804
+ }
805
+ console.log(`Run ${runId} completed with status ${finalStatus}`);
806
+ // Build issues summary
807
+ issues = [];
808
+ for (const r of results) {
809
+ if (r.ai_verdict && /Verdict:\s*FAIL/i.test(String(r.ai_verdict))) {
810
+ issues.push({ type: "verify", message: String(r.ai_verdict), step: r.step });
811
+ }
812
+ if (r.logs) {
813
+ for (const log of r.logs) {
814
+ const lower = log.toLowerCase();
815
+ if (lower.includes("error") || lower.includes("exception") || lower.includes("failed")) {
816
+ issues.push({ type: "console", message: log, step: r.step });
817
+ }
818
+ }
819
+ }
820
+ if (r.network_issues && r.network_issues.length) {
821
+ for (const ni of r.network_issues) {
822
+ issues.push({ type: "network", message: ni, step: r.step });
823
+ }
824
+ }
825
+ }
826
+ // Save summary.json locally and upload to Minio
827
+ const summary = {
828
+ runId,
829
+ status: finalStatus,
830
+ suiteName: meta?.suiteName,
831
+ testName: meta?.testName,
832
+ issues,
833
+ steps: results.map(r => ({
834
+ step: r.step,
835
+ status: r.status,
836
+ action: r.action,
837
+ page: r.page,
838
+ duration_ms: r.duration_ms,
839
+ screenshots: r.screenshots,
840
+ logs: r.logs,
841
+ network_issues: r.network_issues,
842
+ performance_metrics: r.performance_metrics,
843
+ has_console_errors: r.has_console_errors,
844
+ ai_suggestions: r.ai_suggestions,
845
+ ai_verdict: r.ai_verdict,
846
+ })),
847
+ };
848
+ const summaryPath = path_1.default.join(runDir, 'summary.json');
849
+ fs_1.default.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), 'utf8');
850
+ try {
851
+ reportObjName = `run_${runId}/summary.json`;
852
+ await this.storage.uploadFile(summaryPath, reportObjName);
853
+ }
854
+ catch (e) {
855
+ console.log(`Failed to upload summary.json: ${e}`);
856
+ }
857
+ if (pbRecordId) {
858
+ await this.storage.updateRun(pbRecordId, {
859
+ status: finalStatus,
860
+ images: collectedImages,
861
+ report: reportObjName,
862
+ issues: issues || [],
863
+ suiteName: meta?.suiteName,
864
+ testName: meta?.testName,
865
+ currentScreenshots: {},
866
+ currentStep: "",
867
+ currentStepIndex: workflow.steps ? workflow.steps.length : results.length
868
+ });
869
+ }
870
+ else {
871
+ await this.storage.createRun(runId, finalStatus, collectedImages, reportObjName, issues, meta);
872
+ }
873
+ // Save run to local history
874
+ try {
875
+ const historyPath = path_1.default.join(screenshotsBaseDir, 'runs.json');
876
+ let history = [];
877
+ if (fs_1.default.existsSync(historyPath)) {
878
+ history = JSON.parse(fs_1.default.readFileSync(historyPath, 'utf8'));
879
+ }
880
+ history.push({
881
+ id: `run_${runId}`,
882
+ timestamp: Date.now(),
883
+ status: finalStatus,
884
+ suiteName: meta?.suiteName,
885
+ testName: meta?.testName,
886
+ steps: results.length
887
+ });
888
+ fs_1.default.writeFileSync(historyPath, JSON.stringify(history, null, 2));
889
+ }
890
+ catch (err) {
891
+ console.error("Failed to save run history:", err);
892
+ }
893
+ }
894
+ return { runId, status: finalStatus, results, report: reportObjName, issues };
895
+ }
896
+ }
897
+ exports.Navigator = Navigator;