qualty 0.1.13 → 0.1.15

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.
@@ -272,6 +272,7 @@ async function downloadProjectAttachments({ apiUrl, token, orgId, projectId, spe
272
272
  const name = spec.original_filename || `${label}.bin`;
273
273
  const localPath = join(dir, name.replace(/[^a-zA-Z0-9._-]+/g, "_"));
274
274
  writeFileSync(localPath, buf);
275
+ labelToPath[String(fileId)] = localPath;
275
276
  labelToPath[label] = localPath;
276
277
  if (spec.label && spec.label !== label) labelToPath[spec.label] = localPath;
277
278
  }
@@ -1067,13 +1068,34 @@ export async function runLocalExecution({
1067
1068
  const context = await browser.newContext(contextOptions);
1068
1069
  const page = await context.newPage();
1069
1070
  const networkCollector = attachNetworkCollector(page);
1071
+ const attachmentSpecs = Array.isArray(claim.file_attachments) ? claim.file_attachments : [];
1070
1072
  const labelToPath = await downloadProjectAttachments({
1071
1073
  apiUrl,
1072
1074
  token,
1073
1075
  orgId,
1074
1076
  projectId: claim.project_id,
1075
- specs: claim.file_attachments,
1077
+ specs: attachmentSpecs,
1076
1078
  });
1079
+ const missingAttachmentLabels = attachmentSpecs
1080
+ .map((spec) => ({
1081
+ id: String(spec?.id || "").trim(),
1082
+ label: String(spec?.runner_key || spec?.label || "").trim(),
1083
+ rawLabel: String(spec?.label || "").trim(),
1084
+ }))
1085
+ .filter((it) => {
1086
+ if (it.id && labelToPath[it.id]) return false;
1087
+ if (it.label && labelToPath[it.label]) return false;
1088
+ if (it.rawLabel && labelToPath[it.rawLabel]) return false;
1089
+ return true;
1090
+ });
1091
+ if (missingAttachmentLabels.length > 0) {
1092
+ const listed = missingAttachmentLabels
1093
+ .map((it) => it.rawLabel || it.label || it.id || "<unknown>")
1094
+ .join(", ");
1095
+ throw new Error(
1096
+ `Attached file download failed for: ${listed}. Re-attach files in dashboard and retry local run.`
1097
+ );
1098
+ }
1077
1099
  const credentials = claim.credentials || null;
1078
1100
 
1079
1101
  const stepsJson = [];
@@ -1389,5 +1411,8 @@ export async function runLocalCi({
1389
1411
 
1390
1412
  // eslint-disable-next-line no-console
1391
1413
  console.log(`[qualty] Local summary: ${passed} passed, ${failed} failed.`);
1392
- if (failOnFailure && failed > 0) process.exit(1);
1414
+ if (failOnFailure && failed > 0) {
1415
+ throw new Error(`${failed} local run(s) failed`);
1416
+ }
1417
+ return { passed, failed, total: started.length };
1393
1418
  }
package/bin/qualty.js CHANGED
@@ -9,7 +9,7 @@ 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-production.up.railway.app";
13
13
 
14
14
  const QUALTY_CONFIG_DIR = join(homedir(), ".config", "qualty");
15
15
  const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
@@ -979,6 +979,200 @@ async function resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, expl
979
979
  });
980
980
  }
981
981
 
982
+ async function resolveLocalSuitePlan({ apiUrl, token, orgId, suiteId }) {
983
+ const suite = await apiRequest({
984
+ apiUrl,
985
+ token,
986
+ orgId,
987
+ path: `/api/v1/test-suites/${encodeURIComponent(String(suiteId))}`,
988
+ });
989
+ const suiteJobIds = Array.isArray(suite?.job_ids)
990
+ ? suite.job_ids.map((id) => String(id || "").trim()).filter(Boolean)
991
+ : [];
992
+ const sequenceRefs = Array.isArray(suite?.sequences) ? suite.sequences : [];
993
+ const sequenceIds = sequenceRefs
994
+ .map((s) => String(s?.id || "").trim())
995
+ .filter(Boolean);
996
+ if (sequenceIds.length === 0) {
997
+ return { suiteName: String(suite?.name || "").trim() || "Suite", standaloneJobIds: suiteJobIds, sequences: [] };
998
+ }
999
+
1000
+ const sequencePayloads = await Promise.all(
1001
+ sequenceIds.map((sequenceId) =>
1002
+ apiRequest({
1003
+ apiUrl,
1004
+ token,
1005
+ orgId,
1006
+ path: `/api/v1/test-sequences/${encodeURIComponent(sequenceId)}`,
1007
+ }).catch(() => null)
1008
+ )
1009
+ );
1010
+
1011
+ const sequencePlans = [];
1012
+ const sequenceJobIds = new Set();
1013
+ for (const payload of sequencePayloads) {
1014
+ if (!payload || !Array.isArray(payload.items)) continue;
1015
+ const grouped = new Map();
1016
+ for (const item of payload.items) {
1017
+ const stage = Number(item?.stage);
1018
+ const position = Number(item?.position);
1019
+ const jobId = String(item?.job_id || "").trim();
1020
+ if (!Number.isFinite(stage) || !jobId) continue;
1021
+ if (!grouped.has(stage)) grouped.set(stage, []);
1022
+ grouped.get(stage).push({
1023
+ stage,
1024
+ position: Number.isFinite(position) ? position : 9999,
1025
+ jobId,
1026
+ jobName: String(item?.job_name || jobId).trim(),
1027
+ });
1028
+ sequenceJobIds.add(jobId);
1029
+ }
1030
+ const stages = Array.from(grouped.entries())
1031
+ .sort((a, b) => a[0] - b[0])
1032
+ .map(([stage, entries]) => {
1033
+ const sorted = entries.sort((a, b) => a.position - b.position);
1034
+ const seen = new Set();
1035
+ const jobs = [];
1036
+ for (const row of sorted) {
1037
+ if (seen.has(row.jobId)) continue;
1038
+ seen.add(row.jobId);
1039
+ jobs.push({ id: row.jobId, name: row.jobName });
1040
+ }
1041
+ return { stage, jobs };
1042
+ })
1043
+ .filter((s) => s.jobs.length > 0);
1044
+
1045
+ if (stages.length > 0) {
1046
+ sequencePlans.push({
1047
+ id: String(payload.id || "").trim(),
1048
+ name: String(payload.name || "Sequence").trim(),
1049
+ stopOnFailure: Boolean(payload.stop_on_failure),
1050
+ stages,
1051
+ });
1052
+ }
1053
+ }
1054
+
1055
+ const standaloneJobIds = suiteJobIds.filter((jobId) => !sequenceJobIds.has(jobId));
1056
+ return {
1057
+ suiteName: String(suite?.name || "").trim() || "Suite",
1058
+ standaloneJobIds,
1059
+ sequences: sequencePlans,
1060
+ };
1061
+ }
1062
+
1063
+ async function runLocalSuiteWithSequences({
1064
+ apiUrl,
1065
+ token,
1066
+ orgId,
1067
+ projectId,
1068
+ suiteId,
1069
+ resolveSavedJobs,
1070
+ apiRequest,
1071
+ failOnFailure,
1072
+ device,
1073
+ headless,
1074
+ localConcurrency,
1075
+ authProfile,
1076
+ noAuthState,
1077
+ storageStatePath,
1078
+ }) {
1079
+ const plan = await resolveLocalSuitePlan({ apiUrl, token, orgId, suiteId });
1080
+ if (!plan.sequences.length) {
1081
+ return runLocalCi({
1082
+ apiUrl,
1083
+ token,
1084
+ orgId,
1085
+ projectId,
1086
+ suiteId,
1087
+ explicitIds: [],
1088
+ resolveSavedJobs,
1089
+ apiRequest,
1090
+ failOnFailure,
1091
+ device,
1092
+ headless,
1093
+ localConcurrency,
1094
+ authProfile,
1095
+ noAuthState,
1096
+ storageStatePath,
1097
+ });
1098
+ }
1099
+
1100
+ let passed = 0;
1101
+ let failed = 0;
1102
+ let total = 0;
1103
+ // eslint-disable-next-line no-console
1104
+ console.log(`[qualty] Running suite "${plan.suiteName}" with ${plan.sequences.length} sequence(s) locally.`);
1105
+
1106
+ if (plan.standaloneJobIds.length > 0) {
1107
+ // eslint-disable-next-line no-console
1108
+ console.log(`[qualty] Running ${plan.standaloneJobIds.length} standalone test(s) in suite first.`);
1109
+ const result = await runLocalCi({
1110
+ apiUrl,
1111
+ token,
1112
+ orgId,
1113
+ projectId,
1114
+ suiteId: "",
1115
+ explicitIds: plan.standaloneJobIds,
1116
+ resolveSavedJobs,
1117
+ apiRequest,
1118
+ failOnFailure: false,
1119
+ device,
1120
+ headless,
1121
+ localConcurrency,
1122
+ authProfile,
1123
+ noAuthState,
1124
+ storageStatePath,
1125
+ });
1126
+ passed += result.passed;
1127
+ failed += result.failed;
1128
+ total += result.total;
1129
+ }
1130
+
1131
+ for (const sequence of plan.sequences) {
1132
+ // eslint-disable-next-line no-console
1133
+ console.log(`[qualty] Sequence: ${sequence.name}`);
1134
+ for (const stage of sequence.stages) {
1135
+ const stageJobIds = stage.jobs.map((j) => j.id);
1136
+ // eslint-disable-next-line no-console
1137
+ console.log(
1138
+ `[qualty] Stage ${stage.stage}: ${stage.jobs.map((j) => j.name).join(", ")}`
1139
+ );
1140
+ const result = await runLocalCi({
1141
+ apiUrl,
1142
+ token,
1143
+ orgId,
1144
+ projectId,
1145
+ suiteId: "",
1146
+ explicitIds: stageJobIds,
1147
+ resolveSavedJobs,
1148
+ apiRequest,
1149
+ failOnFailure: false,
1150
+ device,
1151
+ headless,
1152
+ localConcurrency,
1153
+ authProfile,
1154
+ noAuthState,
1155
+ storageStatePath,
1156
+ });
1157
+ passed += result.passed;
1158
+ failed += result.failed;
1159
+ total += result.total;
1160
+ if (sequence.stopOnFailure && result.failed > 0) {
1161
+ // eslint-disable-next-line no-console
1162
+ console.log(`[qualty] Stage failed; stopping remaining stages for sequence "${sequence.name}".`);
1163
+ break;
1164
+ }
1165
+ }
1166
+ }
1167
+
1168
+ // eslint-disable-next-line no-console
1169
+ console.log(`[qualty] Local suite summary: ${passed} passed, ${failed} failed.`);
1170
+ if (failOnFailure && failed > 0) {
1171
+ throw new Error(`${failed} local run(s) failed`);
1172
+ }
1173
+ return { passed, failed, total };
1174
+ }
1175
+
982
1176
  async function runResolve(args) {
983
1177
  const config = readQualtyConfig();
984
1178
  const apiUrl = resolveApiUrl(args, config);
@@ -1428,6 +1622,10 @@ async function main() {
1428
1622
  token,
1429
1623
  orgId,
1430
1624
  })) || args["suite-id"];
1625
+ const explicitIds = String(args.ids || "")
1626
+ .split(",")
1627
+ .map((id) => id.trim())
1628
+ .filter(Boolean);
1431
1629
  const shouldPromptAuthProfile = parseBoolean(args["prompt-auth-profile"], false);
1432
1630
  const authChoice = shouldPromptAuthProfile
1433
1631
  ? await maybePromptAuthProfileForLocalRun({
@@ -1449,16 +1647,13 @@ async function main() {
1449
1647
  // eslint-disable-next-line no-console
1450
1648
  console.log(`[qualty][auth] Reusing profile "${localAuth.profile}"`);
1451
1649
  }
1452
- await runLocalCi({
1650
+ const localRunArgs = {
1453
1651
  apiUrl,
1454
1652
  token,
1455
1653
  orgId,
1456
1654
  projectId,
1457
1655
  suiteId,
1458
- explicitIds: String(args.ids || "")
1459
- .split(",")
1460
- .map((id) => id.trim())
1461
- .filter(Boolean),
1656
+ explicitIds,
1462
1657
  resolveSavedJobs,
1463
1658
  apiRequest,
1464
1659
  failOnFailure: parseBoolean(args["fail-on-failure"], true),
@@ -1468,7 +1663,12 @@ async function main() {
1468
1663
  authProfile: localAuth?.profile || authChoice.authProfile || "",
1469
1664
  noAuthState: Boolean(authChoice.noAuthState || parseBoolean(args["no-auth-state"], false)),
1470
1665
  storageStatePath: localAuth?.path,
1471
- });
1666
+ };
1667
+ if (suiteId && explicitIds.length === 0) {
1668
+ await runLocalSuiteWithSequences(localRunArgs);
1669
+ } else {
1670
+ await runLocalCi(localRunArgs);
1671
+ }
1472
1672
  return;
1473
1673
  }
1474
1674
  await runCi(args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualty",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Qualty CLI for localhost and CI test runs",
5
5
  "bin": {
6
6
  "qualty": "bin/qualty.js"