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.
@@ -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
- if (completeResp && completeResp.status) {
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, started.length);
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(`[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
+ );
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 run = started[idx];
1406
- if (!run) return;
1470
+ const item = workItems[idx];
1471
+ if (!item) return;
1407
1472
 
1408
- const id = run.execution_job_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} (${run.saved_job_name || run.saved_job_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 (started.length > 1) {
1530
+ if (workItems.length > 1) {
1466
1531
  // eslint-disable-next-line no-console
1467
- console.log(`[qualty] Parallel local runs complete (${started.length} total).`);
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 { 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";
@@ -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
- 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");
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
- 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,
1671
1968
  headless: !parseBoolean(args.headed, false),
1672
1969
  localConcurrency: resolveLocalConcurrency(args),
1673
1970
  authProfile: localAuth?.profile || authChoice.authProfile || "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.16",
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"