qualty 0.1.7 → 0.1.9
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 +1091 -0
- package/bin/qualty.js +461 -19
- package/package.json +6 -2
package/bin/qualty.js
CHANGED
|
@@ -1,14 +1,108 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { appendFileSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { createInterface } from "node:readline/promises";
|
|
5
8
|
import process from "node:process";
|
|
9
|
+
import { runLocalCi } from "./local-runner.js";
|
|
6
10
|
|
|
7
|
-
/**
|
|
11
|
+
/** CLI backend URL is fixed by Qualty distribution. */
|
|
8
12
|
const DEFAULT_QUALTY_API_URL = "https://qualty-api-development.up.railway.app";
|
|
13
|
+
const QUALTY_CONFIG_DIR = join(homedir(), ".config", "qualty");
|
|
14
|
+
const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
|
|
15
|
+
const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
|
|
16
|
+
const QUALTY_SHARED_CREDS_PATH = join(QUALTY_SHARED_CREDS_DIR, "credentials");
|
|
17
|
+
const QUALTY_PROJECT_CONFIG_PATH = join(process.cwd(), ".qualty.json");
|
|
9
18
|
|
|
10
|
-
function
|
|
11
|
-
|
|
19
|
+
function readQualtyConfig() {
|
|
20
|
+
if (!existsSync(QUALTY_CONFIG_PATH)) return {};
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(QUALTY_CONFIG_PATH, "utf8");
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
25
|
+
return parsed;
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeQualtyConfig(config) {
|
|
32
|
+
mkdirSync(QUALTY_CONFIG_DIR, { recursive: true });
|
|
33
|
+
writeFileSync(QUALTY_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
34
|
+
try {
|
|
35
|
+
chmodSync(QUALTY_CONFIG_PATH, 0o600);
|
|
36
|
+
} catch {
|
|
37
|
+
// best effort on non-posix systems
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readSharedCredentials() {
|
|
42
|
+
if (!existsSync(QUALTY_SHARED_CREDS_PATH)) return {};
|
|
43
|
+
try {
|
|
44
|
+
const raw = readFileSync(QUALTY_SHARED_CREDS_PATH, "utf8");
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
47
|
+
return parsed;
|
|
48
|
+
} catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeSharedCredentials(token) {
|
|
54
|
+
mkdirSync(QUALTY_SHARED_CREDS_DIR, { recursive: true });
|
|
55
|
+
writeFileSync(QUALTY_SHARED_CREDS_PATH, `${JSON.stringify({ api_token: token }, null, 2)}\n`, "utf8");
|
|
56
|
+
try {
|
|
57
|
+
chmodSync(QUALTY_SHARED_CREDS_PATH, 0o600);
|
|
58
|
+
} catch {
|
|
59
|
+
// best effort on non-posix systems
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readProjectConfig() {
|
|
64
|
+
if (!existsSync(QUALTY_PROJECT_CONFIG_PATH)) return {};
|
|
65
|
+
try {
|
|
66
|
+
const raw = readFileSync(QUALTY_PROJECT_CONFIG_PATH, "utf8");
|
|
67
|
+
const parsed = JSON.parse(raw);
|
|
68
|
+
if (!parsed || typeof parsed !== "object") return {};
|
|
69
|
+
return parsed;
|
|
70
|
+
} catch {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function writeProjectConfig(updates) {
|
|
76
|
+
const current = readProjectConfig();
|
|
77
|
+
const next = { ...current };
|
|
78
|
+
for (const [k, v] of Object.entries(updates || {})) {
|
|
79
|
+
if (v === undefined || v === null || v === "") delete next[k];
|
|
80
|
+
else next[k] = v;
|
|
81
|
+
}
|
|
82
|
+
writeFileSync(QUALTY_PROJECT_CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveApiUrl(args, config = {}) {
|
|
86
|
+
void args;
|
|
87
|
+
void config;
|
|
88
|
+
return String(DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolveToken(args, config = {}) {
|
|
92
|
+
const creds = readSharedCredentials();
|
|
93
|
+
return args.token || process.env.QUALTY_API_TOKEN || creds.api_token || config.api_token || "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveOrgId(args, config = {}) {
|
|
97
|
+
const projectCfg = readProjectConfig();
|
|
98
|
+
return args.org || process.env.QUALTY_ORG_ID || config.org_id || projectCfg.default_org_id || "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function maskSecret(secret) {
|
|
102
|
+
const s = String(secret || "");
|
|
103
|
+
if (!s) return "(empty)";
|
|
104
|
+
if (s.length <= 8) return `${"*".repeat(Math.max(0, s.length - 2))}${s.slice(-2)}`;
|
|
105
|
+
return `${s.slice(0, 4)}...${s.slice(-4)}`;
|
|
12
106
|
}
|
|
13
107
|
|
|
14
108
|
function parseArgs(argv) {
|
|
@@ -38,27 +132,32 @@ function usage() {
|
|
|
38
132
|
console.log(
|
|
39
133
|
[
|
|
40
134
|
"Usage:",
|
|
41
|
-
" qualty
|
|
42
|
-
" qualty
|
|
135
|
+
" qualty setup [--token <bearer-token>] [--org <org-id>]",
|
|
136
|
+
" qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
|
|
137
|
+
" qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
|
|
43
138
|
" [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
|
|
139
|
+
" [--local] [--device 1440x900] [--headed] [--local-concurrency 4]",
|
|
44
140
|
"",
|
|
45
141
|
" After each run, logs include step results, explanation, and final evaluator output (dashboard \"View logs\" data).",
|
|
46
142
|
" Pass --no-view-logs for a short log only (e.g. very large evaluator text).",
|
|
47
|
-
" qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--
|
|
143
|
+
" qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--token <bearer-token>]",
|
|
48
144
|
"",
|
|
49
145
|
"Env vars:",
|
|
50
|
-
` QUALTY_API_URL Backend API URL (default: ${DEFAULT_QUALTY_API_URL}; set for local/self-hosted)`,
|
|
51
146
|
" QUALTY_API_TOKEN Bearer token used for auth",
|
|
147
|
+
" QUALTY_ORG_ID Org context for scoped API requests",
|
|
148
|
+
" QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
|
|
52
149
|
].join("\n")
|
|
53
150
|
);
|
|
54
151
|
}
|
|
55
152
|
|
|
56
|
-
async function apiRequest({ apiUrl, token, path, method = "GET", body }) {
|
|
153
|
+
async function apiRequest({ apiUrl, token, orgId, path, method = "GET", body }) {
|
|
154
|
+
const normalizedOrgId = String(orgId || "").trim();
|
|
57
155
|
const response = await fetch(`${apiUrl}${path}`, {
|
|
58
156
|
method,
|
|
59
157
|
headers: {
|
|
60
158
|
"Content-Type": "application/json",
|
|
61
159
|
Authorization: `Bearer ${token}`,
|
|
160
|
+
...(normalizedOrgId ? { "X-Qualty-Org-Id": normalizedOrgId } : {}),
|
|
62
161
|
},
|
|
63
162
|
body: body ? JSON.stringify(body) : undefined,
|
|
64
163
|
});
|
|
@@ -93,10 +192,12 @@ function startCloudflared(token, port) {
|
|
|
93
192
|
}
|
|
94
193
|
|
|
95
194
|
async function runConnect(args) {
|
|
195
|
+
const config = readQualtyConfig();
|
|
96
196
|
const projectId = args.project;
|
|
97
197
|
const port = Number(args.port || 3000);
|
|
98
|
-
const apiUrl = resolveApiUrl(args);
|
|
99
|
-
const token = args
|
|
198
|
+
const apiUrl = resolveApiUrl(args, config);
|
|
199
|
+
const token = resolveToken(args, config);
|
|
200
|
+
const orgId = resolveOrgId(args, config);
|
|
100
201
|
|
|
101
202
|
if (!projectId || !token) {
|
|
102
203
|
usage();
|
|
@@ -109,6 +210,7 @@ async function runConnect(args) {
|
|
|
109
210
|
const connect = await apiRequest({
|
|
110
211
|
apiUrl,
|
|
111
212
|
token,
|
|
213
|
+
orgId,
|
|
112
214
|
path: "/api/v1/localhost/connect",
|
|
113
215
|
method: "POST",
|
|
114
216
|
body: { project_id: projectId, port },
|
|
@@ -129,6 +231,7 @@ async function runConnect(args) {
|
|
|
129
231
|
await apiRequest({
|
|
130
232
|
apiUrl,
|
|
131
233
|
token,
|
|
234
|
+
orgId,
|
|
132
235
|
path: "/api/v1/localhost/heartbeat",
|
|
133
236
|
method: "POST",
|
|
134
237
|
body: { project_id: projectId },
|
|
@@ -145,6 +248,7 @@ async function runConnect(args) {
|
|
|
145
248
|
await apiRequest({
|
|
146
249
|
apiUrl,
|
|
147
250
|
token,
|
|
251
|
+
orgId,
|
|
148
252
|
path: "/api/v1/localhost/disconnect",
|
|
149
253
|
method: "POST",
|
|
150
254
|
body: { project_id: projectId },
|
|
@@ -164,6 +268,163 @@ async function runConnect(args) {
|
|
|
164
268
|
process.on("SIGTERM", shutdown);
|
|
165
269
|
}
|
|
166
270
|
|
|
271
|
+
async function runSetup(args) {
|
|
272
|
+
const existing = readQualtyConfig();
|
|
273
|
+
const sharedCreds = readSharedCredentials();
|
|
274
|
+
const projectCfg = readProjectConfig();
|
|
275
|
+
const rl = createInterface({
|
|
276
|
+
input: process.stdin,
|
|
277
|
+
output: process.stdout,
|
|
278
|
+
});
|
|
279
|
+
const hasStdin = process.stdin.isTTY;
|
|
280
|
+
const ask = async (prompt, fallback = "") => {
|
|
281
|
+
if (!hasStdin) return fallback;
|
|
282
|
+
const answer = await rl.question(prompt);
|
|
283
|
+
const v = String(answer || "").trim();
|
|
284
|
+
return v || fallback;
|
|
285
|
+
};
|
|
286
|
+
const askYesNo = async (prompt, defaultValue = false) => {
|
|
287
|
+
if (!hasStdin) return defaultValue;
|
|
288
|
+
const answer = String(await rl.question(prompt)).trim();
|
|
289
|
+
if (!answer) return defaultValue;
|
|
290
|
+
const raw = answer.toLowerCase();
|
|
291
|
+
if (["y", "yes"].includes(raw)) return true;
|
|
292
|
+
if (["n", "no"].includes(raw)) return false;
|
|
293
|
+
return defaultValue;
|
|
294
|
+
};
|
|
295
|
+
const askChoice = async (prompt, min, max, defaultValue) => {
|
|
296
|
+
if (!hasStdin) return defaultValue;
|
|
297
|
+
while (true) {
|
|
298
|
+
const answer = String(await rl.question(prompt)).trim();
|
|
299
|
+
if (!answer) return defaultValue;
|
|
300
|
+
const n = Number(answer);
|
|
301
|
+
if (Number.isInteger(n) && n >= min && n <= max) return n;
|
|
302
|
+
// eslint-disable-next-line no-console
|
|
303
|
+
console.log(`[qualty] Please enter a number from ${min} to ${max}.`);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
const currentOrgId = String(args.org || existing.org_id || projectCfg.default_org_id || "");
|
|
309
|
+
const currentToken = String(args.token || process.env.QUALTY_API_TOKEN || sharedCreds.api_token || existing.api_token || "");
|
|
310
|
+
|
|
311
|
+
let token = "";
|
|
312
|
+
if (args.token) {
|
|
313
|
+
token = String(args.token).trim();
|
|
314
|
+
} else if (currentToken) {
|
|
315
|
+
const updateToken = await askYesNo(`API token already set (${maskSecret(currentToken)}). Update it? [y/N]: `, false);
|
|
316
|
+
if (updateToken) {
|
|
317
|
+
token = String(await ask("New Qualty API token [required]: ", "")).trim();
|
|
318
|
+
} else {
|
|
319
|
+
token = currentToken;
|
|
320
|
+
}
|
|
321
|
+
} else {
|
|
322
|
+
token = String(await ask("Qualty API token [required]: ", "")).trim();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!token) {
|
|
326
|
+
throw new Error("API token is required. Re-run setup and provide QUALTY_API_TOKEN.");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let organizations = [];
|
|
330
|
+
let currentOrgName = "";
|
|
331
|
+
try {
|
|
332
|
+
const orgResp = await apiRequest({
|
|
333
|
+
apiUrl: resolveApiUrl({}, {}),
|
|
334
|
+
token,
|
|
335
|
+
orgId: "",
|
|
336
|
+
path: "/api/v1/orgs",
|
|
337
|
+
});
|
|
338
|
+
organizations = Array.isArray(orgResp?.organizations) ? orgResp.organizations : [];
|
|
339
|
+
const byId = new Map(
|
|
340
|
+
organizations
|
|
341
|
+
.filter((o) => o && typeof o === "object")
|
|
342
|
+
.map((o) => [String(o.id || ""), String(o.name || "").trim()])
|
|
343
|
+
);
|
|
344
|
+
currentOrgName = byId.get(currentOrgId) || "";
|
|
345
|
+
} catch {
|
|
346
|
+
organizations = [];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let orgId = "";
|
|
350
|
+
let orgName = "";
|
|
351
|
+
if (args.org) {
|
|
352
|
+
orgId = String(args.org).trim();
|
|
353
|
+
orgName = currentOrgName || "selected organization";
|
|
354
|
+
} else if (organizations.length > 0) {
|
|
355
|
+
const orgRows = organizations
|
|
356
|
+
.map((o) => ({
|
|
357
|
+
id: String(o?.id || ""),
|
|
358
|
+
name: String(o?.name || "").trim() || "Unnamed org",
|
|
359
|
+
}))
|
|
360
|
+
.filter((o) => o.id);
|
|
361
|
+
const currentIndex = orgRows.findIndex((o) => o.id === currentOrgId);
|
|
362
|
+
if (currentIndex >= 0) {
|
|
363
|
+
const updateOrg = await askYesNo(
|
|
364
|
+
`Organization already set (${orgRows[currentIndex].name}). Update it? [y/N]: `,
|
|
365
|
+
false
|
|
366
|
+
);
|
|
367
|
+
if (!updateOrg) {
|
|
368
|
+
orgId = orgRows[currentIndex].id;
|
|
369
|
+
orgName = orgRows[currentIndex].name;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (!orgId) {
|
|
373
|
+
// eslint-disable-next-line no-console
|
|
374
|
+
console.log("[qualty] Choose organization:");
|
|
375
|
+
for (let i = 0; i < orgRows.length; i += 1) {
|
|
376
|
+
// eslint-disable-next-line no-console
|
|
377
|
+
console.log(` ${i + 1}) ${orgRows[i].name}`);
|
|
378
|
+
}
|
|
379
|
+
const defaultChoice = currentIndex >= 0 ? currentIndex + 1 : 1;
|
|
380
|
+
const picked = await askChoice(
|
|
381
|
+
`Select org [1-${orgRows.length}] (default ${defaultChoice}): `,
|
|
382
|
+
1,
|
|
383
|
+
orgRows.length,
|
|
384
|
+
defaultChoice
|
|
385
|
+
);
|
|
386
|
+
orgId = orgRows[picked - 1].id;
|
|
387
|
+
orgName = orgRows[picked - 1].name;
|
|
388
|
+
}
|
|
389
|
+
} else if (currentOrgId) {
|
|
390
|
+
const updateOrg = await askYesNo("Organization already set. Update it? [y/N]: ", false);
|
|
391
|
+
if (!updateOrg) {
|
|
392
|
+
orgId = currentOrgId;
|
|
393
|
+
orgName = "saved organization";
|
|
394
|
+
} else {
|
|
395
|
+
orgId = String(await ask("New Qualty Org ID [required]: ", "")).trim();
|
|
396
|
+
}
|
|
397
|
+
} else {
|
|
398
|
+
orgId = String(await ask("Qualty Org ID [required]: ", "")).trim();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (!orgId) {
|
|
402
|
+
throw new Error("Org ID is required. Re-run setup and provide QUALTY_ORG_ID.");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const next = {
|
|
406
|
+
org_id: orgId,
|
|
407
|
+
};
|
|
408
|
+
// Keep token in the same place MCP uses.
|
|
409
|
+
writeSharedCredentials(token);
|
|
410
|
+
writeQualtyConfig(next);
|
|
411
|
+
// Keep org in MCP-compatible per-project config too.
|
|
412
|
+
writeProjectConfig({ default_org_id: orgId });
|
|
413
|
+
// eslint-disable-next-line no-console
|
|
414
|
+
console.log(`[qualty] Saved config to ${QUALTY_CONFIG_PATH}`);
|
|
415
|
+
// eslint-disable-next-line no-console
|
|
416
|
+
console.log(`[qualty] Saved shared token to ${QUALTY_SHARED_CREDS_PATH} (used by MCP)`);
|
|
417
|
+
// eslint-disable-next-line no-console
|
|
418
|
+
console.log(`[qualty] Saved project org to ${QUALTY_PROJECT_CONFIG_PATH} (MCP-compatible)`);
|
|
419
|
+
// eslint-disable-next-line no-console
|
|
420
|
+
console.log(`[qualty] Org: ${orgName || "configured"}`);
|
|
421
|
+
// eslint-disable-next-line no-console
|
|
422
|
+
console.log(`[qualty] Token: ${maskSecret(token)}`);
|
|
423
|
+
} finally {
|
|
424
|
+
rl.close();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
167
428
|
function parseBoolean(value, defaultValue) {
|
|
168
429
|
if (value === undefined) return defaultValue;
|
|
169
430
|
if (typeof value === "boolean") return value;
|
|
@@ -173,6 +434,17 @@ function parseBoolean(value, defaultValue) {
|
|
|
173
434
|
throw new Error(`Invalid boolean value: ${value}`);
|
|
174
435
|
}
|
|
175
436
|
|
|
437
|
+
function resolveLocalConcurrency(args) {
|
|
438
|
+
const raw = args["local-concurrency"] ?? process.env.QUALTY_LOCAL_CONCURRENCY ?? 4;
|
|
439
|
+
const n = Number(raw);
|
|
440
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
441
|
+
throw new Error(
|
|
442
|
+
`Invalid local concurrency: ${raw}. Use --local-concurrency <integer>=1 or QUALTY_LOCAL_CONCURRENCY>=1`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
return n;
|
|
446
|
+
}
|
|
447
|
+
|
|
176
448
|
function sleep(ms) {
|
|
177
449
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
178
450
|
}
|
|
@@ -498,8 +770,10 @@ function printQualtyViewLogsReport(executionId, status) {
|
|
|
498
770
|
}
|
|
499
771
|
|
|
500
772
|
async function runCi(args) {
|
|
501
|
-
const
|
|
502
|
-
const
|
|
773
|
+
const config = readQualtyConfig();
|
|
774
|
+
const apiUrl = resolveApiUrl(args, config);
|
|
775
|
+
const token = resolveToken(args, config);
|
|
776
|
+
const orgId = resolveOrgId(args, config);
|
|
503
777
|
const projectId = args.project;
|
|
504
778
|
const suiteId = args["suite-id"];
|
|
505
779
|
const explicitIds = String(args.ids || "")
|
|
@@ -524,7 +798,7 @@ async function runCi(args) {
|
|
|
524
798
|
throw new Error("Invalid --timeout. Must be > 0.");
|
|
525
799
|
}
|
|
526
800
|
|
|
527
|
-
const savedJobs = await resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds });
|
|
801
|
+
const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
|
|
528
802
|
if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
|
|
529
803
|
throw new Error(
|
|
530
804
|
"No saved tests matched your selector. The test ID may be invalid, deleted, or outside your token authorization scope."
|
|
@@ -546,6 +820,7 @@ async function runCi(args) {
|
|
|
546
820
|
const startResp = await apiRequest({
|
|
547
821
|
apiUrl,
|
|
548
822
|
token,
|
|
823
|
+
orgId,
|
|
549
824
|
path: "/api/v1/ci/run",
|
|
550
825
|
method: "POST",
|
|
551
826
|
body: startPayload,
|
|
@@ -568,6 +843,7 @@ async function runCi(args) {
|
|
|
568
843
|
const batch = await apiRequest({
|
|
569
844
|
apiUrl,
|
|
570
845
|
token,
|
|
846
|
+
orgId,
|
|
571
847
|
path: "/api/v1/status/batch",
|
|
572
848
|
method: "POST",
|
|
573
849
|
body: { job_ids: executionJobIds },
|
|
@@ -633,7 +909,7 @@ async function runCi(args) {
|
|
|
633
909
|
}
|
|
634
910
|
}
|
|
635
911
|
|
|
636
|
-
async function resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds }) {
|
|
912
|
+
async function resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds }) {
|
|
637
913
|
const query = new URLSearchParams();
|
|
638
914
|
if (projectId) query.set("project_id", String(projectId));
|
|
639
915
|
if (suiteId) query.set("suite_id", String(suiteId));
|
|
@@ -641,13 +917,16 @@ async function resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds
|
|
|
641
917
|
return apiRequest({
|
|
642
918
|
apiUrl,
|
|
643
919
|
token,
|
|
920
|
+
orgId,
|
|
644
921
|
path: `/api/v1/saved-jobs?${query.toString()}`,
|
|
645
922
|
});
|
|
646
923
|
}
|
|
647
924
|
|
|
648
925
|
async function runResolve(args) {
|
|
649
|
-
const
|
|
650
|
-
const
|
|
926
|
+
const config = readQualtyConfig();
|
|
927
|
+
const apiUrl = resolveApiUrl(args, config);
|
|
928
|
+
const token = resolveToken(args, config);
|
|
929
|
+
const orgId = resolveOrgId(args, config);
|
|
651
930
|
const projectId = args.project;
|
|
652
931
|
const suiteId = args["suite-id"];
|
|
653
932
|
const explicitIds = String(args.ids || "")
|
|
@@ -663,7 +942,7 @@ async function runResolve(args) {
|
|
|
663
942
|
throw new Error("Provide --project, --suite-id, or --ids to select tests.");
|
|
664
943
|
}
|
|
665
944
|
|
|
666
|
-
const savedJobs = await resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds });
|
|
945
|
+
const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
|
|
667
946
|
if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
|
|
668
947
|
throw new Error(
|
|
669
948
|
"No saved tests matched your selector. The test ID may be invalid, deleted, or outside your token authorization scope."
|
|
@@ -687,6 +966,127 @@ async function runResolve(args) {
|
|
|
687
966
|
}
|
|
688
967
|
}
|
|
689
968
|
|
|
969
|
+
async function maybePromptSuiteIdForLocalRun({ args, apiUrl, token, orgId }) {
|
|
970
|
+
const existingSuiteId = String(args["suite-id"] || "").trim();
|
|
971
|
+
const explicitIds = String(args.ids || "")
|
|
972
|
+
.split(",")
|
|
973
|
+
.map((id) => id.trim())
|
|
974
|
+
.filter(Boolean);
|
|
975
|
+
if (existingSuiteId || explicitIds.length > 0) {
|
|
976
|
+
return existingSuiteId || "";
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const projectId = String(args.project || "").trim();
|
|
980
|
+
if (!projectId) {
|
|
981
|
+
return "";
|
|
982
|
+
}
|
|
983
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
984
|
+
return "";
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const suites = await apiRequest({
|
|
988
|
+
apiUrl,
|
|
989
|
+
token,
|
|
990
|
+
orgId,
|
|
991
|
+
path: `/api/v1/test-suites?project_id=${encodeURIComponent(projectId)}`,
|
|
992
|
+
});
|
|
993
|
+
const rows = (Array.isArray(suites) ? suites : [])
|
|
994
|
+
.map((s) => ({
|
|
995
|
+
id: String(s?.id || "").trim(),
|
|
996
|
+
name: String(s?.name || "").trim() || "Unnamed suite",
|
|
997
|
+
jobCount: Number(Array.isArray(s?.job_ids) ? s.job_ids.length : 0),
|
|
998
|
+
sequenceCount: Number(Array.isArray(s?.sequence_ids) ? s.sequence_ids.length : 0),
|
|
999
|
+
}))
|
|
1000
|
+
.filter((s) => s.id);
|
|
1001
|
+
|
|
1002
|
+
if (rows.length === 0) {
|
|
1003
|
+
throw new Error(
|
|
1004
|
+
"No test suites found for this project. Create a suite first in dashboard, or pass --ids explicitly."
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// eslint-disable-next-line no-console
|
|
1009
|
+
console.log("[qualty] No --suite-id or --ids provided. Choose a suite:");
|
|
1010
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
1011
|
+
const s = rows[i];
|
|
1012
|
+
const details = `${s.jobCount} test(s)${
|
|
1013
|
+
s.sequenceCount > 0 ? `, ${s.sequenceCount} sequence(s)` : ""
|
|
1014
|
+
}`;
|
|
1015
|
+
// eslint-disable-next-line no-console
|
|
1016
|
+
console.log(` ${i + 1}) ${s.name} (${details})`);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1020
|
+
try {
|
|
1021
|
+
while (true) {
|
|
1022
|
+
const raw = String(
|
|
1023
|
+
await rl.question(`Select suite [1-${rows.length}] (default 1): `)
|
|
1024
|
+
).trim();
|
|
1025
|
+
const picked = raw ? Number(raw) : 1;
|
|
1026
|
+
if (Number.isInteger(picked) && picked >= 1 && picked <= rows.length) {
|
|
1027
|
+
const suite = rows[picked - 1];
|
|
1028
|
+
// eslint-disable-next-line no-console
|
|
1029
|
+
console.log(`[qualty] Selected suite: ${suite.name}`);
|
|
1030
|
+
return suite.id;
|
|
1031
|
+
}
|
|
1032
|
+
// eslint-disable-next-line no-console
|
|
1033
|
+
console.log(`[qualty] Please enter a number from 1 to ${rows.length}.`);
|
|
1034
|
+
}
|
|
1035
|
+
} finally {
|
|
1036
|
+
rl.close();
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
|
|
1041
|
+
const existingProjectId = String(args.project || "").trim();
|
|
1042
|
+
if (existingProjectId) return existingProjectId;
|
|
1043
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return existingProjectId;
|
|
1044
|
+
|
|
1045
|
+
const projectsPayload = await apiRequest({
|
|
1046
|
+
apiUrl,
|
|
1047
|
+
token,
|
|
1048
|
+
orgId,
|
|
1049
|
+
path: "/api/v1/projects",
|
|
1050
|
+
});
|
|
1051
|
+
const rows = (Array.isArray(projectsPayload) ? projectsPayload : [])
|
|
1052
|
+
.map((p) => ({
|
|
1053
|
+
id: String(p?.id || "").trim(),
|
|
1054
|
+
name: String(p?.name || "").trim() || "Unnamed project",
|
|
1055
|
+
}))
|
|
1056
|
+
.filter((p) => p.id);
|
|
1057
|
+
|
|
1058
|
+
if (rows.length === 0) {
|
|
1059
|
+
throw new Error("No projects found for this account/org. Create a project first.");
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// eslint-disable-next-line no-console
|
|
1063
|
+
console.log("[qualty] No --project provided. Choose a project:");
|
|
1064
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
1065
|
+
// eslint-disable-next-line no-console
|
|
1066
|
+
console.log(` ${i + 1}) ${rows[i].name}`);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1070
|
+
try {
|
|
1071
|
+
while (true) {
|
|
1072
|
+
const raw = String(
|
|
1073
|
+
await rl.question(`Select project [1-${rows.length}] (default 1): `)
|
|
1074
|
+
).trim();
|
|
1075
|
+
const picked = raw ? Number(raw) : 1;
|
|
1076
|
+
if (Number.isInteger(picked) && picked >= 1 && picked <= rows.length) {
|
|
1077
|
+
const project = rows[picked - 1];
|
|
1078
|
+
// eslint-disable-next-line no-console
|
|
1079
|
+
console.log(`[qualty] Selected project: ${project.name}`);
|
|
1080
|
+
return project.id;
|
|
1081
|
+
}
|
|
1082
|
+
// eslint-disable-next-line no-console
|
|
1083
|
+
console.log(`[qualty] Please enter a number from 1 to ${rows.length}.`);
|
|
1084
|
+
}
|
|
1085
|
+
} finally {
|
|
1086
|
+
rl.close();
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
690
1090
|
async function main() {
|
|
691
1091
|
const args = parseArgs(process.argv);
|
|
692
1092
|
const command = args._[0];
|
|
@@ -698,7 +1098,49 @@ async function main() {
|
|
|
698
1098
|
await runConnect(args);
|
|
699
1099
|
return;
|
|
700
1100
|
}
|
|
1101
|
+
if (command === "setup") {
|
|
1102
|
+
await runSetup(args);
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
701
1105
|
if (command === "run") {
|
|
1106
|
+
const config = readQualtyConfig();
|
|
1107
|
+
if (parseBoolean(args.local, false)) {
|
|
1108
|
+
const apiUrl = resolveApiUrl(args, config);
|
|
1109
|
+
const token = resolveToken(args, config);
|
|
1110
|
+
const orgId = resolveOrgId(args, config);
|
|
1111
|
+
const projectId =
|
|
1112
|
+
(await maybePromptProjectIdForLocalRun({
|
|
1113
|
+
args,
|
|
1114
|
+
apiUrl,
|
|
1115
|
+
token,
|
|
1116
|
+
orgId,
|
|
1117
|
+
})) || args.project;
|
|
1118
|
+
const suiteId =
|
|
1119
|
+
(await maybePromptSuiteIdForLocalRun({
|
|
1120
|
+
args: { ...args, project: projectId },
|
|
1121
|
+
apiUrl,
|
|
1122
|
+
token,
|
|
1123
|
+
orgId,
|
|
1124
|
+
})) || args["suite-id"];
|
|
1125
|
+
await runLocalCi({
|
|
1126
|
+
apiUrl,
|
|
1127
|
+
token,
|
|
1128
|
+
orgId,
|
|
1129
|
+
projectId,
|
|
1130
|
+
suiteId,
|
|
1131
|
+
explicitIds: String(args.ids || "")
|
|
1132
|
+
.split(",")
|
|
1133
|
+
.map((id) => id.trim())
|
|
1134
|
+
.filter(Boolean),
|
|
1135
|
+
resolveSavedJobs,
|
|
1136
|
+
apiRequest,
|
|
1137
|
+
failOnFailure: parseBoolean(args["fail-on-failure"], true),
|
|
1138
|
+
device: args.device || "1440x900",
|
|
1139
|
+
headless: !parseBoolean(args.headed, false),
|
|
1140
|
+
localConcurrency: resolveLocalConcurrency(args),
|
|
1141
|
+
});
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
702
1144
|
await runCi(args);
|
|
703
1145
|
return;
|
|
704
1146
|
}
|
|
@@ -706,7 +1148,7 @@ async function main() {
|
|
|
706
1148
|
await runResolve(args);
|
|
707
1149
|
return;
|
|
708
1150
|
}
|
|
709
|
-
if (command !== "connect" && command !== "run" && command !== "resolve") {
|
|
1151
|
+
if (command !== "setup" && command !== "connect" && command !== "run" && command !== "resolve") {
|
|
710
1152
|
throw new Error(`Unknown command: ${command}`);
|
|
711
1153
|
}
|
|
712
1154
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qualty",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Qualty CLI for localhost and CI test runs",
|
|
5
5
|
"bin": {
|
|
6
6
|
"qualty": "bin/qualty.js"
|
|
@@ -9,5 +9,9 @@
|
|
|
9
9
|
"license": "UNLICENSED",
|
|
10
10
|
"files": [
|
|
11
11
|
"bin"
|
|
12
|
-
]
|
|
12
|
+
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"playwright": "1.60.0",
|
|
15
|
+
"rrweb": "^2.0.0-alpha.4"
|
|
16
|
+
}
|
|
13
17
|
}
|