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.
@@ -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) entry.startUrl = String(job?.url || "").trim();
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 url = claim.url;
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
- if (completeResp && completeResp.status) {
1276
- runSuccess = completeResp.status === "completed";
1277
- }
1357
+ return { success: runSuccess, stepsJson, error: runError, device: effectiveDevice };
1358
+ }
1278
1359
 
1279
- return { success: runSuccess, stepsJson, error: runError };
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, started.length);
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(`[qualty] Running ${started.length} local run(s) with concurrency=${workerCount}`);
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 run = started[idx];
1348
- if (!run) return;
1470
+ const item = workItems[idx];
1471
+ if (!item) return;
1349
1472
 
1350
- const id = run.execution_job_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} (${run.saved_job_name || run.saved_job_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 (started.length > 1) {
1530
+ if (workItems.length > 1) {
1408
1531
  // eslint-disable-next-line no-console
1409
- console.log(`[qualty] Parallel local runs complete (${started.length} total).`);
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 { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
- import { spawn } from "node:child_process";
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 startUrl = project.url || "about:blank";
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
- throw new Error("Unknown auth subcommand. Use: qualty auth setup");
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
- device: args.device || "1440x900",
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 || "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"