qualty 0.1.9 → 0.1.11

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,6 +1,252 @@
1
- import { mkdirSync, writeFileSync, mkdtempSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { tmpdir } from "node:os";
1
+ import { mkdirSync, writeFileSync, mkdtempSync, existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { createInterface } from "node:readline/promises";
5
+ import process from "node:process";
6
+
7
+ function safePathSegment(value, fallback) {
8
+ const normalized = String(value || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "_");
9
+ return normalized || fallback;
10
+ }
11
+
12
+ function localAuthStatePath({ orgId, projectId, profile }) {
13
+ const orgSeg = safePathSegment(orgId || "default-org", "default-org");
14
+ const projectSeg = safePathSegment(projectId || "default-project", "default-project");
15
+ const profileSeg = safePathSegment(profile || "default", "default");
16
+ return join(homedir(), ".qualty", "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
17
+ }
18
+
19
+ const localBindingSetupPromises = new Map();
20
+
21
+ async function promptYesNo(question, defaultYes = true) {
22
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultYes;
23
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
24
+ try {
25
+ const suffix = defaultYes ? " [Y/n]: " : " [y/N]: ";
26
+ const raw = String(await rl.question(`${question}${suffix}`)).trim().toLowerCase();
27
+ if (!raw) return defaultYes;
28
+ if (raw === "y" || raw === "yes") return true;
29
+ if (raw === "n" || raw === "no") return false;
30
+ return defaultYes;
31
+ } finally {
32
+ rl.close();
33
+ }
34
+ }
35
+
36
+ async function runInteractiveLocalBindingSetup({
37
+ apiRequest,
38
+ apiUrl,
39
+ token,
40
+ orgId,
41
+ projectId,
42
+ profileName,
43
+ localProfileName,
44
+ startUrl,
45
+ }) {
46
+ const statePath = localAuthStatePath({
47
+ orgId,
48
+ projectId,
49
+ profile: localProfileName,
50
+ });
51
+ mkdirSync(dirname(statePath), { recursive: true });
52
+
53
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
54
+ try {
55
+ const { chromium } = await import("playwright");
56
+ let browser = null;
57
+ try {
58
+ browser = await chromium.launch({
59
+ headless: false,
60
+ channel: "chrome",
61
+ args: ["--disable-blink-features=AutomationControlled"],
62
+ });
63
+ // eslint-disable-next-line no-console
64
+ console.log("[qualty][auth] Using local Chrome channel for login setup.");
65
+ } catch (err) {
66
+ // eslint-disable-next-line no-console
67
+ console.log(
68
+ `[qualty][auth] Chrome channel unavailable, falling back to Playwright Chromium (${err?.message || err}).`
69
+ );
70
+ browser = await chromium.launch({
71
+ headless: false,
72
+ args: ["--disable-blink-features=AutomationControlled"],
73
+ });
74
+ }
75
+
76
+ const context = await browser.newContext();
77
+ const page = await context.newPage();
78
+ const safeStartUrl = String(startUrl || "about:blank").trim() || "about:blank";
79
+ await page.goto(safeStartUrl, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
80
+ // eslint-disable-next-line no-console
81
+ console.log(
82
+ `[qualty][auth] Browser opened. Complete login for profile "${profileName}", then press Enter here.`
83
+ );
84
+ await rl.question("Press Enter to save local login state...");
85
+ await context.storageState({ path: statePath });
86
+ await context.close();
87
+ await browser.close();
88
+ } finally {
89
+ rl.close();
90
+ }
91
+
92
+ try {
93
+ await apiRequest({
94
+ apiUrl,
95
+ token,
96
+ orgId,
97
+ path: `/api/v1/projects/${projectId}/saved-login-profiles/local-binding`,
98
+ method: "POST",
99
+ body: {
100
+ profile_name: profileName,
101
+ local_profile_name: localProfileName,
102
+ },
103
+ });
104
+ } catch (syncErr) {
105
+ // eslint-disable-next-line no-console
106
+ console.warn(`[qualty][auth] Saved locally, but backend sync failed: ${syncErr?.message || syncErr}`);
107
+ }
108
+ return { profile: localProfileName, path: statePath };
109
+ }
110
+
111
+ async function maybeSetupMissingLocalBinding({
112
+ apiRequest,
113
+ apiUrl,
114
+ token,
115
+ orgId,
116
+ claim,
117
+ explicitAuthProfile,
118
+ noAuthState,
119
+ }) {
120
+ if (noAuthState) return null;
121
+ if (explicitAuthProfile) return null;
122
+ const profile = claim?.saved_login_profile && typeof claim.saved_login_profile === "object"
123
+ ? claim.saved_login_profile
124
+ : null;
125
+ if (!profile || !profile.name) return null;
126
+ if (profile.has_local_binding) return null;
127
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
128
+
129
+ const profileName = String(profile.name || "").trim();
130
+ if (!profileName) return null;
131
+ const key = `${String(orgId || "")}:${String(claim?.project_id || "")}:${profileName}`;
132
+ if (localBindingSetupPromises.has(key)) {
133
+ return localBindingSetupPromises.get(key);
134
+ }
135
+
136
+ const task = (async () => {
137
+ // eslint-disable-next-line no-console
138
+ console.log(
139
+ `[qualty][auth] Saved login profile "${profileName}" is attached to this test, but no local login state is configured.`
140
+ );
141
+ const shouldSetup = await promptYesNo("[qualty][auth] Create local login state now?", true);
142
+ if (!shouldSetup) {
143
+ // eslint-disable-next-line no-console
144
+ console.log("[qualty][auth] Continuing without local login state.");
145
+ return null;
146
+ }
147
+ const localProfileName = String(profile.local_profile_name || "").trim() || profileName;
148
+ const setupResult = await runInteractiveLocalBindingSetup({
149
+ apiRequest,
150
+ apiUrl,
151
+ token,
152
+ orgId,
153
+ projectId: claim.project_id,
154
+ profileName,
155
+ localProfileName,
156
+ startUrl: claim?.url,
157
+ });
158
+ // eslint-disable-next-line no-console
159
+ console.log(`[qualty][auth] Saved local login state for "${profileName}".`);
160
+ return setupResult;
161
+ })();
162
+
163
+ localBindingSetupPromises.set(key, task);
164
+ try {
165
+ return await task;
166
+ } finally {
167
+ localBindingSetupPromises.delete(key);
168
+ }
169
+ }
170
+
171
+ async function preflightMissingLocalBindings({
172
+ apiRequest,
173
+ apiUrl,
174
+ token,
175
+ orgId,
176
+ projectId,
177
+ savedJobs,
178
+ explicitAuthProfile,
179
+ noAuthState,
180
+ }) {
181
+ if (noAuthState) return;
182
+ if (explicitAuthProfile) return;
183
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return;
184
+ const missingByProfile = new Map();
185
+ for (const job of savedJobs || []) {
186
+ const useSaved = Boolean(job?.use_saved_login);
187
+ const profileName = String(job?.saved_login_profile?.name || "").trim();
188
+ if (!useSaved || !profileName) continue;
189
+ const statePath = localAuthStatePath({
190
+ orgId,
191
+ projectId: String(job?.project_id || projectId || "").trim(),
192
+ profile: profileName,
193
+ });
194
+ if (existsSync(statePath)) continue;
195
+ const entry = missingByProfile.get(profileName) || {
196
+ localProfileName: profileName,
197
+ projectId: String(job?.project_id || projectId || "").trim(),
198
+ startUrl: String(job?.url || "").trim(),
199
+ tests: [],
200
+ };
201
+ entry.tests.push({
202
+ id: String(job?.id || "").trim(),
203
+ name: String(job?.name || job?.id || "Unnamed test").trim(),
204
+ });
205
+ if (!entry.startUrl) entry.startUrl = String(job?.url || "").trim();
206
+ missingByProfile.set(profileName, entry);
207
+ }
208
+ if (missingByProfile.size === 0) return;
209
+
210
+ // eslint-disable-next-line no-console
211
+ console.log("[qualty][auth] Missing local login state for attached dashboard profiles:");
212
+ for (const [profileName, entry] of missingByProfile.entries()) {
213
+ // eslint-disable-next-line no-console
214
+ console.log(` - Profile "${profileName}" is required by:`);
215
+ for (const test of entry.tests) {
216
+ // eslint-disable-next-line no-console
217
+ console.log(` - ${test.name} (${test.id})`);
218
+ }
219
+ }
220
+
221
+ for (const [profileName, entry] of missingByProfile.entries()) {
222
+ const shouldSetup = await promptYesNo(
223
+ `[qualty][auth] Set up local login now for profile "${profileName}"?`,
224
+ true
225
+ );
226
+ if (!shouldSetup) {
227
+ throw new Error(
228
+ `Cancelled: local login state is required for attached profile "${profileName}".`
229
+ );
230
+ }
231
+ await runInteractiveLocalBindingSetup({
232
+ apiRequest,
233
+ apiUrl,
234
+ token,
235
+ orgId,
236
+ projectId: entry.projectId,
237
+ profileName,
238
+ localProfileName: entry.localProfileName,
239
+ startUrl: entry.startUrl,
240
+ });
241
+ // eslint-disable-next-line no-console
242
+ console.log(`[qualty][auth] Saved local login state for "${profileName}".`);
243
+ }
244
+
245
+ const confirmed = await promptYesNo("[qualty] Login setup complete. Start all local runs now?", true);
246
+ if (!confirmed) {
247
+ throw new Error("Run cancelled by user before starting tests.");
248
+ }
249
+ }
4
250
 
5
251
  async function downloadProjectAttachments({ apiUrl, token, orgId, projectId, specs }) {
6
252
  const labelToPath = {};
@@ -754,6 +1000,9 @@ export async function runLocalExecution({
754
1000
  executionId,
755
1001
  device,
756
1002
  headless,
1003
+ authProfile,
1004
+ noAuthState,
1005
+ storageStatePath,
757
1006
  }) {
758
1007
  const { chromium } = await import("playwright");
759
1008
 
@@ -770,6 +1019,36 @@ export async function runLocalExecution({
770
1019
  const viewport = claim.viewport || { width: 1440, height: 900 };
771
1020
  const steps = Array.isArray(claim.steps) ? claim.steps : [];
772
1021
  const totalSteps = claim.total_steps || steps.length;
1022
+ const claimSavedLogin = claim.saved_login_profile && typeof claim.saved_login_profile === "object"
1023
+ ? claim.saved_login_profile
1024
+ : null;
1025
+ const setupFromClaim = await maybeSetupMissingLocalBinding({
1026
+ apiRequest,
1027
+ apiUrl,
1028
+ token,
1029
+ orgId,
1030
+ claim,
1031
+ explicitAuthProfile: String(authProfile || "").trim(),
1032
+ noAuthState: Boolean(noAuthState),
1033
+ });
1034
+ const claimProfile = String(claimSavedLogin?.local_profile_name || "").trim();
1035
+ const effectiveProfile = String(authProfile || "").trim() || setupFromClaim?.profile || claimProfile;
1036
+ const resolvedStoragePath =
1037
+ !noAuthState && effectiveProfile
1038
+ ? localAuthStatePath({
1039
+ orgId,
1040
+ projectId: claim.project_id,
1041
+ profile: effectiveProfile,
1042
+ })
1043
+ : null;
1044
+ const finalStoragePath =
1045
+ !noAuthState && setupFromClaim?.path && existsSync(setupFromClaim.path)
1046
+ ? setupFromClaim.path
1047
+ : !noAuthState && storageStatePath && existsSync(storageStatePath)
1048
+ ? storageStatePath
1049
+ : !noAuthState && resolvedStoragePath && existsSync(resolvedStoragePath)
1050
+ ? resolvedStoragePath
1051
+ : null;
773
1052
 
774
1053
  const browserServer = await chromium.launchServer({
775
1054
  headless: headless !== false,
@@ -777,9 +1056,15 @@ export async function runLocalExecution({
777
1056
  });
778
1057
  const cdpEndpoint = browserServer.wsEndpoint();
779
1058
  const browser = await chromium.connect(cdpEndpoint);
780
- const context = await browser.newContext({
1059
+ const contextOptions = {
781
1060
  viewport: { width: viewport.width || 1440, height: viewport.height || 900 },
782
- });
1061
+ };
1062
+ if (finalStoragePath) {
1063
+ contextOptions.storageState = finalStoragePath;
1064
+ const logProfile = effectiveProfile || "default";
1065
+ console.log(`[qualty][auth] using saved context "${logProfile}": ${finalStoragePath}`);
1066
+ }
1067
+ const context = await browser.newContext(contextOptions);
783
1068
  const page = await context.newPage();
784
1069
  const networkCollector = attachNetworkCollector(page);
785
1070
  const labelToPath = await downloadProjectAttachments({
@@ -985,6 +1270,9 @@ export async function runLocalCi({
985
1270
  device,
986
1271
  headless,
987
1272
  localConcurrency = 4,
1273
+ authProfile,
1274
+ noAuthState,
1275
+ storageStatePath,
988
1276
  }) {
989
1277
  const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
990
1278
  if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
@@ -1009,6 +1297,17 @@ export async function runLocalCi({
1009
1297
  const started = runs.filter((r) => r.execution_job_id);
1010
1298
  if (started.length === 0) throw new Error("Failed to start any local runs.");
1011
1299
 
1300
+ await preflightMissingLocalBindings({
1301
+ apiRequest,
1302
+ apiUrl,
1303
+ token,
1304
+ orgId,
1305
+ projectId,
1306
+ savedJobs,
1307
+ explicitAuthProfile: String(authProfile || "").trim(),
1308
+ noAuthState: Boolean(noAuthState),
1309
+ });
1310
+
1012
1311
  const concurrency = Math.max(1, Number(localConcurrency) || 4);
1013
1312
  const workerCount = Math.min(concurrency, started.length);
1014
1313
 
@@ -1040,6 +1339,9 @@ export async function runLocalCi({
1040
1339
  executionId: id,
1041
1340
  device,
1042
1341
  headless,
1342
+ authProfile,
1343
+ noAuthState,
1344
+ storageStatePath,
1043
1345
  });
1044
1346
  if (result.success) {
1045
1347
  passed += 1;
package/bin/qualty.js CHANGED
@@ -1,15 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } 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";
5
+ import { homedir, hostname } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { createInterface } from "node:readline/promises";
8
8
  import process from "node:process";
9
9
  import { runLocalCi } from "./local-runner.js";
10
10
 
11
11
  /** CLI backend URL is fixed by Qualty distribution. */
12
- const DEFAULT_QUALTY_API_URL = "https://qualty-api-development.up.railway.app";
12
+ // const DEFAULT_QUALTY_API_URL = "https://qualty-api-development.up.railway.app";
13
+ const DEFAULT_QUALTY_API_URL = "http://localhost:8000";
14
+
13
15
  const QUALTY_CONFIG_DIR = join(homedir(), ".config", "qualty");
14
16
  const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
15
17
  const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
@@ -133,10 +135,11 @@ function usage() {
133
135
  [
134
136
  "Usage:",
135
137
  " qualty setup [--token <bearer-token>] [--org <org-id>]",
138
+ " qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
136
139
  " qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
137
140
  " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
138
141
  " [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
139
- " [--local] [--device 1440x900] [--headed] [--local-concurrency 4]",
142
+ " [--local] [--device 1440x900] [--headed] [--local-concurrency 4] [--auth-profile <profile>] [--no-auth-state] [--prompt-auth-profile]",
140
143
  "",
141
144
  " After each run, logs include step results, explanation, and final evaluator output (dashboard \"View logs\" data).",
142
145
  " Pass --no-view-logs for a short log only (e.g. very large evaluator text).",
@@ -169,6 +172,29 @@ async function apiRequest({ apiUrl, token, orgId, path, method = "GET", body })
169
172
  return data;
170
173
  }
171
174
 
175
+ async function registerLocalLoginProfileBinding({
176
+ apiUrl,
177
+ token,
178
+ orgId,
179
+ projectId,
180
+ profileName,
181
+ localProfileName,
182
+ }) {
183
+ if (!projectId || !profileName) return null;
184
+ return apiRequest({
185
+ apiUrl,
186
+ token,
187
+ orgId,
188
+ path: `/api/v1/projects/${projectId}/saved-login-profiles/local-binding`,
189
+ method: "POST",
190
+ body: {
191
+ profile_name: profileName,
192
+ local_profile_name: localProfileName || profileName,
193
+ machine_id: hostname(),
194
+ },
195
+ });
196
+ }
197
+
172
198
  function startCloudflared(token, port) {
173
199
  const baseArgs = [
174
200
  "tunnel",
@@ -445,6 +471,38 @@ function resolveLocalConcurrency(args) {
445
471
  return n;
446
472
  }
447
473
 
474
+ function safePathSegment(value, fallback = "default") {
475
+ const out = String(value || "")
476
+ .trim()
477
+ .replace(/[^a-zA-Z0-9._-]+/g, "_")
478
+ .replace(/^_+|_+$/g, "");
479
+ return out || fallback;
480
+ }
481
+
482
+ function localAuthStatePath({ orgId, projectId, profile }) {
483
+ const orgSeg = safePathSegment(orgId || "default-org", "default-org");
484
+ const projectSeg = safePathSegment(projectId || "default-project", "default-project");
485
+ const profileSeg = safePathSegment(profile || "default", "default");
486
+ return join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
487
+ }
488
+
489
+ function listLocalAuthProfiles({ orgId, projectId }) {
490
+ const orgSeg = safePathSegment(orgId || "default-org", "default-org");
491
+ const projectSeg = safePathSegment(projectId || "default-project", "default-project");
492
+ const dir = join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg);
493
+ if (!existsSync(dir)) return [];
494
+ try {
495
+ const entries = readdirSync(dir, { withFileTypes: true });
496
+ return entries
497
+ .filter((e) => e.isFile() && e.name.endsWith(".json"))
498
+ .map((e) => e.name.replace(/\.json$/i, ""))
499
+ .filter(Boolean)
500
+ .sort((a, b) => a.localeCompare(b));
501
+ } catch {
502
+ return [];
503
+ }
504
+ }
505
+
448
506
  function sleep(ms) {
449
507
  return new Promise((resolve) => setTimeout(resolve, ms));
450
508
  }
@@ -1087,6 +1145,203 @@ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
1087
1145
  }
1088
1146
  }
1089
1147
 
1148
+ function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
1149
+ if (parseBoolean(args["no-auth-state"], false)) return null;
1150
+ const requestedProfile = String(args["auth-profile"] || "").trim();
1151
+ const profile = requestedProfile;
1152
+ if (!profile) return null;
1153
+ const path = localAuthStatePath({ orgId, projectId, profile });
1154
+ if (!existsSync(path)) return null;
1155
+ return { profile, path };
1156
+ }
1157
+
1158
+ async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
1159
+ const requestedProfile = String(args["auth-profile"] || "").trim();
1160
+ if (requestedProfile) return { authProfile: requestedProfile, noAuthState: false };
1161
+ if (parseBoolean(args["no-auth-state"], false)) return { authProfile: "", noAuthState: true };
1162
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return { authProfile: "", noAuthState: false };
1163
+ if (!projectId) return { authProfile: "", noAuthState: false };
1164
+
1165
+ const profiles = listLocalAuthProfiles({ orgId, projectId });
1166
+ if (profiles.length === 0) return { authProfile: "", noAuthState: false };
1167
+
1168
+ const projectCfg = readProjectConfig();
1169
+ const defaultProfile = String(projectCfg.local_auth_profile || "").trim();
1170
+ const defaultIndex = profiles.findIndex((p) => p === defaultProfile);
1171
+
1172
+ // eslint-disable-next-line no-console
1173
+ console.log("[qualty][auth] Choose saved login state:");
1174
+ // eslint-disable-next-line no-console
1175
+ console.log(" 0) Run without saved login state");
1176
+ for (let i = 0; i < profiles.length; i += 1) {
1177
+ const isDefault = defaultIndex === i ? " (default)" : "";
1178
+ // eslint-disable-next-line no-console
1179
+ console.log(` ${i + 1}) ${profiles[i]}${isDefault}`);
1180
+ }
1181
+
1182
+ const defaultChoice = defaultIndex >= 0 ? defaultIndex + 1 : 0;
1183
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1184
+ try {
1185
+ while (true) {
1186
+ const raw = String(
1187
+ await rl.question(`Select login state [0-${profiles.length}] (default ${defaultChoice}): `)
1188
+ ).trim();
1189
+ const picked = raw ? Number(raw) : defaultChoice;
1190
+ if (!Number.isInteger(picked) || picked < 0 || picked > profiles.length) {
1191
+ // eslint-disable-next-line no-console
1192
+ console.log(`[qualty] Please enter a number from 0 to ${profiles.length}.`);
1193
+ continue;
1194
+ }
1195
+ if (picked === 0) {
1196
+ // eslint-disable-next-line no-console
1197
+ console.log("[qualty][auth] Running without saved login state.");
1198
+ return { authProfile: "", noAuthState: true };
1199
+ }
1200
+ const profile = profiles[picked - 1];
1201
+ // eslint-disable-next-line no-console
1202
+ console.log(`[qualty][auth] Selected profile "${profile}"`);
1203
+ return { authProfile: profile, noAuthState: false };
1204
+ }
1205
+ } finally {
1206
+ rl.close();
1207
+ }
1208
+ }
1209
+
1210
+ async function runAuthSetup(args) {
1211
+ const config = readQualtyConfig();
1212
+ const apiUrl = resolveApiUrl(args, config);
1213
+ const token = resolveToken(args, config);
1214
+ const orgId = resolveOrgId(args, config);
1215
+ if (!token) {
1216
+ throw new Error("Missing auth token. Run `qualty setup` first.");
1217
+ }
1218
+ if (!orgId) {
1219
+ throw new Error("Missing org. Run `qualty setup` first.");
1220
+ }
1221
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1222
+ throw new Error("Interactive auth setup requires a TTY terminal.");
1223
+ }
1224
+
1225
+ const projectsPayload = await apiRequest({
1226
+ apiUrl,
1227
+ token,
1228
+ orgId,
1229
+ path: "/api/v1/projects",
1230
+ });
1231
+ const projects = (Array.isArray(projectsPayload) ? projectsPayload : [])
1232
+ .map((p) => ({
1233
+ id: String(p?.id || "").trim(),
1234
+ name: String(p?.name || "").trim() || "Unnamed project",
1235
+ url: String(p?.url || "").trim(),
1236
+ }))
1237
+ .filter((p) => p.id);
1238
+ if (projects.length === 0) {
1239
+ throw new Error("No projects found. Create a project first.");
1240
+ }
1241
+
1242
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1243
+ let project = null;
1244
+ try {
1245
+ const requestedProjectId = String(args.project || "").trim();
1246
+ if (requestedProjectId) {
1247
+ project = projects.find((p) => p.id === requestedProjectId) || null;
1248
+ if (!project) {
1249
+ throw new Error(`Project ${requestedProjectId} not found.`);
1250
+ }
1251
+ } else {
1252
+ // eslint-disable-next-line no-console
1253
+ console.log("[qualty] Choose project for saved local auth:");
1254
+ for (let i = 0; i < projects.length; i += 1) {
1255
+ // eslint-disable-next-line no-console
1256
+ console.log(` ${i + 1}) ${projects[i].name}`);
1257
+ }
1258
+ while (!project) {
1259
+ const raw = String(await rl.question(`Select project [1-${projects.length}] (default 1): `)).trim();
1260
+ const picked = raw ? Number(raw) : 1;
1261
+ if (Number.isInteger(picked) && picked >= 1 && picked <= projects.length) {
1262
+ project = projects[picked - 1];
1263
+ } else {
1264
+ // eslint-disable-next-line no-console
1265
+ console.log(`[qualty] Please enter a number from 1 to ${projects.length}.`);
1266
+ }
1267
+ }
1268
+ }
1269
+
1270
+ let profile = String(args.profile || "").trim();
1271
+ if (!profile) {
1272
+ const current = String(readProjectConfig().local_auth_profile || "default").trim() || "default";
1273
+ profile = String(await rl.question(`Profile name [${current}]: `)).trim() || current;
1274
+ }
1275
+ const statePath = localAuthStatePath({
1276
+ orgId,
1277
+ projectId: project.id,
1278
+ profile,
1279
+ });
1280
+ mkdirSync(join(statePath, ".."), { recursive: true });
1281
+
1282
+ const { chromium } = await import("playwright");
1283
+ const headed = parseBoolean(args.headed, true);
1284
+ let browser = null;
1285
+ try {
1286
+ browser = await chromium.launch({
1287
+ headless: !headed,
1288
+ channel: "chrome",
1289
+ args: ["--disable-blink-features=AutomationControlled"],
1290
+ });
1291
+ // eslint-disable-next-line no-console
1292
+ console.log("[qualty][auth] Using local Chrome channel for login setup.");
1293
+ } catch (err) {
1294
+ // eslint-disable-next-line no-console
1295
+ console.log(`[qualty][auth] Chrome channel unavailable, falling back to Playwright Chromium (${err?.message || err}).`);
1296
+ browser = await chromium.launch({
1297
+ headless: !headed,
1298
+ args: ["--disable-blink-features=AutomationControlled"],
1299
+ });
1300
+ }
1301
+ const context = await browser.newContext();
1302
+ const page = await context.newPage();
1303
+ const startUrl = project.url || "about:blank";
1304
+ await page.goto(startUrl, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
1305
+ // eslint-disable-next-line no-console
1306
+ console.log(`[qualty][auth] Browser opened for project "${project.name}".`);
1307
+ // eslint-disable-next-line no-console
1308
+ console.log("[qualty][auth] Complete login in the browser, then press Enter here to save context.");
1309
+ await rl.question("Press Enter to save local auth context...");
1310
+ await context.storageState({ path: statePath });
1311
+ await context.close();
1312
+ await browser.close();
1313
+
1314
+ writeProjectConfig({
1315
+ local_auth_project_id: project.id,
1316
+ local_auth_profile: profile,
1317
+ default_org_id: orgId,
1318
+ });
1319
+ try {
1320
+ const synced = await registerLocalLoginProfileBinding({
1321
+ apiUrl,
1322
+ token,
1323
+ orgId,
1324
+ projectId: project.id,
1325
+ profileName: profile,
1326
+ localProfileName: profile,
1327
+ });
1328
+ if (synced?.name) {
1329
+ // eslint-disable-next-line no-console
1330
+ console.log(`[qualty][auth] Synced profile reference "${synced.name}" to dashboard.`);
1331
+ }
1332
+ } catch (syncErr) {
1333
+ // eslint-disable-next-line no-console
1334
+ console.warn(`[qualty][auth] Failed to sync profile reference to backend: ${syncErr?.message || syncErr}`);
1335
+ }
1336
+ // eslint-disable-next-line no-console
1337
+ console.log(`[qualty][auth] Saved local context profile "${profile}" for project "${project.name}".`);
1338
+ // eslint-disable-next-line no-console
1339
+ console.log(`[qualty][auth] Path: ${statePath}`);
1340
+ } finally {
1341
+ rl.close();
1342
+ }
1343
+ }
1344
+
1090
1345
  async function main() {
1091
1346
  const args = parseArgs(process.argv);
1092
1347
  const command = args._[0];
@@ -1102,6 +1357,14 @@ async function main() {
1102
1357
  await runSetup(args);
1103
1358
  return;
1104
1359
  }
1360
+ if (command === "auth") {
1361
+ const sub = args._[1];
1362
+ if (sub === "setup") {
1363
+ await runAuthSetup(args);
1364
+ return;
1365
+ }
1366
+ throw new Error("Unknown auth subcommand. Use: qualty auth setup");
1367
+ }
1105
1368
  if (command === "run") {
1106
1369
  const config = readQualtyConfig();
1107
1370
  if (parseBoolean(args.local, false)) {
@@ -1122,6 +1385,27 @@ async function main() {
1122
1385
  token,
1123
1386
  orgId,
1124
1387
  })) || args["suite-id"];
1388
+ const shouldPromptAuthProfile = parseBoolean(args["prompt-auth-profile"], false);
1389
+ const authChoice = shouldPromptAuthProfile
1390
+ ? await maybePromptAuthProfileForLocalRun({
1391
+ args,
1392
+ orgId,
1393
+ projectId,
1394
+ })
1395
+ : {
1396
+ authProfile: String(args["auth-profile"] || "").trim(),
1397
+ noAuthState: parseBoolean(args["no-auth-state"], false),
1398
+ };
1399
+ const localAuthArgs = {
1400
+ ...args,
1401
+ ...(authChoice.authProfile ? { "auth-profile": authChoice.authProfile } : {}),
1402
+ ...(authChoice.noAuthState ? { "no-auth-state": true } : {}),
1403
+ };
1404
+ const localAuth = resolveLocalAuthStateForRun({ args: localAuthArgs, orgId, projectId });
1405
+ if (localAuth?.path) {
1406
+ // eslint-disable-next-line no-console
1407
+ console.log(`[qualty][auth] Reusing profile "${localAuth.profile}"`);
1408
+ }
1125
1409
  await runLocalCi({
1126
1410
  apiUrl,
1127
1411
  token,
@@ -1138,6 +1422,9 @@ async function main() {
1138
1422
  device: args.device || "1440x900",
1139
1423
  headless: !parseBoolean(args.headed, false),
1140
1424
  localConcurrency: resolveLocalConcurrency(args),
1425
+ authProfile: localAuth?.profile || authChoice.authProfile || "",
1426
+ noAuthState: Boolean(authChoice.noAuthState || parseBoolean(args["no-auth-state"], false)),
1427
+ storageStatePath: localAuth?.path,
1141
1428
  });
1142
1429
  return;
1143
1430
  }
@@ -1148,7 +1435,13 @@ async function main() {
1148
1435
  await runResolve(args);
1149
1436
  return;
1150
1437
  }
1151
- if (command !== "setup" && command !== "connect" && command !== "run" && command !== "resolve") {
1438
+ if (
1439
+ command !== "setup" &&
1440
+ command !== "auth" &&
1441
+ command !== "connect" &&
1442
+ command !== "run" &&
1443
+ command !== "resolve"
1444
+ ) {
1152
1445
  throw new Error(`Unknown command: ${command}`);
1153
1446
  }
1154
1447
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"