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.
- package/README.md +113 -0
- package/dist/cdp-driver.js +484 -0
- package/dist/cli.js +207 -0
- package/dist/injected-scripts.js +328 -0
- package/dist/navigator.js +897 -0
- package/dist/server.js +409 -0
- package/dist/storage.js +132 -0
- package/package.json +48 -0
- package/public/app.js +155 -0
- package/public/index.html +567 -0
|
@@ -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;
|