qualty 0.1.14 → 0.1.16

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.
@@ -9,6 +9,36 @@ function safePathSegment(value, fallback) {
9
9
  return normalized || fallback;
10
10
  }
11
11
 
12
+ /**
13
+ * Rewrite a dashboard/production test URL to localhost for local CLI runs.
14
+ * Keeps pathname, search, and hash; swaps origin to http://localhost:<port>.
15
+ */
16
+ export function rewriteUrlForLocalRun(rawUrl, port) {
17
+ const portNum = Number(port);
18
+ if (!Number.isFinite(portNum) || portNum < 1 || portNum > 65535) {
19
+ return String(rawUrl || "").trim();
20
+ }
21
+ const trimmed = String(rawUrl || "").trim();
22
+ if (!trimmed) return trimmed;
23
+
24
+ let parsed;
25
+ try {
26
+ const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
27
+ parsed = new URL(withProtocol);
28
+ } catch {
29
+ return trimmed;
30
+ }
31
+
32
+ const suffix = `${parsed.pathname}${parsed.search}${parsed.hash}`;
33
+ return `http://localhost:${portNum}${suffix}`;
34
+ }
35
+
36
+ function resolveLocalPort(rawPort, fallback = 3000) {
37
+ const portNum = Number(rawPort);
38
+ if (Number.isFinite(portNum) && portNum >= 1 && portNum <= 65535) return portNum;
39
+ return fallback;
40
+ }
41
+
12
42
  function localAuthStatePath({ orgId, projectId, profile }) {
13
43
  const orgSeg = safePathSegment(orgId || "default-org", "default-org");
14
44
  const projectSeg = safePathSegment(projectId || "default-project", "default-project");
@@ -177,6 +207,7 @@ async function preflightMissingLocalBindings({
177
207
  savedJobs,
178
208
  explicitAuthProfile,
179
209
  noAuthState,
210
+ localPort = 3000,
180
211
  }) {
181
212
  if (noAuthState) return;
182
213
  if (explicitAuthProfile) return;
@@ -195,14 +226,16 @@ async function preflightMissingLocalBindings({
195
226
  const entry = missingByProfile.get(profileName) || {
196
227
  localProfileName: profileName,
197
228
  projectId: String(job?.project_id || projectId || "").trim(),
198
- startUrl: String(job?.url || "").trim(),
229
+ startUrl: rewriteUrlForLocalRun(String(job?.url || "").trim(), localPort),
199
230
  tests: [],
200
231
  };
201
232
  entry.tests.push({
202
233
  id: String(job?.id || "").trim(),
203
234
  name: String(job?.name || job?.id || "Unnamed test").trim(),
204
235
  });
205
- if (!entry.startUrl) entry.startUrl = String(job?.url || "").trim();
236
+ if (!entry.startUrl) {
237
+ entry.startUrl = rewriteUrlForLocalRun(String(job?.url || "").trim(), localPort);
238
+ }
206
239
  missingByProfile.set(profileName, entry);
207
240
  }
208
241
  if (missingByProfile.size === 0) return;
@@ -1016,7 +1049,14 @@ export async function runLocalExecution({
1016
1049
  body: { device },
1017
1050
  });
1018
1051
 
1019
- const url = claim.url;
1052
+ const originalUrl = String(claim.url || "").trim();
1053
+ const localPort = resolveLocalPort(claim.local_port);
1054
+ const url = rewriteUrlForLocalRun(originalUrl, localPort);
1055
+ if (url !== originalUrl) {
1056
+ // eslint-disable-next-line no-console
1057
+ console.log(`[qualty] Local URL rewrite: ${originalUrl} -> ${url}`);
1058
+ }
1059
+ claim.url = url;
1020
1060
  const viewport = claim.viewport || { width: 1440, height: 900 };
1021
1061
  const steps = Array.isArray(claim.steps) ? claim.steps : [];
1022
1062
  const totalSteps = claim.total_steps || steps.length;
@@ -1279,6 +1319,21 @@ export async function runLocalExecution({
1279
1319
  return { success: runSuccess, stepsJson, error: runError };
1280
1320
  }
1281
1321
 
1322
+ async function fetchProjectLocalPort({ apiRequest, apiUrl, token, orgId, projectId }) {
1323
+ if (!projectId) return 3000;
1324
+ try {
1325
+ const project = await apiRequest({
1326
+ apiUrl,
1327
+ token,
1328
+ orgId,
1329
+ path: `/api/v1/projects/${encodeURIComponent(String(projectId))}`,
1330
+ });
1331
+ return resolveLocalPort(project?.localhost?.port);
1332
+ } catch {
1333
+ return 3000;
1334
+ }
1335
+ }
1336
+
1282
1337
  export async function runLocalCi({
1283
1338
  apiUrl,
1284
1339
  token,
@@ -1301,6 +1356,8 @@ export async function runLocalCi({
1301
1356
  throw new Error("No saved tests matched your selector.");
1302
1357
  }
1303
1358
 
1359
+ const localPort = await fetchProjectLocalPort({ apiRequest, apiUrl, token, orgId, projectId });
1360
+
1304
1361
  const startResp = await apiRequest({
1305
1362
  apiUrl,
1306
1363
  token,
@@ -1328,6 +1385,7 @@ export async function runLocalCi({
1328
1385
  savedJobs,
1329
1386
  explicitAuthProfile: String(authProfile || "").trim(),
1330
1387
  noAuthState: Boolean(noAuthState),
1388
+ localPort,
1331
1389
  });
1332
1390
 
1333
1391
  const concurrency = Math.max(1, Number(localConcurrency) || 4);
@@ -1411,5 +1469,8 @@ export async function runLocalCi({
1411
1469
 
1412
1470
  // eslint-disable-next-line no-console
1413
1471
  console.log(`[qualty] Local summary: ${passed} passed, ${failed} failed.`);
1414
- if (failOnFailure && failed > 0) process.exit(1);
1472
+ if (failOnFailure && failed > 0) {
1473
+ throw new Error(`${failed} local run(s) failed`);
1474
+ }
1475
+ return { passed, failed, total: started.length };
1415
1476
  }
package/bin/qualty.js CHANGED
@@ -6,10 +6,11 @@ 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
- import { runLocalCi } from "./local-runner.js";
9
+ import { rewriteUrlForLocalRun, 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-production.up.railway.app";
13
+
13
14
 
14
15
  const QUALTY_CONFIG_DIR = join(homedir(), ".config", "qualty");
15
16
  const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
@@ -979,6 +980,200 @@ async function resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, expl
979
980
  });
980
981
  }
981
982
 
983
+ async function resolveLocalSuitePlan({ apiUrl, token, orgId, suiteId }) {
984
+ const suite = await apiRequest({
985
+ apiUrl,
986
+ token,
987
+ orgId,
988
+ path: `/api/v1/test-suites/${encodeURIComponent(String(suiteId))}`,
989
+ });
990
+ const suiteJobIds = Array.isArray(suite?.job_ids)
991
+ ? suite.job_ids.map((id) => String(id || "").trim()).filter(Boolean)
992
+ : [];
993
+ const sequenceRefs = Array.isArray(suite?.sequences) ? suite.sequences : [];
994
+ const sequenceIds = sequenceRefs
995
+ .map((s) => String(s?.id || "").trim())
996
+ .filter(Boolean);
997
+ if (sequenceIds.length === 0) {
998
+ return { suiteName: String(suite?.name || "").trim() || "Suite", standaloneJobIds: suiteJobIds, sequences: [] };
999
+ }
1000
+
1001
+ const sequencePayloads = await Promise.all(
1002
+ sequenceIds.map((sequenceId) =>
1003
+ apiRequest({
1004
+ apiUrl,
1005
+ token,
1006
+ orgId,
1007
+ path: `/api/v1/test-sequences/${encodeURIComponent(sequenceId)}`,
1008
+ }).catch(() => null)
1009
+ )
1010
+ );
1011
+
1012
+ const sequencePlans = [];
1013
+ const sequenceJobIds = new Set();
1014
+ for (const payload of sequencePayloads) {
1015
+ if (!payload || !Array.isArray(payload.items)) continue;
1016
+ const grouped = new Map();
1017
+ for (const item of payload.items) {
1018
+ const stage = Number(item?.stage);
1019
+ const position = Number(item?.position);
1020
+ const jobId = String(item?.job_id || "").trim();
1021
+ if (!Number.isFinite(stage) || !jobId) continue;
1022
+ if (!grouped.has(stage)) grouped.set(stage, []);
1023
+ grouped.get(stage).push({
1024
+ stage,
1025
+ position: Number.isFinite(position) ? position : 9999,
1026
+ jobId,
1027
+ jobName: String(item?.job_name || jobId).trim(),
1028
+ });
1029
+ sequenceJobIds.add(jobId);
1030
+ }
1031
+ const stages = Array.from(grouped.entries())
1032
+ .sort((a, b) => a[0] - b[0])
1033
+ .map(([stage, entries]) => {
1034
+ const sorted = entries.sort((a, b) => a.position - b.position);
1035
+ const seen = new Set();
1036
+ const jobs = [];
1037
+ for (const row of sorted) {
1038
+ if (seen.has(row.jobId)) continue;
1039
+ seen.add(row.jobId);
1040
+ jobs.push({ id: row.jobId, name: row.jobName });
1041
+ }
1042
+ return { stage, jobs };
1043
+ })
1044
+ .filter((s) => s.jobs.length > 0);
1045
+
1046
+ if (stages.length > 0) {
1047
+ sequencePlans.push({
1048
+ id: String(payload.id || "").trim(),
1049
+ name: String(payload.name || "Sequence").trim(),
1050
+ stopOnFailure: Boolean(payload.stop_on_failure),
1051
+ stages,
1052
+ });
1053
+ }
1054
+ }
1055
+
1056
+ const standaloneJobIds = suiteJobIds.filter((jobId) => !sequenceJobIds.has(jobId));
1057
+ return {
1058
+ suiteName: String(suite?.name || "").trim() || "Suite",
1059
+ standaloneJobIds,
1060
+ sequences: sequencePlans,
1061
+ };
1062
+ }
1063
+
1064
+ async function runLocalSuiteWithSequences({
1065
+ apiUrl,
1066
+ token,
1067
+ orgId,
1068
+ projectId,
1069
+ suiteId,
1070
+ resolveSavedJobs,
1071
+ apiRequest,
1072
+ failOnFailure,
1073
+ device,
1074
+ headless,
1075
+ localConcurrency,
1076
+ authProfile,
1077
+ noAuthState,
1078
+ storageStatePath,
1079
+ }) {
1080
+ const plan = await resolveLocalSuitePlan({ apiUrl, token, orgId, suiteId });
1081
+ if (!plan.sequences.length) {
1082
+ return runLocalCi({
1083
+ apiUrl,
1084
+ token,
1085
+ orgId,
1086
+ projectId,
1087
+ suiteId,
1088
+ explicitIds: [],
1089
+ resolveSavedJobs,
1090
+ apiRequest,
1091
+ failOnFailure,
1092
+ device,
1093
+ headless,
1094
+ localConcurrency,
1095
+ authProfile,
1096
+ noAuthState,
1097
+ storageStatePath,
1098
+ });
1099
+ }
1100
+
1101
+ let passed = 0;
1102
+ let failed = 0;
1103
+ let total = 0;
1104
+ // eslint-disable-next-line no-console
1105
+ console.log(`[qualty] Running suite "${plan.suiteName}" with ${plan.sequences.length} sequence(s) locally.`);
1106
+
1107
+ if (plan.standaloneJobIds.length > 0) {
1108
+ // eslint-disable-next-line no-console
1109
+ console.log(`[qualty] Running ${plan.standaloneJobIds.length} standalone test(s) in suite first.`);
1110
+ const result = await runLocalCi({
1111
+ apiUrl,
1112
+ token,
1113
+ orgId,
1114
+ projectId,
1115
+ suiteId: "",
1116
+ explicitIds: plan.standaloneJobIds,
1117
+ resolveSavedJobs,
1118
+ apiRequest,
1119
+ failOnFailure: false,
1120
+ device,
1121
+ headless,
1122
+ localConcurrency,
1123
+ authProfile,
1124
+ noAuthState,
1125
+ storageStatePath,
1126
+ });
1127
+ passed += result.passed;
1128
+ failed += result.failed;
1129
+ total += result.total;
1130
+ }
1131
+
1132
+ for (const sequence of plan.sequences) {
1133
+ // eslint-disable-next-line no-console
1134
+ console.log(`[qualty] Sequence: ${sequence.name}`);
1135
+ for (const stage of sequence.stages) {
1136
+ const stageJobIds = stage.jobs.map((j) => j.id);
1137
+ // eslint-disable-next-line no-console
1138
+ console.log(
1139
+ `[qualty] Stage ${stage.stage}: ${stage.jobs.map((j) => j.name).join(", ")}`
1140
+ );
1141
+ const result = await runLocalCi({
1142
+ apiUrl,
1143
+ token,
1144
+ orgId,
1145
+ projectId,
1146
+ suiteId: "",
1147
+ explicitIds: stageJobIds,
1148
+ resolveSavedJobs,
1149
+ apiRequest,
1150
+ failOnFailure: false,
1151
+ device,
1152
+ headless,
1153
+ localConcurrency,
1154
+ authProfile,
1155
+ noAuthState,
1156
+ storageStatePath,
1157
+ });
1158
+ passed += result.passed;
1159
+ failed += result.failed;
1160
+ total += result.total;
1161
+ if (sequence.stopOnFailure && result.failed > 0) {
1162
+ // eslint-disable-next-line no-console
1163
+ console.log(`[qualty] Stage failed; stopping remaining stages for sequence "${sequence.name}".`);
1164
+ break;
1165
+ }
1166
+ }
1167
+ }
1168
+
1169
+ // eslint-disable-next-line no-console
1170
+ console.log(`[qualty] Local suite summary: ${passed} passed, ${failed} failed.`);
1171
+ if (failOnFailure && failed > 0) {
1172
+ throw new Error(`${failed} local run(s) failed`);
1173
+ }
1174
+ return { passed, failed, total };
1175
+ }
1176
+
982
1177
  async function runResolve(args) {
983
1178
  const config = readQualtyConfig();
984
1179
  const apiUrl = resolveApiUrl(args, config);
@@ -1232,6 +1427,7 @@ async function runAuthSetup(args) {
1232
1427
  id: String(p?.id || "").trim(),
1233
1428
  name: String(p?.name || "").trim() || "Unnamed project",
1234
1429
  url: String(p?.url || "").trim(),
1430
+ localPort: Number(p?.localhost?.port) || 3000,
1235
1431
  }))
1236
1432
  .filter((p) => p.id);
1237
1433
  if (projects.length === 0) {
@@ -1343,7 +1539,15 @@ async function runAuthSetup(args) {
1343
1539
  }
1344
1540
  const context = await browser.newContext();
1345
1541
  const page = await context.newPage();
1346
- const startUrl = project.url || "about:blank";
1542
+ const rawStartUrl = project.url || "about:blank";
1543
+ const startUrl =
1544
+ rawStartUrl === "about:blank"
1545
+ ? rawStartUrl
1546
+ : rewriteUrlForLocalRun(rawStartUrl, project.localPort);
1547
+ if (startUrl !== rawStartUrl) {
1548
+ // eslint-disable-next-line no-console
1549
+ console.log(`[qualty][auth] Local URL rewrite: ${rawStartUrl} -> ${startUrl}`);
1550
+ }
1347
1551
  await page.goto(startUrl, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
1348
1552
  // eslint-disable-next-line no-console
1349
1553
  console.log(`[qualty][auth] Browser opened for project "${project.name}".`);
@@ -1428,6 +1632,10 @@ async function main() {
1428
1632
  token,
1429
1633
  orgId,
1430
1634
  })) || args["suite-id"];
1635
+ const explicitIds = String(args.ids || "")
1636
+ .split(",")
1637
+ .map((id) => id.trim())
1638
+ .filter(Boolean);
1431
1639
  const shouldPromptAuthProfile = parseBoolean(args["prompt-auth-profile"], false);
1432
1640
  const authChoice = shouldPromptAuthProfile
1433
1641
  ? await maybePromptAuthProfileForLocalRun({
@@ -1449,16 +1657,13 @@ async function main() {
1449
1657
  // eslint-disable-next-line no-console
1450
1658
  console.log(`[qualty][auth] Reusing profile "${localAuth.profile}"`);
1451
1659
  }
1452
- await runLocalCi({
1660
+ const localRunArgs = {
1453
1661
  apiUrl,
1454
1662
  token,
1455
1663
  orgId,
1456
1664
  projectId,
1457
1665
  suiteId,
1458
- explicitIds: String(args.ids || "")
1459
- .split(",")
1460
- .map((id) => id.trim())
1461
- .filter(Boolean),
1666
+ explicitIds,
1462
1667
  resolveSavedJobs,
1463
1668
  apiRequest,
1464
1669
  failOnFailure: parseBoolean(args["fail-on-failure"], true),
@@ -1468,7 +1673,12 @@ async function main() {
1468
1673
  authProfile: localAuth?.profile || authChoice.authProfile || "",
1469
1674
  noAuthState: Boolean(authChoice.noAuthState || parseBoolean(args["no-auth-state"], false)),
1470
1675
  storageStatePath: localAuth?.path,
1471
- });
1676
+ };
1677
+ if (suiteId && explicitIds.length === 0) {
1678
+ await runLocalSuiteWithSequences(localRunArgs);
1679
+ } else {
1680
+ await runLocalCi(localRunArgs);
1681
+ }
1472
1682
  return;
1473
1683
  }
1474
1684
  await runCi(args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"