qualty 0.1.9 → 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,6 +1,6 @@
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
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
@@ -133,10 +133,11 @@ function usage() {
133
133
  [
134
134
  "Usage:",
135
135
  " qualty setup [--token <bearer-token>] [--org <org-id>]",
136
+ " qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
136
137
  " qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
137
138
  " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
138
139
  " [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
139
- " [--local] [--device 1440x900] [--headed] [--local-concurrency 4]",
140
+ " [--local] [--device 1440x900] [--headed] [--local-concurrency 4] [--auth-profile <profile>] [--no-auth-state]",
140
141
  "",
141
142
  " After each run, logs include step results, explanation, and final evaluator output (dashboard \"View logs\" data).",
142
143
  " Pass --no-view-logs for a short log only (e.g. very large evaluator text).",
@@ -445,6 +446,38 @@ function resolveLocalConcurrency(args) {
445
446
  return n;
446
447
  }
447
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
+
448
481
  function sleep(ms) {
449
482
  return new Promise((resolve) => setTimeout(resolve, ms));
450
483
  }
@@ -1087,6 +1120,191 @@ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
1087
1120
  }
1088
1121
  }
1089
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
+
1090
1308
  async function main() {
1091
1309
  const args = parseArgs(process.argv);
1092
1310
  const command = args._[0];
@@ -1102,6 +1320,14 @@ async function main() {
1102
1320
  await runSetup(args);
1103
1321
  return;
1104
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
+ }
1105
1331
  if (command === "run") {
1106
1332
  const config = readQualtyConfig();
1107
1333
  if (parseBoolean(args.local, false)) {
@@ -1122,6 +1348,21 @@ async function main() {
1122
1348
  token,
1123
1349
  orgId,
1124
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
+ }
1125
1366
  await runLocalCi({
1126
1367
  apiUrl,
1127
1368
  token,
@@ -1138,6 +1379,7 @@ async function main() {
1138
1379
  device: args.device || "1440x900",
1139
1380
  headless: !parseBoolean(args.headed, false),
1140
1381
  localConcurrency: resolveLocalConcurrency(args),
1382
+ storageStatePath: localAuth?.path,
1141
1383
  });
1142
1384
  return;
1143
1385
  }
@@ -1148,7 +1390,13 @@ async function main() {
1148
1390
  await runResolve(args);
1149
1391
  return;
1150
1392
  }
1151
- if (command !== "setup" && command !== "connect" && command !== "run" && command !== "resolve") {
1393
+ if (
1394
+ command !== "setup" &&
1395
+ command !== "auth" &&
1396
+ command !== "connect" &&
1397
+ command !== "run" &&
1398
+ command !== "resolve"
1399
+ ) {
1152
1400
  throw new Error(`Unknown command: ${command}`);
1153
1401
  }
1154
1402
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.9",
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"