qualty 0.1.29 → 0.1.31
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 +239 -1018
- package/bin/qualty.js +32 -3
- package/package.json +1 -1
package/bin/local-runner.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { mkdirSync,
|
|
1
|
+
import { mkdirSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { homedir
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import process from "node:process";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { resolve4, resolve6 } from "node:dns/promises";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { request as httpsRequest } from "node:https";
|
|
6
10
|
|
|
7
11
|
function safePathSegment(value, fallback) {
|
|
8
12
|
const normalized = String(value || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
@@ -71,6 +75,194 @@ function withDevice(device, body = {}) {
|
|
|
71
75
|
return { device, ...body };
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
function delay(ms) {
|
|
79
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseWsEndpoint(endpoint) {
|
|
83
|
+
const parsed = new URL(String(endpoint || "").replace(/^ws:/i, "http:").replace(/^wss:/i, "https:"));
|
|
84
|
+
return {
|
|
85
|
+
port: Number(parsed.port),
|
|
86
|
+
path: `${parsed.pathname || "/"}${parsed.search || ""}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function startCloudflaredForPort(token, port) {
|
|
91
|
+
const baseArgs = [
|
|
92
|
+
"tunnel",
|
|
93
|
+
"--no-autoupdate",
|
|
94
|
+
"--protocol",
|
|
95
|
+
"http2",
|
|
96
|
+
"--url",
|
|
97
|
+
`http://127.0.0.1:${port}`,
|
|
98
|
+
"run",
|
|
99
|
+
"--token",
|
|
100
|
+
token,
|
|
101
|
+
];
|
|
102
|
+
let fallbackStarted = false;
|
|
103
|
+
const cmd = spawn("cloudflared", baseArgs, { stdio: "inherit" });
|
|
104
|
+
cmd.on("error", () => {
|
|
105
|
+
if (fallbackStarted) return;
|
|
106
|
+
fallbackStarted = true;
|
|
107
|
+
spawn("npx", ["cloudflared", ...baseArgs], { stdio: "inherit" });
|
|
108
|
+
});
|
|
109
|
+
return cmd;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function resolvePublicHostname(hostname) {
|
|
113
|
+
const [ipv4, ipv6] = await Promise.allSettled([resolve4(hostname), resolve6(hostname)]);
|
|
114
|
+
const addresses = [
|
|
115
|
+
...(ipv4.status === "fulfilled" ? ipv4.value.map((address) => ({ address, family: 4 })) : []),
|
|
116
|
+
...(ipv6.status === "fulfilled" ? ipv6.value.map((address) => ({ address, family: 6 })) : []),
|
|
117
|
+
].sort((a, b) => a.family - b.family);
|
|
118
|
+
if (addresses.length > 0) return addresses;
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const dohAddresses = await resolvePublicHostnameViaDoh(hostname);
|
|
122
|
+
if (dohAddresses.length > 0) return dohAddresses;
|
|
123
|
+
} catch {
|
|
124
|
+
/* Surface the original resolver error below; it is usually more familiar. */
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const reason = ipv4.status === "rejected" ? ipv4.reason : ipv6.reason;
|
|
128
|
+
throw reason instanceof Error ? reason : new Error(String(reason || `Could not resolve ${hostname}`));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function resolvePublicHostnameViaDoh(hostname) {
|
|
132
|
+
const queries = [
|
|
133
|
+
{ type: "A", family: 4 },
|
|
134
|
+
{ type: "AAAA", family: 6 },
|
|
135
|
+
];
|
|
136
|
+
const results = await Promise.allSettled(
|
|
137
|
+
queries.map(async ({ type, family }) => {
|
|
138
|
+
const response = await fetch(
|
|
139
|
+
`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=${type}`,
|
|
140
|
+
{ headers: { Accept: "application/dns-json" } }
|
|
141
|
+
);
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
throw new Error(`DoH ${type} returned HTTP ${response.status}`);
|
|
144
|
+
}
|
|
145
|
+
const body = await response.json();
|
|
146
|
+
return (Array.isArray(body.Answer) ? body.Answer : [])
|
|
147
|
+
.filter((answer) => String(answer?.data || "").trim())
|
|
148
|
+
.map((answer) => ({ address: String(answer.data).trim(), family }));
|
|
149
|
+
})
|
|
150
|
+
);
|
|
151
|
+
return results
|
|
152
|
+
.flatMap((result) => (result.status === "fulfilled" ? result.value : []))
|
|
153
|
+
.sort((a, b) => a.family - b.family);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function probeWebSocketUpgrade(connectUrl, addresses, timeoutMs = 10000) {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const url = new URL(connectUrl);
|
|
159
|
+
const req = httpsRequest({
|
|
160
|
+
protocol: url.protocol === "wss:" ? "https:" : url.protocol,
|
|
161
|
+
hostname: url.hostname,
|
|
162
|
+
port: url.port || 443,
|
|
163
|
+
path: `${url.pathname || "/"}${url.search || ""}`,
|
|
164
|
+
method: "GET",
|
|
165
|
+
headers: {
|
|
166
|
+
Connection: "Upgrade",
|
|
167
|
+
Upgrade: "websocket",
|
|
168
|
+
"Sec-WebSocket-Key": randomBytes(16).toString("base64"),
|
|
169
|
+
"Sec-WebSocket-Version": "13",
|
|
170
|
+
},
|
|
171
|
+
timeout: timeoutMs,
|
|
172
|
+
lookup: (_hostname, options, callback) => {
|
|
173
|
+
const cb = typeof options === "function" ? options : callback;
|
|
174
|
+
const lookupOptions = typeof options === "object" && options ? options : {};
|
|
175
|
+
const picked = addresses[0];
|
|
176
|
+
if (!picked?.address || !picked?.family) {
|
|
177
|
+
cb(new Error("No resolved tunnel addresses available"));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (lookupOptions.all) {
|
|
181
|
+
cb(null, addresses);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
cb(null, picked.address, picked.family);
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
req.on("upgrade", (_res, socket) => {
|
|
189
|
+
socket.destroy();
|
|
190
|
+
resolve();
|
|
191
|
+
});
|
|
192
|
+
req.on("response", (res) => {
|
|
193
|
+
res.resume();
|
|
194
|
+
reject(new Error(`WebSocket upgrade returned HTTP ${res.statusCode}`));
|
|
195
|
+
});
|
|
196
|
+
req.on("timeout", () => req.destroy(new Error(`WebSocket upgrade timed out after ${timeoutMs}ms`)));
|
|
197
|
+
req.on("error", reject);
|
|
198
|
+
req.end();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function waitForPublicBrowserTunnel({ connectUrl, hostname, timeoutMs = 60000 }) {
|
|
203
|
+
const deadline = Date.now() + timeoutMs;
|
|
204
|
+
let attempts = 0;
|
|
205
|
+
let lastError = null;
|
|
206
|
+
|
|
207
|
+
while (Date.now() < deadline) {
|
|
208
|
+
attempts += 1;
|
|
209
|
+
try {
|
|
210
|
+
const addresses = await resolvePublicHostname(hostname);
|
|
211
|
+
// eslint-disable-next-line no-console
|
|
212
|
+
console.log(
|
|
213
|
+
`[qualty][tunnel] DNS ready for ${hostname}: ${addresses.map((a) => a.address).join(", ")}`
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
await probeWebSocketUpgrade(connectUrl, addresses);
|
|
217
|
+
// eslint-disable-next-line no-console
|
|
218
|
+
console.log(`[qualty][tunnel] WebSocket ready after ${attempts} attempt(s): ${hostname}`);
|
|
219
|
+
return;
|
|
220
|
+
} catch (err) {
|
|
221
|
+
lastError = err;
|
|
222
|
+
const message = String(err?.message || err);
|
|
223
|
+
// eslint-disable-next-line no-console
|
|
224
|
+
console.log(`[qualty][tunnel] Waiting for public browser tunnel (${attempts}): ${message}`);
|
|
225
|
+
await delay(Math.min(1000 + attempts * 500, 5000));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Public browser tunnel was not reachable within ${Math.round(timeoutMs / 1000)}s for ${hostname}: ${
|
|
231
|
+
lastError?.message || lastError || "unknown error"
|
|
232
|
+
}`
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function waitForCombinationDone({ apiRequest, apiUrl, token, orgId, executionId, device }) {
|
|
237
|
+
const deadline = Date.now() + 20 * 60 * 1000;
|
|
238
|
+
while (Date.now() < deadline) {
|
|
239
|
+
const status = await apiRequest({
|
|
240
|
+
apiUrl,
|
|
241
|
+
token,
|
|
242
|
+
orgId,
|
|
243
|
+
path: `/api/v1/status/${executionId}`,
|
|
244
|
+
});
|
|
245
|
+
const combos = Array.isArray(status?.combinations) ? status.combinations : [];
|
|
246
|
+
const combo = combos.find((c) => String(c?.device || "").trim() === String(device || "").trim());
|
|
247
|
+
if (combo && ["completed", "failed", "cancelled"].includes(String(combo.status || ""))) {
|
|
248
|
+
return {
|
|
249
|
+
success: combo.status === "completed" && combo.success !== false,
|
|
250
|
+
error: combo.status === "completed" ? null : combo.explanation || combo.status,
|
|
251
|
+
combinationsRemaining: combos.filter((c) => !["completed", "failed", "cancelled"].includes(String(c?.status || ""))).length,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
if (["completed", "failed", "cancelled"].includes(String(status?.status || ""))) {
|
|
255
|
+
return {
|
|
256
|
+
success: status.status === "completed",
|
|
257
|
+
error: status.error || status.message || null,
|
|
258
|
+
combinationsRemaining: 0,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
await delay(2000);
|
|
262
|
+
}
|
|
263
|
+
throw new Error(`Timed out waiting for local tunneled run ${executionId} (${device})`);
|
|
264
|
+
}
|
|
265
|
+
|
|
74
266
|
function localAuthStatePath({ orgId, projectId, profile }) {
|
|
75
267
|
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
76
268
|
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
@@ -237,46 +429,6 @@ export function logAuthStateDiagnostics(prefix, summary, details = {}) {
|
|
|
237
429
|
}
|
|
238
430
|
}
|
|
239
431
|
|
|
240
|
-
export async function logAuthStateAfterInjection(tag, context, { testUrl = "" } = {}) {
|
|
241
|
-
try {
|
|
242
|
-
const cookies = await context.cookies();
|
|
243
|
-
// eslint-disable-next-line no-console
|
|
244
|
-
console.log(`${tag} injected into browser context: ${cookies.length} cookie(s) loaded`);
|
|
245
|
-
if (testUrl) {
|
|
246
|
-
// eslint-disable-next-line no-console
|
|
247
|
-
console.log(`${tag} localStorage applies after navigation to a matching origin (${testUrl})`);
|
|
248
|
-
}
|
|
249
|
-
} catch (err) {
|
|
250
|
-
// eslint-disable-next-line no-console
|
|
251
|
-
console.warn(`${tag} could not verify injected auth state: ${err?.message || err}`);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export async function logAuthStateAfterNavigation(tag, page) {
|
|
256
|
-
try {
|
|
257
|
-
const snapshot = await page.evaluate(() => ({
|
|
258
|
-
origin: window.location.origin,
|
|
259
|
-
href: window.location.href,
|
|
260
|
-
localStorageKeys: Object.keys(localStorage),
|
|
261
|
-
cookieCount: document.cookie ? document.cookie.split(";").filter(Boolean).length : 0,
|
|
262
|
-
}));
|
|
263
|
-
const keys = snapshot.localStorageKeys.length ? snapshot.localStorageKeys.join(", ") : "none";
|
|
264
|
-
// eslint-disable-next-line no-console
|
|
265
|
-
console.log(
|
|
266
|
-
`${tag} after navigation (${snapshot.origin}): localStorage keys [${keys}], document.cookie entries ~${snapshot.cookieCount}`
|
|
267
|
-
);
|
|
268
|
-
if (snapshot.localStorageKeys.length === 0 && snapshot.cookieCount === 0) {
|
|
269
|
-
// eslint-disable-next-line no-console
|
|
270
|
-
console.warn(
|
|
271
|
-
`${tag} warning: page has no localStorage keys and no cookies after navigation — app will likely appear logged out`
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
} catch (err) {
|
|
275
|
-
// eslint-disable-next-line no-console
|
|
276
|
-
console.warn(`${tag} could not read auth state after navigation: ${err?.message || err}`);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
432
|
const localBindingSetupPromises = new Map();
|
|
281
433
|
|
|
282
434
|
async function promptYesNo(question, defaultYes = true) {
|
|
@@ -530,755 +682,6 @@ async function preflightMissingLocalBindings({
|
|
|
530
682
|
}
|
|
531
683
|
}
|
|
532
684
|
|
|
533
|
-
async function downloadProjectAttachments({ apiUrl, token, orgId, projectId, specs }) {
|
|
534
|
-
const labelToPath = {};
|
|
535
|
-
if (!projectId || !Array.isArray(specs) || specs.length === 0) return labelToPath;
|
|
536
|
-
const dir = mkdtempSync(join(tmpdir(), "qualty-attach-"));
|
|
537
|
-
const orgHeader = String(orgId || "").trim();
|
|
538
|
-
for (const spec of specs) {
|
|
539
|
-
const fileId = spec.id;
|
|
540
|
-
const label = spec.runner_key || spec.label;
|
|
541
|
-
if (!fileId || !label) continue;
|
|
542
|
-
const response = await fetch(
|
|
543
|
-
`${apiUrl}/api/v1/projects/${projectId}/upload-files/${encodeURIComponent(fileId)}`,
|
|
544
|
-
{
|
|
545
|
-
method: "GET",
|
|
546
|
-
headers: {
|
|
547
|
-
Authorization: `Bearer ${token}`,
|
|
548
|
-
...(orgHeader ? { "X-Qualty-Org-Id": orgHeader } : {}),
|
|
549
|
-
},
|
|
550
|
-
}
|
|
551
|
-
);
|
|
552
|
-
if (!response.ok) continue;
|
|
553
|
-
const buf = Buffer.from(await response.arrayBuffer());
|
|
554
|
-
const name = spec.original_filename || `${label}.bin`;
|
|
555
|
-
const localPath = join(dir, name.replace(/[^a-zA-Z0-9._-]+/g, "_"));
|
|
556
|
-
writeFileSync(localPath, buf);
|
|
557
|
-
labelToPath[String(fileId)] = localPath;
|
|
558
|
-
labelToPath[label] = localPath;
|
|
559
|
-
if (spec.label && spec.label !== label) labelToPath[spec.label] = localPath;
|
|
560
|
-
}
|
|
561
|
-
return labelToPath;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function attachNetworkCollector(page) {
|
|
565
|
-
const events = [];
|
|
566
|
-
const onRequest = (req) => {
|
|
567
|
-
try {
|
|
568
|
-
events.push({
|
|
569
|
-
url: req.url(),
|
|
570
|
-
method: req.method(),
|
|
571
|
-
resource_type: req.resourceType(),
|
|
572
|
-
failed: false,
|
|
573
|
-
});
|
|
574
|
-
} catch {
|
|
575
|
-
/* ignore */
|
|
576
|
-
}
|
|
577
|
-
};
|
|
578
|
-
const onRequestFailed = (req) => {
|
|
579
|
-
try {
|
|
580
|
-
events.push({
|
|
581
|
-
url: req.url(),
|
|
582
|
-
method: req.method(),
|
|
583
|
-
resource_type: req.resourceType(),
|
|
584
|
-
failed: true,
|
|
585
|
-
});
|
|
586
|
-
} catch {
|
|
587
|
-
/* ignore */
|
|
588
|
-
}
|
|
589
|
-
};
|
|
590
|
-
page.on("request", onRequest);
|
|
591
|
-
page.on("requestfailed", onRequestFailed);
|
|
592
|
-
return {
|
|
593
|
-
snapshot() {
|
|
594
|
-
return { events_app_related: events.slice(-500) };
|
|
595
|
-
},
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
function resolveFileUploadArguments(action, labelToPath) {
|
|
600
|
-
if (!action || !Array.isArray(action.arguments)) return action;
|
|
601
|
-
const method = String(action.method || "").toLowerCase();
|
|
602
|
-
if (method !== "file_upload" && method !== "set_input_files") return action;
|
|
603
|
-
const resolved = action.arguments.map((arg) => {
|
|
604
|
-
const key = String(arg || "").trim();
|
|
605
|
-
if (labelToPath[key]) return labelToPath[key];
|
|
606
|
-
return key;
|
|
607
|
-
});
|
|
608
|
-
return { ...action, arguments: resolved };
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
export async function capturePageContext(page) {
|
|
612
|
-
try {
|
|
613
|
-
const text = await page.evaluate(() => {
|
|
614
|
-
const body = document.body;
|
|
615
|
-
return body ? body.innerText.slice(0, 12000) : "";
|
|
616
|
-
});
|
|
617
|
-
return { page_text: String(text || ""), interactive_summary: "" };
|
|
618
|
-
} catch {
|
|
619
|
-
return { page_text: "", interactive_summary: "" };
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
function _actLog(stepIndex, attempt, message) {
|
|
624
|
-
const stepLabel = Number.isFinite(stepIndex) ? stepIndex + 1 : stepIndex;
|
|
625
|
-
console.log(`[qualty][act][step ${stepLabel}][attempt ${attempt}] ${message}`);
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
function _summarizeAction(action) {
|
|
629
|
-
if (!action || typeof action !== "object") return "no-action";
|
|
630
|
-
const method = String(action.method || "").trim() || "unknown";
|
|
631
|
-
const selector = String(action.selector || "").trim();
|
|
632
|
-
return selector ? `${method} selector=${selector}` : method;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
async function capturePageContextViaApi({
|
|
636
|
-
page,
|
|
637
|
-
}) {
|
|
638
|
-
return capturePageContext(page);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
async function _extractInteractiveCandidates(page, limit = 200) {
|
|
642
|
-
try {
|
|
643
|
-
const rows = await page.evaluate((maxRows) => {
|
|
644
|
-
const normalizeText = (v) => (v || "").replace(/\s+/g, " ").trim();
|
|
645
|
-
const getXPath = (element) => {
|
|
646
|
-
if (!element || !element.tagName) return "";
|
|
647
|
-
if (element.id) return `//*[@id="${element.id}"]`;
|
|
648
|
-
if (element === document.body) return "/html/body";
|
|
649
|
-
const parent = element.parentElement;
|
|
650
|
-
if (!parent) return "";
|
|
651
|
-
const tag = element.tagName.toLowerCase();
|
|
652
|
-
const sameTag = Array.from(parent.children).filter((c) => c.tagName === element.tagName);
|
|
653
|
-
const index = sameTag.length > 1 ? `[${sameTag.indexOf(element) + 1}]` : "";
|
|
654
|
-
const parentXPath = getXPath(parent);
|
|
655
|
-
return parentXPath ? `${parentXPath}/${tag}${index}` : `//${tag}${index}`;
|
|
656
|
-
};
|
|
657
|
-
const isVisible = (el) => {
|
|
658
|
-
if (!el) return false;
|
|
659
|
-
const style = window.getComputedStyle(el);
|
|
660
|
-
if (!style) return false;
|
|
661
|
-
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false;
|
|
662
|
-
const rect = el.getBoundingClientRect();
|
|
663
|
-
return rect.width > 0 && rect.height > 0;
|
|
664
|
-
};
|
|
665
|
-
const inferRole = (el) => {
|
|
666
|
-
const explicit = el.getAttribute("role");
|
|
667
|
-
if (explicit) return explicit;
|
|
668
|
-
const tag = (el.tagName || "").toLowerCase();
|
|
669
|
-
if (tag === "a") return "link";
|
|
670
|
-
if (tag === "button") return "button";
|
|
671
|
-
if (tag === "input") return "textbox";
|
|
672
|
-
if (tag === "select") return "listbox";
|
|
673
|
-
if (tag === "textarea") return "textbox";
|
|
674
|
-
return null;
|
|
675
|
-
};
|
|
676
|
-
const getTestId = (el) =>
|
|
677
|
-
el.getAttribute("data-testid") ||
|
|
678
|
-
el.getAttribute("data-test-id") ||
|
|
679
|
-
el.getAttribute("data-test") ||
|
|
680
|
-
el.getAttribute("data-qa") ||
|
|
681
|
-
null;
|
|
682
|
-
|
|
683
|
-
const selector =
|
|
684
|
-
"a,button,input,select,textarea,[role],[tabindex],[onclick],[contenteditable='true']";
|
|
685
|
-
const nodes = Array.from(document.querySelectorAll(selector));
|
|
686
|
-
const seen = new Set();
|
|
687
|
-
|
|
688
|
-
const out = [];
|
|
689
|
-
for (const el of nodes) {
|
|
690
|
-
if (seen.has(el) || !isVisible(el) || out.length >= maxRows) continue;
|
|
691
|
-
seen.add(el);
|
|
692
|
-
if (!el || out.length >= maxRows) break;
|
|
693
|
-
const xpath = getXPath(el);
|
|
694
|
-
if (!xpath) continue;
|
|
695
|
-
const rect = el.getBoundingClientRect();
|
|
696
|
-
const tag = (el.tagName || "").toLowerCase();
|
|
697
|
-
const role = inferRole(el);
|
|
698
|
-
const text = normalizeText(el.innerText || el.textContent || "").slice(0, 120) || null;
|
|
699
|
-
const className =
|
|
700
|
-
typeof el.className === "string" ? normalizeText(el.className).slice(0, 120) : null;
|
|
701
|
-
const bbox = {
|
|
702
|
-
x: Math.round(rect.x),
|
|
703
|
-
y: Math.round(rect.y),
|
|
704
|
-
width: Math.round(rect.width),
|
|
705
|
-
height: Math.round(rect.height),
|
|
706
|
-
right: Math.round(rect.right),
|
|
707
|
-
bottom: Math.round(rect.bottom),
|
|
708
|
-
};
|
|
709
|
-
const midpoint = {
|
|
710
|
-
x: Math.round(rect.x + rect.width / 2),
|
|
711
|
-
y: Math.round(rect.y + rect.height / 2),
|
|
712
|
-
};
|
|
713
|
-
out.push({
|
|
714
|
-
selector: xpath,
|
|
715
|
-
id: el.id || null,
|
|
716
|
-
tag: tag || null,
|
|
717
|
-
role: role || null,
|
|
718
|
-
text,
|
|
719
|
-
type: el.getAttribute("type") || null,
|
|
720
|
-
title: el.getAttribute("title") || null,
|
|
721
|
-
test_id: getTestId(el),
|
|
722
|
-
name_attr: el.getAttribute("name") || null,
|
|
723
|
-
aria_label: el.getAttribute("aria-label") || null,
|
|
724
|
-
placeholder: el.getAttribute("placeholder") || null,
|
|
725
|
-
href: el.getAttribute("href") || null,
|
|
726
|
-
class_name: className,
|
|
727
|
-
bbox,
|
|
728
|
-
midpoint,
|
|
729
|
-
target_coordinate: `${midpoint.x},${midpoint.y}`,
|
|
730
|
-
});
|
|
731
|
-
}
|
|
732
|
-
return out.slice(0, 400);
|
|
733
|
-
}, Math.max(1, Math.min(500, Number(limit) || 200)));
|
|
734
|
-
return Array.isArray(rows) ? rows : [];
|
|
735
|
-
} catch {
|
|
736
|
-
return [];
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
/**
|
|
741
|
-
* Cloud ActHandler parity: attempt 1 (cache / bundle resolve) → 2 (selector LLM) → 3 (heal).
|
|
742
|
-
*/
|
|
743
|
-
async function runActStep({
|
|
744
|
-
apiRequest,
|
|
745
|
-
apiUrl,
|
|
746
|
-
token,
|
|
747
|
-
orgId,
|
|
748
|
-
executionId,
|
|
749
|
-
device,
|
|
750
|
-
stepIndex,
|
|
751
|
-
page,
|
|
752
|
-
credentials,
|
|
753
|
-
labelToPath,
|
|
754
|
-
step,
|
|
755
|
-
}) {
|
|
756
|
-
const attemptHistory = [];
|
|
757
|
-
const excluded = [];
|
|
758
|
-
let topCandidatesCache = null;
|
|
759
|
-
let lastErr = null;
|
|
760
|
-
let action = null;
|
|
761
|
-
const maxAttempts = 3;
|
|
762
|
-
let executionSource = null;
|
|
763
|
-
|
|
764
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
765
|
-
_actLog(stepIndex, attempt, "observe: requesting plan");
|
|
766
|
-
let ctx = await capturePageContextViaApi({
|
|
767
|
-
page,
|
|
768
|
-
});
|
|
769
|
-
|
|
770
|
-
const observeRequest = async (extraBody = null) =>
|
|
771
|
-
apiRequest({
|
|
772
|
-
apiUrl,
|
|
773
|
-
token,
|
|
774
|
-
orgId,
|
|
775
|
-
path: `/api/v1/cli/executions/${executionId}/observe`,
|
|
776
|
-
method: "POST",
|
|
777
|
-
body: withDevice(device, {
|
|
778
|
-
step_index: stepIndex,
|
|
779
|
-
attempt,
|
|
780
|
-
page_text: ctx.page_text,
|
|
781
|
-
interactive_summary: ctx.interactive_summary,
|
|
782
|
-
attempt_history: attemptHistory,
|
|
783
|
-
excluded_selectors: excluded,
|
|
784
|
-
top_candidates_cache: topCandidatesCache,
|
|
785
|
-
...(extraBody && typeof extraBody === "object" ? extraBody : {}),
|
|
786
|
-
}),
|
|
787
|
-
});
|
|
788
|
-
|
|
789
|
-
let observe = await observeRequest();
|
|
790
|
-
|
|
791
|
-
const status = String(observe.status || "");
|
|
792
|
-
const source = String(observe.resolution_source || "").trim();
|
|
793
|
-
_actLog(
|
|
794
|
-
stepIndex,
|
|
795
|
-
attempt,
|
|
796
|
-
`observe: status=${status || "unknown"}${source ? ` source=${source}` : ""}`
|
|
797
|
-
);
|
|
798
|
-
if (Array.isArray(observe.top_candidates_cache)) {
|
|
799
|
-
topCandidatesCache = observe.top_candidates_cache;
|
|
800
|
-
}
|
|
801
|
-
if (observe.step_kind === "done" || status === "done") {
|
|
802
|
-
break;
|
|
803
|
-
}
|
|
804
|
-
if (observe.step_kind === "agent" || observe.step_kind === "login" || status === "agent") {
|
|
805
|
-
throw new Error(observe.error || `${observe.step_kind} steps require agent path`);
|
|
806
|
-
}
|
|
807
|
-
if (observe.step_kind === "check" || status === "check") {
|
|
808
|
-
throw new Error(observe.error || "Check steps are not supported in local CLI yet");
|
|
809
|
-
}
|
|
810
|
-
if (status === "heal") {
|
|
811
|
-
_actLog(stepIndex, attempt, "escalating to agent heal");
|
|
812
|
-
const healResult = await runAgentStep({
|
|
813
|
-
apiRequest,
|
|
814
|
-
apiUrl,
|
|
815
|
-
token,
|
|
816
|
-
orgId,
|
|
817
|
-
executionId,
|
|
818
|
-
device,
|
|
819
|
-
stepIndex,
|
|
820
|
-
instruction: String(observe.remainder_instruction || ""),
|
|
821
|
-
page,
|
|
822
|
-
credentials,
|
|
823
|
-
labelToPath,
|
|
824
|
-
instructionOverride: true,
|
|
825
|
-
});
|
|
826
|
-
return {
|
|
827
|
-
ok: healResult.ok,
|
|
828
|
-
error: healResult.error,
|
|
829
|
-
action: { method: "agent_heal", description: observe.remainder_instruction || "" },
|
|
830
|
-
skipRemainingSteps: healResult.ok,
|
|
831
|
-
source: "agent_heal",
|
|
832
|
-
};
|
|
833
|
-
}
|
|
834
|
-
if (status === "retry") {
|
|
835
|
-
_actLog(stepIndex, attempt, `retry: ${observe.error || "planner requested retry"}`);
|
|
836
|
-
attemptHistory.push({ attempt, error: observe.error || "retry" });
|
|
837
|
-
continue;
|
|
838
|
-
}
|
|
839
|
-
if (status === "needs_bundle_resolve") {
|
|
840
|
-
const collectedCandidates = await _extractInteractiveCandidates(page, 200);
|
|
841
|
-
_actLog(
|
|
842
|
-
stepIndex,
|
|
843
|
-
attempt,
|
|
844
|
-
`needs_bundle_resolve: collected ${collectedCandidates.length} candidates, retrying observe`
|
|
845
|
-
);
|
|
846
|
-
observe = await observeRequest({
|
|
847
|
-
collected_candidates: collectedCandidates,
|
|
848
|
-
collector_version: "candidate_js_v1",
|
|
849
|
-
});
|
|
850
|
-
if (Array.isArray(observe.top_candidates_cache)) {
|
|
851
|
-
topCandidatesCache = observe.top_candidates_cache;
|
|
852
|
-
}
|
|
853
|
-
const retryStatus = String(observe.status || "");
|
|
854
|
-
const retrySource = String(observe.resolution_source || "").trim();
|
|
855
|
-
_actLog(
|
|
856
|
-
stepIndex,
|
|
857
|
-
attempt,
|
|
858
|
-
`observe(after candidates): status=${retryStatus || "unknown"}${
|
|
859
|
-
retrySource ? ` source=${retrySource}` : ""
|
|
860
|
-
}`
|
|
861
|
-
);
|
|
862
|
-
if (retryStatus === "action" && observe.action) {
|
|
863
|
-
action = resolveFileUploadArguments(observe.action, labelToPath);
|
|
864
|
-
executionSource = retrySource || source || null;
|
|
865
|
-
_actLog(stepIndex, attempt, `execute: ${_summarizeAction(action)} source=${executionSource || "unknown"}`);
|
|
866
|
-
try {
|
|
867
|
-
await executeAction(page, action, credentials);
|
|
868
|
-
_actLog(stepIndex, attempt, "execute: success");
|
|
869
|
-
lastErr = null;
|
|
870
|
-
break;
|
|
871
|
-
} catch (err) {
|
|
872
|
-
lastErr = err.message || String(err);
|
|
873
|
-
_actLog(stepIndex, attempt, `execute: failed error=${lastErr}`);
|
|
874
|
-
attemptHistory.push({
|
|
875
|
-
attempt,
|
|
876
|
-
method: action.method,
|
|
877
|
-
selector: action.selector,
|
|
878
|
-
error: lastErr,
|
|
879
|
-
});
|
|
880
|
-
if (action.selector) excluded.push(action.selector);
|
|
881
|
-
continue;
|
|
882
|
-
}
|
|
883
|
-
}
|
|
884
|
-
if (retryStatus === "retry") {
|
|
885
|
-
_actLog(stepIndex, attempt, `retry(after candidates): ${observe.error || "planner requested retry"}`);
|
|
886
|
-
attemptHistory.push({ attempt, error: observe.error || "retry" });
|
|
887
|
-
continue;
|
|
888
|
-
}
|
|
889
|
-
if (retryStatus === "heal") {
|
|
890
|
-
_actLog(stepIndex, attempt, "escalating to agent heal");
|
|
891
|
-
const healResult = await runAgentStep({
|
|
892
|
-
apiRequest,
|
|
893
|
-
apiUrl,
|
|
894
|
-
token,
|
|
895
|
-
orgId,
|
|
896
|
-
executionId,
|
|
897
|
-
device,
|
|
898
|
-
stepIndex,
|
|
899
|
-
instruction: String(observe.remainder_instruction || ""),
|
|
900
|
-
page,
|
|
901
|
-
credentials,
|
|
902
|
-
labelToPath,
|
|
903
|
-
instructionOverride: true,
|
|
904
|
-
});
|
|
905
|
-
return {
|
|
906
|
-
ok: healResult.ok,
|
|
907
|
-
error: healResult.error,
|
|
908
|
-
action: { method: "agent_heal", description: observe.remainder_instruction || "" },
|
|
909
|
-
skipRemainingSteps: healResult.ok,
|
|
910
|
-
source: "agent_heal",
|
|
911
|
-
};
|
|
912
|
-
}
|
|
913
|
-
lastErr = observe.error || "No action returned after bundle resolve";
|
|
914
|
-
_actLog(stepIndex, attempt, `retry(after candidates): ${lastErr}`);
|
|
915
|
-
attemptHistory.push({ attempt, error: lastErr });
|
|
916
|
-
continue;
|
|
917
|
-
}
|
|
918
|
-
if (status === "action" && observe.action) {
|
|
919
|
-
action = resolveFileUploadArguments(observe.action, labelToPath);
|
|
920
|
-
executionSource = source || null;
|
|
921
|
-
_actLog(stepIndex, attempt, `execute: ${_summarizeAction(action)} source=${executionSource || "unknown"}`);
|
|
922
|
-
try {
|
|
923
|
-
await executeAction(page, action, credentials);
|
|
924
|
-
_actLog(stepIndex, attempt, "execute: success");
|
|
925
|
-
lastErr = null;
|
|
926
|
-
break;
|
|
927
|
-
} catch (err) {
|
|
928
|
-
lastErr = err.message || String(err);
|
|
929
|
-
_actLog(stepIndex, attempt, `execute: failed error=${lastErr}`);
|
|
930
|
-
attemptHistory.push({
|
|
931
|
-
attempt,
|
|
932
|
-
method: action.method,
|
|
933
|
-
selector: action.selector,
|
|
934
|
-
error: lastErr,
|
|
935
|
-
});
|
|
936
|
-
if (action.selector) excluded.push(action.selector);
|
|
937
|
-
continue;
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
lastErr = observe.error || "No action returned";
|
|
941
|
-
_actLog(stepIndex, attempt, `retry: ${lastErr}`);
|
|
942
|
-
attemptHistory.push({ attempt, error: lastErr });
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
return {
|
|
946
|
-
ok: !lastErr,
|
|
947
|
-
error: lastErr,
|
|
948
|
-
action,
|
|
949
|
-
skipRemainingSteps: false,
|
|
950
|
-
source: executionSource,
|
|
951
|
-
};
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
async function clickAtCoordinate(page, coord, button = "left") {
|
|
955
|
-
const [x, y] = coord.split(",").map((v) => Number(v.trim()));
|
|
956
|
-
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
957
|
-
throw new Error(`Invalid target_coordinate: ${coord}`);
|
|
958
|
-
}
|
|
959
|
-
if (button === "right") {
|
|
960
|
-
await page.mouse.click(x, y, { button: "right" });
|
|
961
|
-
} else {
|
|
962
|
-
await page.mouse.click(x, y);
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
async function resolveFileInputAtCoordinate(page, x, y) {
|
|
967
|
-
return page.evaluate(
|
|
968
|
-
({ px, py }) => {
|
|
969
|
-
const el = document.elementFromPoint(px, py);
|
|
970
|
-
if (!el) return null;
|
|
971
|
-
const walk = (node) => {
|
|
972
|
-
if (!node) return null;
|
|
973
|
-
if (node.tagName && String(node.tagName).toLowerCase() === "input") {
|
|
974
|
-
const t = (node.getAttribute("type") || "").toLowerCase();
|
|
975
|
-
if (t === "file") return node;
|
|
976
|
-
}
|
|
977
|
-
const label = node.closest && node.closest("label");
|
|
978
|
-
if (label && label.control && label.control.type === "file") return label.control;
|
|
979
|
-
return node.parentElement ? walk(node.parentElement) : null;
|
|
980
|
-
};
|
|
981
|
-
const found = walk(el);
|
|
982
|
-
if (!found) return null;
|
|
983
|
-
if (found.id) return `#${found.id}`;
|
|
984
|
-
return null;
|
|
985
|
-
},
|
|
986
|
-
{ px: x, py: y }
|
|
987
|
-
);
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
export async function executeAction(page, action, credentials = null) {
|
|
991
|
-
const method = String(action.method || "click").toLowerCase();
|
|
992
|
-
const selector = String(action.selector || "").trim();
|
|
993
|
-
const args = Array.isArray(action.arguments) ? action.arguments.map(String) : [];
|
|
994
|
-
const coord = String(action.target_coordinate || "").trim();
|
|
995
|
-
|
|
996
|
-
if (method === "wait") {
|
|
997
|
-
const ms = Math.max(0, Number(args[0] || 500));
|
|
998
|
-
await page.waitForTimeout(ms);
|
|
999
|
-
return;
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
if (method === "scrollto" && coord) {
|
|
1003
|
-
const [x, y] = coord.split(",").map((v) => Number(v.trim()));
|
|
1004
|
-
await page.evaluate(
|
|
1005
|
-
({ x, y }) => window.scrollTo(Number(x) || 0, Number(y) || 0),
|
|
1006
|
-
{ x, y }
|
|
1007
|
-
);
|
|
1008
|
-
return;
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
if (method === "scroll") {
|
|
1012
|
-
const [x, y] = coord
|
|
1013
|
-
? coord.split(",").map((v) => Number(v.trim()))
|
|
1014
|
-
: [null, null];
|
|
1015
|
-
const deltaX = Number(args[0] || 0);
|
|
1016
|
-
const deltaY = Number(args[1] || 700);
|
|
1017
|
-
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
1018
|
-
await page.mouse.move(x, y);
|
|
1019
|
-
}
|
|
1020
|
-
await page.mouse.wheel(deltaX, deltaY);
|
|
1021
|
-
return;
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
if (method === "focused_keypress") {
|
|
1025
|
-
for (const key of args) {
|
|
1026
|
-
if (key) await page.keyboard.press(key);
|
|
1027
|
-
}
|
|
1028
|
-
return;
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
if (method === "fill_sensitive_field" && credentials) {
|
|
1032
|
-
const fieldType = String(action.field_type || "").toLowerCase();
|
|
1033
|
-
const value =
|
|
1034
|
-
fieldType === "password"
|
|
1035
|
-
? credentials.password || ""
|
|
1036
|
-
: credentials.username || "";
|
|
1037
|
-
if (!value) throw new Error(`No ${fieldType} credential available`);
|
|
1038
|
-
if (coord) await clickAtCoordinate(page, coord);
|
|
1039
|
-
await page.waitForTimeout(200);
|
|
1040
|
-
const isMac = process.platform === "darwin";
|
|
1041
|
-
const selectAll = isMac ? "Meta+A" : "Control+A";
|
|
1042
|
-
await page.keyboard.press(selectAll);
|
|
1043
|
-
await page.keyboard.press("Backspace");
|
|
1044
|
-
await page.keyboard.type(value, { delay: 40 });
|
|
1045
|
-
if (action.press_enter) await page.keyboard.press("Enter");
|
|
1046
|
-
return;
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
const coordinateMethods = new Set([
|
|
1050
|
-
"click",
|
|
1051
|
-
"dblclick",
|
|
1052
|
-
"rightclick",
|
|
1053
|
-
"hover",
|
|
1054
|
-
"dragdrop",
|
|
1055
|
-
]);
|
|
1056
|
-
if (coord && coordinateMethods.has(method)) {
|
|
1057
|
-
if (method === "click") {
|
|
1058
|
-
await clickAtCoordinate(page, coord, "left");
|
|
1059
|
-
return;
|
|
1060
|
-
}
|
|
1061
|
-
if (method === "rightclick") {
|
|
1062
|
-
await clickAtCoordinate(page, coord, "right");
|
|
1063
|
-
return;
|
|
1064
|
-
}
|
|
1065
|
-
if (method === "dblclick") {
|
|
1066
|
-
const [x, y] = coord.split(",").map((v) => Number(v.trim()));
|
|
1067
|
-
await page.mouse.dblclick(x, y);
|
|
1068
|
-
return;
|
|
1069
|
-
}
|
|
1070
|
-
if (method === "hover") {
|
|
1071
|
-
const [x, y] = coord.split(",").map((v) => Number(v.trim()));
|
|
1072
|
-
await page.mouse.move(x, y);
|
|
1073
|
-
return;
|
|
1074
|
-
}
|
|
1075
|
-
if (method === "dragdrop") {
|
|
1076
|
-
const [sx, sy] = coord.split(",").map((v) => Number(v.trim()));
|
|
1077
|
-
const ex = Number(args[0]);
|
|
1078
|
-
const ey = Number(args[1]);
|
|
1079
|
-
await page.mouse.move(sx, sy);
|
|
1080
|
-
await page.mouse.down();
|
|
1081
|
-
await page.mouse.move(ex, ey);
|
|
1082
|
-
await page.mouse.up();
|
|
1083
|
-
return;
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
if (method === "file_upload" && coord && !selector) {
|
|
1088
|
-
const [x, y] = coord.split(",").map((v) => Number(v.trim()));
|
|
1089
|
-
const sel = await resolveFileInputAtCoordinate(page, x, y);
|
|
1090
|
-
if (!sel) throw new Error("file_upload: no file input at coordinate");
|
|
1091
|
-
await page.locator(sel).first().setInputFiles(args, { timeout: 30000 });
|
|
1092
|
-
return;
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
if (method === "type" && !selector && coord) {
|
|
1096
|
-
await clickAtCoordinate(page, coord);
|
|
1097
|
-
await page.waitForTimeout(200);
|
|
1098
|
-
await page.keyboard.type(args[0] || "", { delay: 40 });
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
if (method === "type" && !selector && !coord) {
|
|
1103
|
-
await page.keyboard.type(args[0] || "", { delay: 40 });
|
|
1104
|
-
return;
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
if (!selector && method !== "focused_keypress") {
|
|
1108
|
-
throw new Error(`Action ${method} requires a selector or target_coordinate`);
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
const locator = page.locator(selector).first();
|
|
1112
|
-
|
|
1113
|
-
switch (method) {
|
|
1114
|
-
case "click":
|
|
1115
|
-
await locator.click({ timeout: 15000 });
|
|
1116
|
-
break;
|
|
1117
|
-
case "dblclick":
|
|
1118
|
-
await locator.dblclick({ timeout: 15000 });
|
|
1119
|
-
break;
|
|
1120
|
-
case "rightclick":
|
|
1121
|
-
await locator.click({ button: "right", timeout: 15000 });
|
|
1122
|
-
break;
|
|
1123
|
-
case "hover":
|
|
1124
|
-
await locator.hover({ timeout: 15000 });
|
|
1125
|
-
break;
|
|
1126
|
-
case "fill":
|
|
1127
|
-
case "type":
|
|
1128
|
-
await locator.fill(args[0] || "", { timeout: 15000 });
|
|
1129
|
-
break;
|
|
1130
|
-
case "press":
|
|
1131
|
-
for (const key of args) {
|
|
1132
|
-
if (key) await locator.press(key, { timeout: 15000 });
|
|
1133
|
-
}
|
|
1134
|
-
break;
|
|
1135
|
-
case "file_upload":
|
|
1136
|
-
case "set_input_files":
|
|
1137
|
-
await locator.setInputFiles(args, { timeout: 30000 });
|
|
1138
|
-
break;
|
|
1139
|
-
case "scroll":
|
|
1140
|
-
await locator.scrollIntoViewIfNeeded({ timeout: 15000 });
|
|
1141
|
-
break;
|
|
1142
|
-
case "scrollto":
|
|
1143
|
-
await locator.scrollIntoViewIfNeeded({ timeout: 15000 });
|
|
1144
|
-
break;
|
|
1145
|
-
case "dragdrop": {
|
|
1146
|
-
const box = await locator.boundingBox();
|
|
1147
|
-
if (!box) throw new Error("dragdrop: element not visible");
|
|
1148
|
-
const sx = box.x + box.width / 2;
|
|
1149
|
-
const sy = box.y + box.height / 2;
|
|
1150
|
-
const ex = Number(args[0]);
|
|
1151
|
-
const ey = Number(args[1]);
|
|
1152
|
-
await page.mouse.move(sx, sy);
|
|
1153
|
-
await page.mouse.down();
|
|
1154
|
-
await page.mouse.move(ex, ey);
|
|
1155
|
-
await page.mouse.up();
|
|
1156
|
-
break;
|
|
1157
|
-
}
|
|
1158
|
-
default:
|
|
1159
|
-
throw new Error(`Unsupported action method: ${method}`);
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
async function runAgentStep({
|
|
1164
|
-
apiRequest,
|
|
1165
|
-
apiUrl,
|
|
1166
|
-
token,
|
|
1167
|
-
orgId,
|
|
1168
|
-
executionId,
|
|
1169
|
-
device,
|
|
1170
|
-
stepIndex,
|
|
1171
|
-
instruction,
|
|
1172
|
-
page,
|
|
1173
|
-
credentials,
|
|
1174
|
-
labelToPath,
|
|
1175
|
-
maxTurns = 100,
|
|
1176
|
-
instructionOverride = false,
|
|
1177
|
-
}) {
|
|
1178
|
-
let reset = true;
|
|
1179
|
-
let lastErr = null;
|
|
1180
|
-
let turnCount = 0;
|
|
1181
|
-
const pendingFunctionResults = [];
|
|
1182
|
-
|
|
1183
|
-
while (turnCount < maxTurns) {
|
|
1184
|
-
const screenshotB64 = (await page.screenshot({ type: "png" })).toString("base64");
|
|
1185
|
-
const turnBody = withDevice(device, {
|
|
1186
|
-
step_index: stepIndex,
|
|
1187
|
-
screenshot_b64: screenshotB64,
|
|
1188
|
-
page_url: page.url(),
|
|
1189
|
-
reset,
|
|
1190
|
-
function_results: pendingFunctionResults.length ? pendingFunctionResults : undefined,
|
|
1191
|
-
});
|
|
1192
|
-
if (instructionOverride && instruction) {
|
|
1193
|
-
turnBody.instruction_override = instruction;
|
|
1194
|
-
}
|
|
1195
|
-
const turn = await apiRequest({
|
|
1196
|
-
apiUrl,
|
|
1197
|
-
token,
|
|
1198
|
-
orgId,
|
|
1199
|
-
path: `/api/v1/cli/executions/${executionId}/agent-turn`,
|
|
1200
|
-
method: "POST",
|
|
1201
|
-
body: turnBody,
|
|
1202
|
-
});
|
|
1203
|
-
reset = false;
|
|
1204
|
-
pendingFunctionResults.length = 0;
|
|
1205
|
-
turnCount += 1;
|
|
1206
|
-
|
|
1207
|
-
if (turn.status === "error") {
|
|
1208
|
-
lastErr = turn.error || "Agent turn failed";
|
|
1209
|
-
break;
|
|
1210
|
-
}
|
|
1211
|
-
if (turn.status === "done") {
|
|
1212
|
-
lastErr = null;
|
|
1213
|
-
break;
|
|
1214
|
-
}
|
|
1215
|
-
if (turn.status === "continue") {
|
|
1216
|
-
continue;
|
|
1217
|
-
}
|
|
1218
|
-
if (turn.status !== "actions" || !Array.isArray(turn.actions) || !turn.actions.length) {
|
|
1219
|
-
lastErr = turn.error || "Agent returned no actions";
|
|
1220
|
-
break;
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
for (const raw of turn.actions) {
|
|
1224
|
-
const action = resolveFileUploadArguments(raw, labelToPath);
|
|
1225
|
-
let actionOk = true;
|
|
1226
|
-
let actionErr = null;
|
|
1227
|
-
try {
|
|
1228
|
-
if (action.method === "fill_sensitive_field") {
|
|
1229
|
-
await executeAction(page, action, credentials);
|
|
1230
|
-
const fnId = action._function_call_id;
|
|
1231
|
-
if (fnId) {
|
|
1232
|
-
pendingFunctionResults.push({
|
|
1233
|
-
call_id: fnId,
|
|
1234
|
-
success: true,
|
|
1235
|
-
message: "fill_sensitive_field executed locally",
|
|
1236
|
-
});
|
|
1237
|
-
}
|
|
1238
|
-
} else {
|
|
1239
|
-
await executeAction(page, action, credentials);
|
|
1240
|
-
}
|
|
1241
|
-
await page.waitForTimeout(200);
|
|
1242
|
-
} catch (err) {
|
|
1243
|
-
actionOk = false;
|
|
1244
|
-
actionErr = err.message || String(err);
|
|
1245
|
-
lastErr = actionErr;
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
|
-
const afterB64 = (await page.screenshot({ type: "png" })).toString("base64");
|
|
1249
|
-
try {
|
|
1250
|
-
await apiRequest({
|
|
1251
|
-
apiUrl,
|
|
1252
|
-
token,
|
|
1253
|
-
orgId,
|
|
1254
|
-
path: `/api/v1/cli/executions/${executionId}/agent-segment`,
|
|
1255
|
-
method: "POST",
|
|
1256
|
-
body: withDevice(device, {
|
|
1257
|
-
step_index: stepIndex,
|
|
1258
|
-
turn: turnCount,
|
|
1259
|
-
turn_thought: turn.turn_thought || "",
|
|
1260
|
-
action,
|
|
1261
|
-
executor_ok: actionOk,
|
|
1262
|
-
error: actionErr || undefined,
|
|
1263
|
-
screenshot_b64: afterB64,
|
|
1264
|
-
page_url: page.url(),
|
|
1265
|
-
}),
|
|
1266
|
-
});
|
|
1267
|
-
} catch (segErr) {
|
|
1268
|
-
console.warn("[CLI] agent-segment upload failed:", segErr.message || segErr);
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
if (!actionOk) break;
|
|
1272
|
-
}
|
|
1273
|
-
if (lastErr) break;
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
if (!lastErr && turnCount >= maxTurns) {
|
|
1277
|
-
lastErr = `Agent exceeded ${maxTurns} turns`;
|
|
1278
|
-
}
|
|
1279
|
-
return { ok: !lastErr, error: lastErr, turns: turnCount };
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
685
|
export async function runLocalExecution({
|
|
1283
686
|
apiRequest,
|
|
1284
687
|
apiUrl,
|
|
@@ -1359,9 +762,6 @@ async function runLocalSingleCombination({
|
|
|
1359
762
|
claim.url = url;
|
|
1360
763
|
// eslint-disable-next-line no-console
|
|
1361
764
|
console.log(`[qualty] Local run ${executionId} device=${effectiveDevice}`);
|
|
1362
|
-
const viewport = claim.viewport || { width: 1440, height: 900 };
|
|
1363
|
-
const steps = Array.isArray(claim.steps) ? claim.steps : [];
|
|
1364
|
-
const totalSteps = claim.total_steps || steps.length;
|
|
1365
765
|
const claimSavedLogin = claim.saved_login_profile && typeof claim.saved_login_profile === "object"
|
|
1366
766
|
? claim.saved_login_profile
|
|
1367
767
|
: null;
|
|
@@ -1443,237 +843,58 @@ async function runLocalSingleCombination({
|
|
|
1443
843
|
host: "127.0.0.1",
|
|
1444
844
|
});
|
|
1445
845
|
const cdpEndpoint = browserServer.wsEndpoint();
|
|
1446
|
-
const browser = await chromium.connect(cdpEndpoint);
|
|
1447
|
-
const contextOptions = {
|
|
1448
|
-
viewport: { width: viewport.width || 1440, height: viewport.height || 900 },
|
|
1449
|
-
};
|
|
1450
|
-
if (finalStoragePath) {
|
|
1451
|
-
contextOptions.storageState = finalStoragePath;
|
|
1452
|
-
}
|
|
1453
|
-
const context = await browser.newContext(contextOptions);
|
|
1454
|
-
if (finalStoragePath) {
|
|
1455
|
-
await logAuthStateAfterInjection("[qualty][auth]", context, { testUrl: url });
|
|
1456
|
-
} else if (!noAuthState && (effectiveProfile || claimSavedLogin?.name)) {
|
|
1457
|
-
// eslint-disable-next-line no-console
|
|
1458
|
-
console.log("[qualty][auth] running without saved login state injection");
|
|
1459
|
-
}
|
|
1460
|
-
const page = await context.newPage();
|
|
1461
|
-
const networkCollector = attachNetworkCollector(page);
|
|
1462
|
-
const attachmentSpecs = Array.isArray(claim.file_attachments) ? claim.file_attachments : [];
|
|
1463
|
-
const labelToPath = await downloadProjectAttachments({
|
|
1464
|
-
apiUrl,
|
|
1465
|
-
token,
|
|
1466
|
-
orgId,
|
|
1467
|
-
projectId: claim.project_id,
|
|
1468
|
-
specs: attachmentSpecs,
|
|
1469
|
-
});
|
|
1470
|
-
const missingAttachmentLabels = attachmentSpecs
|
|
1471
|
-
.map((spec) => ({
|
|
1472
|
-
id: String(spec?.id || "").trim(),
|
|
1473
|
-
label: String(spec?.runner_key || spec?.label || "").trim(),
|
|
1474
|
-
rawLabel: String(spec?.label || "").trim(),
|
|
1475
|
-
}))
|
|
1476
|
-
.filter((it) => {
|
|
1477
|
-
if (it.id && labelToPath[it.id]) return false;
|
|
1478
|
-
if (it.label && labelToPath[it.label]) return false;
|
|
1479
|
-
if (it.rawLabel && labelToPath[it.rawLabel]) return false;
|
|
1480
|
-
return true;
|
|
1481
|
-
});
|
|
1482
|
-
if (missingAttachmentLabels.length > 0) {
|
|
1483
|
-
const listed = missingAttachmentLabels
|
|
1484
|
-
.map((it) => it.rawLabel || it.label || it.id || "<unknown>")
|
|
1485
|
-
.join(", ");
|
|
1486
|
-
throw new Error(
|
|
1487
|
-
`Attached file download failed for: ${listed}. Re-attach files in dashboard and retry local run.`
|
|
1488
|
-
);
|
|
1489
|
-
}
|
|
1490
|
-
const credentials = claim.credentials || null;
|
|
1491
|
-
|
|
1492
|
-
const stepsJson = [];
|
|
1493
|
-
let runSuccess = true;
|
|
1494
|
-
let runError = null;
|
|
1495
|
-
const started = Date.now();
|
|
1496
|
-
let lastPrevScreenshotB64 = null;
|
|
1497
|
-
let lastCurrScreenshotB64 = null;
|
|
1498
|
-
let firstPrevScreenshotB64 = null;
|
|
1499
846
|
|
|
847
|
+
let cloudflared = null;
|
|
1500
848
|
try {
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
await logAuthStateAfterNavigation("[qualty][auth]", page);
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
for (let stepIndex = 0; stepIndex < totalSteps; stepIndex += 1) {
|
|
1508
|
-
const step = steps[stepIndex] || {};
|
|
1509
|
-
const kind = String(step.kind || "act").toLowerCase();
|
|
1510
|
-
const instruction = step.instruction || step.name || `Step ${stepIndex + 1}`;
|
|
1511
|
-
let action = null;
|
|
1512
|
-
let lastErr = null;
|
|
1513
|
-
const prevBuf = await page.screenshot({ type: "png" });
|
|
1514
|
-
|
|
1515
|
-
if (kind === "agent" || kind === "login") {
|
|
1516
|
-
console.log(`[qualty][agent][step ${stepIndex + 1}] mode=pure_agent kind=${kind}`);
|
|
1517
|
-
const agentResult = await runAgentStep({
|
|
1518
|
-
apiRequest,
|
|
1519
|
-
apiUrl,
|
|
1520
|
-
token,
|
|
1521
|
-
orgId,
|
|
1522
|
-
executionId,
|
|
1523
|
-
device: effectiveDevice,
|
|
1524
|
-
stepIndex,
|
|
1525
|
-
instruction,
|
|
1526
|
-
page,
|
|
1527
|
-
credentials,
|
|
1528
|
-
labelToPath,
|
|
1529
|
-
});
|
|
1530
|
-
lastErr = agentResult.ok ? null : agentResult.error;
|
|
1531
|
-
action = { method: "agent", description: instruction };
|
|
1532
|
-
} else if (kind === "check") {
|
|
1533
|
-
lastErr = "Check steps are not supported in local CLI yet; use cloud run or act steps.";
|
|
1534
|
-
} else {
|
|
1535
|
-
console.log(`[qualty][act][step ${stepIndex + 1}] mode=act`);
|
|
1536
|
-
const actResult = await runActStep({
|
|
1537
|
-
apiRequest,
|
|
1538
|
-
apiUrl,
|
|
1539
|
-
token,
|
|
1540
|
-
orgId,
|
|
1541
|
-
executionId,
|
|
1542
|
-
device: effectiveDevice,
|
|
1543
|
-
stepIndex,
|
|
1544
|
-
page,
|
|
1545
|
-
credentials,
|
|
1546
|
-
labelToPath,
|
|
1547
|
-
step,
|
|
1548
|
-
});
|
|
1549
|
-
lastErr = actResult.ok ? null : actResult.error;
|
|
1550
|
-
action = actResult.action;
|
|
1551
|
-
if (actResult.source) {
|
|
1552
|
-
console.log(`[qualty][act][step ${stepIndex + 1}] resolved_source=${actResult.source}`);
|
|
1553
|
-
}
|
|
1554
|
-
if (actResult.skipRemainingSteps && actResult.ok) {
|
|
1555
|
-
const executorOk = true;
|
|
1556
|
-
const currBuf = await page.screenshot({ type: "png" });
|
|
1557
|
-
const prevB64 = prevBuf.toString("base64");
|
|
1558
|
-
const currB64 = currBuf.toString("base64");
|
|
1559
|
-
lastPrevScreenshotB64 = prevB64;
|
|
1560
|
-
lastCurrScreenshotB64 = currB64;
|
|
1561
|
-
if (firstPrevScreenshotB64 == null) firstPrevScreenshotB64 = prevB64;
|
|
1562
|
-
let row = {
|
|
1563
|
-
name: instruction,
|
|
1564
|
-
description: instruction,
|
|
1565
|
-
status: "passed",
|
|
1566
|
-
action: action
|
|
1567
|
-
? `${action.method}${action.selector ? ` ${action.selector}` : ""}`
|
|
1568
|
-
: "agent_heal",
|
|
1569
|
-
};
|
|
1570
|
-
const stepResp = await apiRequest({
|
|
1571
|
-
apiUrl,
|
|
1572
|
-
token,
|
|
1573
|
-
orgId,
|
|
1574
|
-
path: `/api/v1/cli/executions/${executionId}/step-result`,
|
|
1575
|
-
method: "POST",
|
|
1576
|
-
body: withDevice(effectiveDevice, {
|
|
1577
|
-
step_index: stepIndex,
|
|
1578
|
-
executor_ok: executorOk,
|
|
1579
|
-
action,
|
|
1580
|
-
result: row,
|
|
1581
|
-
prev_screenshot_b64: prevB64,
|
|
1582
|
-
curr_screenshot_b64: currB64,
|
|
1583
|
-
}),
|
|
1584
|
-
});
|
|
1585
|
-
if (stepResp && stepResp.result && typeof stepResp.result === "object") {
|
|
1586
|
-
row = { ...row, ...stepResp.result };
|
|
1587
|
-
}
|
|
1588
|
-
stepsJson.push(row);
|
|
1589
|
-
break;
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
const executorOk = !lastErr;
|
|
1594
|
-
const currBuf = await page.screenshot({ type: "png" });
|
|
1595
|
-
const prevB64 = prevBuf.toString("base64");
|
|
1596
|
-
const currB64 = currBuf.toString("base64");
|
|
1597
|
-
lastPrevScreenshotB64 = prevB64;
|
|
1598
|
-
lastCurrScreenshotB64 = currB64;
|
|
1599
|
-
if (firstPrevScreenshotB64 == null) firstPrevScreenshotB64 = prevB64;
|
|
1600
|
-
|
|
1601
|
-
let row = {
|
|
1602
|
-
name: instruction,
|
|
1603
|
-
description: instruction,
|
|
1604
|
-
status: executorOk ? "passed" : "failed",
|
|
1605
|
-
action: action
|
|
1606
|
-
? `${action.method}${action.selector ? ` ${action.selector}` : ""}`
|
|
1607
|
-
: "",
|
|
1608
|
-
error: lastErr || undefined,
|
|
1609
|
-
};
|
|
1610
|
-
|
|
1611
|
-
const stepResp = await apiRequest({
|
|
1612
|
-
apiUrl,
|
|
1613
|
-
token,
|
|
1614
|
-
orgId,
|
|
1615
|
-
path: `/api/v1/cli/executions/${executionId}/step-result`,
|
|
1616
|
-
method: "POST",
|
|
1617
|
-
body: withDevice(effectiveDevice, {
|
|
1618
|
-
step_index: stepIndex,
|
|
1619
|
-
executor_ok: executorOk,
|
|
1620
|
-
action,
|
|
1621
|
-
result: row,
|
|
1622
|
-
prev_screenshot_b64: prevB64,
|
|
1623
|
-
curr_screenshot_b64: currB64,
|
|
1624
|
-
}),
|
|
1625
|
-
});
|
|
1626
|
-
|
|
1627
|
-
if (stepResp && stepResp.result && typeof stepResp.result === "object") {
|
|
1628
|
-
row = { ...row, ...stepResp.result };
|
|
1629
|
-
}
|
|
1630
|
-
const passed = row.status === "passed";
|
|
1631
|
-
if (!passed) runSuccess = false;
|
|
1632
|
-
stepsJson.push(row);
|
|
1633
|
-
|
|
1634
|
-
if (!passed) {
|
|
1635
|
-
runError = lastErr || row.step_thought || "Step failed";
|
|
1636
|
-
break;
|
|
1637
|
-
}
|
|
1638
|
-
await page.waitForTimeout(300);
|
|
849
|
+
const wsParts = parseWsEndpoint(cdpEndpoint);
|
|
850
|
+
if (!Number.isFinite(wsParts.port) || wsParts.port < 1 || wsParts.port > 65535) {
|
|
851
|
+
throw new Error(`Could not parse local browser WebSocket port from ${cdpEndpoint}`);
|
|
1639
852
|
}
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
853
|
+
const tunnel = await apiRequest({
|
|
854
|
+
apiUrl,
|
|
855
|
+
token,
|
|
856
|
+
orgId,
|
|
857
|
+
path: `/api/v1/cli/executions/${executionId}/browser-tunnel`,
|
|
858
|
+
method: "POST",
|
|
859
|
+
body: withDevice(effectiveDevice, { local_port: wsParts.port }),
|
|
860
|
+
});
|
|
861
|
+
const tunnelToken = tunnel?.tunnel?.token;
|
|
862
|
+
const hostname = String(tunnel?.tunnel?.hostname || "").trim();
|
|
863
|
+
if (!tunnelToken || !hostname) {
|
|
864
|
+
throw new Error("Backend did not return a Cloudflare browser tunnel token and hostname.");
|
|
1645
865
|
}
|
|
866
|
+
cloudflared = startCloudflaredForPort(tunnelToken, wsParts.port);
|
|
867
|
+
const publicConnectUrl = `wss://${hostname}${wsParts.path}`;
|
|
868
|
+
// eslint-disable-next-line no-console
|
|
869
|
+
console.log(`[qualty][tunnel] Probing public browser tunnel ${hostname}`);
|
|
870
|
+
await waitForPublicBrowserTunnel({ connectUrl: publicConnectUrl, hostname });
|
|
871
|
+
await apiRequest({
|
|
872
|
+
apiUrl,
|
|
873
|
+
token,
|
|
874
|
+
orgId,
|
|
875
|
+
path: `/api/v1/cli/executions/${executionId}/register-browser`,
|
|
876
|
+
method: "POST",
|
|
877
|
+
body: withDevice(effectiveDevice, {
|
|
878
|
+
connect_url: publicConnectUrl,
|
|
879
|
+
connect_kind: "playwright",
|
|
880
|
+
start_url: url,
|
|
881
|
+
}),
|
|
882
|
+
});
|
|
883
|
+
// eslint-disable-next-line no-console
|
|
884
|
+
console.log(`[qualty] Registered local browser tunnel for ${effectiveDevice}`);
|
|
885
|
+
const result = await waitForCombinationDone({
|
|
886
|
+
apiRequest,
|
|
887
|
+
apiUrl,
|
|
888
|
+
token,
|
|
889
|
+
orgId,
|
|
890
|
+
executionId,
|
|
891
|
+
device: effectiveDevice,
|
|
892
|
+
});
|
|
893
|
+
return { success: result.success, error: result.error, combinationsRemaining: result.combinationsRemaining, device: effectiveDevice };
|
|
1646
894
|
} finally {
|
|
1647
|
-
|
|
1648
|
-
await browser.close().catch(() => {});
|
|
895
|
+
if (cloudflared) cloudflared.kill();
|
|
1649
896
|
await browserServer.close().catch(() => {});
|
|
1650
897
|
}
|
|
1651
|
-
|
|
1652
|
-
const runtimeSec = (Date.now() - started) / 1000;
|
|
1653
|
-
const completeResp = await apiRequest({
|
|
1654
|
-
apiUrl,
|
|
1655
|
-
token,
|
|
1656
|
-
orgId,
|
|
1657
|
-
path: `/api/v1/cli/executions/${executionId}/complete`,
|
|
1658
|
-
method: "POST",
|
|
1659
|
-
body: withDevice(effectiveDevice, {
|
|
1660
|
-
success: runSuccess,
|
|
1661
|
-
steps_json: stepsJson,
|
|
1662
|
-
explanation: runSuccess ? "Local run completed" : runError || "Local run failed",
|
|
1663
|
-
runtime_sec: runtimeSec,
|
|
1664
|
-
error_message: runError,
|
|
1665
|
-
final_screenshot_b64: lastCurrScreenshotB64,
|
|
1666
|
-
prev_screenshot_b64: firstPrevScreenshotB64 || lastPrevScreenshotB64,
|
|
1667
|
-
network_report: networkCollector.snapshot(),
|
|
1668
|
-
}),
|
|
1669
|
-
});
|
|
1670
|
-
|
|
1671
|
-
if (completeResp && completeResp.status) {
|
|
1672
|
-
runSuccess = completeResp.status === "completed" || runSuccess;
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
const combinationsRemaining = Number(completeResp?.combinations_remaining ?? 0);
|
|
1676
|
-
return { success: runSuccess, stepsJson, error: runError, combinationsRemaining, device: effectiveDevice };
|
|
1677
898
|
}
|
|
1678
899
|
|
|
1679
900
|
async function fetchProjectLocalPort({ apiRequest, apiUrl, token, orgId, projectId }) {
|
package/bin/qualty.js
CHANGED
|
@@ -27,7 +27,10 @@ import {
|
|
|
27
27
|
} from "./auth-ci-bundle.js";
|
|
28
28
|
|
|
29
29
|
/** CLI backend URL is fixed by Qualty distribution. */
|
|
30
|
-
const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
|
|
30
|
+
// const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
|
|
31
|
+
// const DEFAULT_QUALTY_API_URL = "http://localhost:8000";
|
|
32
|
+
const DEFAULT_QUALTY_API_URL = "https://qualty-api-development.up.railway.app";
|
|
33
|
+
|
|
31
34
|
|
|
32
35
|
const PROJECT_CONFIG_FILENAME = ".qualty.json";
|
|
33
36
|
const DOTENV_FILENAME = ".env";
|
|
@@ -2133,8 +2136,20 @@ async function runCi(args) {
|
|
|
2133
2136
|
async function resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds }) {
|
|
2134
2137
|
const query = new URLSearchParams();
|
|
2135
2138
|
if (projectId) query.set("project_id", String(projectId));
|
|
2136
|
-
|
|
2137
|
-
|
|
2139
|
+
|
|
2140
|
+
let effectiveSuiteId = suiteId;
|
|
2141
|
+
let ids = [...explicitIds];
|
|
2142
|
+
// Suite M2M jobs exclude sequence members; expand so ci setup and profile resolution see all tests.
|
|
2143
|
+
if (suiteId && ids.length === 0) {
|
|
2144
|
+
const suiteJobIds = await collectSuiteJobIds({ apiUrl, token, orgId, suiteId });
|
|
2145
|
+
if (suiteJobIds.length > 0) {
|
|
2146
|
+
ids = suiteJobIds;
|
|
2147
|
+
effectiveSuiteId = "";
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
if (effectiveSuiteId) query.set("suite_id", String(effectiveSuiteId));
|
|
2152
|
+
if (ids.length > 0) query.set("ids", ids.join(","));
|
|
2138
2153
|
return apiRequest({
|
|
2139
2154
|
apiUrl,
|
|
2140
2155
|
token,
|
|
@@ -2224,6 +2239,20 @@ async function resolveLocalSuitePlan({ apiUrl, token, orgId, suiteId }) {
|
|
|
2224
2239
|
};
|
|
2225
2240
|
}
|
|
2226
2241
|
|
|
2242
|
+
async function collectSuiteJobIds({ apiUrl, token, orgId, suiteId }) {
|
|
2243
|
+
const plan = await resolveLocalSuitePlan({ apiUrl, token, orgId, suiteId });
|
|
2244
|
+
const ids = new Set(plan.standaloneJobIds || []);
|
|
2245
|
+
for (const sequence of plan.sequences || []) {
|
|
2246
|
+
for (const stage of sequence.stages || []) {
|
|
2247
|
+
for (const job of stage.jobs || []) {
|
|
2248
|
+
const jobId = String(job.id || "").trim();
|
|
2249
|
+
if (jobId) ids.add(jobId);
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
return [...ids];
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2227
2256
|
async function runLocalSuiteWithSequences({
|
|
2228
2257
|
apiUrl,
|
|
2229
2258
|
token,
|