qualty 0.1.6 → 0.1.8
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/bin/local-runner.js +1091 -0
- package/bin/qualty.js +56 -8
- package/package.json +5 -2
|
@@ -0,0 +1,1091 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, mkdtempSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
|
|
5
|
+
async function downloadProjectAttachments({ apiUrl, token, orgId, projectId, specs }) {
|
|
6
|
+
const labelToPath = {};
|
|
7
|
+
if (!projectId || !Array.isArray(specs) || specs.length === 0) return labelToPath;
|
|
8
|
+
const dir = mkdtempSync(join(tmpdir(), "qualty-attach-"));
|
|
9
|
+
const orgHeader = String(orgId || "").trim();
|
|
10
|
+
for (const spec of specs) {
|
|
11
|
+
const fileId = spec.id;
|
|
12
|
+
const label = spec.runner_key || spec.label;
|
|
13
|
+
if (!fileId || !label) continue;
|
|
14
|
+
const response = await fetch(
|
|
15
|
+
`${apiUrl}/api/v1/projects/${projectId}/upload-files/${encodeURIComponent(fileId)}`,
|
|
16
|
+
{
|
|
17
|
+
method: "GET",
|
|
18
|
+
headers: {
|
|
19
|
+
Authorization: `Bearer ${token}`,
|
|
20
|
+
...(orgHeader ? { "X-Qualty-Org-Id": orgHeader } : {}),
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
if (!response.ok) continue;
|
|
25
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
26
|
+
const name = spec.original_filename || `${label}.bin`;
|
|
27
|
+
const localPath = join(dir, name.replace(/[^a-zA-Z0-9._-]+/g, "_"));
|
|
28
|
+
writeFileSync(localPath, buf);
|
|
29
|
+
labelToPath[label] = localPath;
|
|
30
|
+
if (spec.label && spec.label !== label) labelToPath[spec.label] = localPath;
|
|
31
|
+
}
|
|
32
|
+
return labelToPath;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function attachNetworkCollector(page) {
|
|
36
|
+
const events = [];
|
|
37
|
+
const onRequest = (req) => {
|
|
38
|
+
try {
|
|
39
|
+
events.push({
|
|
40
|
+
url: req.url(),
|
|
41
|
+
method: req.method(),
|
|
42
|
+
resource_type: req.resourceType(),
|
|
43
|
+
failed: false,
|
|
44
|
+
});
|
|
45
|
+
} catch {
|
|
46
|
+
/* ignore */
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const onRequestFailed = (req) => {
|
|
50
|
+
try {
|
|
51
|
+
events.push({
|
|
52
|
+
url: req.url(),
|
|
53
|
+
method: req.method(),
|
|
54
|
+
resource_type: req.resourceType(),
|
|
55
|
+
failed: true,
|
|
56
|
+
});
|
|
57
|
+
} catch {
|
|
58
|
+
/* ignore */
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
page.on("request", onRequest);
|
|
62
|
+
page.on("requestfailed", onRequestFailed);
|
|
63
|
+
return {
|
|
64
|
+
snapshot() {
|
|
65
|
+
return { events_app_related: events.slice(-500) };
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolveFileUploadArguments(action, labelToPath) {
|
|
71
|
+
if (!action || !Array.isArray(action.arguments)) return action;
|
|
72
|
+
const method = String(action.method || "").toLowerCase();
|
|
73
|
+
if (method !== "file_upload" && method !== "set_input_files") return action;
|
|
74
|
+
const resolved = action.arguments.map((arg) => {
|
|
75
|
+
const key = String(arg || "").trim();
|
|
76
|
+
if (labelToPath[key]) return labelToPath[key];
|
|
77
|
+
return key;
|
|
78
|
+
});
|
|
79
|
+
return { ...action, arguments: resolved };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function capturePageContext(page) {
|
|
83
|
+
try {
|
|
84
|
+
const text = await page.evaluate(() => {
|
|
85
|
+
const body = document.body;
|
|
86
|
+
return body ? body.innerText.slice(0, 12000) : "";
|
|
87
|
+
});
|
|
88
|
+
return { page_text: String(text || ""), interactive_summary: "" };
|
|
89
|
+
} catch {
|
|
90
|
+
return { page_text: "", interactive_summary: "" };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function _actLog(stepIndex, attempt, message) {
|
|
95
|
+
const stepLabel = Number.isFinite(stepIndex) ? stepIndex + 1 : stepIndex;
|
|
96
|
+
console.log(`[qualty][act][step ${stepLabel}][attempt ${attempt}] ${message}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function _summarizeAction(action) {
|
|
100
|
+
if (!action || typeof action !== "object") return "no-action";
|
|
101
|
+
const method = String(action.method || "").trim() || "unknown";
|
|
102
|
+
const selector = String(action.selector || "").trim();
|
|
103
|
+
return selector ? `${method} selector=${selector}` : method;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function capturePageContextViaApi({
|
|
107
|
+
page,
|
|
108
|
+
}) {
|
|
109
|
+
return capturePageContext(page);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function _extractInteractiveCandidates(page, limit = 200) {
|
|
113
|
+
try {
|
|
114
|
+
const rows = await page.evaluate((maxRows) => {
|
|
115
|
+
const normalizeText = (v) => (v || "").replace(/\s+/g, " ").trim();
|
|
116
|
+
const getXPath = (element) => {
|
|
117
|
+
if (!element || !element.tagName) return "";
|
|
118
|
+
if (element.id) return `//*[@id="${element.id}"]`;
|
|
119
|
+
if (element === document.body) return "/html/body";
|
|
120
|
+
const parent = element.parentElement;
|
|
121
|
+
if (!parent) return "";
|
|
122
|
+
const tag = element.tagName.toLowerCase();
|
|
123
|
+
const sameTag = Array.from(parent.children).filter((c) => c.tagName === element.tagName);
|
|
124
|
+
const index = sameTag.length > 1 ? `[${sameTag.indexOf(element) + 1}]` : "";
|
|
125
|
+
const parentXPath = getXPath(parent);
|
|
126
|
+
return parentXPath ? `${parentXPath}/${tag}${index}` : `//${tag}${index}`;
|
|
127
|
+
};
|
|
128
|
+
const isVisible = (el) => {
|
|
129
|
+
if (!el) return false;
|
|
130
|
+
const style = window.getComputedStyle(el);
|
|
131
|
+
if (!style) return false;
|
|
132
|
+
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false;
|
|
133
|
+
const rect = el.getBoundingClientRect();
|
|
134
|
+
return rect.width > 0 && rect.height > 0;
|
|
135
|
+
};
|
|
136
|
+
const inferRole = (el) => {
|
|
137
|
+
const explicit = el.getAttribute("role");
|
|
138
|
+
if (explicit) return explicit;
|
|
139
|
+
const tag = (el.tagName || "").toLowerCase();
|
|
140
|
+
if (tag === "a") return "link";
|
|
141
|
+
if (tag === "button") return "button";
|
|
142
|
+
if (tag === "input") return "textbox";
|
|
143
|
+
if (tag === "select") return "listbox";
|
|
144
|
+
if (tag === "textarea") return "textbox";
|
|
145
|
+
return null;
|
|
146
|
+
};
|
|
147
|
+
const getTestId = (el) =>
|
|
148
|
+
el.getAttribute("data-testid") ||
|
|
149
|
+
el.getAttribute("data-test-id") ||
|
|
150
|
+
el.getAttribute("data-test") ||
|
|
151
|
+
el.getAttribute("data-qa") ||
|
|
152
|
+
null;
|
|
153
|
+
|
|
154
|
+
const selector =
|
|
155
|
+
"a,button,input,select,textarea,[role],[tabindex],[onclick],[contenteditable='true']";
|
|
156
|
+
const nodes = Array.from(document.querySelectorAll(selector));
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
|
|
159
|
+
const out = [];
|
|
160
|
+
for (const el of nodes) {
|
|
161
|
+
if (seen.has(el) || !isVisible(el) || out.length >= maxRows) continue;
|
|
162
|
+
seen.add(el);
|
|
163
|
+
if (!el || out.length >= maxRows) break;
|
|
164
|
+
const xpath = getXPath(el);
|
|
165
|
+
if (!xpath) continue;
|
|
166
|
+
const rect = el.getBoundingClientRect();
|
|
167
|
+
const tag = (el.tagName || "").toLowerCase();
|
|
168
|
+
const role = inferRole(el);
|
|
169
|
+
const text = normalizeText(el.innerText || el.textContent || "").slice(0, 120) || null;
|
|
170
|
+
const className =
|
|
171
|
+
typeof el.className === "string" ? normalizeText(el.className).slice(0, 120) : null;
|
|
172
|
+
const bbox = {
|
|
173
|
+
x: Math.round(rect.x),
|
|
174
|
+
y: Math.round(rect.y),
|
|
175
|
+
width: Math.round(rect.width),
|
|
176
|
+
height: Math.round(rect.height),
|
|
177
|
+
right: Math.round(rect.right),
|
|
178
|
+
bottom: Math.round(rect.bottom),
|
|
179
|
+
};
|
|
180
|
+
const midpoint = {
|
|
181
|
+
x: Math.round(rect.x + rect.width / 2),
|
|
182
|
+
y: Math.round(rect.y + rect.height / 2),
|
|
183
|
+
};
|
|
184
|
+
out.push({
|
|
185
|
+
selector: xpath,
|
|
186
|
+
id: el.id || null,
|
|
187
|
+
tag: tag || null,
|
|
188
|
+
role: role || null,
|
|
189
|
+
text,
|
|
190
|
+
type: el.getAttribute("type") || null,
|
|
191
|
+
title: el.getAttribute("title") || null,
|
|
192
|
+
test_id: getTestId(el),
|
|
193
|
+
name_attr: el.getAttribute("name") || null,
|
|
194
|
+
aria_label: el.getAttribute("aria-label") || null,
|
|
195
|
+
placeholder: el.getAttribute("placeholder") || null,
|
|
196
|
+
href: el.getAttribute("href") || null,
|
|
197
|
+
class_name: className,
|
|
198
|
+
bbox,
|
|
199
|
+
midpoint,
|
|
200
|
+
target_coordinate: `${midpoint.x},${midpoint.y}`,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return out.slice(0, 400);
|
|
204
|
+
}, Math.max(1, Math.min(500, Number(limit) || 200)));
|
|
205
|
+
return Array.isArray(rows) ? rows : [];
|
|
206
|
+
} catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Cloud ActHandler parity: attempt 1 (cache / bundle resolve) → 2 (selector LLM) → 3 (heal).
|
|
213
|
+
*/
|
|
214
|
+
async function runActStep({
|
|
215
|
+
apiRequest,
|
|
216
|
+
apiUrl,
|
|
217
|
+
token,
|
|
218
|
+
orgId,
|
|
219
|
+
executionId,
|
|
220
|
+
stepIndex,
|
|
221
|
+
page,
|
|
222
|
+
credentials,
|
|
223
|
+
labelToPath,
|
|
224
|
+
step,
|
|
225
|
+
}) {
|
|
226
|
+
const attemptHistory = [];
|
|
227
|
+
const excluded = [];
|
|
228
|
+
let topCandidatesCache = null;
|
|
229
|
+
let lastErr = null;
|
|
230
|
+
let action = null;
|
|
231
|
+
const maxAttempts = 3;
|
|
232
|
+
let executionSource = null;
|
|
233
|
+
|
|
234
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
235
|
+
_actLog(stepIndex, attempt, "observe: requesting plan");
|
|
236
|
+
let ctx = await capturePageContextViaApi({
|
|
237
|
+
page,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const observeRequest = async (extraBody = null) =>
|
|
241
|
+
apiRequest({
|
|
242
|
+
apiUrl,
|
|
243
|
+
token,
|
|
244
|
+
orgId,
|
|
245
|
+
path: `/api/v1/cli/executions/${executionId}/observe`,
|
|
246
|
+
method: "POST",
|
|
247
|
+
body: {
|
|
248
|
+
step_index: stepIndex,
|
|
249
|
+
attempt,
|
|
250
|
+
page_text: ctx.page_text,
|
|
251
|
+
interactive_summary: ctx.interactive_summary,
|
|
252
|
+
attempt_history: attemptHistory,
|
|
253
|
+
excluded_selectors: excluded,
|
|
254
|
+
top_candidates_cache: topCandidatesCache,
|
|
255
|
+
...(extraBody && typeof extraBody === "object" ? extraBody : {}),
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
let observe = await observeRequest();
|
|
260
|
+
|
|
261
|
+
const status = String(observe.status || "");
|
|
262
|
+
const source = String(observe.resolution_source || "").trim();
|
|
263
|
+
_actLog(
|
|
264
|
+
stepIndex,
|
|
265
|
+
attempt,
|
|
266
|
+
`observe: status=${status || "unknown"}${source ? ` source=${source}` : ""}`
|
|
267
|
+
);
|
|
268
|
+
if (Array.isArray(observe.top_candidates_cache)) {
|
|
269
|
+
topCandidatesCache = observe.top_candidates_cache;
|
|
270
|
+
}
|
|
271
|
+
if (observe.step_kind === "done" || status === "done") {
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
if (observe.step_kind === "agent" || observe.step_kind === "login" || status === "agent") {
|
|
275
|
+
throw new Error(observe.error || `${observe.step_kind} steps require agent path`);
|
|
276
|
+
}
|
|
277
|
+
if (observe.step_kind === "check" || status === "check") {
|
|
278
|
+
throw new Error(observe.error || "Check steps are not supported in local CLI yet");
|
|
279
|
+
}
|
|
280
|
+
if (status === "heal") {
|
|
281
|
+
_actLog(stepIndex, attempt, "escalating to agent heal");
|
|
282
|
+
const healResult = await runAgentStep({
|
|
283
|
+
apiRequest,
|
|
284
|
+
apiUrl,
|
|
285
|
+
token,
|
|
286
|
+
orgId,
|
|
287
|
+
executionId,
|
|
288
|
+
stepIndex,
|
|
289
|
+
instruction: String(observe.remainder_instruction || ""),
|
|
290
|
+
page,
|
|
291
|
+
credentials,
|
|
292
|
+
labelToPath,
|
|
293
|
+
instructionOverride: true,
|
|
294
|
+
});
|
|
295
|
+
return {
|
|
296
|
+
ok: healResult.ok,
|
|
297
|
+
error: healResult.error,
|
|
298
|
+
action: { method: "agent_heal", description: observe.remainder_instruction || "" },
|
|
299
|
+
skipRemainingSteps: healResult.ok,
|
|
300
|
+
source: "agent_heal",
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (status === "retry") {
|
|
304
|
+
_actLog(stepIndex, attempt, `retry: ${observe.error || "planner requested retry"}`);
|
|
305
|
+
attemptHistory.push({ attempt, error: observe.error || "retry" });
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (status === "needs_bundle_resolve") {
|
|
309
|
+
const collectedCandidates = await _extractInteractiveCandidates(page, 200);
|
|
310
|
+
_actLog(
|
|
311
|
+
stepIndex,
|
|
312
|
+
attempt,
|
|
313
|
+
`needs_bundle_resolve: collected ${collectedCandidates.length} candidates, retrying observe`
|
|
314
|
+
);
|
|
315
|
+
observe = await observeRequest({
|
|
316
|
+
collected_candidates: collectedCandidates,
|
|
317
|
+
collector_version: "candidate_js_v1",
|
|
318
|
+
});
|
|
319
|
+
if (Array.isArray(observe.top_candidates_cache)) {
|
|
320
|
+
topCandidatesCache = observe.top_candidates_cache;
|
|
321
|
+
}
|
|
322
|
+
const retryStatus = String(observe.status || "");
|
|
323
|
+
const retrySource = String(observe.resolution_source || "").trim();
|
|
324
|
+
_actLog(
|
|
325
|
+
stepIndex,
|
|
326
|
+
attempt,
|
|
327
|
+
`observe(after candidates): status=${retryStatus || "unknown"}${
|
|
328
|
+
retrySource ? ` source=${retrySource}` : ""
|
|
329
|
+
}`
|
|
330
|
+
);
|
|
331
|
+
if (retryStatus === "action" && observe.action) {
|
|
332
|
+
action = resolveFileUploadArguments(observe.action, labelToPath);
|
|
333
|
+
executionSource = retrySource || source || null;
|
|
334
|
+
_actLog(stepIndex, attempt, `execute: ${_summarizeAction(action)} source=${executionSource || "unknown"}`);
|
|
335
|
+
try {
|
|
336
|
+
await executeAction(page, action, credentials);
|
|
337
|
+
_actLog(stepIndex, attempt, "execute: success");
|
|
338
|
+
lastErr = null;
|
|
339
|
+
break;
|
|
340
|
+
} catch (err) {
|
|
341
|
+
lastErr = err.message || String(err);
|
|
342
|
+
_actLog(stepIndex, attempt, `execute: failed error=${lastErr}`);
|
|
343
|
+
attemptHistory.push({
|
|
344
|
+
attempt,
|
|
345
|
+
method: action.method,
|
|
346
|
+
selector: action.selector,
|
|
347
|
+
error: lastErr,
|
|
348
|
+
});
|
|
349
|
+
if (action.selector) excluded.push(action.selector);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (retryStatus === "retry") {
|
|
354
|
+
_actLog(stepIndex, attempt, `retry(after candidates): ${observe.error || "planner requested retry"}`);
|
|
355
|
+
attemptHistory.push({ attempt, error: observe.error || "retry" });
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (retryStatus === "heal") {
|
|
359
|
+
_actLog(stepIndex, attempt, "escalating to agent heal");
|
|
360
|
+
const healResult = await runAgentStep({
|
|
361
|
+
apiRequest,
|
|
362
|
+
apiUrl,
|
|
363
|
+
token,
|
|
364
|
+
orgId,
|
|
365
|
+
executionId,
|
|
366
|
+
stepIndex,
|
|
367
|
+
instruction: String(observe.remainder_instruction || ""),
|
|
368
|
+
page,
|
|
369
|
+
credentials,
|
|
370
|
+
labelToPath,
|
|
371
|
+
instructionOverride: true,
|
|
372
|
+
});
|
|
373
|
+
return {
|
|
374
|
+
ok: healResult.ok,
|
|
375
|
+
error: healResult.error,
|
|
376
|
+
action: { method: "agent_heal", description: observe.remainder_instruction || "" },
|
|
377
|
+
skipRemainingSteps: healResult.ok,
|
|
378
|
+
source: "agent_heal",
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
lastErr = observe.error || "No action returned after bundle resolve";
|
|
382
|
+
_actLog(stepIndex, attempt, `retry(after candidates): ${lastErr}`);
|
|
383
|
+
attemptHistory.push({ attempt, error: lastErr });
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (status === "action" && observe.action) {
|
|
387
|
+
action = resolveFileUploadArguments(observe.action, labelToPath);
|
|
388
|
+
executionSource = source || null;
|
|
389
|
+
_actLog(stepIndex, attempt, `execute: ${_summarizeAction(action)} source=${executionSource || "unknown"}`);
|
|
390
|
+
try {
|
|
391
|
+
await executeAction(page, action, credentials);
|
|
392
|
+
_actLog(stepIndex, attempt, "execute: success");
|
|
393
|
+
lastErr = null;
|
|
394
|
+
break;
|
|
395
|
+
} catch (err) {
|
|
396
|
+
lastErr = err.message || String(err);
|
|
397
|
+
_actLog(stepIndex, attempt, `execute: failed error=${lastErr}`);
|
|
398
|
+
attemptHistory.push({
|
|
399
|
+
attempt,
|
|
400
|
+
method: action.method,
|
|
401
|
+
selector: action.selector,
|
|
402
|
+
error: lastErr,
|
|
403
|
+
});
|
|
404
|
+
if (action.selector) excluded.push(action.selector);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
lastErr = observe.error || "No action returned";
|
|
409
|
+
_actLog(stepIndex, attempt, `retry: ${lastErr}`);
|
|
410
|
+
attemptHistory.push({ attempt, error: lastErr });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
ok: !lastErr,
|
|
415
|
+
error: lastErr,
|
|
416
|
+
action,
|
|
417
|
+
skipRemainingSteps: false,
|
|
418
|
+
source: executionSource,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function clickAtCoordinate(page, coord, button = "left") {
|
|
423
|
+
const [x, y] = coord.split(",").map((v) => Number(v.trim()));
|
|
424
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
425
|
+
throw new Error(`Invalid target_coordinate: ${coord}`);
|
|
426
|
+
}
|
|
427
|
+
if (button === "right") {
|
|
428
|
+
await page.mouse.click(x, y, { button: "right" });
|
|
429
|
+
} else {
|
|
430
|
+
await page.mouse.click(x, y);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function resolveFileInputAtCoordinate(page, x, y) {
|
|
435
|
+
return page.evaluate(
|
|
436
|
+
({ px, py }) => {
|
|
437
|
+
const el = document.elementFromPoint(px, py);
|
|
438
|
+
if (!el) return null;
|
|
439
|
+
const walk = (node) => {
|
|
440
|
+
if (!node) return null;
|
|
441
|
+
if (node.tagName && String(node.tagName).toLowerCase() === "input") {
|
|
442
|
+
const t = (node.getAttribute("type") || "").toLowerCase();
|
|
443
|
+
if (t === "file") return node;
|
|
444
|
+
}
|
|
445
|
+
const label = node.closest && node.closest("label");
|
|
446
|
+
if (label && label.control && label.control.type === "file") return label.control;
|
|
447
|
+
return node.parentElement ? walk(node.parentElement) : null;
|
|
448
|
+
};
|
|
449
|
+
const found = walk(el);
|
|
450
|
+
if (!found) return null;
|
|
451
|
+
if (found.id) return `#${found.id}`;
|
|
452
|
+
return null;
|
|
453
|
+
},
|
|
454
|
+
{ px: x, py: y }
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export async function executeAction(page, action, credentials = null) {
|
|
459
|
+
const method = String(action.method || "click").toLowerCase();
|
|
460
|
+
const selector = String(action.selector || "").trim();
|
|
461
|
+
const args = Array.isArray(action.arguments) ? action.arguments.map(String) : [];
|
|
462
|
+
const coord = String(action.target_coordinate || "").trim();
|
|
463
|
+
|
|
464
|
+
if (method === "wait") {
|
|
465
|
+
const ms = Math.max(0, Number(args[0] || 500));
|
|
466
|
+
await page.waitForTimeout(ms);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (method === "scrollto" && coord) {
|
|
471
|
+
const [x, y] = coord.split(",").map((v) => Number(v.trim()));
|
|
472
|
+
await page.evaluate(
|
|
473
|
+
({ x, y }) => window.scrollTo(Number(x) || 0, Number(y) || 0),
|
|
474
|
+
{ x, y }
|
|
475
|
+
);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (method === "scroll") {
|
|
480
|
+
const [x, y] = coord
|
|
481
|
+
? coord.split(",").map((v) => Number(v.trim()))
|
|
482
|
+
: [null, null];
|
|
483
|
+
const deltaX = Number(args[0] || 0);
|
|
484
|
+
const deltaY = Number(args[1] || 700);
|
|
485
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
486
|
+
await page.mouse.move(x, y);
|
|
487
|
+
}
|
|
488
|
+
await page.mouse.wheel(deltaX, deltaY);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (method === "focused_keypress") {
|
|
493
|
+
for (const key of args) {
|
|
494
|
+
if (key) await page.keyboard.press(key);
|
|
495
|
+
}
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (method === "fill_sensitive_field" && credentials) {
|
|
500
|
+
const fieldType = String(action.field_type || "").toLowerCase();
|
|
501
|
+
const value =
|
|
502
|
+
fieldType === "password"
|
|
503
|
+
? credentials.password || ""
|
|
504
|
+
: credentials.username || "";
|
|
505
|
+
if (!value) throw new Error(`No ${fieldType} credential available`);
|
|
506
|
+
if (coord) await clickAtCoordinate(page, coord);
|
|
507
|
+
await page.waitForTimeout(200);
|
|
508
|
+
const isMac = process.platform === "darwin";
|
|
509
|
+
const selectAll = isMac ? "Meta+A" : "Control+A";
|
|
510
|
+
await page.keyboard.press(selectAll);
|
|
511
|
+
await page.keyboard.press("Backspace");
|
|
512
|
+
await page.keyboard.type(value, { delay: 40 });
|
|
513
|
+
if (action.press_enter) await page.keyboard.press("Enter");
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const coordinateMethods = new Set([
|
|
518
|
+
"click",
|
|
519
|
+
"dblclick",
|
|
520
|
+
"rightclick",
|
|
521
|
+
"hover",
|
|
522
|
+
"dragdrop",
|
|
523
|
+
]);
|
|
524
|
+
if (coord && coordinateMethods.has(method)) {
|
|
525
|
+
if (method === "click") {
|
|
526
|
+
await clickAtCoordinate(page, coord, "left");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (method === "rightclick") {
|
|
530
|
+
await clickAtCoordinate(page, coord, "right");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (method === "dblclick") {
|
|
534
|
+
const [x, y] = coord.split(",").map((v) => Number(v.trim()));
|
|
535
|
+
await page.mouse.dblclick(x, y);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (method === "hover") {
|
|
539
|
+
const [x, y] = coord.split(",").map((v) => Number(v.trim()));
|
|
540
|
+
await page.mouse.move(x, y);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (method === "dragdrop") {
|
|
544
|
+
const [sx, sy] = coord.split(",").map((v) => Number(v.trim()));
|
|
545
|
+
const ex = Number(args[0]);
|
|
546
|
+
const ey = Number(args[1]);
|
|
547
|
+
await page.mouse.move(sx, sy);
|
|
548
|
+
await page.mouse.down();
|
|
549
|
+
await page.mouse.move(ex, ey);
|
|
550
|
+
await page.mouse.up();
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (method === "file_upload" && coord && !selector) {
|
|
556
|
+
const [x, y] = coord.split(",").map((v) => Number(v.trim()));
|
|
557
|
+
const sel = await resolveFileInputAtCoordinate(page, x, y);
|
|
558
|
+
if (!sel) throw new Error("file_upload: no file input at coordinate");
|
|
559
|
+
await page.locator(sel).first().setInputFiles(args, { timeout: 30000 });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (method === "type" && !selector && coord) {
|
|
564
|
+
await clickAtCoordinate(page, coord);
|
|
565
|
+
await page.waitForTimeout(200);
|
|
566
|
+
await page.keyboard.type(args[0] || "", { delay: 40 });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (method === "type" && !selector && !coord) {
|
|
571
|
+
await page.keyboard.type(args[0] || "", { delay: 40 });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (!selector && method !== "focused_keypress") {
|
|
576
|
+
throw new Error(`Action ${method} requires a selector or target_coordinate`);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const locator = page.locator(selector).first();
|
|
580
|
+
|
|
581
|
+
switch (method) {
|
|
582
|
+
case "click":
|
|
583
|
+
await locator.click({ timeout: 15000 });
|
|
584
|
+
break;
|
|
585
|
+
case "dblclick":
|
|
586
|
+
await locator.dblclick({ timeout: 15000 });
|
|
587
|
+
break;
|
|
588
|
+
case "rightclick":
|
|
589
|
+
await locator.click({ button: "right", timeout: 15000 });
|
|
590
|
+
break;
|
|
591
|
+
case "hover":
|
|
592
|
+
await locator.hover({ timeout: 15000 });
|
|
593
|
+
break;
|
|
594
|
+
case "fill":
|
|
595
|
+
case "type":
|
|
596
|
+
await locator.fill(args[0] || "", { timeout: 15000 });
|
|
597
|
+
break;
|
|
598
|
+
case "press":
|
|
599
|
+
for (const key of args) {
|
|
600
|
+
if (key) await locator.press(key, { timeout: 15000 });
|
|
601
|
+
}
|
|
602
|
+
break;
|
|
603
|
+
case "file_upload":
|
|
604
|
+
case "set_input_files":
|
|
605
|
+
await locator.setInputFiles(args, { timeout: 30000 });
|
|
606
|
+
break;
|
|
607
|
+
case "scroll":
|
|
608
|
+
await locator.scrollIntoViewIfNeeded({ timeout: 15000 });
|
|
609
|
+
break;
|
|
610
|
+
case "scrollto":
|
|
611
|
+
await locator.scrollIntoViewIfNeeded({ timeout: 15000 });
|
|
612
|
+
break;
|
|
613
|
+
case "dragdrop": {
|
|
614
|
+
const box = await locator.boundingBox();
|
|
615
|
+
if (!box) throw new Error("dragdrop: element not visible");
|
|
616
|
+
const sx = box.x + box.width / 2;
|
|
617
|
+
const sy = box.y + box.height / 2;
|
|
618
|
+
const ex = Number(args[0]);
|
|
619
|
+
const ey = Number(args[1]);
|
|
620
|
+
await page.mouse.move(sx, sy);
|
|
621
|
+
await page.mouse.down();
|
|
622
|
+
await page.mouse.move(ex, ey);
|
|
623
|
+
await page.mouse.up();
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
default:
|
|
627
|
+
throw new Error(`Unsupported action method: ${method}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function runAgentStep({
|
|
632
|
+
apiRequest,
|
|
633
|
+
apiUrl,
|
|
634
|
+
token,
|
|
635
|
+
orgId,
|
|
636
|
+
executionId,
|
|
637
|
+
stepIndex,
|
|
638
|
+
instruction,
|
|
639
|
+
page,
|
|
640
|
+
credentials,
|
|
641
|
+
labelToPath,
|
|
642
|
+
maxTurns = 100,
|
|
643
|
+
instructionOverride = false,
|
|
644
|
+
}) {
|
|
645
|
+
let reset = true;
|
|
646
|
+
let lastErr = null;
|
|
647
|
+
let turnCount = 0;
|
|
648
|
+
const pendingFunctionResults = [];
|
|
649
|
+
|
|
650
|
+
while (turnCount < maxTurns) {
|
|
651
|
+
const screenshotB64 = (await page.screenshot({ type: "png" })).toString("base64");
|
|
652
|
+
const turnBody = {
|
|
653
|
+
step_index: stepIndex,
|
|
654
|
+
screenshot_b64: screenshotB64,
|
|
655
|
+
page_url: page.url(),
|
|
656
|
+
reset,
|
|
657
|
+
function_results: pendingFunctionResults.length ? pendingFunctionResults : undefined,
|
|
658
|
+
};
|
|
659
|
+
if (instructionOverride && instruction) {
|
|
660
|
+
turnBody.instruction_override = instruction;
|
|
661
|
+
}
|
|
662
|
+
const turn = await apiRequest({
|
|
663
|
+
apiUrl,
|
|
664
|
+
token,
|
|
665
|
+
orgId,
|
|
666
|
+
path: `/api/v1/cli/executions/${executionId}/agent-turn`,
|
|
667
|
+
method: "POST",
|
|
668
|
+
body: turnBody,
|
|
669
|
+
});
|
|
670
|
+
reset = false;
|
|
671
|
+
pendingFunctionResults.length = 0;
|
|
672
|
+
turnCount += 1;
|
|
673
|
+
|
|
674
|
+
if (turn.status === "error") {
|
|
675
|
+
lastErr = turn.error || "Agent turn failed";
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
if (turn.status === "done") {
|
|
679
|
+
lastErr = null;
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
if (turn.status === "continue") {
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
if (turn.status !== "actions" || !Array.isArray(turn.actions) || !turn.actions.length) {
|
|
686
|
+
lastErr = turn.error || "Agent returned no actions";
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
for (const raw of turn.actions) {
|
|
691
|
+
const action = resolveFileUploadArguments(raw, labelToPath);
|
|
692
|
+
let actionOk = true;
|
|
693
|
+
let actionErr = null;
|
|
694
|
+
try {
|
|
695
|
+
if (action.method === "fill_sensitive_field") {
|
|
696
|
+
await executeAction(page, action, credentials);
|
|
697
|
+
const fnId = action._function_call_id;
|
|
698
|
+
if (fnId) {
|
|
699
|
+
pendingFunctionResults.push({
|
|
700
|
+
call_id: fnId,
|
|
701
|
+
success: true,
|
|
702
|
+
message: "fill_sensitive_field executed locally",
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
} else {
|
|
706
|
+
await executeAction(page, action, credentials);
|
|
707
|
+
}
|
|
708
|
+
await page.waitForTimeout(200);
|
|
709
|
+
} catch (err) {
|
|
710
|
+
actionOk = false;
|
|
711
|
+
actionErr = err.message || String(err);
|
|
712
|
+
lastErr = actionErr;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const afterB64 = (await page.screenshot({ type: "png" })).toString("base64");
|
|
716
|
+
try {
|
|
717
|
+
await apiRequest({
|
|
718
|
+
apiUrl,
|
|
719
|
+
token,
|
|
720
|
+
orgId,
|
|
721
|
+
path: `/api/v1/cli/executions/${executionId}/agent-segment`,
|
|
722
|
+
method: "POST",
|
|
723
|
+
body: {
|
|
724
|
+
step_index: stepIndex,
|
|
725
|
+
turn: turnCount,
|
|
726
|
+
turn_thought: turn.turn_thought || "",
|
|
727
|
+
action,
|
|
728
|
+
executor_ok: actionOk,
|
|
729
|
+
error: actionErr || undefined,
|
|
730
|
+
screenshot_b64: afterB64,
|
|
731
|
+
page_url: page.url(),
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
} catch (segErr) {
|
|
735
|
+
console.warn("[CLI] agent-segment upload failed:", segErr.message || segErr);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (!actionOk) break;
|
|
739
|
+
}
|
|
740
|
+
if (lastErr) break;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (!lastErr && turnCount >= maxTurns) {
|
|
744
|
+
lastErr = `Agent exceeded ${maxTurns} turns`;
|
|
745
|
+
}
|
|
746
|
+
return { ok: !lastErr, error: lastErr, turns: turnCount };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export async function runLocalExecution({
|
|
750
|
+
apiRequest,
|
|
751
|
+
apiUrl,
|
|
752
|
+
token,
|
|
753
|
+
orgId,
|
|
754
|
+
executionId,
|
|
755
|
+
device,
|
|
756
|
+
headless,
|
|
757
|
+
}) {
|
|
758
|
+
const { chromium } = await import("playwright");
|
|
759
|
+
|
|
760
|
+
const claim = await apiRequest({
|
|
761
|
+
apiUrl,
|
|
762
|
+
token,
|
|
763
|
+
orgId,
|
|
764
|
+
path: `/api/v1/cli/executions/${executionId}/claim`,
|
|
765
|
+
method: "POST",
|
|
766
|
+
body: { device },
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
const url = claim.url;
|
|
770
|
+
const viewport = claim.viewport || { width: 1440, height: 900 };
|
|
771
|
+
const steps = Array.isArray(claim.steps) ? claim.steps : [];
|
|
772
|
+
const totalSteps = claim.total_steps || steps.length;
|
|
773
|
+
|
|
774
|
+
const browserServer = await chromium.launchServer({
|
|
775
|
+
headless: headless !== false,
|
|
776
|
+
host: "127.0.0.1",
|
|
777
|
+
});
|
|
778
|
+
const cdpEndpoint = browserServer.wsEndpoint();
|
|
779
|
+
const browser = await chromium.connect(cdpEndpoint);
|
|
780
|
+
const context = await browser.newContext({
|
|
781
|
+
viewport: { width: viewport.width || 1440, height: viewport.height || 900 },
|
|
782
|
+
});
|
|
783
|
+
const page = await context.newPage();
|
|
784
|
+
const networkCollector = attachNetworkCollector(page);
|
|
785
|
+
const labelToPath = await downloadProjectAttachments({
|
|
786
|
+
apiUrl,
|
|
787
|
+
token,
|
|
788
|
+
orgId,
|
|
789
|
+
projectId: claim.project_id,
|
|
790
|
+
specs: claim.file_attachments,
|
|
791
|
+
});
|
|
792
|
+
const credentials = claim.credentials || null;
|
|
793
|
+
|
|
794
|
+
const stepsJson = [];
|
|
795
|
+
let runSuccess = true;
|
|
796
|
+
let runError = null;
|
|
797
|
+
const started = Date.now();
|
|
798
|
+
let lastPrevScreenshotB64 = null;
|
|
799
|
+
let lastCurrScreenshotB64 = null;
|
|
800
|
+
let firstPrevScreenshotB64 = null;
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
804
|
+
await page.waitForTimeout(500);
|
|
805
|
+
|
|
806
|
+
for (let stepIndex = 0; stepIndex < totalSteps; stepIndex += 1) {
|
|
807
|
+
const step = steps[stepIndex] || {};
|
|
808
|
+
const kind = String(step.kind || "act").toLowerCase();
|
|
809
|
+
const instruction = step.instruction || step.name || `Step ${stepIndex + 1}`;
|
|
810
|
+
let action = null;
|
|
811
|
+
let lastErr = null;
|
|
812
|
+
const prevBuf = await page.screenshot({ type: "png" });
|
|
813
|
+
|
|
814
|
+
if (kind === "agent" || kind === "login") {
|
|
815
|
+
console.log(`[qualty][agent][step ${stepIndex + 1}] mode=pure_agent kind=${kind}`);
|
|
816
|
+
const agentResult = await runAgentStep({
|
|
817
|
+
apiRequest,
|
|
818
|
+
apiUrl,
|
|
819
|
+
token,
|
|
820
|
+
orgId,
|
|
821
|
+
executionId,
|
|
822
|
+
stepIndex,
|
|
823
|
+
instruction,
|
|
824
|
+
page,
|
|
825
|
+
credentials,
|
|
826
|
+
labelToPath,
|
|
827
|
+
});
|
|
828
|
+
lastErr = agentResult.ok ? null : agentResult.error;
|
|
829
|
+
action = { method: "agent", description: instruction };
|
|
830
|
+
} else if (kind === "check") {
|
|
831
|
+
lastErr = "Check steps are not supported in local CLI yet; use cloud run or act steps.";
|
|
832
|
+
} else {
|
|
833
|
+
console.log(`[qualty][act][step ${stepIndex + 1}] mode=act`);
|
|
834
|
+
const actResult = await runActStep({
|
|
835
|
+
apiRequest,
|
|
836
|
+
apiUrl,
|
|
837
|
+
token,
|
|
838
|
+
orgId,
|
|
839
|
+
executionId,
|
|
840
|
+
stepIndex,
|
|
841
|
+
page,
|
|
842
|
+
credentials,
|
|
843
|
+
labelToPath,
|
|
844
|
+
step,
|
|
845
|
+
});
|
|
846
|
+
lastErr = actResult.ok ? null : actResult.error;
|
|
847
|
+
action = actResult.action;
|
|
848
|
+
if (actResult.source) {
|
|
849
|
+
console.log(`[qualty][act][step ${stepIndex + 1}] resolved_source=${actResult.source}`);
|
|
850
|
+
}
|
|
851
|
+
if (actResult.skipRemainingSteps && actResult.ok) {
|
|
852
|
+
const executorOk = true;
|
|
853
|
+
const currBuf = await page.screenshot({ type: "png" });
|
|
854
|
+
const prevB64 = prevBuf.toString("base64");
|
|
855
|
+
const currB64 = currBuf.toString("base64");
|
|
856
|
+
lastPrevScreenshotB64 = prevB64;
|
|
857
|
+
lastCurrScreenshotB64 = currB64;
|
|
858
|
+
if (firstPrevScreenshotB64 == null) firstPrevScreenshotB64 = prevB64;
|
|
859
|
+
let row = {
|
|
860
|
+
name: instruction,
|
|
861
|
+
description: instruction,
|
|
862
|
+
status: "passed",
|
|
863
|
+
action: action
|
|
864
|
+
? `${action.method}${action.selector ? ` ${action.selector}` : ""}`
|
|
865
|
+
: "agent_heal",
|
|
866
|
+
};
|
|
867
|
+
const stepResp = await apiRequest({
|
|
868
|
+
apiUrl,
|
|
869
|
+
token,
|
|
870
|
+
orgId,
|
|
871
|
+
path: `/api/v1/cli/executions/${executionId}/step-result`,
|
|
872
|
+
method: "POST",
|
|
873
|
+
body: {
|
|
874
|
+
step_index: stepIndex,
|
|
875
|
+
executor_ok: executorOk,
|
|
876
|
+
action,
|
|
877
|
+
result: row,
|
|
878
|
+
prev_screenshot_b64: prevB64,
|
|
879
|
+
curr_screenshot_b64: currB64,
|
|
880
|
+
},
|
|
881
|
+
});
|
|
882
|
+
if (stepResp && stepResp.result && typeof stepResp.result === "object") {
|
|
883
|
+
row = { ...row, ...stepResp.result };
|
|
884
|
+
}
|
|
885
|
+
stepsJson.push(row);
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const executorOk = !lastErr;
|
|
891
|
+
const currBuf = await page.screenshot({ type: "png" });
|
|
892
|
+
const prevB64 = prevBuf.toString("base64");
|
|
893
|
+
const currB64 = currBuf.toString("base64");
|
|
894
|
+
lastPrevScreenshotB64 = prevB64;
|
|
895
|
+
lastCurrScreenshotB64 = currB64;
|
|
896
|
+
if (firstPrevScreenshotB64 == null) firstPrevScreenshotB64 = prevB64;
|
|
897
|
+
|
|
898
|
+
let row = {
|
|
899
|
+
name: instruction,
|
|
900
|
+
description: instruction,
|
|
901
|
+
status: executorOk ? "passed" : "failed",
|
|
902
|
+
action: action
|
|
903
|
+
? `${action.method}${action.selector ? ` ${action.selector}` : ""}`
|
|
904
|
+
: "",
|
|
905
|
+
error: lastErr || undefined,
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
const stepResp = await apiRequest({
|
|
909
|
+
apiUrl,
|
|
910
|
+
token,
|
|
911
|
+
orgId,
|
|
912
|
+
path: `/api/v1/cli/executions/${executionId}/step-result`,
|
|
913
|
+
method: "POST",
|
|
914
|
+
body: {
|
|
915
|
+
step_index: stepIndex,
|
|
916
|
+
executor_ok: executorOk,
|
|
917
|
+
action,
|
|
918
|
+
result: row,
|
|
919
|
+
prev_screenshot_b64: prevB64,
|
|
920
|
+
curr_screenshot_b64: currB64,
|
|
921
|
+
},
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
if (stepResp && stepResp.result && typeof stepResp.result === "object") {
|
|
925
|
+
row = { ...row, ...stepResp.result };
|
|
926
|
+
}
|
|
927
|
+
const passed = row.status === "passed";
|
|
928
|
+
if (!passed) runSuccess = false;
|
|
929
|
+
stepsJson.push(row);
|
|
930
|
+
|
|
931
|
+
if (!passed) {
|
|
932
|
+
runError = lastErr || row.step_thought || "Step failed";
|
|
933
|
+
break;
|
|
934
|
+
}
|
|
935
|
+
await page.waitForTimeout(300);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
try {
|
|
939
|
+
lastCurrScreenshotB64 = (await page.screenshot({ type: "png" })).toString("base64");
|
|
940
|
+
} catch {
|
|
941
|
+
/* keep last step screenshot */
|
|
942
|
+
}
|
|
943
|
+
} finally {
|
|
944
|
+
await context.close().catch(() => {});
|
|
945
|
+
await browser.close().catch(() => {});
|
|
946
|
+
await browserServer.close().catch(() => {});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
const runtimeSec = (Date.now() - started) / 1000;
|
|
950
|
+
const completeResp = await apiRequest({
|
|
951
|
+
apiUrl,
|
|
952
|
+
token,
|
|
953
|
+
orgId,
|
|
954
|
+
path: `/api/v1/cli/executions/${executionId}/complete`,
|
|
955
|
+
method: "POST",
|
|
956
|
+
body: {
|
|
957
|
+
success: runSuccess,
|
|
958
|
+
steps_json: stepsJson,
|
|
959
|
+
explanation: runSuccess ? "Local run completed" : runError || "Local run failed",
|
|
960
|
+
runtime_sec: runtimeSec,
|
|
961
|
+
error_message: runError,
|
|
962
|
+
final_screenshot_b64: lastCurrScreenshotB64,
|
|
963
|
+
prev_screenshot_b64: firstPrevScreenshotB64 || lastPrevScreenshotB64,
|
|
964
|
+
network_report: networkCollector.snapshot(),
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
if (completeResp && completeResp.status) {
|
|
969
|
+
runSuccess = completeResp.status === "completed";
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return { success: runSuccess, stepsJson, error: runError };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
export async function runLocalCi({
|
|
976
|
+
apiUrl,
|
|
977
|
+
token,
|
|
978
|
+
orgId,
|
|
979
|
+
projectId,
|
|
980
|
+
suiteId,
|
|
981
|
+
explicitIds,
|
|
982
|
+
resolveSavedJobs,
|
|
983
|
+
apiRequest,
|
|
984
|
+
failOnFailure,
|
|
985
|
+
device,
|
|
986
|
+
headless,
|
|
987
|
+
localConcurrency = 4,
|
|
988
|
+
}) {
|
|
989
|
+
const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
|
|
990
|
+
if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
|
|
991
|
+
throw new Error("No saved tests matched your selector.");
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const startResp = await apiRequest({
|
|
995
|
+
apiUrl,
|
|
996
|
+
token,
|
|
997
|
+
orgId,
|
|
998
|
+
path: "/api/v1/ci/run",
|
|
999
|
+
method: "POST",
|
|
1000
|
+
body: {
|
|
1001
|
+
project_id: projectId ? String(projectId) : undefined,
|
|
1002
|
+
suite_id: suiteId ? String(suiteId) : undefined,
|
|
1003
|
+
job_ids: explicitIds.length > 0 ? explicitIds : savedJobs.map((j) => j.id),
|
|
1004
|
+
execution_backend: "cli_local",
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
const runs = Array.isArray(startResp.runs) ? startResp.runs : [];
|
|
1009
|
+
const started = runs.filter((r) => r.execution_job_id);
|
|
1010
|
+
if (started.length === 0) throw new Error("Failed to start any local runs.");
|
|
1011
|
+
|
|
1012
|
+
const concurrency = Math.max(1, Number(localConcurrency) || 4);
|
|
1013
|
+
const workerCount = Math.min(concurrency, started.length);
|
|
1014
|
+
|
|
1015
|
+
let passed = 0;
|
|
1016
|
+
let failed = 0;
|
|
1017
|
+
|
|
1018
|
+
// eslint-disable-next-line no-console
|
|
1019
|
+
console.log(`[qualty] Running ${started.length} local run(s) with concurrency=${workerCount}`);
|
|
1020
|
+
|
|
1021
|
+
let cursor = 0;
|
|
1022
|
+
const worker = async (workerId) => {
|
|
1023
|
+
while (true) {
|
|
1024
|
+
const idx = cursor;
|
|
1025
|
+
cursor += 1;
|
|
1026
|
+
const run = started[idx];
|
|
1027
|
+
if (!run) return;
|
|
1028
|
+
|
|
1029
|
+
const id = run.execution_job_id;
|
|
1030
|
+
// eslint-disable-next-line no-console
|
|
1031
|
+
console.log(
|
|
1032
|
+
`[qualty][worker ${workerId}] Local run ${id} (${run.saved_job_name || run.saved_job_id})`
|
|
1033
|
+
);
|
|
1034
|
+
try {
|
|
1035
|
+
const result = await runLocalExecution({
|
|
1036
|
+
apiRequest,
|
|
1037
|
+
apiUrl,
|
|
1038
|
+
token,
|
|
1039
|
+
orgId,
|
|
1040
|
+
executionId: id,
|
|
1041
|
+
device,
|
|
1042
|
+
headless,
|
|
1043
|
+
});
|
|
1044
|
+
if (result.success) {
|
|
1045
|
+
passed += 1;
|
|
1046
|
+
// eslint-disable-next-line no-console
|
|
1047
|
+
console.log(`[qualty] ${id} => passed`);
|
|
1048
|
+
} else {
|
|
1049
|
+
failed += 1;
|
|
1050
|
+
// eslint-disable-next-line no-console
|
|
1051
|
+
console.log(`[qualty] ${id} => failed: ${result.error || "unknown"}`);
|
|
1052
|
+
}
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
failed += 1;
|
|
1055
|
+
// eslint-disable-next-line no-console
|
|
1056
|
+
console.error(`[qualty] ${id} => error: ${err.message || err}`);
|
|
1057
|
+
try {
|
|
1058
|
+
await apiRequest({
|
|
1059
|
+
apiUrl,
|
|
1060
|
+
token,
|
|
1061
|
+
orgId,
|
|
1062
|
+
path: `/api/v1/cli/executions/${id}/complete`,
|
|
1063
|
+
method: "POST",
|
|
1064
|
+
body: {
|
|
1065
|
+
success: false,
|
|
1066
|
+
steps_json: [],
|
|
1067
|
+
explanation: String(err.message || err),
|
|
1068
|
+
runtime_sec: 0,
|
|
1069
|
+
error_message: String(err.message || err),
|
|
1070
|
+
},
|
|
1071
|
+
});
|
|
1072
|
+
} catch {
|
|
1073
|
+
/* best effort */
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
await Promise.all(
|
|
1080
|
+
Array.from({ length: workerCount }, (_, idx) => worker(idx + 1))
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
if (started.length > 1) {
|
|
1084
|
+
// eslint-disable-next-line no-console
|
|
1085
|
+
console.log(`[qualty] Parallel local runs complete (${started.length} total).`);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// eslint-disable-next-line no-console
|
|
1089
|
+
console.log(`[qualty] Local summary: ${passed} passed, ${failed} failed.`);
|
|
1090
|
+
if (failOnFailure && failed > 0) process.exit(1);
|
|
1091
|
+
}
|
package/bin/qualty.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { appendFileSync } from "node:fs";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import process from "node:process";
|
|
6
|
+
import { runLocalCi } from "./local-runner.js";
|
|
6
7
|
|
|
7
8
|
/** When unset, CLI uses Qualty development API. Override for production: QUALTY_API_URL=https://qualty-api-production.up.railway.app or local: http://localhost:8000 */
|
|
8
9
|
const DEFAULT_QUALTY_API_URL = "https://qualty-api-development.up.railway.app";
|
|
@@ -38,9 +39,10 @@ function usage() {
|
|
|
38
39
|
console.log(
|
|
39
40
|
[
|
|
40
41
|
"Usage:",
|
|
41
|
-
" qualty connect --project <project-id> [--port 3000] [--api https://your-api] [--token <bearer-token>]",
|
|
42
|
-
" qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--api https://your-api] [--token <bearer-token>]",
|
|
42
|
+
" qualty connect --project <project-id> [--port 3000] [--api https://your-api] [--token <bearer-token>] [--org <org-id>]",
|
|
43
|
+
" qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--api https://your-api] [--token <bearer-token>] [--org <org-id>]",
|
|
43
44
|
" [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
|
|
45
|
+
" [--local] [--device 1440x900] [--headed] [--local-concurrency 4]",
|
|
44
46
|
"",
|
|
45
47
|
" After each run, logs include step results, explanation, and final evaluator output (dashboard \"View logs\" data).",
|
|
46
48
|
" Pass --no-view-logs for a short log only (e.g. very large evaluator text).",
|
|
@@ -49,16 +51,20 @@ function usage() {
|
|
|
49
51
|
"Env vars:",
|
|
50
52
|
` QUALTY_API_URL Backend API URL (default: ${DEFAULT_QUALTY_API_URL}; set for local/self-hosted)`,
|
|
51
53
|
" QUALTY_API_TOKEN Bearer token used for auth",
|
|
54
|
+
" QUALTY_ORG_ID Org context for scoped API requests",
|
|
55
|
+
" QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
|
|
52
56
|
].join("\n")
|
|
53
57
|
);
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
async function apiRequest({ apiUrl, token, path, method = "GET", body }) {
|
|
60
|
+
async function apiRequest({ apiUrl, token, orgId, path, method = "GET", body }) {
|
|
61
|
+
const normalizedOrgId = String(orgId || "").trim();
|
|
57
62
|
const response = await fetch(`${apiUrl}${path}`, {
|
|
58
63
|
method,
|
|
59
64
|
headers: {
|
|
60
65
|
"Content-Type": "application/json",
|
|
61
66
|
Authorization: `Bearer ${token}`,
|
|
67
|
+
...(normalizedOrgId ? { "X-Qualty-Org-Id": normalizedOrgId } : {}),
|
|
62
68
|
},
|
|
63
69
|
body: body ? JSON.stringify(body) : undefined,
|
|
64
70
|
});
|
|
@@ -77,7 +83,9 @@ function startCloudflared(token, port) {
|
|
|
77
83
|
"--protocol",
|
|
78
84
|
"http2",
|
|
79
85
|
"--url",
|
|
80
|
-
|
|
86
|
+
// Use 127.0.0.1 so cloudflared does not prefer [::1] (common on Linux/GitHub Actions) while
|
|
87
|
+
// Next/webpack dev servers often bind --hostname 127.0.0.1 only.
|
|
88
|
+
`http://127.0.0.1:${port}`,
|
|
81
89
|
"run",
|
|
82
90
|
"--token",
|
|
83
91
|
token,
|
|
@@ -95,6 +103,7 @@ async function runConnect(args) {
|
|
|
95
103
|
const port = Number(args.port || 3000);
|
|
96
104
|
const apiUrl = resolveApiUrl(args);
|
|
97
105
|
const token = args.token || process.env.QUALTY_API_TOKEN;
|
|
106
|
+
const orgId = args.org || process.env.QUALTY_ORG_ID;
|
|
98
107
|
|
|
99
108
|
if (!projectId || !token) {
|
|
100
109
|
usage();
|
|
@@ -107,6 +116,7 @@ async function runConnect(args) {
|
|
|
107
116
|
const connect = await apiRequest({
|
|
108
117
|
apiUrl,
|
|
109
118
|
token,
|
|
119
|
+
orgId,
|
|
110
120
|
path: "/api/v1/localhost/connect",
|
|
111
121
|
method: "POST",
|
|
112
122
|
body: { project_id: projectId, port },
|
|
@@ -114,7 +124,7 @@ async function runConnect(args) {
|
|
|
114
124
|
|
|
115
125
|
const hostUrl = connect.localhost?.url;
|
|
116
126
|
// eslint-disable-next-line no-console
|
|
117
|
-
console.log(`Connected ✅ ${hostUrl} -> http://
|
|
127
|
+
console.log(`Connected ✅ ${hostUrl} -> http://127.0.0.1:${port}`);
|
|
118
128
|
|
|
119
129
|
const cloudflaredToken = connect.tunnel?.token;
|
|
120
130
|
if (!cloudflaredToken) {
|
|
@@ -127,6 +137,7 @@ async function runConnect(args) {
|
|
|
127
137
|
await apiRequest({
|
|
128
138
|
apiUrl,
|
|
129
139
|
token,
|
|
140
|
+
orgId,
|
|
130
141
|
path: "/api/v1/localhost/heartbeat",
|
|
131
142
|
method: "POST",
|
|
132
143
|
body: { project_id: projectId },
|
|
@@ -143,6 +154,7 @@ async function runConnect(args) {
|
|
|
143
154
|
await apiRequest({
|
|
144
155
|
apiUrl,
|
|
145
156
|
token,
|
|
157
|
+
orgId,
|
|
146
158
|
path: "/api/v1/localhost/disconnect",
|
|
147
159
|
method: "POST",
|
|
148
160
|
body: { project_id: projectId },
|
|
@@ -171,6 +183,17 @@ function parseBoolean(value, defaultValue) {
|
|
|
171
183
|
throw new Error(`Invalid boolean value: ${value}`);
|
|
172
184
|
}
|
|
173
185
|
|
|
186
|
+
function resolveLocalConcurrency(args) {
|
|
187
|
+
const raw = args["local-concurrency"] ?? process.env.QUALTY_LOCAL_CONCURRENCY ?? 4;
|
|
188
|
+
const n = Number(raw);
|
|
189
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Invalid local concurrency: ${raw}. Use --local-concurrency <integer>=1 or QUALTY_LOCAL_CONCURRENCY>=1`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return n;
|
|
195
|
+
}
|
|
196
|
+
|
|
174
197
|
function sleep(ms) {
|
|
175
198
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
176
199
|
}
|
|
@@ -498,6 +521,7 @@ function printQualtyViewLogsReport(executionId, status) {
|
|
|
498
521
|
async function runCi(args) {
|
|
499
522
|
const apiUrl = resolveApiUrl(args);
|
|
500
523
|
const token = args.token || process.env.QUALTY_API_TOKEN;
|
|
524
|
+
const orgId = args.org || process.env.QUALTY_ORG_ID;
|
|
501
525
|
const projectId = args.project;
|
|
502
526
|
const suiteId = args["suite-id"];
|
|
503
527
|
const explicitIds = String(args.ids || "")
|
|
@@ -522,7 +546,7 @@ async function runCi(args) {
|
|
|
522
546
|
throw new Error("Invalid --timeout. Must be > 0.");
|
|
523
547
|
}
|
|
524
548
|
|
|
525
|
-
const savedJobs = await resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds });
|
|
549
|
+
const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
|
|
526
550
|
if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
|
|
527
551
|
throw new Error(
|
|
528
552
|
"No saved tests matched your selector. The test ID may be invalid, deleted, or outside your token authorization scope."
|
|
@@ -544,6 +568,7 @@ async function runCi(args) {
|
|
|
544
568
|
const startResp = await apiRequest({
|
|
545
569
|
apiUrl,
|
|
546
570
|
token,
|
|
571
|
+
orgId,
|
|
547
572
|
path: "/api/v1/ci/run",
|
|
548
573
|
method: "POST",
|
|
549
574
|
body: startPayload,
|
|
@@ -566,6 +591,7 @@ async function runCi(args) {
|
|
|
566
591
|
const batch = await apiRequest({
|
|
567
592
|
apiUrl,
|
|
568
593
|
token,
|
|
594
|
+
orgId,
|
|
569
595
|
path: "/api/v1/status/batch",
|
|
570
596
|
method: "POST",
|
|
571
597
|
body: { job_ids: executionJobIds },
|
|
@@ -631,7 +657,7 @@ async function runCi(args) {
|
|
|
631
657
|
}
|
|
632
658
|
}
|
|
633
659
|
|
|
634
|
-
async function resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds }) {
|
|
660
|
+
async function resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds }) {
|
|
635
661
|
const query = new URLSearchParams();
|
|
636
662
|
if (projectId) query.set("project_id", String(projectId));
|
|
637
663
|
if (suiteId) query.set("suite_id", String(suiteId));
|
|
@@ -639,6 +665,7 @@ async function resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds
|
|
|
639
665
|
return apiRequest({
|
|
640
666
|
apiUrl,
|
|
641
667
|
token,
|
|
668
|
+
orgId,
|
|
642
669
|
path: `/api/v1/saved-jobs?${query.toString()}`,
|
|
643
670
|
});
|
|
644
671
|
}
|
|
@@ -646,6 +673,7 @@ async function resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds
|
|
|
646
673
|
async function runResolve(args) {
|
|
647
674
|
const apiUrl = resolveApiUrl(args);
|
|
648
675
|
const token = args.token || process.env.QUALTY_API_TOKEN;
|
|
676
|
+
const orgId = args.org || process.env.QUALTY_ORG_ID;
|
|
649
677
|
const projectId = args.project;
|
|
650
678
|
const suiteId = args["suite-id"];
|
|
651
679
|
const explicitIds = String(args.ids || "")
|
|
@@ -661,7 +689,7 @@ async function runResolve(args) {
|
|
|
661
689
|
throw new Error("Provide --project, --suite-id, or --ids to select tests.");
|
|
662
690
|
}
|
|
663
691
|
|
|
664
|
-
const savedJobs = await resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds });
|
|
692
|
+
const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
|
|
665
693
|
if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
|
|
666
694
|
throw new Error(
|
|
667
695
|
"No saved tests matched your selector. The test ID may be invalid, deleted, or outside your token authorization scope."
|
|
@@ -697,6 +725,26 @@ async function main() {
|
|
|
697
725
|
return;
|
|
698
726
|
}
|
|
699
727
|
if (command === "run") {
|
|
728
|
+
if (parseBoolean(args.local, false)) {
|
|
729
|
+
await runLocalCi({
|
|
730
|
+
apiUrl: resolveApiUrl(args),
|
|
731
|
+
token: args.token || process.env.QUALTY_API_TOKEN,
|
|
732
|
+
orgId: args.org || process.env.QUALTY_ORG_ID,
|
|
733
|
+
projectId: args.project,
|
|
734
|
+
suiteId: args["suite-id"],
|
|
735
|
+
explicitIds: String(args.ids || "")
|
|
736
|
+
.split(",")
|
|
737
|
+
.map((id) => id.trim())
|
|
738
|
+
.filter(Boolean),
|
|
739
|
+
resolveSavedJobs,
|
|
740
|
+
apiRequest,
|
|
741
|
+
failOnFailure: parseBoolean(args["fail-on-failure"], true),
|
|
742
|
+
device: args.device || "1440x900",
|
|
743
|
+
headless: !parseBoolean(args.headed, false),
|
|
744
|
+
localConcurrency: resolveLocalConcurrency(args),
|
|
745
|
+
});
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
700
748
|
await runCi(args);
|
|
701
749
|
return;
|
|
702
750
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qualty",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Qualty CLI for localhost and CI test runs",
|
|
5
5
|
"bin": {
|
|
6
6
|
"qualty": "bin/qualty.js"
|
|
@@ -9,5 +9,8 @@
|
|
|
9
9
|
"license": "UNLICENSED",
|
|
10
10
|
"files": [
|
|
11
11
|
"bin"
|
|
12
|
-
]
|
|
12
|
+
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"playwright": "1.60.0"
|
|
15
|
+
}
|
|
13
16
|
}
|