qualty 0.1.0

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.
Files changed (2) hide show
  1. package/bin/qualty.js +367 -0
  2. package/package.json +13 -0
package/bin/qualty.js ADDED
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import process from "node:process";
5
+
6
+ function parseArgs(argv) {
7
+ const args = { _: [] };
8
+ for (let i = 2; i < argv.length; i += 1) {
9
+ const token = argv[i];
10
+ if (token.startsWith("--")) {
11
+ const [k, v] = token.split("=");
12
+ const key = k.slice(2);
13
+ if (v !== undefined) {
14
+ args[key] = v;
15
+ } else if (argv[i + 1] && !argv[i + 1].startsWith("--")) {
16
+ args[key] = argv[i + 1];
17
+ i += 1;
18
+ } else {
19
+ args[key] = true;
20
+ }
21
+ } else {
22
+ args._.push(token);
23
+ }
24
+ }
25
+ return args;
26
+ }
27
+
28
+ function usage() {
29
+ // eslint-disable-next-line no-console
30
+ console.log(
31
+ [
32
+ "Usage:",
33
+ " qualty connect --project <project-id> [--port 3000] [--api https://your-api] [--token <bearer-token>]",
34
+ " qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--api https://your-api] [--token <bearer-token>]",
35
+ " [--poll-interval 5] [--timeout 30] [--fail-on-failure true]",
36
+ " qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--api https://your-api] [--token <bearer-token>]",
37
+ "",
38
+ "Env vars:",
39
+ " QUALTY_API_URL Backend API URL (default: http://localhost:8000)",
40
+ " QUALTY_API_TOKEN Bearer token used for auth",
41
+ ].join("\n")
42
+ );
43
+ }
44
+
45
+ async function apiRequest({ apiUrl, token, path, method = "GET", body }) {
46
+ const response = await fetch(`${apiUrl}${path}`, {
47
+ method,
48
+ headers: {
49
+ "Content-Type": "application/json",
50
+ Authorization: `Bearer ${token}`,
51
+ },
52
+ body: body ? JSON.stringify(body) : undefined,
53
+ });
54
+ const data = await response.json().catch(() => ({}));
55
+ if (!response.ok) {
56
+ const detail = data.detail || data.error || `HTTP ${response.status}`;
57
+ throw new Error(detail);
58
+ }
59
+ return data;
60
+ }
61
+
62
+ function startCloudflared(token, port) {
63
+ const baseArgs = [
64
+ "tunnel",
65
+ "--no-autoupdate",
66
+ "--protocol",
67
+ "http2",
68
+ "--url",
69
+ `http://localhost:${port}`,
70
+ "run",
71
+ "--token",
72
+ token,
73
+ ];
74
+ const cmd = spawn("cloudflared", baseArgs, { stdio: "inherit" });
75
+ cmd.on("error", () => {
76
+ // Fallback for machines without global cloudflared.
77
+ spawn("npx", ["cloudflared", ...baseArgs], { stdio: "inherit" });
78
+ });
79
+ return cmd;
80
+ }
81
+
82
+ async function runConnect(args) {
83
+ const projectId = args.project;
84
+ const port = Number(args.port || 3000);
85
+ const apiUrl = (args.api || process.env.QUALTY_API_URL || "http://localhost:8000").replace(/\/$/, "");
86
+ const token = args.token || process.env.QUALTY_API_TOKEN;
87
+
88
+ if (!projectId || !token) {
89
+ usage();
90
+ process.exit(1);
91
+ }
92
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
93
+ throw new Error("Invalid --port. Must be between 1 and 65535.");
94
+ }
95
+
96
+ const connect = await apiRequest({
97
+ apiUrl,
98
+ token,
99
+ path: "/api/v1/localhost/connect",
100
+ method: "POST",
101
+ body: { project_id: projectId, port },
102
+ });
103
+
104
+ const hostUrl = connect.localhost?.url;
105
+ // eslint-disable-next-line no-console
106
+ console.log(`Connected ✅ ${hostUrl} -> http://localhost:${port}`);
107
+
108
+ const cloudflaredToken = connect.tunnel?.token;
109
+ if (!cloudflaredToken) {
110
+ throw new Error("Backend did not return a cloudflared token.");
111
+ }
112
+ const cloudflared = startCloudflared(cloudflaredToken, port);
113
+
114
+ const hb = setInterval(async () => {
115
+ try {
116
+ await apiRequest({
117
+ apiUrl,
118
+ token,
119
+ path: "/api/v1/localhost/heartbeat",
120
+ method: "POST",
121
+ body: { project_id: projectId },
122
+ });
123
+ } catch (err) {
124
+ // eslint-disable-next-line no-console
125
+ console.error(`[heartbeat] ${(err && err.message) || err}`);
126
+ }
127
+ }, 15000);
128
+
129
+ const shutdown = async () => {
130
+ clearInterval(hb);
131
+ try {
132
+ await apiRequest({
133
+ apiUrl,
134
+ token,
135
+ path: "/api/v1/localhost/disconnect",
136
+ method: "POST",
137
+ body: { project_id: projectId },
138
+ });
139
+ } catch {
140
+ // best effort
141
+ }
142
+ try {
143
+ cloudflared.kill("SIGTERM");
144
+ } catch {
145
+ // ignore
146
+ }
147
+ process.exit(0);
148
+ };
149
+
150
+ process.on("SIGINT", shutdown);
151
+ process.on("SIGTERM", shutdown);
152
+ }
153
+
154
+ function parseBoolean(value, defaultValue) {
155
+ if (value === undefined) return defaultValue;
156
+ if (typeof value === "boolean") return value;
157
+ const raw = String(value).trim().toLowerCase();
158
+ if (["1", "true", "yes", "y", "on"].includes(raw)) return true;
159
+ if (["0", "false", "no", "n", "off"].includes(raw)) return false;
160
+ throw new Error(`Invalid boolean value: ${value}`);
161
+ }
162
+
163
+ function sleep(ms) {
164
+ return new Promise((resolve) => setTimeout(resolve, ms));
165
+ }
166
+
167
+ async function runCi(args) {
168
+ const apiUrl = (args.api || process.env.QUALTY_API_URL || "http://localhost:8000").replace(/\/$/, "");
169
+ const token = args.token || process.env.QUALTY_API_TOKEN;
170
+ const projectId = args.project;
171
+ const suiteId = args["suite-id"];
172
+ const explicitIds = String(args.ids || "")
173
+ .split(",")
174
+ .map((id) => id.trim())
175
+ .filter(Boolean);
176
+ const pollIntervalSec = Number(args["poll-interval"] || 5);
177
+ const timeoutMin = Number(args.timeout || 30);
178
+ const failOnFailure = parseBoolean(args["fail-on-failure"], true);
179
+
180
+ if (!token) {
181
+ throw new Error("Missing auth token. Pass --token or set QUALTY_API_TOKEN.");
182
+ }
183
+ if (!projectId && !suiteId && explicitIds.length === 0) {
184
+ throw new Error("Provide --project, --suite-id, or --ids to select tests.");
185
+ }
186
+ if (!Number.isFinite(pollIntervalSec) || pollIntervalSec <= 0) {
187
+ throw new Error("Invalid --poll-interval. Must be > 0.");
188
+ }
189
+ if (!Number.isFinite(timeoutMin) || timeoutMin <= 0) {
190
+ throw new Error("Invalid --timeout. Must be > 0.");
191
+ }
192
+
193
+ const savedJobs = await resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds });
194
+ if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
195
+ throw new Error(
196
+ "No saved tests matched your selector. The test ID may be invalid, deleted, or outside your token authorization scope."
197
+ );
198
+ }
199
+ // eslint-disable-next-line no-console
200
+ console.log(`[qualty] Selected ${savedJobs.length} saved test(s).`);
201
+ for (const job of savedJobs) {
202
+ // eslint-disable-next-line no-console
203
+ console.log(` - ${job.id} :: ${job.name}`);
204
+ }
205
+
206
+ const startPayload = {
207
+ project_id: projectId ? String(projectId) : undefined,
208
+ suite_id: suiteId ? String(suiteId) : undefined,
209
+ job_ids: explicitIds.length > 0 ? explicitIds : savedJobs.map((job) => job.id),
210
+ };
211
+
212
+ const startResp = await apiRequest({
213
+ apiUrl,
214
+ token,
215
+ path: "/api/v1/ci/run",
216
+ method: "POST",
217
+ body: startPayload,
218
+ });
219
+
220
+ const runs = Array.isArray(startResp.runs) ? startResp.runs : [];
221
+ const started = runs.filter((run) => run.execution_job_id);
222
+ if (started.length === 0) {
223
+ throw new Error("Failed to start any runs.");
224
+ }
225
+ // eslint-disable-next-line no-console
226
+ console.log(`[qualty] Started ${started.length}/${runs.length} run(s).`);
227
+
228
+ const executionJobIds = started.map((run) => run.execution_job_id);
229
+ const deadlineMs = Date.now() + timeoutMin * 60 * 1000;
230
+ const terminalStatuses = new Set(["completed", "failed", "cancelled"]);
231
+ const finalStatuses = {};
232
+
233
+ while (Date.now() < deadlineMs) {
234
+ const batch = await apiRequest({
235
+ apiUrl,
236
+ token,
237
+ path: "/api/v1/status/batch",
238
+ method: "POST",
239
+ body: { job_ids: executionJobIds },
240
+ });
241
+ const statuses = batch.statuses || {};
242
+ let doneCount = 0;
243
+ for (const executionId of executionJobIds) {
244
+ const status = statuses[executionId];
245
+ if (!status) continue;
246
+ finalStatuses[executionId] = status;
247
+ if (terminalStatuses.has(status.status)) {
248
+ doneCount += 1;
249
+ }
250
+ }
251
+
252
+ // eslint-disable-next-line no-console
253
+ console.log(`[qualty] Progress: ${doneCount}/${executionJobIds.length} finished`);
254
+ if (doneCount === executionJobIds.length) {
255
+ break;
256
+ }
257
+ await sleep(pollIntervalSec * 1000);
258
+ }
259
+
260
+ const unresolved = executionJobIds.filter((id) => !terminalStatuses.has(finalStatuses[id]?.status));
261
+ if (unresolved.length > 0) {
262
+ throw new Error(`Timeout after ${timeoutMin} minute(s). Unfinished runs: ${unresolved.join(", ")}`);
263
+ }
264
+
265
+ let passed = 0;
266
+ let failed = 0;
267
+ for (const executionId of executionJobIds) {
268
+ const status = finalStatuses[executionId];
269
+ const isPass = status.status === "completed" && Number(status.failed_tests || 0) === 0;
270
+ if (isPass) passed += 1;
271
+ else failed += 1;
272
+ // eslint-disable-next-line no-console
273
+ console.log(
274
+ `[qualty] ${executionId} => status=${status.status}, failed_tests=${status.failed_tests ?? "n/a"}`
275
+ );
276
+ }
277
+
278
+ // eslint-disable-next-line no-console
279
+ console.log(`[qualty] Summary: ${passed} passed, ${failed} failed.`);
280
+ if (failOnFailure && failed > 0) {
281
+ process.exit(1);
282
+ }
283
+ }
284
+
285
+ async function resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds }) {
286
+ const query = new URLSearchParams();
287
+ if (projectId) query.set("project_id", String(projectId));
288
+ if (suiteId) query.set("suite_id", String(suiteId));
289
+ if (explicitIds.length > 0) query.set("ids", explicitIds.join(","));
290
+ return apiRequest({
291
+ apiUrl,
292
+ token,
293
+ path: `/api/v1/saved-jobs?${query.toString()}`,
294
+ });
295
+ }
296
+
297
+ async function runResolve(args) {
298
+ const apiUrl = (args.api || process.env.QUALTY_API_URL || "http://localhost:8000").replace(/\/$/, "");
299
+ const token = args.token || process.env.QUALTY_API_TOKEN;
300
+ const projectId = args.project;
301
+ const suiteId = args["suite-id"];
302
+ const explicitIds = String(args.ids || "")
303
+ .split(",")
304
+ .map((id) => id.trim())
305
+ .filter(Boolean);
306
+ const asJson = parseBoolean(args.json, false);
307
+
308
+ if (!token) {
309
+ throw new Error("Missing auth token. Pass --token or set QUALTY_API_TOKEN.");
310
+ }
311
+ if (!projectId && !suiteId && explicitIds.length === 0) {
312
+ throw new Error("Provide --project, --suite-id, or --ids to select tests.");
313
+ }
314
+
315
+ const savedJobs = await resolveSavedJobs({ apiUrl, token, projectId, suiteId, explicitIds });
316
+ if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
317
+ throw new Error(
318
+ "No saved tests matched your selector. The test ID may be invalid, deleted, or outside your token authorization scope."
319
+ );
320
+ }
321
+
322
+ const tests = savedJobs.map((job) => ({
323
+ id: job.id,
324
+ name: String(job.name || job.id),
325
+ }));
326
+ if (asJson) {
327
+ // eslint-disable-next-line no-console
328
+ console.log(JSON.stringify(tests));
329
+ return;
330
+ }
331
+ // eslint-disable-next-line no-console
332
+ console.log(`[qualty] Resolved ${tests.length} saved test(s).`);
333
+ for (const test of tests) {
334
+ // eslint-disable-next-line no-console
335
+ console.log(` - ${test.id} :: ${test.name}`);
336
+ }
337
+ }
338
+
339
+ async function main() {
340
+ const args = parseArgs(process.argv);
341
+ const command = args._[0];
342
+ if (!command || command === "help" || command === "--help") {
343
+ usage();
344
+ process.exit(0);
345
+ }
346
+ if (command === "connect") {
347
+ await runConnect(args);
348
+ return;
349
+ }
350
+ if (command === "run") {
351
+ await runCi(args);
352
+ return;
353
+ }
354
+ if (command === "resolve") {
355
+ await runResolve(args);
356
+ return;
357
+ }
358
+ if (command !== "connect" && command !== "run" && command !== "resolve") {
359
+ throw new Error(`Unknown command: ${command}`);
360
+ }
361
+ }
362
+
363
+ main().catch((err) => {
364
+ // eslint-disable-next-line no-console
365
+ console.error(`[qualty] ${err.message || err}`);
366
+ process.exit(1);
367
+ });
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "qualty",
3
+ "version": "0.1.0",
4
+ "description": "Qualty CLI for localhost and CI test runs",
5
+ "bin": {
6
+ "qualty": "bin/qualty.js"
7
+ },
8
+ "type": "module",
9
+ "license": "UNLICENSED",
10
+ "files": [
11
+ "bin"
12
+ ]
13
+ }