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.
- package/bin/local-runner.js +65 -4
- package/bin/qualty.js +219 -9
- package/package.json +1 -1
package/bin/local-runner.js
CHANGED
|
@@ -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)
|
|
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
|
|
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)
|
|
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-
|
|
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
|
|
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
|
-
|
|
1660
|
+
const localRunArgs = {
|
|
1453
1661
|
apiUrl,
|
|
1454
1662
|
token,
|
|
1455
1663
|
orgId,
|
|
1456
1664
|
projectId,
|
|
1457
1665
|
suiteId,
|
|
1458
|
-
explicitIds
|
|
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);
|