qualty 0.1.15 → 0.1.17
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 +152 -29
- package/bin/qualty.js +313 -6
- package/package.json +1 -1
package/bin/local-runner.js
CHANGED
|
@@ -9,6 +9,68 @@ function safePathSegment(value, fallback) {
|
|
|
9
9
|
return normalized || fallback;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Rewrite a dashboard/production test URL to localhost for local CLI runs.
|
|
14
|
+
* Keeps pathname, search, and hash; swaps origin to http://localhost:<port>.
|
|
15
|
+
*/
|
|
16
|
+
export function rewriteUrlForLocalRun(rawUrl, port) {
|
|
17
|
+
const portNum = Number(port);
|
|
18
|
+
if (!Number.isFinite(portNum) || portNum < 1 || portNum > 65535) {
|
|
19
|
+
return String(rawUrl || "").trim();
|
|
20
|
+
}
|
|
21
|
+
const trimmed = String(rawUrl || "").trim();
|
|
22
|
+
if (!trimmed) return trimmed;
|
|
23
|
+
|
|
24
|
+
let parsed;
|
|
25
|
+
try {
|
|
26
|
+
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
27
|
+
parsed = new URL(withProtocol);
|
|
28
|
+
} catch {
|
|
29
|
+
return trimmed;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const suffix = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
33
|
+
return `http://localhost:${portNum}${suffix}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveLocalPort(rawPort, fallback = 3000) {
|
|
37
|
+
const portNum = Number(rawPort);
|
|
38
|
+
if (Number.isFinite(portNum) && portNum >= 1 && portNum <= 65535) return portNum;
|
|
39
|
+
return fallback;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_DESKTOP_DEVICE = "1440x900";
|
|
43
|
+
const DEFAULT_MOBILE_DEVICE = "390x844";
|
|
44
|
+
const LEGACY_DEVICE_PRESETS = {
|
|
45
|
+
desktop: DEFAULT_DESKTOP_DEVICE,
|
|
46
|
+
mobile: DEFAULT_MOBILE_DEVICE,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Match backend/dashboard device list (desktop + mobile toggles). */
|
|
50
|
+
export function normalizeJobDevices(devices) {
|
|
51
|
+
const raw = Array.isArray(devices) ? devices : devices != null ? [devices] : [];
|
|
52
|
+
const out = [];
|
|
53
|
+
for (const entry of raw) {
|
|
54
|
+
const token = String(entry || "").trim().toLowerCase();
|
|
55
|
+
if (!token) continue;
|
|
56
|
+
out.push(LEGACY_DEVICE_PRESETS[token] || token.replace(/\s+/g, ""));
|
|
57
|
+
}
|
|
58
|
+
const unique = [...new Set(out)];
|
|
59
|
+
return unique.length > 0 ? unique : [DEFAULT_DESKTOP_DEVICE];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function devicesForLocalRun(job, cliDeviceFilter) {
|
|
63
|
+
const jobDevices = normalizeJobDevices(job?.devices);
|
|
64
|
+
const filter = String(cliDeviceFilter || "").trim();
|
|
65
|
+
if (!filter) return jobDevices;
|
|
66
|
+
const allowed = new Set(normalizeJobDevices([filter]));
|
|
67
|
+
return jobDevices.filter((d) => allowed.has(d));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function withDevice(device, body = {}) {
|
|
71
|
+
return { device, ...body };
|
|
72
|
+
}
|
|
73
|
+
|
|
12
74
|
function localAuthStatePath({ orgId, projectId, profile }) {
|
|
13
75
|
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
14
76
|
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
@@ -177,6 +239,7 @@ async function preflightMissingLocalBindings({
|
|
|
177
239
|
savedJobs,
|
|
178
240
|
explicitAuthProfile,
|
|
179
241
|
noAuthState,
|
|
242
|
+
localPort = 3000,
|
|
180
243
|
}) {
|
|
181
244
|
if (noAuthState) return;
|
|
182
245
|
if (explicitAuthProfile) return;
|
|
@@ -195,14 +258,16 @@ async function preflightMissingLocalBindings({
|
|
|
195
258
|
const entry = missingByProfile.get(profileName) || {
|
|
196
259
|
localProfileName: profileName,
|
|
197
260
|
projectId: String(job?.project_id || projectId || "").trim(),
|
|
198
|
-
startUrl: String(job?.url || "").trim(),
|
|
261
|
+
startUrl: rewriteUrlForLocalRun(String(job?.url || "").trim(), localPort),
|
|
199
262
|
tests: [],
|
|
200
263
|
};
|
|
201
264
|
entry.tests.push({
|
|
202
265
|
id: String(job?.id || "").trim(),
|
|
203
266
|
name: String(job?.name || job?.id || "Unnamed test").trim(),
|
|
204
267
|
});
|
|
205
|
-
if (!entry.startUrl)
|
|
268
|
+
if (!entry.startUrl) {
|
|
269
|
+
entry.startUrl = rewriteUrlForLocalRun(String(job?.url || "").trim(), localPort);
|
|
270
|
+
}
|
|
206
271
|
missingByProfile.set(profileName, entry);
|
|
207
272
|
}
|
|
208
273
|
if (missingByProfile.size === 0) return;
|
|
@@ -464,6 +529,7 @@ async function runActStep({
|
|
|
464
529
|
token,
|
|
465
530
|
orgId,
|
|
466
531
|
executionId,
|
|
532
|
+
device,
|
|
467
533
|
stepIndex,
|
|
468
534
|
page,
|
|
469
535
|
credentials,
|
|
@@ -491,7 +557,7 @@ async function runActStep({
|
|
|
491
557
|
orgId,
|
|
492
558
|
path: `/api/v1/cli/executions/${executionId}/observe`,
|
|
493
559
|
method: "POST",
|
|
494
|
-
body: {
|
|
560
|
+
body: withDevice(device, {
|
|
495
561
|
step_index: stepIndex,
|
|
496
562
|
attempt,
|
|
497
563
|
page_text: ctx.page_text,
|
|
@@ -500,7 +566,7 @@ async function runActStep({
|
|
|
500
566
|
excluded_selectors: excluded,
|
|
501
567
|
top_candidates_cache: topCandidatesCache,
|
|
502
568
|
...(extraBody && typeof extraBody === "object" ? extraBody : {}),
|
|
503
|
-
},
|
|
569
|
+
}),
|
|
504
570
|
});
|
|
505
571
|
|
|
506
572
|
let observe = await observeRequest();
|
|
@@ -532,6 +598,7 @@ async function runActStep({
|
|
|
532
598
|
token,
|
|
533
599
|
orgId,
|
|
534
600
|
executionId,
|
|
601
|
+
device,
|
|
535
602
|
stepIndex,
|
|
536
603
|
instruction: String(observe.remainder_instruction || ""),
|
|
537
604
|
page,
|
|
@@ -610,6 +677,7 @@ async function runActStep({
|
|
|
610
677
|
token,
|
|
611
678
|
orgId,
|
|
612
679
|
executionId,
|
|
680
|
+
device,
|
|
613
681
|
stepIndex,
|
|
614
682
|
instruction: String(observe.remainder_instruction || ""),
|
|
615
683
|
page,
|
|
@@ -881,6 +949,7 @@ async function runAgentStep({
|
|
|
881
949
|
token,
|
|
882
950
|
orgId,
|
|
883
951
|
executionId,
|
|
952
|
+
device,
|
|
884
953
|
stepIndex,
|
|
885
954
|
instruction,
|
|
886
955
|
page,
|
|
@@ -896,13 +965,13 @@ async function runAgentStep({
|
|
|
896
965
|
|
|
897
966
|
while (turnCount < maxTurns) {
|
|
898
967
|
const screenshotB64 = (await page.screenshot({ type: "png" })).toString("base64");
|
|
899
|
-
const turnBody = {
|
|
968
|
+
const turnBody = withDevice(device, {
|
|
900
969
|
step_index: stepIndex,
|
|
901
970
|
screenshot_b64: screenshotB64,
|
|
902
971
|
page_url: page.url(),
|
|
903
972
|
reset,
|
|
904
973
|
function_results: pendingFunctionResults.length ? pendingFunctionResults : undefined,
|
|
905
|
-
};
|
|
974
|
+
});
|
|
906
975
|
if (instructionOverride && instruction) {
|
|
907
976
|
turnBody.instruction_override = instruction;
|
|
908
977
|
}
|
|
@@ -967,7 +1036,7 @@ async function runAgentStep({
|
|
|
967
1036
|
orgId,
|
|
968
1037
|
path: `/api/v1/cli/executions/${executionId}/agent-segment`,
|
|
969
1038
|
method: "POST",
|
|
970
|
-
body: {
|
|
1039
|
+
body: withDevice(device, {
|
|
971
1040
|
step_index: stepIndex,
|
|
972
1041
|
turn: turnCount,
|
|
973
1042
|
turn_thought: turn.turn_thought || "",
|
|
@@ -976,7 +1045,7 @@ async function runAgentStep({
|
|
|
976
1045
|
error: actionErr || undefined,
|
|
977
1046
|
screenshot_b64: afterB64,
|
|
978
1047
|
page_url: page.url(),
|
|
979
|
-
},
|
|
1048
|
+
}),
|
|
980
1049
|
});
|
|
981
1050
|
} catch (segErr) {
|
|
982
1051
|
console.warn("[CLI] agent-segment upload failed:", segErr.message || segErr);
|
|
@@ -1007,16 +1076,27 @@ export async function runLocalExecution({
|
|
|
1007
1076
|
}) {
|
|
1008
1077
|
const { chromium } = await import("playwright");
|
|
1009
1078
|
|
|
1079
|
+
const runDevice = String(device || "").trim() || "1440x900";
|
|
1010
1080
|
const claim = await apiRequest({
|
|
1011
1081
|
apiUrl,
|
|
1012
1082
|
token,
|
|
1013
1083
|
orgId,
|
|
1014
1084
|
path: `/api/v1/cli/executions/${executionId}/claim`,
|
|
1015
1085
|
method: "POST",
|
|
1016
|
-
body: { device },
|
|
1086
|
+
body: { device: runDevice },
|
|
1017
1087
|
});
|
|
1088
|
+
const effectiveDevice = String(claim.device || runDevice).trim() || runDevice;
|
|
1018
1089
|
|
|
1019
|
-
const
|
|
1090
|
+
const originalUrl = String(claim.url || "").trim();
|
|
1091
|
+
const localPort = resolveLocalPort(claim.local_port);
|
|
1092
|
+
const url = rewriteUrlForLocalRun(originalUrl, localPort);
|
|
1093
|
+
if (url !== originalUrl) {
|
|
1094
|
+
// eslint-disable-next-line no-console
|
|
1095
|
+
console.log(`[qualty] Local URL rewrite: ${originalUrl} -> ${url}`);
|
|
1096
|
+
}
|
|
1097
|
+
claim.url = url;
|
|
1098
|
+
// eslint-disable-next-line no-console
|
|
1099
|
+
console.log(`[qualty] Local run ${executionId} device=${effectiveDevice}`);
|
|
1020
1100
|
const viewport = claim.viewport || { width: 1440, height: 900 };
|
|
1021
1101
|
const steps = Array.isArray(claim.steps) ? claim.steps : [];
|
|
1022
1102
|
const totalSteps = claim.total_steps || steps.length;
|
|
@@ -1126,6 +1206,7 @@ export async function runLocalExecution({
|
|
|
1126
1206
|
token,
|
|
1127
1207
|
orgId,
|
|
1128
1208
|
executionId,
|
|
1209
|
+
device: effectiveDevice,
|
|
1129
1210
|
stepIndex,
|
|
1130
1211
|
instruction,
|
|
1131
1212
|
page,
|
|
@@ -1144,6 +1225,7 @@ export async function runLocalExecution({
|
|
|
1144
1225
|
token,
|
|
1145
1226
|
orgId,
|
|
1146
1227
|
executionId,
|
|
1228
|
+
device: effectiveDevice,
|
|
1147
1229
|
stepIndex,
|
|
1148
1230
|
page,
|
|
1149
1231
|
credentials,
|
|
@@ -1177,14 +1259,14 @@ export async function runLocalExecution({
|
|
|
1177
1259
|
orgId,
|
|
1178
1260
|
path: `/api/v1/cli/executions/${executionId}/step-result`,
|
|
1179
1261
|
method: "POST",
|
|
1180
|
-
body: {
|
|
1262
|
+
body: withDevice(effectiveDevice, {
|
|
1181
1263
|
step_index: stepIndex,
|
|
1182
1264
|
executor_ok: executorOk,
|
|
1183
1265
|
action,
|
|
1184
1266
|
result: row,
|
|
1185
1267
|
prev_screenshot_b64: prevB64,
|
|
1186
1268
|
curr_screenshot_b64: currB64,
|
|
1187
|
-
},
|
|
1269
|
+
}),
|
|
1188
1270
|
});
|
|
1189
1271
|
if (stepResp && stepResp.result && typeof stepResp.result === "object") {
|
|
1190
1272
|
row = { ...row, ...stepResp.result };
|
|
@@ -1218,14 +1300,14 @@ export async function runLocalExecution({
|
|
|
1218
1300
|
orgId,
|
|
1219
1301
|
path: `/api/v1/cli/executions/${executionId}/step-result`,
|
|
1220
1302
|
method: "POST",
|
|
1221
|
-
body: {
|
|
1303
|
+
body: withDevice(effectiveDevice, {
|
|
1222
1304
|
step_index: stepIndex,
|
|
1223
1305
|
executor_ok: executorOk,
|
|
1224
1306
|
action,
|
|
1225
1307
|
result: row,
|
|
1226
1308
|
prev_screenshot_b64: prevB64,
|
|
1227
1309
|
curr_screenshot_b64: currB64,
|
|
1228
|
-
},
|
|
1310
|
+
}),
|
|
1229
1311
|
});
|
|
1230
1312
|
|
|
1231
1313
|
if (stepResp && stepResp.result && typeof stepResp.result === "object") {
|
|
@@ -1260,7 +1342,7 @@ export async function runLocalExecution({
|
|
|
1260
1342
|
orgId,
|
|
1261
1343
|
path: `/api/v1/cli/executions/${executionId}/complete`,
|
|
1262
1344
|
method: "POST",
|
|
1263
|
-
body: {
|
|
1345
|
+
body: withDevice(effectiveDevice, {
|
|
1264
1346
|
success: runSuccess,
|
|
1265
1347
|
steps_json: stepsJson,
|
|
1266
1348
|
explanation: runSuccess ? "Local run completed" : runError || "Local run failed",
|
|
@@ -1269,14 +1351,25 @@ export async function runLocalExecution({
|
|
|
1269
1351
|
final_screenshot_b64: lastCurrScreenshotB64,
|
|
1270
1352
|
prev_screenshot_b64: firstPrevScreenshotB64 || lastPrevScreenshotB64,
|
|
1271
1353
|
network_report: networkCollector.snapshot(),
|
|
1272
|
-
},
|
|
1354
|
+
}),
|
|
1273
1355
|
});
|
|
1274
1356
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
}
|
|
1357
|
+
return { success: runSuccess, stepsJson, error: runError, device: effectiveDevice };
|
|
1358
|
+
}
|
|
1278
1359
|
|
|
1279
|
-
|
|
1360
|
+
async function fetchProjectLocalPort({ apiRequest, apiUrl, token, orgId, projectId }) {
|
|
1361
|
+
if (!projectId) return 3000;
|
|
1362
|
+
try {
|
|
1363
|
+
const project = await apiRequest({
|
|
1364
|
+
apiUrl,
|
|
1365
|
+
token,
|
|
1366
|
+
orgId,
|
|
1367
|
+
path: `/api/v1/projects/${encodeURIComponent(String(projectId))}`,
|
|
1368
|
+
});
|
|
1369
|
+
return resolveLocalPort(project?.localhost?.port);
|
|
1370
|
+
} catch {
|
|
1371
|
+
return 3000;
|
|
1372
|
+
}
|
|
1280
1373
|
}
|
|
1281
1374
|
|
|
1282
1375
|
export async function runLocalCi({
|
|
@@ -1301,6 +1394,8 @@ export async function runLocalCi({
|
|
|
1301
1394
|
throw new Error("No saved tests matched your selector.");
|
|
1302
1395
|
}
|
|
1303
1396
|
|
|
1397
|
+
const localPort = await fetchProjectLocalPort({ apiRequest, apiUrl, token, orgId, projectId });
|
|
1398
|
+
|
|
1304
1399
|
const startResp = await apiRequest({
|
|
1305
1400
|
apiUrl,
|
|
1306
1401
|
token,
|
|
@@ -1328,29 +1423,57 @@ export async function runLocalCi({
|
|
|
1328
1423
|
savedJobs,
|
|
1329
1424
|
explicitAuthProfile: String(authProfile || "").trim(),
|
|
1330
1425
|
noAuthState: Boolean(noAuthState),
|
|
1426
|
+
localPort,
|
|
1331
1427
|
});
|
|
1332
1428
|
|
|
1429
|
+
const jobById = new Map(savedJobs.map((j) => [String(j.id || "").trim(), j]));
|
|
1430
|
+
const workItems = [];
|
|
1431
|
+
for (const run of started) {
|
|
1432
|
+
const jobId = String(run.saved_job_id || "").trim();
|
|
1433
|
+
const job = jobById.get(jobId);
|
|
1434
|
+
const devices = devicesForLocalRun(job, device);
|
|
1435
|
+
if (devices.length === 0) {
|
|
1436
|
+
// eslint-disable-next-line no-console
|
|
1437
|
+
console.warn(
|
|
1438
|
+
`[qualty] Skipping ${run.saved_job_name || jobId}: no devices match filter${device ? ` (--device ${device})` : ""}`
|
|
1439
|
+
);
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
for (const dev of devices) {
|
|
1443
|
+
workItems.push({
|
|
1444
|
+
executionId: run.execution_job_id,
|
|
1445
|
+
device: dev,
|
|
1446
|
+
testName: run.saved_job_name || run.saved_job_id,
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
if (workItems.length === 0) {
|
|
1451
|
+
throw new Error("No device combinations to run.");
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1333
1454
|
const concurrency = Math.max(1, Number(localConcurrency) || 4);
|
|
1334
|
-
const workerCount = Math.min(concurrency,
|
|
1455
|
+
const workerCount = Math.min(concurrency, workItems.length);
|
|
1335
1456
|
|
|
1336
1457
|
let passed = 0;
|
|
1337
1458
|
let failed = 0;
|
|
1338
1459
|
|
|
1339
1460
|
// eslint-disable-next-line no-console
|
|
1340
|
-
console.log(
|
|
1461
|
+
console.log(
|
|
1462
|
+
`[qualty] Running ${workItems.length} local device run(s) across ${started.length} test(s) with concurrency=${workerCount}`
|
|
1463
|
+
);
|
|
1341
1464
|
|
|
1342
1465
|
let cursor = 0;
|
|
1343
1466
|
const worker = async (workerId) => {
|
|
1344
1467
|
while (true) {
|
|
1345
1468
|
const idx = cursor;
|
|
1346
1469
|
cursor += 1;
|
|
1347
|
-
const
|
|
1348
|
-
if (!
|
|
1470
|
+
const item = workItems[idx];
|
|
1471
|
+
if (!item) return;
|
|
1349
1472
|
|
|
1350
|
-
const id =
|
|
1473
|
+
const id = item.executionId;
|
|
1351
1474
|
// eslint-disable-next-line no-console
|
|
1352
1475
|
console.log(
|
|
1353
|
-
`[qualty][worker ${workerId}] Local run ${id} (${
|
|
1476
|
+
`[qualty][worker ${workerId}] Local run ${id} (${item.testName}) device=${item.device}`
|
|
1354
1477
|
);
|
|
1355
1478
|
try {
|
|
1356
1479
|
const result = await runLocalExecution({
|
|
@@ -1359,7 +1482,7 @@ export async function runLocalCi({
|
|
|
1359
1482
|
token,
|
|
1360
1483
|
orgId,
|
|
1361
1484
|
executionId: id,
|
|
1362
|
-
device,
|
|
1485
|
+
device: item.device,
|
|
1363
1486
|
headless,
|
|
1364
1487
|
authProfile,
|
|
1365
1488
|
noAuthState,
|
|
@@ -1404,9 +1527,9 @@ export async function runLocalCi({
|
|
|
1404
1527
|
Array.from({ length: workerCount }, (_, idx) => worker(idx + 1))
|
|
1405
1528
|
);
|
|
1406
1529
|
|
|
1407
|
-
if (
|
|
1530
|
+
if (workItems.length > 1) {
|
|
1408
1531
|
// eslint-disable-next-line no-console
|
|
1409
|
-
console.log(`[qualty] Parallel local runs complete (${
|
|
1532
|
+
console.log(`[qualty] Parallel local runs complete (${workItems.length} device run(s)).`);
|
|
1410
1533
|
}
|
|
1411
1534
|
|
|
1412
1535
|
// eslint-disable-next-line no-console
|
package/bin/qualty.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
appendFileSync,
|
|
5
|
+
chmodSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { gzipSync } from "node:zlib";
|
|
13
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
14
|
import { homedir, hostname } from "node:os";
|
|
6
15
|
import { join } from "node:path";
|
|
7
16
|
import { createInterface } from "node:readline/promises";
|
|
8
17
|
import process from "node:process";
|
|
9
|
-
import { runLocalCi } from "./local-runner.js";
|
|
18
|
+
import { rewriteUrlForLocalRun, runLocalCi } from "./local-runner.js";
|
|
10
19
|
|
|
11
20
|
/** CLI backend URL is fixed by Qualty distribution. */
|
|
12
21
|
const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
|
|
@@ -135,6 +144,8 @@ function usage() {
|
|
|
135
144
|
"Usage:",
|
|
136
145
|
" qualty setup [--token <bearer-token>] [--org <org-id>]",
|
|
137
146
|
" qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
|
|
147
|
+
" qualty auth export-ci --project <project-id> [--profile <profile-name>] [--org <org-id>]",
|
|
148
|
+
" [--output <path>] [--no-file] [--copy] [--set-secret] [--gzip] [--include-token]",
|
|
138
149
|
" qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
|
|
139
150
|
" qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
|
|
140
151
|
" [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
|
|
@@ -485,6 +496,281 @@ function localAuthStatePath({ orgId, projectId, profile }) {
|
|
|
485
496
|
return join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
|
|
486
497
|
}
|
|
487
498
|
|
|
499
|
+
function authExportCiOutputPath(statePath) {
|
|
500
|
+
return statePath.replace(/\.json$/i, ".b64");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const GITHUB_SECRET_MAX_BYTES = 64 * 1024;
|
|
504
|
+
|
|
505
|
+
function copyToClipboard(text) {
|
|
506
|
+
if (process.platform === "darwin") {
|
|
507
|
+
const result = spawnSync("pbcopy", { input: text, encoding: "utf8" });
|
|
508
|
+
return result.status === 0;
|
|
509
|
+
}
|
|
510
|
+
if (process.platform === "linux") {
|
|
511
|
+
for (const [cmd, ...cmdArgs] of [
|
|
512
|
+
["xclip", "-selection", "clipboard"],
|
|
513
|
+
["xsel", "--clipboard", "--input"],
|
|
514
|
+
]) {
|
|
515
|
+
const result = spawnSync(cmd, cmdArgs, { input: text, encoding: "utf8" });
|
|
516
|
+
if (result.status === 0) return true;
|
|
517
|
+
}
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
if (process.platform === "win32") {
|
|
521
|
+
const result = spawnSync("clip", { input: text, shell: true, encoding: "utf8" });
|
|
522
|
+
return result.status === 0;
|
|
523
|
+
}
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function setGithubSecret(name, value) {
|
|
528
|
+
const result = spawnSync("gh", ["secret", "set", name], { input: value, encoding: "utf8" });
|
|
529
|
+
if (result.error?.code === "ENOENT") {
|
|
530
|
+
return { ok: false, reason: "GitHub CLI (gh) is not installed." };
|
|
531
|
+
}
|
|
532
|
+
if (result.status !== 0) {
|
|
533
|
+
const detail = String(result.stderr || result.stdout || "").trim();
|
|
534
|
+
return { ok: false, reason: detail || `gh secret set failed (exit ${result.status})` };
|
|
535
|
+
}
|
|
536
|
+
return { ok: true };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
|
|
540
|
+
const raw = readFileSync(statePath);
|
|
541
|
+
if (gzip) {
|
|
542
|
+
return {
|
|
543
|
+
encoding: "gzip+base64",
|
|
544
|
+
payload: gzipSync(raw).toString("base64"),
|
|
545
|
+
rawBytes: raw.length,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
encoding: "base64",
|
|
550
|
+
payload: raw.toString("base64"),
|
|
551
|
+
rawBytes: raw.length,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function githubAuthRestoreShell({ orgId, projectId, profile, encoding }) {
|
|
556
|
+
const orgSeg = safePathSegment(orgId, "default-org");
|
|
557
|
+
const projectSeg = safePathSegment(projectId, "default-project");
|
|
558
|
+
const profileSeg = safePathSegment(profile, "default");
|
|
559
|
+
const decodeCmd =
|
|
560
|
+
encoding === "gzip+base64"
|
|
561
|
+
? 'echo "$QUALTY_AUTH_STATE_B64" | base64 -d | gunzip'
|
|
562
|
+
: 'echo "$QUALTY_AUTH_STATE_B64" | base64 -d';
|
|
563
|
+
return [
|
|
564
|
+
`- name: Restore Qualty login state`,
|
|
565
|
+
` env:`,
|
|
566
|
+
` QUALTY_AUTH_STATE_B64: \${{ secrets.QUALTY_AUTH_STATE_B64 }}`,
|
|
567
|
+
` run: |`,
|
|
568
|
+
` AUTH_DIR="$HOME/.qualty/local-contexts/${orgSeg}/${projectSeg}"`,
|
|
569
|
+
` mkdir -p "$AUTH_DIR"`,
|
|
570
|
+
` ${decodeCmd} > "$AUTH_DIR/${profileSeg}.json"`,
|
|
571
|
+
` test -s "$AUTH_DIR/${profileSeg}.json"`,
|
|
572
|
+
``,
|
|
573
|
+
`- name: Run Qualty (local)`,
|
|
574
|
+
` env:`,
|
|
575
|
+
` QUALTY_API_TOKEN: \${{ secrets.QUALTY_API_TOKEN }}`,
|
|
576
|
+
` QUALTY_ORG_ID: \${{ secrets.QUALTY_ORG_ID }}`,
|
|
577
|
+
` run: |`,
|
|
578
|
+
` npx qualty@latest run --local \\`,
|
|
579
|
+
` --project ${projectId} \\`,
|
|
580
|
+
` --suite-id YOUR_SUITE_UUID \\`,
|
|
581
|
+
` --auth-profile "${profile}" \\`,
|
|
582
|
+
` --fail-on-failure true`,
|
|
583
|
+
].join("\n");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function runAuthExportCi(args) {
|
|
587
|
+
const config = readQualtyConfig();
|
|
588
|
+
const orgId = resolveOrgId(args, config);
|
|
589
|
+
const token = resolveToken(args, config);
|
|
590
|
+
const projectCfg = readProjectConfig();
|
|
591
|
+
const projectId = String(args.project || projectCfg.local_auth_project_id || "").trim();
|
|
592
|
+
const profile = String(args.profile || args["auth-profile"] || projectCfg.local_auth_profile || "").trim();
|
|
593
|
+
|
|
594
|
+
if (!orgId) {
|
|
595
|
+
throw new Error("Missing org. Pass --org, set QUALTY_ORG_ID, or add default_org_id to .qualty.json.");
|
|
596
|
+
}
|
|
597
|
+
if (!projectId) {
|
|
598
|
+
throw new Error("Missing project. Pass --project (or run qualty auth setup first).");
|
|
599
|
+
}
|
|
600
|
+
if (!profile) {
|
|
601
|
+
throw new Error('Missing profile. Pass --profile "your-profile" (or run qualty auth setup first).');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const statePath = localAuthStatePath({ orgId, projectId, profile });
|
|
605
|
+
if (!existsSync(statePath)) {
|
|
606
|
+
throw new Error(
|
|
607
|
+
[
|
|
608
|
+
`Local auth state not found:`,
|
|
609
|
+
` ${statePath}`,
|
|
610
|
+
"",
|
|
611
|
+
"Create it first:",
|
|
612
|
+
` qualty auth setup --project ${projectId} --profile "${profile}"`,
|
|
613
|
+
].join("\n")
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
let gzip = parseBoolean(args.gzip, false);
|
|
618
|
+
let encoded = encodeAuthStateForGithubSecret(statePath, { gzip });
|
|
619
|
+
let secretBytes = Buffer.byteLength(encoded.payload, "utf8");
|
|
620
|
+
if (!gzip && secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
|
|
621
|
+
gzip = true;
|
|
622
|
+
encoded = encodeAuthStateForGithubSecret(statePath, { gzip: true });
|
|
623
|
+
secretBytes = Buffer.byteLength(encoded.payload, "utf8");
|
|
624
|
+
}
|
|
625
|
+
if (secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
|
|
626
|
+
throw new Error(
|
|
627
|
+
`Auth state is too large for a GitHub secret (${secretBytes} bytes; limit ~64 KB). ` +
|
|
628
|
+
"Try a shorter-lived session, fewer cookies, or contact Qualty support."
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const noFile = parseBoolean(args["no-file"], false);
|
|
633
|
+
const outputPath = noFile
|
|
634
|
+
? String(args.output || "").trim()
|
|
635
|
+
: String(args.output || "").trim() || authExportCiOutputPath(statePath);
|
|
636
|
+
const copyToClipboardFlag = parseBoolean(args.copy, false);
|
|
637
|
+
const setSecretFlag = parseBoolean(args["set-secret"], false);
|
|
638
|
+
if (outputPath) {
|
|
639
|
+
writeFileSync(outputPath, encoded.payload, "utf8");
|
|
640
|
+
try {
|
|
641
|
+
chmodSync(outputPath, 0o600);
|
|
642
|
+
} catch {
|
|
643
|
+
// ignore platforms that do not support chmod
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const includeToken = parseBoolean(args["include-token"], false);
|
|
648
|
+
const profileSeg = safePathSegment(profile, "default");
|
|
649
|
+
const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setSecretFlag);
|
|
650
|
+
|
|
651
|
+
// eslint-disable-next-line no-console
|
|
652
|
+
console.log("");
|
|
653
|
+
// eslint-disable-next-line no-console
|
|
654
|
+
console.log("=== Qualty → GitHub Actions secrets ===");
|
|
655
|
+
// eslint-disable-next-line no-console
|
|
656
|
+
console.log("");
|
|
657
|
+
if (encoded.encoding === "gzip+base64") {
|
|
658
|
+
// eslint-disable-next-line no-console
|
|
659
|
+
console.log(`Auth state: gzip+base64 (${encoded.rawBytes} bytes raw → ${secretBytes} bytes secret)`);
|
|
660
|
+
} else {
|
|
661
|
+
// eslint-disable-next-line no-console
|
|
662
|
+
console.log(`Auth state: base64 (${encoded.rawBytes} bytes raw → ${secretBytes} bytes secret)`);
|
|
663
|
+
}
|
|
664
|
+
// eslint-disable-next-line no-console
|
|
665
|
+
console.log("");
|
|
666
|
+
if (outputPath) {
|
|
667
|
+
// eslint-disable-next-line no-console
|
|
668
|
+
console.log(`Wrote QUALTY_AUTH_STATE_B64 to ${outputPath}`);
|
|
669
|
+
// eslint-disable-next-line no-console
|
|
670
|
+
console.log(` gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
|
|
671
|
+
// eslint-disable-next-line no-console
|
|
672
|
+
console.log("");
|
|
673
|
+
}
|
|
674
|
+
if (setSecretFlag) {
|
|
675
|
+
const result = setGithubSecret("QUALTY_AUTH_STATE_B64", encoded.payload);
|
|
676
|
+
if (result.ok) {
|
|
677
|
+
// eslint-disable-next-line no-console
|
|
678
|
+
console.log("✓ Set GitHub secret QUALTY_AUTH_STATE_B64 via gh CLI.");
|
|
679
|
+
} else {
|
|
680
|
+
// eslint-disable-next-line no-console
|
|
681
|
+
console.log(`Could not set QUALTY_AUTH_STATE_B64 automatically: ${result.reason}`);
|
|
682
|
+
if (outputPath) {
|
|
683
|
+
// eslint-disable-next-line no-console
|
|
684
|
+
console.log(` gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// eslint-disable-next-line no-console
|
|
688
|
+
console.log("");
|
|
689
|
+
}
|
|
690
|
+
if (copyToClipboardFlag) {
|
|
691
|
+
if (copyToClipboard(encoded.payload)) {
|
|
692
|
+
// eslint-disable-next-line no-console
|
|
693
|
+
console.log("✓ Copied QUALTY_AUTH_STATE_B64 to clipboard. Paste into GitHub → Settings → Secrets.");
|
|
694
|
+
} else {
|
|
695
|
+
// eslint-disable-next-line no-console
|
|
696
|
+
console.log("Could not copy to clipboard on this machine.");
|
|
697
|
+
if (outputPath) {
|
|
698
|
+
// eslint-disable-next-line no-console
|
|
699
|
+
console.log(` Use the file instead: ${outputPath}`);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// eslint-disable-next-line no-console
|
|
703
|
+
console.log("");
|
|
704
|
+
}
|
|
705
|
+
// eslint-disable-next-line no-console
|
|
706
|
+
console.log("1. Add these GitHub secrets (Settings → Secrets and variables → Actions):");
|
|
707
|
+
// eslint-disable-next-line no-console
|
|
708
|
+
console.log("");
|
|
709
|
+
// eslint-disable-next-line no-console
|
|
710
|
+
console.log("---");
|
|
711
|
+
// eslint-disable-next-line no-console
|
|
712
|
+
console.log("Name: QUALTY_ORG_ID");
|
|
713
|
+
// eslint-disable-next-line no-console
|
|
714
|
+
console.log("Value:");
|
|
715
|
+
// eslint-disable-next-line no-console
|
|
716
|
+
console.log(orgId);
|
|
717
|
+
// eslint-disable-next-line no-console
|
|
718
|
+
console.log("---");
|
|
719
|
+
// eslint-disable-next-line no-console
|
|
720
|
+
console.log("Name: QUALTY_API_TOKEN");
|
|
721
|
+
// eslint-disable-next-line no-console
|
|
722
|
+
console.log("Value:");
|
|
723
|
+
if (includeToken && token) {
|
|
724
|
+
// eslint-disable-next-line no-console
|
|
725
|
+
console.log(token);
|
|
726
|
+
} else if (token) {
|
|
727
|
+
// eslint-disable-next-line no-console
|
|
728
|
+
console.log(`(your qlty_ci_ token — already configured locally as ${maskSecret(token)})`);
|
|
729
|
+
// eslint-disable-next-line no-console
|
|
730
|
+
console.log("Paste the full token from Qualty → Account → API tokens.");
|
|
731
|
+
// eslint-disable-next-line no-console
|
|
732
|
+
console.log("Re-run with --include-token to print the token from this machine (avoid shared screens).");
|
|
733
|
+
} else {
|
|
734
|
+
// eslint-disable-next-line no-console
|
|
735
|
+
console.log("(create in Qualty → Account → API tokens; must start with qlty_ci_)");
|
|
736
|
+
}
|
|
737
|
+
// eslint-disable-next-line no-console
|
|
738
|
+
console.log("---");
|
|
739
|
+
// eslint-disable-next-line no-console
|
|
740
|
+
console.log("Name: QUALTY_AUTH_STATE_B64");
|
|
741
|
+
if (skipPayloadPrint) {
|
|
742
|
+
// eslint-disable-next-line no-console
|
|
743
|
+
console.log(`Value: (see file above${copyToClipboardFlag ? " or clipboard" : ""}${setSecretFlag ? " or gh secret" : ""})`);
|
|
744
|
+
} else {
|
|
745
|
+
// eslint-disable-next-line no-console
|
|
746
|
+
console.log("Value:");
|
|
747
|
+
// eslint-disable-next-line no-console
|
|
748
|
+
console.log(encoded.payload);
|
|
749
|
+
}
|
|
750
|
+
// eslint-disable-next-line no-console
|
|
751
|
+
console.log("---");
|
|
752
|
+
// eslint-disable-next-line no-console
|
|
753
|
+
console.log("");
|
|
754
|
+
// eslint-disable-next-line no-console
|
|
755
|
+
console.log("3. Paste this workflow snippet before qualty run:");
|
|
756
|
+
// eslint-disable-next-line no-console
|
|
757
|
+
console.log("");
|
|
758
|
+
// eslint-disable-next-line no-console
|
|
759
|
+
console.log(githubAuthRestoreShell({ orgId, projectId, profile, encoding: encoded.encoding }));
|
|
760
|
+
// eslint-disable-next-line no-console
|
|
761
|
+
console.log("");
|
|
762
|
+
// eslint-disable-next-line no-console
|
|
763
|
+
console.log(`Local file: ${statePath}`);
|
|
764
|
+
// eslint-disable-next-line no-console
|
|
765
|
+
console.log(`Profile flag: --auth-profile "${profile}" (file on disk: ${profileSeg}.json)`);
|
|
766
|
+
// eslint-disable-next-line no-console
|
|
767
|
+
console.log("");
|
|
768
|
+
// eslint-disable-next-line no-console
|
|
769
|
+
console.log("When login expires, re-run qualty auth setup and qualty auth export-ci to refresh the secret.");
|
|
770
|
+
// eslint-disable-next-line no-console
|
|
771
|
+
console.log("");
|
|
772
|
+
}
|
|
773
|
+
|
|
488
774
|
function listLocalAuthProfiles({ orgId, projectId }) {
|
|
489
775
|
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
490
776
|
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
@@ -1426,6 +1712,7 @@ async function runAuthSetup(args) {
|
|
|
1426
1712
|
id: String(p?.id || "").trim(),
|
|
1427
1713
|
name: String(p?.name || "").trim() || "Unnamed project",
|
|
1428
1714
|
url: String(p?.url || "").trim(),
|
|
1715
|
+
localPort: Number(p?.localhost?.port) || 3000,
|
|
1429
1716
|
}))
|
|
1430
1717
|
.filter((p) => p.id);
|
|
1431
1718
|
if (projects.length === 0) {
|
|
@@ -1537,7 +1824,15 @@ async function runAuthSetup(args) {
|
|
|
1537
1824
|
}
|
|
1538
1825
|
const context = await browser.newContext();
|
|
1539
1826
|
const page = await context.newPage();
|
|
1540
|
-
const
|
|
1827
|
+
const rawStartUrl = project.url || "about:blank";
|
|
1828
|
+
const startUrl =
|
|
1829
|
+
rawStartUrl === "about:blank"
|
|
1830
|
+
? rawStartUrl
|
|
1831
|
+
: rewriteUrlForLocalRun(rawStartUrl, project.localPort);
|
|
1832
|
+
if (startUrl !== rawStartUrl) {
|
|
1833
|
+
// eslint-disable-next-line no-console
|
|
1834
|
+
console.log(`[qualty][auth] Local URL rewrite: ${rawStartUrl} -> ${startUrl}`);
|
|
1835
|
+
}
|
|
1541
1836
|
await page.goto(startUrl, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
|
|
1542
1837
|
// eslint-disable-next-line no-console
|
|
1543
1838
|
console.log(`[qualty][auth] Browser opened for project "${project.name}".`);
|
|
@@ -1574,6 +1869,13 @@ async function runAuthSetup(args) {
|
|
|
1574
1869
|
console.log(`[qualty][auth] Saved local context profile "${profile}" for project "${project.name}".`);
|
|
1575
1870
|
// eslint-disable-next-line no-console
|
|
1576
1871
|
console.log(`[qualty][auth] Path: ${statePath}`);
|
|
1872
|
+
// eslint-disable-next-line no-console
|
|
1873
|
+
console.log("");
|
|
1874
|
+
// eslint-disable-next-line no-console
|
|
1875
|
+
console.log(
|
|
1876
|
+
`[qualty][auth] For GitHub Actions secrets, run:\n` +
|
|
1877
|
+
` qualty auth export-ci --project ${project.id} --profile "${profile}"`
|
|
1878
|
+
);
|
|
1577
1879
|
} finally {
|
|
1578
1880
|
rl.close();
|
|
1579
1881
|
}
|
|
@@ -1600,7 +1902,11 @@ async function main() {
|
|
|
1600
1902
|
await runAuthSetup(args);
|
|
1601
1903
|
return;
|
|
1602
1904
|
}
|
|
1603
|
-
|
|
1905
|
+
if (sub === "export-ci") {
|
|
1906
|
+
await runAuthExportCi(args);
|
|
1907
|
+
return;
|
|
1908
|
+
}
|
|
1909
|
+
throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth export-ci");
|
|
1604
1910
|
}
|
|
1605
1911
|
if (command === "run") {
|
|
1606
1912
|
const config = readQualtyConfig();
|
|
@@ -1657,7 +1963,8 @@ async function main() {
|
|
|
1657
1963
|
resolveSavedJobs,
|
|
1658
1964
|
apiRequest,
|
|
1659
1965
|
failOnFailure: parseBoolean(args["fail-on-failure"], true),
|
|
1660
|
-
|
|
1966
|
+
// Only filter viewports when --device is passed; otherwise run all devices on each test.
|
|
1967
|
+
device: args.device ? String(args.device).trim() : undefined,
|
|
1661
1968
|
headless: !parseBoolean(args.headed, false),
|
|
1662
1969
|
localConcurrency: resolveLocalConcurrency(args),
|
|
1663
1970
|
authProfile: localAuth?.profile || authChoice.authProfile || "",
|