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.
- package/bin/local-runner.js +27 -2
- package/bin/qualty.js +207 -7
- package/package.json +1 -1
package/bin/local-runner.js
CHANGED
|
@@ -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:
|
|
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)
|
|
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-
|
|
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
|
-
|
|
1650
|
+
const localRunArgs = {
|
|
1453
1651
|
apiUrl,
|
|
1454
1652
|
token,
|
|
1455
1653
|
orgId,
|
|
1456
1654
|
projectId,
|
|
1457
1655
|
suiteId,
|
|
1458
|
-
explicitIds
|
|
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);
|