qualty 0.1.10 → 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
1
  import { mkdirSync, writeFileSync, mkdtempSync, existsSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { tmpdir } from "node:os";
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,8 @@ export async function runLocalExecution({
754
1000
  executionId,
755
1001
  device,
756
1002
  headless,
1003
+ authProfile,
1004
+ noAuthState,
757
1005
  storageStatePath,
758
1006
  }) {
759
1007
  const { chromium } = await import("playwright");
@@ -771,6 +1019,36 @@ export async function runLocalExecution({
771
1019
  const viewport = claim.viewport || { width: 1440, height: 900 };
772
1020
  const steps = Array.isArray(claim.steps) ? claim.steps : [];
773
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;
774
1052
 
775
1053
  const browserServer = await chromium.launchServer({
776
1054
  headless: headless !== false,
@@ -781,9 +1059,10 @@ export async function runLocalExecution({
781
1059
  const contextOptions = {
782
1060
  viewport: { width: viewport.width || 1440, height: viewport.height || 900 },
783
1061
  };
784
- if (storageStatePath && existsSync(storageStatePath)) {
785
- contextOptions.storageState = storageStatePath;
786
- console.log(`[qualty][auth] using saved context: ${storageStatePath}`);
1062
+ if (finalStoragePath) {
1063
+ contextOptions.storageState = finalStoragePath;
1064
+ const logProfile = effectiveProfile || "default";
1065
+ console.log(`[qualty][auth] using saved context "${logProfile}": ${finalStoragePath}`);
787
1066
  }
788
1067
  const context = await browser.newContext(contextOptions);
789
1068
  const page = await context.newPage();
@@ -991,6 +1270,8 @@ export async function runLocalCi({
991
1270
  device,
992
1271
  headless,
993
1272
  localConcurrency = 4,
1273
+ authProfile,
1274
+ noAuthState,
994
1275
  storageStatePath,
995
1276
  }) {
996
1277
  const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
@@ -1016,6 +1297,17 @@ export async function runLocalCi({
1016
1297
  const started = runs.filter((r) => r.execution_job_id);
1017
1298
  if (started.length === 0) throw new Error("Failed to start any local runs.");
1018
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
+
1019
1311
  const concurrency = Math.max(1, Number(localConcurrency) || 4);
1020
1312
  const workerCount = Math.min(concurrency, started.length);
1021
1313
 
@@ -1047,6 +1339,8 @@ export async function runLocalCi({
1047
1339
  executionId: id,
1048
1340
  device,
1049
1341
  headless,
1342
+ authProfile,
1343
+ noAuthState,
1050
1344
  storageStatePath,
1051
1345
  });
1052
1346
  if (result.success) {
package/bin/qualty.js CHANGED
@@ -2,14 +2,16 @@
2
2
 
3
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");
@@ -137,7 +139,7 @@ function usage() {
137
139
  " qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
138
140
  " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
139
141
  " [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
140
- " [--local] [--device 1440x900] [--headed] [--local-concurrency 4] [--auth-profile <profile>] [--no-auth-state]",
142
+ " [--local] [--device 1440x900] [--headed] [--local-concurrency 4] [--auth-profile <profile>] [--no-auth-state] [--prompt-auth-profile]",
141
143
  "",
142
144
  " After each run, logs include step results, explanation, and final evaluator output (dashboard \"View logs\" data).",
143
145
  " Pass --no-view-logs for a short log only (e.g. very large evaluator text).",
@@ -170,6 +172,29 @@ async function apiRequest({ apiUrl, token, orgId, path, method = "GET", body })
170
172
  return data;
171
173
  }
172
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
+
173
198
  function startCloudflared(token, port) {
174
199
  const baseArgs = [
175
200
  "tunnel",
@@ -1122,13 +1147,8 @@ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
1122
1147
 
1123
1148
  function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
1124
1149
  if (parseBoolean(args["no-auth-state"], false)) return null;
1125
- const projectCfg = readProjectConfig();
1126
1150
  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 : "");
1151
+ const profile = requestedProfile;
1132
1152
  if (!profile) return null;
1133
1153
  const path = localAuthStatePath({ orgId, projectId, profile });
1134
1154
  if (!existsSync(path)) return null;
@@ -1296,6 +1316,23 @@ async function runAuthSetup(args) {
1296
1316
  local_auth_profile: profile,
1297
1317
  default_org_id: orgId,
1298
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
+ }
1299
1336
  // eslint-disable-next-line no-console
1300
1337
  console.log(`[qualty][auth] Saved local context profile "${profile}" for project "${project.name}".`);
1301
1338
  // eslint-disable-next-line no-console
@@ -1348,11 +1385,17 @@ async function main() {
1348
1385
  token,
1349
1386
  orgId,
1350
1387
  })) || args["suite-id"];
1351
- const authChoice = await maybePromptAuthProfileForLocalRun({
1352
- args,
1353
- orgId,
1354
- projectId,
1355
- });
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
+ };
1356
1399
  const localAuthArgs = {
1357
1400
  ...args,
1358
1401
  ...(authChoice.authProfile ? { "auth-profile": authChoice.authProfile } : {}),
@@ -1379,6 +1422,8 @@ async function main() {
1379
1422
  device: args.device || "1440x900",
1380
1423
  headless: !parseBoolean(args.headed, false),
1381
1424
  localConcurrency: resolveLocalConcurrency(args),
1425
+ authProfile: localAuth?.profile || authChoice.authProfile || "",
1426
+ noAuthState: Boolean(authChoice.noAuthState || parseBoolean(args["no-auth-state"], false)),
1382
1427
  storageStatePath: localAuth?.path,
1383
1428
  });
1384
1429
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.10",
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"