qualty 0.1.16 → 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 +92 -27
- package/bin/qualty.js +302 -5
- package/package.json +1 -1
package/bin/local-runner.js
CHANGED
|
@@ -39,6 +39,38 @@ function resolveLocalPort(rawPort, fallback = 3000) {
|
|
|
39
39
|
return fallback;
|
|
40
40
|
}
|
|
41
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
|
+
|
|
42
74
|
function localAuthStatePath({ orgId, projectId, profile }) {
|
|
43
75
|
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
44
76
|
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
@@ -497,6 +529,7 @@ async function runActStep({
|
|
|
497
529
|
token,
|
|
498
530
|
orgId,
|
|
499
531
|
executionId,
|
|
532
|
+
device,
|
|
500
533
|
stepIndex,
|
|
501
534
|
page,
|
|
502
535
|
credentials,
|
|
@@ -524,7 +557,7 @@ async function runActStep({
|
|
|
524
557
|
orgId,
|
|
525
558
|
path: `/api/v1/cli/executions/${executionId}/observe`,
|
|
526
559
|
method: "POST",
|
|
527
|
-
body: {
|
|
560
|
+
body: withDevice(device, {
|
|
528
561
|
step_index: stepIndex,
|
|
529
562
|
attempt,
|
|
530
563
|
page_text: ctx.page_text,
|
|
@@ -533,7 +566,7 @@ async function runActStep({
|
|
|
533
566
|
excluded_selectors: excluded,
|
|
534
567
|
top_candidates_cache: topCandidatesCache,
|
|
535
568
|
...(extraBody && typeof extraBody === "object" ? extraBody : {}),
|
|
536
|
-
},
|
|
569
|
+
}),
|
|
537
570
|
});
|
|
538
571
|
|
|
539
572
|
let observe = await observeRequest();
|
|
@@ -565,6 +598,7 @@ async function runActStep({
|
|
|
565
598
|
token,
|
|
566
599
|
orgId,
|
|
567
600
|
executionId,
|
|
601
|
+
device,
|
|
568
602
|
stepIndex,
|
|
569
603
|
instruction: String(observe.remainder_instruction || ""),
|
|
570
604
|
page,
|
|
@@ -643,6 +677,7 @@ async function runActStep({
|
|
|
643
677
|
token,
|
|
644
678
|
orgId,
|
|
645
679
|
executionId,
|
|
680
|
+
device,
|
|
646
681
|
stepIndex,
|
|
647
682
|
instruction: String(observe.remainder_instruction || ""),
|
|
648
683
|
page,
|
|
@@ -914,6 +949,7 @@ async function runAgentStep({
|
|
|
914
949
|
token,
|
|
915
950
|
orgId,
|
|
916
951
|
executionId,
|
|
952
|
+
device,
|
|
917
953
|
stepIndex,
|
|
918
954
|
instruction,
|
|
919
955
|
page,
|
|
@@ -929,13 +965,13 @@ async function runAgentStep({
|
|
|
929
965
|
|
|
930
966
|
while (turnCount < maxTurns) {
|
|
931
967
|
const screenshotB64 = (await page.screenshot({ type: "png" })).toString("base64");
|
|
932
|
-
const turnBody = {
|
|
968
|
+
const turnBody = withDevice(device, {
|
|
933
969
|
step_index: stepIndex,
|
|
934
970
|
screenshot_b64: screenshotB64,
|
|
935
971
|
page_url: page.url(),
|
|
936
972
|
reset,
|
|
937
973
|
function_results: pendingFunctionResults.length ? pendingFunctionResults : undefined,
|
|
938
|
-
};
|
|
974
|
+
});
|
|
939
975
|
if (instructionOverride && instruction) {
|
|
940
976
|
turnBody.instruction_override = instruction;
|
|
941
977
|
}
|
|
@@ -1000,7 +1036,7 @@ async function runAgentStep({
|
|
|
1000
1036
|
orgId,
|
|
1001
1037
|
path: `/api/v1/cli/executions/${executionId}/agent-segment`,
|
|
1002
1038
|
method: "POST",
|
|
1003
|
-
body: {
|
|
1039
|
+
body: withDevice(device, {
|
|
1004
1040
|
step_index: stepIndex,
|
|
1005
1041
|
turn: turnCount,
|
|
1006
1042
|
turn_thought: turn.turn_thought || "",
|
|
@@ -1009,7 +1045,7 @@ async function runAgentStep({
|
|
|
1009
1045
|
error: actionErr || undefined,
|
|
1010
1046
|
screenshot_b64: afterB64,
|
|
1011
1047
|
page_url: page.url(),
|
|
1012
|
-
},
|
|
1048
|
+
}),
|
|
1013
1049
|
});
|
|
1014
1050
|
} catch (segErr) {
|
|
1015
1051
|
console.warn("[CLI] agent-segment upload failed:", segErr.message || segErr);
|
|
@@ -1040,14 +1076,16 @@ export async function runLocalExecution({
|
|
|
1040
1076
|
}) {
|
|
1041
1077
|
const { chromium } = await import("playwright");
|
|
1042
1078
|
|
|
1079
|
+
const runDevice = String(device || "").trim() || "1440x900";
|
|
1043
1080
|
const claim = await apiRequest({
|
|
1044
1081
|
apiUrl,
|
|
1045
1082
|
token,
|
|
1046
1083
|
orgId,
|
|
1047
1084
|
path: `/api/v1/cli/executions/${executionId}/claim`,
|
|
1048
1085
|
method: "POST",
|
|
1049
|
-
body: { device },
|
|
1086
|
+
body: { device: runDevice },
|
|
1050
1087
|
});
|
|
1088
|
+
const effectiveDevice = String(claim.device || runDevice).trim() || runDevice;
|
|
1051
1089
|
|
|
1052
1090
|
const originalUrl = String(claim.url || "").trim();
|
|
1053
1091
|
const localPort = resolveLocalPort(claim.local_port);
|
|
@@ -1057,6 +1095,8 @@ export async function runLocalExecution({
|
|
|
1057
1095
|
console.log(`[qualty] Local URL rewrite: ${originalUrl} -> ${url}`);
|
|
1058
1096
|
}
|
|
1059
1097
|
claim.url = url;
|
|
1098
|
+
// eslint-disable-next-line no-console
|
|
1099
|
+
console.log(`[qualty] Local run ${executionId} device=${effectiveDevice}`);
|
|
1060
1100
|
const viewport = claim.viewport || { width: 1440, height: 900 };
|
|
1061
1101
|
const steps = Array.isArray(claim.steps) ? claim.steps : [];
|
|
1062
1102
|
const totalSteps = claim.total_steps || steps.length;
|
|
@@ -1166,6 +1206,7 @@ export async function runLocalExecution({
|
|
|
1166
1206
|
token,
|
|
1167
1207
|
orgId,
|
|
1168
1208
|
executionId,
|
|
1209
|
+
device: effectiveDevice,
|
|
1169
1210
|
stepIndex,
|
|
1170
1211
|
instruction,
|
|
1171
1212
|
page,
|
|
@@ -1184,6 +1225,7 @@ export async function runLocalExecution({
|
|
|
1184
1225
|
token,
|
|
1185
1226
|
orgId,
|
|
1186
1227
|
executionId,
|
|
1228
|
+
device: effectiveDevice,
|
|
1187
1229
|
stepIndex,
|
|
1188
1230
|
page,
|
|
1189
1231
|
credentials,
|
|
@@ -1217,14 +1259,14 @@ export async function runLocalExecution({
|
|
|
1217
1259
|
orgId,
|
|
1218
1260
|
path: `/api/v1/cli/executions/${executionId}/step-result`,
|
|
1219
1261
|
method: "POST",
|
|
1220
|
-
body: {
|
|
1262
|
+
body: withDevice(effectiveDevice, {
|
|
1221
1263
|
step_index: stepIndex,
|
|
1222
1264
|
executor_ok: executorOk,
|
|
1223
1265
|
action,
|
|
1224
1266
|
result: row,
|
|
1225
1267
|
prev_screenshot_b64: prevB64,
|
|
1226
1268
|
curr_screenshot_b64: currB64,
|
|
1227
|
-
},
|
|
1269
|
+
}),
|
|
1228
1270
|
});
|
|
1229
1271
|
if (stepResp && stepResp.result && typeof stepResp.result === "object") {
|
|
1230
1272
|
row = { ...row, ...stepResp.result };
|
|
@@ -1258,14 +1300,14 @@ export async function runLocalExecution({
|
|
|
1258
1300
|
orgId,
|
|
1259
1301
|
path: `/api/v1/cli/executions/${executionId}/step-result`,
|
|
1260
1302
|
method: "POST",
|
|
1261
|
-
body: {
|
|
1303
|
+
body: withDevice(effectiveDevice, {
|
|
1262
1304
|
step_index: stepIndex,
|
|
1263
1305
|
executor_ok: executorOk,
|
|
1264
1306
|
action,
|
|
1265
1307
|
result: row,
|
|
1266
1308
|
prev_screenshot_b64: prevB64,
|
|
1267
1309
|
curr_screenshot_b64: currB64,
|
|
1268
|
-
},
|
|
1310
|
+
}),
|
|
1269
1311
|
});
|
|
1270
1312
|
|
|
1271
1313
|
if (stepResp && stepResp.result && typeof stepResp.result === "object") {
|
|
@@ -1300,7 +1342,7 @@ export async function runLocalExecution({
|
|
|
1300
1342
|
orgId,
|
|
1301
1343
|
path: `/api/v1/cli/executions/${executionId}/complete`,
|
|
1302
1344
|
method: "POST",
|
|
1303
|
-
body: {
|
|
1345
|
+
body: withDevice(effectiveDevice, {
|
|
1304
1346
|
success: runSuccess,
|
|
1305
1347
|
steps_json: stepsJson,
|
|
1306
1348
|
explanation: runSuccess ? "Local run completed" : runError || "Local run failed",
|
|
@@ -1309,14 +1351,10 @@ export async function runLocalExecution({
|
|
|
1309
1351
|
final_screenshot_b64: lastCurrScreenshotB64,
|
|
1310
1352
|
prev_screenshot_b64: firstPrevScreenshotB64 || lastPrevScreenshotB64,
|
|
1311
1353
|
network_report: networkCollector.snapshot(),
|
|
1312
|
-
},
|
|
1354
|
+
}),
|
|
1313
1355
|
});
|
|
1314
1356
|
|
|
1315
|
-
|
|
1316
|
-
runSuccess = completeResp.status === "completed";
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
return { success: runSuccess, stepsJson, error: runError };
|
|
1357
|
+
return { success: runSuccess, stepsJson, error: runError, device: effectiveDevice };
|
|
1320
1358
|
}
|
|
1321
1359
|
|
|
1322
1360
|
async function fetchProjectLocalPort({ apiRequest, apiUrl, token, orgId, projectId }) {
|
|
@@ -1388,27 +1426,54 @@ export async function runLocalCi({
|
|
|
1388
1426
|
localPort,
|
|
1389
1427
|
});
|
|
1390
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
|
+
|
|
1391
1454
|
const concurrency = Math.max(1, Number(localConcurrency) || 4);
|
|
1392
|
-
const workerCount = Math.min(concurrency,
|
|
1455
|
+
const workerCount = Math.min(concurrency, workItems.length);
|
|
1393
1456
|
|
|
1394
1457
|
let passed = 0;
|
|
1395
1458
|
let failed = 0;
|
|
1396
1459
|
|
|
1397
1460
|
// eslint-disable-next-line no-console
|
|
1398
|
-
console.log(
|
|
1461
|
+
console.log(
|
|
1462
|
+
`[qualty] Running ${workItems.length} local device run(s) across ${started.length} test(s) with concurrency=${workerCount}`
|
|
1463
|
+
);
|
|
1399
1464
|
|
|
1400
1465
|
let cursor = 0;
|
|
1401
1466
|
const worker = async (workerId) => {
|
|
1402
1467
|
while (true) {
|
|
1403
1468
|
const idx = cursor;
|
|
1404
1469
|
cursor += 1;
|
|
1405
|
-
const
|
|
1406
|
-
if (!
|
|
1470
|
+
const item = workItems[idx];
|
|
1471
|
+
if (!item) return;
|
|
1407
1472
|
|
|
1408
|
-
const id =
|
|
1473
|
+
const id = item.executionId;
|
|
1409
1474
|
// eslint-disable-next-line no-console
|
|
1410
1475
|
console.log(
|
|
1411
|
-
`[qualty][worker ${workerId}] Local run ${id} (${
|
|
1476
|
+
`[qualty][worker ${workerId}] Local run ${id} (${item.testName}) device=${item.device}`
|
|
1412
1477
|
);
|
|
1413
1478
|
try {
|
|
1414
1479
|
const result = await runLocalExecution({
|
|
@@ -1417,7 +1482,7 @@ export async function runLocalCi({
|
|
|
1417
1482
|
token,
|
|
1418
1483
|
orgId,
|
|
1419
1484
|
executionId: id,
|
|
1420
|
-
device,
|
|
1485
|
+
device: item.device,
|
|
1421
1486
|
headless,
|
|
1422
1487
|
authProfile,
|
|
1423
1488
|
noAuthState,
|
|
@@ -1462,9 +1527,9 @@ export async function runLocalCi({
|
|
|
1462
1527
|
Array.from({ length: workerCount }, (_, idx) => worker(idx + 1))
|
|
1463
1528
|
);
|
|
1464
1529
|
|
|
1465
|
-
if (
|
|
1530
|
+
if (workItems.length > 1) {
|
|
1466
1531
|
// eslint-disable-next-line no-console
|
|
1467
|
-
console.log(`[qualty] Parallel local runs complete (${
|
|
1532
|
+
console.log(`[qualty] Parallel local runs complete (${workItems.length} device run(s)).`);
|
|
1468
1533
|
}
|
|
1469
1534
|
|
|
1470
1535
|
// eslint-disable-next-line no-console
|
package/bin/qualty.js
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
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";
|
|
@@ -11,7 +20,6 @@ import { rewriteUrlForLocalRun, runLocalCi } from "./local-runner.js";
|
|
|
11
20
|
/** CLI backend URL is fixed by Qualty distribution. */
|
|
12
21
|
const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
|
|
13
22
|
|
|
14
|
-
|
|
15
23
|
const QUALTY_CONFIG_DIR = join(homedir(), ".config", "qualty");
|
|
16
24
|
const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
|
|
17
25
|
const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
|
|
@@ -136,6 +144,8 @@ function usage() {
|
|
|
136
144
|
"Usage:",
|
|
137
145
|
" qualty setup [--token <bearer-token>] [--org <org-id>]",
|
|
138
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]",
|
|
139
149
|
" qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
|
|
140
150
|
" qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
|
|
141
151
|
" [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
|
|
@@ -486,6 +496,281 @@ function localAuthStatePath({ orgId, projectId, profile }) {
|
|
|
486
496
|
return join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
|
|
487
497
|
}
|
|
488
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
|
+
|
|
489
774
|
function listLocalAuthProfiles({ orgId, projectId }) {
|
|
490
775
|
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
491
776
|
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
@@ -1584,6 +1869,13 @@ async function runAuthSetup(args) {
|
|
|
1584
1869
|
console.log(`[qualty][auth] Saved local context profile "${profile}" for project "${project.name}".`);
|
|
1585
1870
|
// eslint-disable-next-line no-console
|
|
1586
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
|
+
);
|
|
1587
1879
|
} finally {
|
|
1588
1880
|
rl.close();
|
|
1589
1881
|
}
|
|
@@ -1610,7 +1902,11 @@ async function main() {
|
|
|
1610
1902
|
await runAuthSetup(args);
|
|
1611
1903
|
return;
|
|
1612
1904
|
}
|
|
1613
|
-
|
|
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");
|
|
1614
1910
|
}
|
|
1615
1911
|
if (command === "run") {
|
|
1616
1912
|
const config = readQualtyConfig();
|
|
@@ -1667,7 +1963,8 @@ async function main() {
|
|
|
1667
1963
|
resolveSavedJobs,
|
|
1668
1964
|
apiRequest,
|
|
1669
1965
|
failOnFailure: parseBoolean(args["fail-on-failure"], true),
|
|
1670
|
-
|
|
1966
|
+
// Only filter viewports when --device is passed; otherwise run all devices on each test.
|
|
1967
|
+
device: args.device ? String(args.device).trim() : undefined,
|
|
1671
1968
|
headless: !parseBoolean(args.headed, false),
|
|
1672
1969
|
localConcurrency: resolveLocalConcurrency(args),
|
|
1673
1970
|
authProfile: localAuth?.profile || authChoice.authProfile || "",
|