qualty 0.1.8 → 0.1.10

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.
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync, mkdtempSync } from "node:fs";
1
+ import { mkdirSync, writeFileSync, mkdtempSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
 
@@ -754,6 +754,7 @@ export async function runLocalExecution({
754
754
  executionId,
755
755
  device,
756
756
  headless,
757
+ storageStatePath,
757
758
  }) {
758
759
  const { chromium } = await import("playwright");
759
760
 
@@ -777,9 +778,14 @@ export async function runLocalExecution({
777
778
  });
778
779
  const cdpEndpoint = browserServer.wsEndpoint();
779
780
  const browser = await chromium.connect(cdpEndpoint);
780
- const context = await browser.newContext({
781
+ const contextOptions = {
781
782
  viewport: { width: viewport.width || 1440, height: viewport.height || 900 },
782
- });
783
+ };
784
+ if (storageStatePath && existsSync(storageStatePath)) {
785
+ contextOptions.storageState = storageStatePath;
786
+ console.log(`[qualty][auth] using saved context: ${storageStatePath}`);
787
+ }
788
+ const context = await browser.newContext(contextOptions);
783
789
  const page = await context.newPage();
784
790
  const networkCollector = attachNetworkCollector(page);
785
791
  const labelToPath = await downloadProjectAttachments({
@@ -985,6 +991,7 @@ export async function runLocalCi({
985
991
  device,
986
992
  headless,
987
993
  localConcurrency = 4,
994
+ storageStatePath,
988
995
  }) {
989
996
  const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
990
997
  if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
@@ -1040,6 +1047,7 @@ export async function runLocalCi({
1040
1047
  executionId: id,
1041
1048
  device,
1042
1049
  headless,
1050
+ storageStatePath,
1043
1051
  });
1044
1052
  if (result.success) {
1045
1053
  passed += 1;
package/bin/qualty.js CHANGED
@@ -1,15 +1,108 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { appendFileSync } from "node:fs";
3
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
4
  import { spawn } from "node:child_process";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { createInterface } from "node:readline/promises";
5
8
  import process from "node:process";
6
9
  import { runLocalCi } from "./local-runner.js";
7
10
 
8
- /** When unset, CLI uses Qualty development API. Override for production: QUALTY_API_URL=https://qualty-api-production.up.railway.app or local: http://localhost:8000 */
11
+ /** CLI backend URL is fixed by Qualty distribution. */
9
12
  const DEFAULT_QUALTY_API_URL = "https://qualty-api-development.up.railway.app";
13
+ const QUALTY_CONFIG_DIR = join(homedir(), ".config", "qualty");
14
+ const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
15
+ const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
16
+ const QUALTY_SHARED_CREDS_PATH = join(QUALTY_SHARED_CREDS_DIR, "credentials");
17
+ const QUALTY_PROJECT_CONFIG_PATH = join(process.cwd(), ".qualty.json");
10
18
 
11
- function resolveApiUrl(args) {
12
- return String(args.api || process.env.QUALTY_API_URL || DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
19
+ function readQualtyConfig() {
20
+ if (!existsSync(QUALTY_CONFIG_PATH)) return {};
21
+ try {
22
+ const raw = readFileSync(QUALTY_CONFIG_PATH, "utf8");
23
+ const parsed = JSON.parse(raw);
24
+ if (!parsed || typeof parsed !== "object") return {};
25
+ return parsed;
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ function writeQualtyConfig(config) {
32
+ mkdirSync(QUALTY_CONFIG_DIR, { recursive: true });
33
+ writeFileSync(QUALTY_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
34
+ try {
35
+ chmodSync(QUALTY_CONFIG_PATH, 0o600);
36
+ } catch {
37
+ // best effort on non-posix systems
38
+ }
39
+ }
40
+
41
+ function readSharedCredentials() {
42
+ if (!existsSync(QUALTY_SHARED_CREDS_PATH)) return {};
43
+ try {
44
+ const raw = readFileSync(QUALTY_SHARED_CREDS_PATH, "utf8");
45
+ const parsed = JSON.parse(raw);
46
+ if (!parsed || typeof parsed !== "object") return {};
47
+ return parsed;
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ function writeSharedCredentials(token) {
54
+ mkdirSync(QUALTY_SHARED_CREDS_DIR, { recursive: true });
55
+ writeFileSync(QUALTY_SHARED_CREDS_PATH, `${JSON.stringify({ api_token: token }, null, 2)}\n`, "utf8");
56
+ try {
57
+ chmodSync(QUALTY_SHARED_CREDS_PATH, 0o600);
58
+ } catch {
59
+ // best effort on non-posix systems
60
+ }
61
+ }
62
+
63
+ function readProjectConfig() {
64
+ if (!existsSync(QUALTY_PROJECT_CONFIG_PATH)) return {};
65
+ try {
66
+ const raw = readFileSync(QUALTY_PROJECT_CONFIG_PATH, "utf8");
67
+ const parsed = JSON.parse(raw);
68
+ if (!parsed || typeof parsed !== "object") return {};
69
+ return parsed;
70
+ } catch {
71
+ return {};
72
+ }
73
+ }
74
+
75
+ function writeProjectConfig(updates) {
76
+ const current = readProjectConfig();
77
+ const next = { ...current };
78
+ for (const [k, v] of Object.entries(updates || {})) {
79
+ if (v === undefined || v === null || v === "") delete next[k];
80
+ else next[k] = v;
81
+ }
82
+ writeFileSync(QUALTY_PROJECT_CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, "utf8");
83
+ }
84
+
85
+ function resolveApiUrl(args, config = {}) {
86
+ void args;
87
+ void config;
88
+ return String(DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
89
+ }
90
+
91
+ function resolveToken(args, config = {}) {
92
+ const creds = readSharedCredentials();
93
+ return args.token || process.env.QUALTY_API_TOKEN || creds.api_token || config.api_token || "";
94
+ }
95
+
96
+ function resolveOrgId(args, config = {}) {
97
+ const projectCfg = readProjectConfig();
98
+ return args.org || process.env.QUALTY_ORG_ID || config.org_id || projectCfg.default_org_id || "";
99
+ }
100
+
101
+ function maskSecret(secret) {
102
+ const s = String(secret || "");
103
+ if (!s) return "(empty)";
104
+ if (s.length <= 8) return `${"*".repeat(Math.max(0, s.length - 2))}${s.slice(-2)}`;
105
+ return `${s.slice(0, 4)}...${s.slice(-4)}`;
13
106
  }
14
107
 
15
108
  function parseArgs(argv) {
@@ -39,17 +132,18 @@ function usage() {
39
132
  console.log(
40
133
  [
41
134
  "Usage:",
42
- " qualty connect --project <project-id> [--port 3000] [--api https://your-api] [--token <bearer-token>] [--org <org-id>]",
43
- " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--api https://your-api] [--token <bearer-token>] [--org <org-id>]",
135
+ " qualty setup [--token <bearer-token>] [--org <org-id>]",
136
+ " qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
137
+ " qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
138
+ " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
44
139
  " [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
45
- " [--local] [--device 1440x900] [--headed] [--local-concurrency 4]",
140
+ " [--local] [--device 1440x900] [--headed] [--local-concurrency 4] [--auth-profile <profile>] [--no-auth-state]",
46
141
  "",
47
142
  " After each run, logs include step results, explanation, and final evaluator output (dashboard \"View logs\" data).",
48
143
  " Pass --no-view-logs for a short log only (e.g. very large evaluator text).",
49
- " qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--api https://your-api] [--token <bearer-token>]",
144
+ " qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--token <bearer-token>]",
50
145
  "",
51
146
  "Env vars:",
52
- ` QUALTY_API_URL Backend API URL (default: ${DEFAULT_QUALTY_API_URL}; set for local/self-hosted)`,
53
147
  " QUALTY_API_TOKEN Bearer token used for auth",
54
148
  " QUALTY_ORG_ID Org context for scoped API requests",
55
149
  " QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
@@ -99,11 +193,12 @@ function startCloudflared(token, port) {
99
193
  }
100
194
 
101
195
  async function runConnect(args) {
196
+ const config = readQualtyConfig();
102
197
  const projectId = args.project;
103
198
  const port = Number(args.port || 3000);
104
- const apiUrl = resolveApiUrl(args);
105
- const token = args.token || process.env.QUALTY_API_TOKEN;
106
- const orgId = args.org || process.env.QUALTY_ORG_ID;
199
+ const apiUrl = resolveApiUrl(args, config);
200
+ const token = resolveToken(args, config);
201
+ const orgId = resolveOrgId(args, config);
107
202
 
108
203
  if (!projectId || !token) {
109
204
  usage();
@@ -174,6 +269,163 @@ async function runConnect(args) {
174
269
  process.on("SIGTERM", shutdown);
175
270
  }
176
271
 
272
+ async function runSetup(args) {
273
+ const existing = readQualtyConfig();
274
+ const sharedCreds = readSharedCredentials();
275
+ const projectCfg = readProjectConfig();
276
+ const rl = createInterface({
277
+ input: process.stdin,
278
+ output: process.stdout,
279
+ });
280
+ const hasStdin = process.stdin.isTTY;
281
+ const ask = async (prompt, fallback = "") => {
282
+ if (!hasStdin) return fallback;
283
+ const answer = await rl.question(prompt);
284
+ const v = String(answer || "").trim();
285
+ return v || fallback;
286
+ };
287
+ const askYesNo = async (prompt, defaultValue = false) => {
288
+ if (!hasStdin) return defaultValue;
289
+ const answer = String(await rl.question(prompt)).trim();
290
+ if (!answer) return defaultValue;
291
+ const raw = answer.toLowerCase();
292
+ if (["y", "yes"].includes(raw)) return true;
293
+ if (["n", "no"].includes(raw)) return false;
294
+ return defaultValue;
295
+ };
296
+ const askChoice = async (prompt, min, max, defaultValue) => {
297
+ if (!hasStdin) return defaultValue;
298
+ while (true) {
299
+ const answer = String(await rl.question(prompt)).trim();
300
+ if (!answer) return defaultValue;
301
+ const n = Number(answer);
302
+ if (Number.isInteger(n) && n >= min && n <= max) return n;
303
+ // eslint-disable-next-line no-console
304
+ console.log(`[qualty] Please enter a number from ${min} to ${max}.`);
305
+ }
306
+ };
307
+
308
+ try {
309
+ const currentOrgId = String(args.org || existing.org_id || projectCfg.default_org_id || "");
310
+ const currentToken = String(args.token || process.env.QUALTY_API_TOKEN || sharedCreds.api_token || existing.api_token || "");
311
+
312
+ let token = "";
313
+ if (args.token) {
314
+ token = String(args.token).trim();
315
+ } else if (currentToken) {
316
+ const updateToken = await askYesNo(`API token already set (${maskSecret(currentToken)}). Update it? [y/N]: `, false);
317
+ if (updateToken) {
318
+ token = String(await ask("New Qualty API token [required]: ", "")).trim();
319
+ } else {
320
+ token = currentToken;
321
+ }
322
+ } else {
323
+ token = String(await ask("Qualty API token [required]: ", "")).trim();
324
+ }
325
+
326
+ if (!token) {
327
+ throw new Error("API token is required. Re-run setup and provide QUALTY_API_TOKEN.");
328
+ }
329
+
330
+ let organizations = [];
331
+ let currentOrgName = "";
332
+ try {
333
+ const orgResp = await apiRequest({
334
+ apiUrl: resolveApiUrl({}, {}),
335
+ token,
336
+ orgId: "",
337
+ path: "/api/v1/orgs",
338
+ });
339
+ organizations = Array.isArray(orgResp?.organizations) ? orgResp.organizations : [];
340
+ const byId = new Map(
341
+ organizations
342
+ .filter((o) => o && typeof o === "object")
343
+ .map((o) => [String(o.id || ""), String(o.name || "").trim()])
344
+ );
345
+ currentOrgName = byId.get(currentOrgId) || "";
346
+ } catch {
347
+ organizations = [];
348
+ }
349
+
350
+ let orgId = "";
351
+ let orgName = "";
352
+ if (args.org) {
353
+ orgId = String(args.org).trim();
354
+ orgName = currentOrgName || "selected organization";
355
+ } else if (organizations.length > 0) {
356
+ const orgRows = organizations
357
+ .map((o) => ({
358
+ id: String(o?.id || ""),
359
+ name: String(o?.name || "").trim() || "Unnamed org",
360
+ }))
361
+ .filter((o) => o.id);
362
+ const currentIndex = orgRows.findIndex((o) => o.id === currentOrgId);
363
+ if (currentIndex >= 0) {
364
+ const updateOrg = await askYesNo(
365
+ `Organization already set (${orgRows[currentIndex].name}). Update it? [y/N]: `,
366
+ false
367
+ );
368
+ if (!updateOrg) {
369
+ orgId = orgRows[currentIndex].id;
370
+ orgName = orgRows[currentIndex].name;
371
+ }
372
+ }
373
+ if (!orgId) {
374
+ // eslint-disable-next-line no-console
375
+ console.log("[qualty] Choose organization:");
376
+ for (let i = 0; i < orgRows.length; i += 1) {
377
+ // eslint-disable-next-line no-console
378
+ console.log(` ${i + 1}) ${orgRows[i].name}`);
379
+ }
380
+ const defaultChoice = currentIndex >= 0 ? currentIndex + 1 : 1;
381
+ const picked = await askChoice(
382
+ `Select org [1-${orgRows.length}] (default ${defaultChoice}): `,
383
+ 1,
384
+ orgRows.length,
385
+ defaultChoice
386
+ );
387
+ orgId = orgRows[picked - 1].id;
388
+ orgName = orgRows[picked - 1].name;
389
+ }
390
+ } else if (currentOrgId) {
391
+ const updateOrg = await askYesNo("Organization already set. Update it? [y/N]: ", false);
392
+ if (!updateOrg) {
393
+ orgId = currentOrgId;
394
+ orgName = "saved organization";
395
+ } else {
396
+ orgId = String(await ask("New Qualty Org ID [required]: ", "")).trim();
397
+ }
398
+ } else {
399
+ orgId = String(await ask("Qualty Org ID [required]: ", "")).trim();
400
+ }
401
+
402
+ if (!orgId) {
403
+ throw new Error("Org ID is required. Re-run setup and provide QUALTY_ORG_ID.");
404
+ }
405
+
406
+ const next = {
407
+ org_id: orgId,
408
+ };
409
+ // Keep token in the same place MCP uses.
410
+ writeSharedCredentials(token);
411
+ writeQualtyConfig(next);
412
+ // Keep org in MCP-compatible per-project config too.
413
+ writeProjectConfig({ default_org_id: orgId });
414
+ // eslint-disable-next-line no-console
415
+ console.log(`[qualty] Saved config to ${QUALTY_CONFIG_PATH}`);
416
+ // eslint-disable-next-line no-console
417
+ console.log(`[qualty] Saved shared token to ${QUALTY_SHARED_CREDS_PATH} (used by MCP)`);
418
+ // eslint-disable-next-line no-console
419
+ console.log(`[qualty] Saved project org to ${QUALTY_PROJECT_CONFIG_PATH} (MCP-compatible)`);
420
+ // eslint-disable-next-line no-console
421
+ console.log(`[qualty] Org: ${orgName || "configured"}`);
422
+ // eslint-disable-next-line no-console
423
+ console.log(`[qualty] Token: ${maskSecret(token)}`);
424
+ } finally {
425
+ rl.close();
426
+ }
427
+ }
428
+
177
429
  function parseBoolean(value, defaultValue) {
178
430
  if (value === undefined) return defaultValue;
179
431
  if (typeof value === "boolean") return value;
@@ -194,6 +446,38 @@ function resolveLocalConcurrency(args) {
194
446
  return n;
195
447
  }
196
448
 
449
+ function safePathSegment(value, fallback = "default") {
450
+ const out = String(value || "")
451
+ .trim()
452
+ .replace(/[^a-zA-Z0-9._-]+/g, "_")
453
+ .replace(/^_+|_+$/g, "");
454
+ return out || fallback;
455
+ }
456
+
457
+ function localAuthStatePath({ orgId, projectId, profile }) {
458
+ const orgSeg = safePathSegment(orgId || "default-org", "default-org");
459
+ const projectSeg = safePathSegment(projectId || "default-project", "default-project");
460
+ const profileSeg = safePathSegment(profile || "default", "default");
461
+ return join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
462
+ }
463
+
464
+ function listLocalAuthProfiles({ orgId, projectId }) {
465
+ const orgSeg = safePathSegment(orgId || "default-org", "default-org");
466
+ const projectSeg = safePathSegment(projectId || "default-project", "default-project");
467
+ const dir = join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg);
468
+ if (!existsSync(dir)) return [];
469
+ try {
470
+ const entries = readdirSync(dir, { withFileTypes: true });
471
+ return entries
472
+ .filter((e) => e.isFile() && e.name.endsWith(".json"))
473
+ .map((e) => e.name.replace(/\.json$/i, ""))
474
+ .filter(Boolean)
475
+ .sort((a, b) => a.localeCompare(b));
476
+ } catch {
477
+ return [];
478
+ }
479
+ }
480
+
197
481
  function sleep(ms) {
198
482
  return new Promise((resolve) => setTimeout(resolve, ms));
199
483
  }
@@ -519,9 +803,10 @@ function printQualtyViewLogsReport(executionId, status) {
519
803
  }
520
804
 
521
805
  async function runCi(args) {
522
- const apiUrl = resolveApiUrl(args);
523
- const token = args.token || process.env.QUALTY_API_TOKEN;
524
- const orgId = args.org || process.env.QUALTY_ORG_ID;
806
+ const config = readQualtyConfig();
807
+ const apiUrl = resolveApiUrl(args, config);
808
+ const token = resolveToken(args, config);
809
+ const orgId = resolveOrgId(args, config);
525
810
  const projectId = args.project;
526
811
  const suiteId = args["suite-id"];
527
812
  const explicitIds = String(args.ids || "")
@@ -671,9 +956,10 @@ async function resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, expl
671
956
  }
672
957
 
673
958
  async function runResolve(args) {
674
- const apiUrl = resolveApiUrl(args);
675
- const token = args.token || process.env.QUALTY_API_TOKEN;
676
- const orgId = args.org || process.env.QUALTY_ORG_ID;
959
+ const config = readQualtyConfig();
960
+ const apiUrl = resolveApiUrl(args, config);
961
+ const token = resolveToken(args, config);
962
+ const orgId = resolveOrgId(args, config);
677
963
  const projectId = args.project;
678
964
  const suiteId = args["suite-id"];
679
965
  const explicitIds = String(args.ids || "")
@@ -713,6 +999,312 @@ async function runResolve(args) {
713
999
  }
714
1000
  }
715
1001
 
1002
+ async function maybePromptSuiteIdForLocalRun({ args, apiUrl, token, orgId }) {
1003
+ const existingSuiteId = String(args["suite-id"] || "").trim();
1004
+ const explicitIds = String(args.ids || "")
1005
+ .split(",")
1006
+ .map((id) => id.trim())
1007
+ .filter(Boolean);
1008
+ if (existingSuiteId || explicitIds.length > 0) {
1009
+ return existingSuiteId || "";
1010
+ }
1011
+
1012
+ const projectId = String(args.project || "").trim();
1013
+ if (!projectId) {
1014
+ return "";
1015
+ }
1016
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1017
+ return "";
1018
+ }
1019
+
1020
+ const suites = await apiRequest({
1021
+ apiUrl,
1022
+ token,
1023
+ orgId,
1024
+ path: `/api/v1/test-suites?project_id=${encodeURIComponent(projectId)}`,
1025
+ });
1026
+ const rows = (Array.isArray(suites) ? suites : [])
1027
+ .map((s) => ({
1028
+ id: String(s?.id || "").trim(),
1029
+ name: String(s?.name || "").trim() || "Unnamed suite",
1030
+ jobCount: Number(Array.isArray(s?.job_ids) ? s.job_ids.length : 0),
1031
+ sequenceCount: Number(Array.isArray(s?.sequence_ids) ? s.sequence_ids.length : 0),
1032
+ }))
1033
+ .filter((s) => s.id);
1034
+
1035
+ if (rows.length === 0) {
1036
+ throw new Error(
1037
+ "No test suites found for this project. Create a suite first in dashboard, or pass --ids explicitly."
1038
+ );
1039
+ }
1040
+
1041
+ // eslint-disable-next-line no-console
1042
+ console.log("[qualty] No --suite-id or --ids provided. Choose a suite:");
1043
+ for (let i = 0; i < rows.length; i += 1) {
1044
+ const s = rows[i];
1045
+ const details = `${s.jobCount} test(s)${
1046
+ s.sequenceCount > 0 ? `, ${s.sequenceCount} sequence(s)` : ""
1047
+ }`;
1048
+ // eslint-disable-next-line no-console
1049
+ console.log(` ${i + 1}) ${s.name} (${details})`);
1050
+ }
1051
+
1052
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1053
+ try {
1054
+ while (true) {
1055
+ const raw = String(
1056
+ await rl.question(`Select suite [1-${rows.length}] (default 1): `)
1057
+ ).trim();
1058
+ const picked = raw ? Number(raw) : 1;
1059
+ if (Number.isInteger(picked) && picked >= 1 && picked <= rows.length) {
1060
+ const suite = rows[picked - 1];
1061
+ // eslint-disable-next-line no-console
1062
+ console.log(`[qualty] Selected suite: ${suite.name}`);
1063
+ return suite.id;
1064
+ }
1065
+ // eslint-disable-next-line no-console
1066
+ console.log(`[qualty] Please enter a number from 1 to ${rows.length}.`);
1067
+ }
1068
+ } finally {
1069
+ rl.close();
1070
+ }
1071
+ }
1072
+
1073
+ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
1074
+ const existingProjectId = String(args.project || "").trim();
1075
+ if (existingProjectId) return existingProjectId;
1076
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return existingProjectId;
1077
+
1078
+ const projectsPayload = await apiRequest({
1079
+ apiUrl,
1080
+ token,
1081
+ orgId,
1082
+ path: "/api/v1/projects",
1083
+ });
1084
+ const rows = (Array.isArray(projectsPayload) ? projectsPayload : [])
1085
+ .map((p) => ({
1086
+ id: String(p?.id || "").trim(),
1087
+ name: String(p?.name || "").trim() || "Unnamed project",
1088
+ }))
1089
+ .filter((p) => p.id);
1090
+
1091
+ if (rows.length === 0) {
1092
+ throw new Error("No projects found for this account/org. Create a project first.");
1093
+ }
1094
+
1095
+ // eslint-disable-next-line no-console
1096
+ console.log("[qualty] No --project provided. Choose a project:");
1097
+ for (let i = 0; i < rows.length; i += 1) {
1098
+ // eslint-disable-next-line no-console
1099
+ console.log(` ${i + 1}) ${rows[i].name}`);
1100
+ }
1101
+
1102
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1103
+ try {
1104
+ while (true) {
1105
+ const raw = String(
1106
+ await rl.question(`Select project [1-${rows.length}] (default 1): `)
1107
+ ).trim();
1108
+ const picked = raw ? Number(raw) : 1;
1109
+ if (Number.isInteger(picked) && picked >= 1 && picked <= rows.length) {
1110
+ const project = rows[picked - 1];
1111
+ // eslint-disable-next-line no-console
1112
+ console.log(`[qualty] Selected project: ${project.name}`);
1113
+ return project.id;
1114
+ }
1115
+ // eslint-disable-next-line no-console
1116
+ console.log(`[qualty] Please enter a number from 1 to ${rows.length}.`);
1117
+ }
1118
+ } finally {
1119
+ rl.close();
1120
+ }
1121
+ }
1122
+
1123
+ function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
1124
+ if (parseBoolean(args["no-auth-state"], false)) return null;
1125
+ const projectCfg = readProjectConfig();
1126
+ const requestedProfile = String(args["auth-profile"] || "").trim();
1127
+ const savedProjectId = String(projectCfg.local_auth_project_id || "").trim();
1128
+ const savedProfile = String(projectCfg.local_auth_profile || "").trim();
1129
+ const profile =
1130
+ requestedProfile ||
1131
+ (savedProjectId && String(projectId || "").trim() === savedProjectId ? savedProfile : "");
1132
+ if (!profile) return null;
1133
+ const path = localAuthStatePath({ orgId, projectId, profile });
1134
+ if (!existsSync(path)) return null;
1135
+ return { profile, path };
1136
+ }
1137
+
1138
+ async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
1139
+ const requestedProfile = String(args["auth-profile"] || "").trim();
1140
+ if (requestedProfile) return { authProfile: requestedProfile, noAuthState: false };
1141
+ if (parseBoolean(args["no-auth-state"], false)) return { authProfile: "", noAuthState: true };
1142
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return { authProfile: "", noAuthState: false };
1143
+ if (!projectId) return { authProfile: "", noAuthState: false };
1144
+
1145
+ const profiles = listLocalAuthProfiles({ orgId, projectId });
1146
+ if (profiles.length === 0) return { authProfile: "", noAuthState: false };
1147
+
1148
+ const projectCfg = readProjectConfig();
1149
+ const defaultProfile = String(projectCfg.local_auth_profile || "").trim();
1150
+ const defaultIndex = profiles.findIndex((p) => p === defaultProfile);
1151
+
1152
+ // eslint-disable-next-line no-console
1153
+ console.log("[qualty][auth] Choose saved login state:");
1154
+ // eslint-disable-next-line no-console
1155
+ console.log(" 0) Run without saved login state");
1156
+ for (let i = 0; i < profiles.length; i += 1) {
1157
+ const isDefault = defaultIndex === i ? " (default)" : "";
1158
+ // eslint-disable-next-line no-console
1159
+ console.log(` ${i + 1}) ${profiles[i]}${isDefault}`);
1160
+ }
1161
+
1162
+ const defaultChoice = defaultIndex >= 0 ? defaultIndex + 1 : 0;
1163
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1164
+ try {
1165
+ while (true) {
1166
+ const raw = String(
1167
+ await rl.question(`Select login state [0-${profiles.length}] (default ${defaultChoice}): `)
1168
+ ).trim();
1169
+ const picked = raw ? Number(raw) : defaultChoice;
1170
+ if (!Number.isInteger(picked) || picked < 0 || picked > profiles.length) {
1171
+ // eslint-disable-next-line no-console
1172
+ console.log(`[qualty] Please enter a number from 0 to ${profiles.length}.`);
1173
+ continue;
1174
+ }
1175
+ if (picked === 0) {
1176
+ // eslint-disable-next-line no-console
1177
+ console.log("[qualty][auth] Running without saved login state.");
1178
+ return { authProfile: "", noAuthState: true };
1179
+ }
1180
+ const profile = profiles[picked - 1];
1181
+ // eslint-disable-next-line no-console
1182
+ console.log(`[qualty][auth] Selected profile "${profile}"`);
1183
+ return { authProfile: profile, noAuthState: false };
1184
+ }
1185
+ } finally {
1186
+ rl.close();
1187
+ }
1188
+ }
1189
+
1190
+ async function runAuthSetup(args) {
1191
+ const config = readQualtyConfig();
1192
+ const apiUrl = resolveApiUrl(args, config);
1193
+ const token = resolveToken(args, config);
1194
+ const orgId = resolveOrgId(args, config);
1195
+ if (!token) {
1196
+ throw new Error("Missing auth token. Run `qualty setup` first.");
1197
+ }
1198
+ if (!orgId) {
1199
+ throw new Error("Missing org. Run `qualty setup` first.");
1200
+ }
1201
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1202
+ throw new Error("Interactive auth setup requires a TTY terminal.");
1203
+ }
1204
+
1205
+ const projectsPayload = await apiRequest({
1206
+ apiUrl,
1207
+ token,
1208
+ orgId,
1209
+ path: "/api/v1/projects",
1210
+ });
1211
+ const projects = (Array.isArray(projectsPayload) ? projectsPayload : [])
1212
+ .map((p) => ({
1213
+ id: String(p?.id || "").trim(),
1214
+ name: String(p?.name || "").trim() || "Unnamed project",
1215
+ url: String(p?.url || "").trim(),
1216
+ }))
1217
+ .filter((p) => p.id);
1218
+ if (projects.length === 0) {
1219
+ throw new Error("No projects found. Create a project first.");
1220
+ }
1221
+
1222
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1223
+ let project = null;
1224
+ try {
1225
+ const requestedProjectId = String(args.project || "").trim();
1226
+ if (requestedProjectId) {
1227
+ project = projects.find((p) => p.id === requestedProjectId) || null;
1228
+ if (!project) {
1229
+ throw new Error(`Project ${requestedProjectId} not found.`);
1230
+ }
1231
+ } else {
1232
+ // eslint-disable-next-line no-console
1233
+ console.log("[qualty] Choose project for saved local auth:");
1234
+ for (let i = 0; i < projects.length; i += 1) {
1235
+ // eslint-disable-next-line no-console
1236
+ console.log(` ${i + 1}) ${projects[i].name}`);
1237
+ }
1238
+ while (!project) {
1239
+ const raw = String(await rl.question(`Select project [1-${projects.length}] (default 1): `)).trim();
1240
+ const picked = raw ? Number(raw) : 1;
1241
+ if (Number.isInteger(picked) && picked >= 1 && picked <= projects.length) {
1242
+ project = projects[picked - 1];
1243
+ } else {
1244
+ // eslint-disable-next-line no-console
1245
+ console.log(`[qualty] Please enter a number from 1 to ${projects.length}.`);
1246
+ }
1247
+ }
1248
+ }
1249
+
1250
+ let profile = String(args.profile || "").trim();
1251
+ if (!profile) {
1252
+ const current = String(readProjectConfig().local_auth_profile || "default").trim() || "default";
1253
+ profile = String(await rl.question(`Profile name [${current}]: `)).trim() || current;
1254
+ }
1255
+ const statePath = localAuthStatePath({
1256
+ orgId,
1257
+ projectId: project.id,
1258
+ profile,
1259
+ });
1260
+ mkdirSync(join(statePath, ".."), { recursive: true });
1261
+
1262
+ const { chromium } = await import("playwright");
1263
+ const headed = parseBoolean(args.headed, true);
1264
+ let browser = null;
1265
+ try {
1266
+ browser = await chromium.launch({
1267
+ headless: !headed,
1268
+ channel: "chrome",
1269
+ args: ["--disable-blink-features=AutomationControlled"],
1270
+ });
1271
+ // eslint-disable-next-line no-console
1272
+ console.log("[qualty][auth] Using local Chrome channel for login setup.");
1273
+ } catch (err) {
1274
+ // eslint-disable-next-line no-console
1275
+ console.log(`[qualty][auth] Chrome channel unavailable, falling back to Playwright Chromium (${err?.message || err}).`);
1276
+ browser = await chromium.launch({
1277
+ headless: !headed,
1278
+ args: ["--disable-blink-features=AutomationControlled"],
1279
+ });
1280
+ }
1281
+ const context = await browser.newContext();
1282
+ const page = await context.newPage();
1283
+ const startUrl = project.url || "about:blank";
1284
+ await page.goto(startUrl, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
1285
+ // eslint-disable-next-line no-console
1286
+ console.log(`[qualty][auth] Browser opened for project "${project.name}".`);
1287
+ // eslint-disable-next-line no-console
1288
+ console.log("[qualty][auth] Complete login in the browser, then press Enter here to save context.");
1289
+ await rl.question("Press Enter to save local auth context...");
1290
+ await context.storageState({ path: statePath });
1291
+ await context.close();
1292
+ await browser.close();
1293
+
1294
+ writeProjectConfig({
1295
+ local_auth_project_id: project.id,
1296
+ local_auth_profile: profile,
1297
+ default_org_id: orgId,
1298
+ });
1299
+ // eslint-disable-next-line no-console
1300
+ console.log(`[qualty][auth] Saved local context profile "${profile}" for project "${project.name}".`);
1301
+ // eslint-disable-next-line no-console
1302
+ console.log(`[qualty][auth] Path: ${statePath}`);
1303
+ } finally {
1304
+ rl.close();
1305
+ }
1306
+ }
1307
+
716
1308
  async function main() {
717
1309
  const args = parseArgs(process.argv);
718
1310
  const command = args._[0];
@@ -724,14 +1316,59 @@ async function main() {
724
1316
  await runConnect(args);
725
1317
  return;
726
1318
  }
1319
+ if (command === "setup") {
1320
+ await runSetup(args);
1321
+ return;
1322
+ }
1323
+ if (command === "auth") {
1324
+ const sub = args._[1];
1325
+ if (sub === "setup") {
1326
+ await runAuthSetup(args);
1327
+ return;
1328
+ }
1329
+ throw new Error("Unknown auth subcommand. Use: qualty auth setup");
1330
+ }
727
1331
  if (command === "run") {
1332
+ const config = readQualtyConfig();
728
1333
  if (parseBoolean(args.local, false)) {
1334
+ const apiUrl = resolveApiUrl(args, config);
1335
+ const token = resolveToken(args, config);
1336
+ const orgId = resolveOrgId(args, config);
1337
+ const projectId =
1338
+ (await maybePromptProjectIdForLocalRun({
1339
+ args,
1340
+ apiUrl,
1341
+ token,
1342
+ orgId,
1343
+ })) || args.project;
1344
+ const suiteId =
1345
+ (await maybePromptSuiteIdForLocalRun({
1346
+ args: { ...args, project: projectId },
1347
+ apiUrl,
1348
+ token,
1349
+ orgId,
1350
+ })) || args["suite-id"];
1351
+ const authChoice = await maybePromptAuthProfileForLocalRun({
1352
+ args,
1353
+ orgId,
1354
+ projectId,
1355
+ });
1356
+ const localAuthArgs = {
1357
+ ...args,
1358
+ ...(authChoice.authProfile ? { "auth-profile": authChoice.authProfile } : {}),
1359
+ ...(authChoice.noAuthState ? { "no-auth-state": true } : {}),
1360
+ };
1361
+ const localAuth = resolveLocalAuthStateForRun({ args: localAuthArgs, orgId, projectId });
1362
+ if (localAuth?.path) {
1363
+ // eslint-disable-next-line no-console
1364
+ console.log(`[qualty][auth] Reusing profile "${localAuth.profile}"`);
1365
+ }
729
1366
  await runLocalCi({
730
- apiUrl: resolveApiUrl(args),
731
- token: args.token || process.env.QUALTY_API_TOKEN,
732
- orgId: args.org || process.env.QUALTY_ORG_ID,
733
- projectId: args.project,
734
- suiteId: args["suite-id"],
1367
+ apiUrl,
1368
+ token,
1369
+ orgId,
1370
+ projectId,
1371
+ suiteId,
735
1372
  explicitIds: String(args.ids || "")
736
1373
  .split(",")
737
1374
  .map((id) => id.trim())
@@ -742,6 +1379,7 @@ async function main() {
742
1379
  device: args.device || "1440x900",
743
1380
  headless: !parseBoolean(args.headed, false),
744
1381
  localConcurrency: resolveLocalConcurrency(args),
1382
+ storageStatePath: localAuth?.path,
745
1383
  });
746
1384
  return;
747
1385
  }
@@ -752,7 +1390,13 @@ async function main() {
752
1390
  await runResolve(args);
753
1391
  return;
754
1392
  }
755
- if (command !== "connect" && command !== "run" && command !== "resolve") {
1393
+ if (
1394
+ command !== "setup" &&
1395
+ command !== "auth" &&
1396
+ command !== "connect" &&
1397
+ command !== "run" &&
1398
+ command !== "resolve"
1399
+ ) {
756
1400
  throw new Error(`Unknown command: ${command}`);
757
1401
  }
758
1402
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"
@@ -11,6 +11,7 @@
11
11
  "bin"
12
12
  ],
13
13
  "dependencies": {
14
- "playwright": "1.60.0"
14
+ "playwright": "1.60.0",
15
+ "rrweb": "^2.0.0-alpha.4"
15
16
  }
16
17
  }