qualty 0.1.8 → 0.1.10
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 +11 -3
- package/bin/qualty.js +668 -24
- package/package.json +3 -2
package/bin/local-runner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, mkdtempSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, writeFileSync, mkdtempSync, existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
|
|
@@ -754,6 +754,7 @@ export async function runLocalExecution({
|
|
|
754
754
|
executionId,
|
|
755
755
|
device,
|
|
756
756
|
headless,
|
|
757
|
+
storageStatePath,
|
|
757
758
|
}) {
|
|
758
759
|
const { chromium } = await import("playwright");
|
|
759
760
|
|
|
@@ -777,9 +778,14 @@ export async function runLocalExecution({
|
|
|
777
778
|
});
|
|
778
779
|
const cdpEndpoint = browserServer.wsEndpoint();
|
|
779
780
|
const browser = await chromium.connect(cdpEndpoint);
|
|
780
|
-
const
|
|
781
|
+
const contextOptions = {
|
|
781
782
|
viewport: { width: viewport.width || 1440, height: viewport.height || 900 },
|
|
782
|
-
}
|
|
783
|
+
};
|
|
784
|
+
if (storageStatePath && existsSync(storageStatePath)) {
|
|
785
|
+
contextOptions.storageState = storageStatePath;
|
|
786
|
+
console.log(`[qualty][auth] using saved context: ${storageStatePath}`);
|
|
787
|
+
}
|
|
788
|
+
const context = await browser.newContext(contextOptions);
|
|
783
789
|
const page = await context.newPage();
|
|
784
790
|
const networkCollector = attachNetworkCollector(page);
|
|
785
791
|
const labelToPath = await downloadProjectAttachments({
|
|
@@ -985,6 +991,7 @@ export async function runLocalCi({
|
|
|
985
991
|
device,
|
|
986
992
|
headless,
|
|
987
993
|
localConcurrency = 4,
|
|
994
|
+
storageStatePath,
|
|
988
995
|
}) {
|
|
989
996
|
const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
|
|
990
997
|
if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
|
|
@@ -1040,6 +1047,7 @@ export async function runLocalCi({
|
|
|
1040
1047
|
executionId: id,
|
|
1041
1048
|
device,
|
|
1042
1049
|
headless,
|
|
1050
|
+
storageStatePath,
|
|
1043
1051
|
});
|
|
1044
1052
|
if (result.success) {
|
|
1045
1053
|
passed += 1;
|
package/bin/qualty.js
CHANGED
|
@@ -1,15 +1,108 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { appendFileSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, 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";
|
|
6
9
|
import { runLocalCi } from "./local-runner.js";
|
|
7
10
|
|
|
8
|
-
/**
|
|
11
|
+
/** CLI backend URL is fixed by Qualty distribution. */
|
|
9
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");
|
|
10
18
|
|
|
11
|
-
function
|
|
12
|
-
|
|
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)}`;
|
|
13
106
|
}
|
|
14
107
|
|
|
15
108
|
function parseArgs(argv) {
|
|
@@ -39,17 +132,18 @@ function usage() {
|
|
|
39
132
|
console.log(
|
|
40
133
|
[
|
|
41
134
|
"Usage:",
|
|
42
|
-
" qualty
|
|
43
|
-
" qualty
|
|
135
|
+
" qualty setup [--token <bearer-token>] [--org <org-id>]",
|
|
136
|
+
" qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
|
|
137
|
+
" qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
|
|
138
|
+
" qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
|
|
44
139
|
" [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
|
|
45
|
-
" [--local] [--device 1440x900] [--headed] [--local-concurrency 4]",
|
|
140
|
+
" [--local] [--device 1440x900] [--headed] [--local-concurrency 4] [--auth-profile <profile>] [--no-auth-state]",
|
|
46
141
|
"",
|
|
47
142
|
" After each run, logs include step results, explanation, and final evaluator output (dashboard \"View logs\" data).",
|
|
48
143
|
" Pass --no-view-logs for a short log only (e.g. very large evaluator text).",
|
|
49
|
-
" qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--
|
|
144
|
+
" qualty resolve --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--json] [--token <bearer-token>]",
|
|
50
145
|
"",
|
|
51
146
|
"Env vars:",
|
|
52
|
-
` QUALTY_API_URL Backend API URL (default: ${DEFAULT_QUALTY_API_URL}; set for local/self-hosted)`,
|
|
53
147
|
" QUALTY_API_TOKEN Bearer token used for auth",
|
|
54
148
|
" QUALTY_ORG_ID Org context for scoped API requests",
|
|
55
149
|
" QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
|
|
@@ -99,11 +193,12 @@ function startCloudflared(token, port) {
|
|
|
99
193
|
}
|
|
100
194
|
|
|
101
195
|
async function runConnect(args) {
|
|
196
|
+
const config = readQualtyConfig();
|
|
102
197
|
const projectId = args.project;
|
|
103
198
|
const port = Number(args.port || 3000);
|
|
104
|
-
const apiUrl = resolveApiUrl(args);
|
|
105
|
-
const token = args
|
|
106
|
-
const orgId = args
|
|
199
|
+
const apiUrl = resolveApiUrl(args, config);
|
|
200
|
+
const token = resolveToken(args, config);
|
|
201
|
+
const orgId = resolveOrgId(args, config);
|
|
107
202
|
|
|
108
203
|
if (!projectId || !token) {
|
|
109
204
|
usage();
|
|
@@ -174,6 +269,163 @@ async function runConnect(args) {
|
|
|
174
269
|
process.on("SIGTERM", shutdown);
|
|
175
270
|
}
|
|
176
271
|
|
|
272
|
+
async function runSetup(args) {
|
|
273
|
+
const existing = readQualtyConfig();
|
|
274
|
+
const sharedCreds = readSharedCredentials();
|
|
275
|
+
const projectCfg = readProjectConfig();
|
|
276
|
+
const rl = createInterface({
|
|
277
|
+
input: process.stdin,
|
|
278
|
+
output: process.stdout,
|
|
279
|
+
});
|
|
280
|
+
const hasStdin = process.stdin.isTTY;
|
|
281
|
+
const ask = async (prompt, fallback = "") => {
|
|
282
|
+
if (!hasStdin) return fallback;
|
|
283
|
+
const answer = await rl.question(prompt);
|
|
284
|
+
const v = String(answer || "").trim();
|
|
285
|
+
return v || fallback;
|
|
286
|
+
};
|
|
287
|
+
const askYesNo = async (prompt, defaultValue = false) => {
|
|
288
|
+
if (!hasStdin) return defaultValue;
|
|
289
|
+
const answer = String(await rl.question(prompt)).trim();
|
|
290
|
+
if (!answer) return defaultValue;
|
|
291
|
+
const raw = answer.toLowerCase();
|
|
292
|
+
if (["y", "yes"].includes(raw)) return true;
|
|
293
|
+
if (["n", "no"].includes(raw)) return false;
|
|
294
|
+
return defaultValue;
|
|
295
|
+
};
|
|
296
|
+
const askChoice = async (prompt, min, max, defaultValue) => {
|
|
297
|
+
if (!hasStdin) return defaultValue;
|
|
298
|
+
while (true) {
|
|
299
|
+
const answer = String(await rl.question(prompt)).trim();
|
|
300
|
+
if (!answer) return defaultValue;
|
|
301
|
+
const n = Number(answer);
|
|
302
|
+
if (Number.isInteger(n) && n >= min && n <= max) return n;
|
|
303
|
+
// eslint-disable-next-line no-console
|
|
304
|
+
console.log(`[qualty] Please enter a number from ${min} to ${max}.`);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const currentOrgId = String(args.org || existing.org_id || projectCfg.default_org_id || "");
|
|
310
|
+
const currentToken = String(args.token || process.env.QUALTY_API_TOKEN || sharedCreds.api_token || existing.api_token || "");
|
|
311
|
+
|
|
312
|
+
let token = "";
|
|
313
|
+
if (args.token) {
|
|
314
|
+
token = String(args.token).trim();
|
|
315
|
+
} else if (currentToken) {
|
|
316
|
+
const updateToken = await askYesNo(`API token already set (${maskSecret(currentToken)}). Update it? [y/N]: `, false);
|
|
317
|
+
if (updateToken) {
|
|
318
|
+
token = String(await ask("New Qualty API token [required]: ", "")).trim();
|
|
319
|
+
} else {
|
|
320
|
+
token = currentToken;
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
token = String(await ask("Qualty API token [required]: ", "")).trim();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!token) {
|
|
327
|
+
throw new Error("API token is required. Re-run setup and provide QUALTY_API_TOKEN.");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let organizations = [];
|
|
331
|
+
let currentOrgName = "";
|
|
332
|
+
try {
|
|
333
|
+
const orgResp = await apiRequest({
|
|
334
|
+
apiUrl: resolveApiUrl({}, {}),
|
|
335
|
+
token,
|
|
336
|
+
orgId: "",
|
|
337
|
+
path: "/api/v1/orgs",
|
|
338
|
+
});
|
|
339
|
+
organizations = Array.isArray(orgResp?.organizations) ? orgResp.organizations : [];
|
|
340
|
+
const byId = new Map(
|
|
341
|
+
organizations
|
|
342
|
+
.filter((o) => o && typeof o === "object")
|
|
343
|
+
.map((o) => [String(o.id || ""), String(o.name || "").trim()])
|
|
344
|
+
);
|
|
345
|
+
currentOrgName = byId.get(currentOrgId) || "";
|
|
346
|
+
} catch {
|
|
347
|
+
organizations = [];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let orgId = "";
|
|
351
|
+
let orgName = "";
|
|
352
|
+
if (args.org) {
|
|
353
|
+
orgId = String(args.org).trim();
|
|
354
|
+
orgName = currentOrgName || "selected organization";
|
|
355
|
+
} else if (organizations.length > 0) {
|
|
356
|
+
const orgRows = organizations
|
|
357
|
+
.map((o) => ({
|
|
358
|
+
id: String(o?.id || ""),
|
|
359
|
+
name: String(o?.name || "").trim() || "Unnamed org",
|
|
360
|
+
}))
|
|
361
|
+
.filter((o) => o.id);
|
|
362
|
+
const currentIndex = orgRows.findIndex((o) => o.id === currentOrgId);
|
|
363
|
+
if (currentIndex >= 0) {
|
|
364
|
+
const updateOrg = await askYesNo(
|
|
365
|
+
`Organization already set (${orgRows[currentIndex].name}). Update it? [y/N]: `,
|
|
366
|
+
false
|
|
367
|
+
);
|
|
368
|
+
if (!updateOrg) {
|
|
369
|
+
orgId = orgRows[currentIndex].id;
|
|
370
|
+
orgName = orgRows[currentIndex].name;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (!orgId) {
|
|
374
|
+
// eslint-disable-next-line no-console
|
|
375
|
+
console.log("[qualty] Choose organization:");
|
|
376
|
+
for (let i = 0; i < orgRows.length; i += 1) {
|
|
377
|
+
// eslint-disable-next-line no-console
|
|
378
|
+
console.log(` ${i + 1}) ${orgRows[i].name}`);
|
|
379
|
+
}
|
|
380
|
+
const defaultChoice = currentIndex >= 0 ? currentIndex + 1 : 1;
|
|
381
|
+
const picked = await askChoice(
|
|
382
|
+
`Select org [1-${orgRows.length}] (default ${defaultChoice}): `,
|
|
383
|
+
1,
|
|
384
|
+
orgRows.length,
|
|
385
|
+
defaultChoice
|
|
386
|
+
);
|
|
387
|
+
orgId = orgRows[picked - 1].id;
|
|
388
|
+
orgName = orgRows[picked - 1].name;
|
|
389
|
+
}
|
|
390
|
+
} else if (currentOrgId) {
|
|
391
|
+
const updateOrg = await askYesNo("Organization already set. Update it? [y/N]: ", false);
|
|
392
|
+
if (!updateOrg) {
|
|
393
|
+
orgId = currentOrgId;
|
|
394
|
+
orgName = "saved organization";
|
|
395
|
+
} else {
|
|
396
|
+
orgId = String(await ask("New Qualty Org ID [required]: ", "")).trim();
|
|
397
|
+
}
|
|
398
|
+
} else {
|
|
399
|
+
orgId = String(await ask("Qualty Org ID [required]: ", "")).trim();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!orgId) {
|
|
403
|
+
throw new Error("Org ID is required. Re-run setup and provide QUALTY_ORG_ID.");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const next = {
|
|
407
|
+
org_id: orgId,
|
|
408
|
+
};
|
|
409
|
+
// Keep token in the same place MCP uses.
|
|
410
|
+
writeSharedCredentials(token);
|
|
411
|
+
writeQualtyConfig(next);
|
|
412
|
+
// Keep org in MCP-compatible per-project config too.
|
|
413
|
+
writeProjectConfig({ default_org_id: orgId });
|
|
414
|
+
// eslint-disable-next-line no-console
|
|
415
|
+
console.log(`[qualty] Saved config to ${QUALTY_CONFIG_PATH}`);
|
|
416
|
+
// eslint-disable-next-line no-console
|
|
417
|
+
console.log(`[qualty] Saved shared token to ${QUALTY_SHARED_CREDS_PATH} (used by MCP)`);
|
|
418
|
+
// eslint-disable-next-line no-console
|
|
419
|
+
console.log(`[qualty] Saved project org to ${QUALTY_PROJECT_CONFIG_PATH} (MCP-compatible)`);
|
|
420
|
+
// eslint-disable-next-line no-console
|
|
421
|
+
console.log(`[qualty] Org: ${orgName || "configured"}`);
|
|
422
|
+
// eslint-disable-next-line no-console
|
|
423
|
+
console.log(`[qualty] Token: ${maskSecret(token)}`);
|
|
424
|
+
} finally {
|
|
425
|
+
rl.close();
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
177
429
|
function parseBoolean(value, defaultValue) {
|
|
178
430
|
if (value === undefined) return defaultValue;
|
|
179
431
|
if (typeof value === "boolean") return value;
|
|
@@ -194,6 +446,38 @@ function resolveLocalConcurrency(args) {
|
|
|
194
446
|
return n;
|
|
195
447
|
}
|
|
196
448
|
|
|
449
|
+
function safePathSegment(value, fallback = "default") {
|
|
450
|
+
const out = String(value || "")
|
|
451
|
+
.trim()
|
|
452
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
|
453
|
+
.replace(/^_+|_+$/g, "");
|
|
454
|
+
return out || fallback;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function localAuthStatePath({ orgId, projectId, profile }) {
|
|
458
|
+
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
459
|
+
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
460
|
+
const profileSeg = safePathSegment(profile || "default", "default");
|
|
461
|
+
return join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function listLocalAuthProfiles({ orgId, projectId }) {
|
|
465
|
+
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
466
|
+
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
467
|
+
const dir = join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg);
|
|
468
|
+
if (!existsSync(dir)) return [];
|
|
469
|
+
try {
|
|
470
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
471
|
+
return entries
|
|
472
|
+
.filter((e) => e.isFile() && e.name.endsWith(".json"))
|
|
473
|
+
.map((e) => e.name.replace(/\.json$/i, ""))
|
|
474
|
+
.filter(Boolean)
|
|
475
|
+
.sort((a, b) => a.localeCompare(b));
|
|
476
|
+
} catch {
|
|
477
|
+
return [];
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
197
481
|
function sleep(ms) {
|
|
198
482
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
199
483
|
}
|
|
@@ -519,9 +803,10 @@ function printQualtyViewLogsReport(executionId, status) {
|
|
|
519
803
|
}
|
|
520
804
|
|
|
521
805
|
async function runCi(args) {
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
const
|
|
806
|
+
const config = readQualtyConfig();
|
|
807
|
+
const apiUrl = resolveApiUrl(args, config);
|
|
808
|
+
const token = resolveToken(args, config);
|
|
809
|
+
const orgId = resolveOrgId(args, config);
|
|
525
810
|
const projectId = args.project;
|
|
526
811
|
const suiteId = args["suite-id"];
|
|
527
812
|
const explicitIds = String(args.ids || "")
|
|
@@ -671,9 +956,10 @@ async function resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, expl
|
|
|
671
956
|
}
|
|
672
957
|
|
|
673
958
|
async function runResolve(args) {
|
|
674
|
-
const
|
|
675
|
-
const
|
|
676
|
-
const
|
|
959
|
+
const config = readQualtyConfig();
|
|
960
|
+
const apiUrl = resolveApiUrl(args, config);
|
|
961
|
+
const token = resolveToken(args, config);
|
|
962
|
+
const orgId = resolveOrgId(args, config);
|
|
677
963
|
const projectId = args.project;
|
|
678
964
|
const suiteId = args["suite-id"];
|
|
679
965
|
const explicitIds = String(args.ids || "")
|
|
@@ -713,6 +999,312 @@ async function runResolve(args) {
|
|
|
713
999
|
}
|
|
714
1000
|
}
|
|
715
1001
|
|
|
1002
|
+
async function maybePromptSuiteIdForLocalRun({ args, apiUrl, token, orgId }) {
|
|
1003
|
+
const existingSuiteId = String(args["suite-id"] || "").trim();
|
|
1004
|
+
const explicitIds = String(args.ids || "")
|
|
1005
|
+
.split(",")
|
|
1006
|
+
.map((id) => id.trim())
|
|
1007
|
+
.filter(Boolean);
|
|
1008
|
+
if (existingSuiteId || explicitIds.length > 0) {
|
|
1009
|
+
return existingSuiteId || "";
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const projectId = String(args.project || "").trim();
|
|
1013
|
+
if (!projectId) {
|
|
1014
|
+
return "";
|
|
1015
|
+
}
|
|
1016
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1017
|
+
return "";
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const suites = await apiRequest({
|
|
1021
|
+
apiUrl,
|
|
1022
|
+
token,
|
|
1023
|
+
orgId,
|
|
1024
|
+
path: `/api/v1/test-suites?project_id=${encodeURIComponent(projectId)}`,
|
|
1025
|
+
});
|
|
1026
|
+
const rows = (Array.isArray(suites) ? suites : [])
|
|
1027
|
+
.map((s) => ({
|
|
1028
|
+
id: String(s?.id || "").trim(),
|
|
1029
|
+
name: String(s?.name || "").trim() || "Unnamed suite",
|
|
1030
|
+
jobCount: Number(Array.isArray(s?.job_ids) ? s.job_ids.length : 0),
|
|
1031
|
+
sequenceCount: Number(Array.isArray(s?.sequence_ids) ? s.sequence_ids.length : 0),
|
|
1032
|
+
}))
|
|
1033
|
+
.filter((s) => s.id);
|
|
1034
|
+
|
|
1035
|
+
if (rows.length === 0) {
|
|
1036
|
+
throw new Error(
|
|
1037
|
+
"No test suites found for this project. Create a suite first in dashboard, or pass --ids explicitly."
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// eslint-disable-next-line no-console
|
|
1042
|
+
console.log("[qualty] No --suite-id or --ids provided. Choose a suite:");
|
|
1043
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
1044
|
+
const s = rows[i];
|
|
1045
|
+
const details = `${s.jobCount} test(s)${
|
|
1046
|
+
s.sequenceCount > 0 ? `, ${s.sequenceCount} sequence(s)` : ""
|
|
1047
|
+
}`;
|
|
1048
|
+
// eslint-disable-next-line no-console
|
|
1049
|
+
console.log(` ${i + 1}) ${s.name} (${details})`);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1053
|
+
try {
|
|
1054
|
+
while (true) {
|
|
1055
|
+
const raw = String(
|
|
1056
|
+
await rl.question(`Select suite [1-${rows.length}] (default 1): `)
|
|
1057
|
+
).trim();
|
|
1058
|
+
const picked = raw ? Number(raw) : 1;
|
|
1059
|
+
if (Number.isInteger(picked) && picked >= 1 && picked <= rows.length) {
|
|
1060
|
+
const suite = rows[picked - 1];
|
|
1061
|
+
// eslint-disable-next-line no-console
|
|
1062
|
+
console.log(`[qualty] Selected suite: ${suite.name}`);
|
|
1063
|
+
return suite.id;
|
|
1064
|
+
}
|
|
1065
|
+
// eslint-disable-next-line no-console
|
|
1066
|
+
console.log(`[qualty] Please enter a number from 1 to ${rows.length}.`);
|
|
1067
|
+
}
|
|
1068
|
+
} finally {
|
|
1069
|
+
rl.close();
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
|
|
1074
|
+
const existingProjectId = String(args.project || "").trim();
|
|
1075
|
+
if (existingProjectId) return existingProjectId;
|
|
1076
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return existingProjectId;
|
|
1077
|
+
|
|
1078
|
+
const projectsPayload = await apiRequest({
|
|
1079
|
+
apiUrl,
|
|
1080
|
+
token,
|
|
1081
|
+
orgId,
|
|
1082
|
+
path: "/api/v1/projects",
|
|
1083
|
+
});
|
|
1084
|
+
const rows = (Array.isArray(projectsPayload) ? projectsPayload : [])
|
|
1085
|
+
.map((p) => ({
|
|
1086
|
+
id: String(p?.id || "").trim(),
|
|
1087
|
+
name: String(p?.name || "").trim() || "Unnamed project",
|
|
1088
|
+
}))
|
|
1089
|
+
.filter((p) => p.id);
|
|
1090
|
+
|
|
1091
|
+
if (rows.length === 0) {
|
|
1092
|
+
throw new Error("No projects found for this account/org. Create a project first.");
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// eslint-disable-next-line no-console
|
|
1096
|
+
console.log("[qualty] No --project provided. Choose a project:");
|
|
1097
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
1098
|
+
// eslint-disable-next-line no-console
|
|
1099
|
+
console.log(` ${i + 1}) ${rows[i].name}`);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1103
|
+
try {
|
|
1104
|
+
while (true) {
|
|
1105
|
+
const raw = String(
|
|
1106
|
+
await rl.question(`Select project [1-${rows.length}] (default 1): `)
|
|
1107
|
+
).trim();
|
|
1108
|
+
const picked = raw ? Number(raw) : 1;
|
|
1109
|
+
if (Number.isInteger(picked) && picked >= 1 && picked <= rows.length) {
|
|
1110
|
+
const project = rows[picked - 1];
|
|
1111
|
+
// eslint-disable-next-line no-console
|
|
1112
|
+
console.log(`[qualty] Selected project: ${project.name}`);
|
|
1113
|
+
return project.id;
|
|
1114
|
+
}
|
|
1115
|
+
// eslint-disable-next-line no-console
|
|
1116
|
+
console.log(`[qualty] Please enter a number from 1 to ${rows.length}.`);
|
|
1117
|
+
}
|
|
1118
|
+
} finally {
|
|
1119
|
+
rl.close();
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
|
|
1124
|
+
if (parseBoolean(args["no-auth-state"], false)) return null;
|
|
1125
|
+
const projectCfg = readProjectConfig();
|
|
1126
|
+
const requestedProfile = String(args["auth-profile"] || "").trim();
|
|
1127
|
+
const savedProjectId = String(projectCfg.local_auth_project_id || "").trim();
|
|
1128
|
+
const savedProfile = String(projectCfg.local_auth_profile || "").trim();
|
|
1129
|
+
const profile =
|
|
1130
|
+
requestedProfile ||
|
|
1131
|
+
(savedProjectId && String(projectId || "").trim() === savedProjectId ? savedProfile : "");
|
|
1132
|
+
if (!profile) return null;
|
|
1133
|
+
const path = localAuthStatePath({ orgId, projectId, profile });
|
|
1134
|
+
if (!existsSync(path)) return null;
|
|
1135
|
+
return { profile, path };
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
|
|
1139
|
+
const requestedProfile = String(args["auth-profile"] || "").trim();
|
|
1140
|
+
if (requestedProfile) return { authProfile: requestedProfile, noAuthState: false };
|
|
1141
|
+
if (parseBoolean(args["no-auth-state"], false)) return { authProfile: "", noAuthState: true };
|
|
1142
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return { authProfile: "", noAuthState: false };
|
|
1143
|
+
if (!projectId) return { authProfile: "", noAuthState: false };
|
|
1144
|
+
|
|
1145
|
+
const profiles = listLocalAuthProfiles({ orgId, projectId });
|
|
1146
|
+
if (profiles.length === 0) return { authProfile: "", noAuthState: false };
|
|
1147
|
+
|
|
1148
|
+
const projectCfg = readProjectConfig();
|
|
1149
|
+
const defaultProfile = String(projectCfg.local_auth_profile || "").trim();
|
|
1150
|
+
const defaultIndex = profiles.findIndex((p) => p === defaultProfile);
|
|
1151
|
+
|
|
1152
|
+
// eslint-disable-next-line no-console
|
|
1153
|
+
console.log("[qualty][auth] Choose saved login state:");
|
|
1154
|
+
// eslint-disable-next-line no-console
|
|
1155
|
+
console.log(" 0) Run without saved login state");
|
|
1156
|
+
for (let i = 0; i < profiles.length; i += 1) {
|
|
1157
|
+
const isDefault = defaultIndex === i ? " (default)" : "";
|
|
1158
|
+
// eslint-disable-next-line no-console
|
|
1159
|
+
console.log(` ${i + 1}) ${profiles[i]}${isDefault}`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const defaultChoice = defaultIndex >= 0 ? defaultIndex + 1 : 0;
|
|
1163
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1164
|
+
try {
|
|
1165
|
+
while (true) {
|
|
1166
|
+
const raw = String(
|
|
1167
|
+
await rl.question(`Select login state [0-${profiles.length}] (default ${defaultChoice}): `)
|
|
1168
|
+
).trim();
|
|
1169
|
+
const picked = raw ? Number(raw) : defaultChoice;
|
|
1170
|
+
if (!Number.isInteger(picked) || picked < 0 || picked > profiles.length) {
|
|
1171
|
+
// eslint-disable-next-line no-console
|
|
1172
|
+
console.log(`[qualty] Please enter a number from 0 to ${profiles.length}.`);
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
if (picked === 0) {
|
|
1176
|
+
// eslint-disable-next-line no-console
|
|
1177
|
+
console.log("[qualty][auth] Running without saved login state.");
|
|
1178
|
+
return { authProfile: "", noAuthState: true };
|
|
1179
|
+
}
|
|
1180
|
+
const profile = profiles[picked - 1];
|
|
1181
|
+
// eslint-disable-next-line no-console
|
|
1182
|
+
console.log(`[qualty][auth] Selected profile "${profile}"`);
|
|
1183
|
+
return { authProfile: profile, noAuthState: false };
|
|
1184
|
+
}
|
|
1185
|
+
} finally {
|
|
1186
|
+
rl.close();
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async function runAuthSetup(args) {
|
|
1191
|
+
const config = readQualtyConfig();
|
|
1192
|
+
const apiUrl = resolveApiUrl(args, config);
|
|
1193
|
+
const token = resolveToken(args, config);
|
|
1194
|
+
const orgId = resolveOrgId(args, config);
|
|
1195
|
+
if (!token) {
|
|
1196
|
+
throw new Error("Missing auth token. Run `qualty setup` first.");
|
|
1197
|
+
}
|
|
1198
|
+
if (!orgId) {
|
|
1199
|
+
throw new Error("Missing org. Run `qualty setup` first.");
|
|
1200
|
+
}
|
|
1201
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1202
|
+
throw new Error("Interactive auth setup requires a TTY terminal.");
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const projectsPayload = await apiRequest({
|
|
1206
|
+
apiUrl,
|
|
1207
|
+
token,
|
|
1208
|
+
orgId,
|
|
1209
|
+
path: "/api/v1/projects",
|
|
1210
|
+
});
|
|
1211
|
+
const projects = (Array.isArray(projectsPayload) ? projectsPayload : [])
|
|
1212
|
+
.map((p) => ({
|
|
1213
|
+
id: String(p?.id || "").trim(),
|
|
1214
|
+
name: String(p?.name || "").trim() || "Unnamed project",
|
|
1215
|
+
url: String(p?.url || "").trim(),
|
|
1216
|
+
}))
|
|
1217
|
+
.filter((p) => p.id);
|
|
1218
|
+
if (projects.length === 0) {
|
|
1219
|
+
throw new Error("No projects found. Create a project first.");
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1223
|
+
let project = null;
|
|
1224
|
+
try {
|
|
1225
|
+
const requestedProjectId = String(args.project || "").trim();
|
|
1226
|
+
if (requestedProjectId) {
|
|
1227
|
+
project = projects.find((p) => p.id === requestedProjectId) || null;
|
|
1228
|
+
if (!project) {
|
|
1229
|
+
throw new Error(`Project ${requestedProjectId} not found.`);
|
|
1230
|
+
}
|
|
1231
|
+
} else {
|
|
1232
|
+
// eslint-disable-next-line no-console
|
|
1233
|
+
console.log("[qualty] Choose project for saved local auth:");
|
|
1234
|
+
for (let i = 0; i < projects.length; i += 1) {
|
|
1235
|
+
// eslint-disable-next-line no-console
|
|
1236
|
+
console.log(` ${i + 1}) ${projects[i].name}`);
|
|
1237
|
+
}
|
|
1238
|
+
while (!project) {
|
|
1239
|
+
const raw = String(await rl.question(`Select project [1-${projects.length}] (default 1): `)).trim();
|
|
1240
|
+
const picked = raw ? Number(raw) : 1;
|
|
1241
|
+
if (Number.isInteger(picked) && picked >= 1 && picked <= projects.length) {
|
|
1242
|
+
project = projects[picked - 1];
|
|
1243
|
+
} else {
|
|
1244
|
+
// eslint-disable-next-line no-console
|
|
1245
|
+
console.log(`[qualty] Please enter a number from 1 to ${projects.length}.`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
let profile = String(args.profile || "").trim();
|
|
1251
|
+
if (!profile) {
|
|
1252
|
+
const current = String(readProjectConfig().local_auth_profile || "default").trim() || "default";
|
|
1253
|
+
profile = String(await rl.question(`Profile name [${current}]: `)).trim() || current;
|
|
1254
|
+
}
|
|
1255
|
+
const statePath = localAuthStatePath({
|
|
1256
|
+
orgId,
|
|
1257
|
+
projectId: project.id,
|
|
1258
|
+
profile,
|
|
1259
|
+
});
|
|
1260
|
+
mkdirSync(join(statePath, ".."), { recursive: true });
|
|
1261
|
+
|
|
1262
|
+
const { chromium } = await import("playwright");
|
|
1263
|
+
const headed = parseBoolean(args.headed, true);
|
|
1264
|
+
let browser = null;
|
|
1265
|
+
try {
|
|
1266
|
+
browser = await chromium.launch({
|
|
1267
|
+
headless: !headed,
|
|
1268
|
+
channel: "chrome",
|
|
1269
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
1270
|
+
});
|
|
1271
|
+
// eslint-disable-next-line no-console
|
|
1272
|
+
console.log("[qualty][auth] Using local Chrome channel for login setup.");
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
// eslint-disable-next-line no-console
|
|
1275
|
+
console.log(`[qualty][auth] Chrome channel unavailable, falling back to Playwright Chromium (${err?.message || err}).`);
|
|
1276
|
+
browser = await chromium.launch({
|
|
1277
|
+
headless: !headed,
|
|
1278
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
const context = await browser.newContext();
|
|
1282
|
+
const page = await context.newPage();
|
|
1283
|
+
const startUrl = project.url || "about:blank";
|
|
1284
|
+
await page.goto(startUrl, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
|
|
1285
|
+
// eslint-disable-next-line no-console
|
|
1286
|
+
console.log(`[qualty][auth] Browser opened for project "${project.name}".`);
|
|
1287
|
+
// eslint-disable-next-line no-console
|
|
1288
|
+
console.log("[qualty][auth] Complete login in the browser, then press Enter here to save context.");
|
|
1289
|
+
await rl.question("Press Enter to save local auth context...");
|
|
1290
|
+
await context.storageState({ path: statePath });
|
|
1291
|
+
await context.close();
|
|
1292
|
+
await browser.close();
|
|
1293
|
+
|
|
1294
|
+
writeProjectConfig({
|
|
1295
|
+
local_auth_project_id: project.id,
|
|
1296
|
+
local_auth_profile: profile,
|
|
1297
|
+
default_org_id: orgId,
|
|
1298
|
+
});
|
|
1299
|
+
// eslint-disable-next-line no-console
|
|
1300
|
+
console.log(`[qualty][auth] Saved local context profile "${profile}" for project "${project.name}".`);
|
|
1301
|
+
// eslint-disable-next-line no-console
|
|
1302
|
+
console.log(`[qualty][auth] Path: ${statePath}`);
|
|
1303
|
+
} finally {
|
|
1304
|
+
rl.close();
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
716
1308
|
async function main() {
|
|
717
1309
|
const args = parseArgs(process.argv);
|
|
718
1310
|
const command = args._[0];
|
|
@@ -724,14 +1316,59 @@ async function main() {
|
|
|
724
1316
|
await runConnect(args);
|
|
725
1317
|
return;
|
|
726
1318
|
}
|
|
1319
|
+
if (command === "setup") {
|
|
1320
|
+
await runSetup(args);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (command === "auth") {
|
|
1324
|
+
const sub = args._[1];
|
|
1325
|
+
if (sub === "setup") {
|
|
1326
|
+
await runAuthSetup(args);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
throw new Error("Unknown auth subcommand. Use: qualty auth setup");
|
|
1330
|
+
}
|
|
727
1331
|
if (command === "run") {
|
|
1332
|
+
const config = readQualtyConfig();
|
|
728
1333
|
if (parseBoolean(args.local, false)) {
|
|
1334
|
+
const apiUrl = resolveApiUrl(args, config);
|
|
1335
|
+
const token = resolveToken(args, config);
|
|
1336
|
+
const orgId = resolveOrgId(args, config);
|
|
1337
|
+
const projectId =
|
|
1338
|
+
(await maybePromptProjectIdForLocalRun({
|
|
1339
|
+
args,
|
|
1340
|
+
apiUrl,
|
|
1341
|
+
token,
|
|
1342
|
+
orgId,
|
|
1343
|
+
})) || args.project;
|
|
1344
|
+
const suiteId =
|
|
1345
|
+
(await maybePromptSuiteIdForLocalRun({
|
|
1346
|
+
args: { ...args, project: projectId },
|
|
1347
|
+
apiUrl,
|
|
1348
|
+
token,
|
|
1349
|
+
orgId,
|
|
1350
|
+
})) || args["suite-id"];
|
|
1351
|
+
const authChoice = await maybePromptAuthProfileForLocalRun({
|
|
1352
|
+
args,
|
|
1353
|
+
orgId,
|
|
1354
|
+
projectId,
|
|
1355
|
+
});
|
|
1356
|
+
const localAuthArgs = {
|
|
1357
|
+
...args,
|
|
1358
|
+
...(authChoice.authProfile ? { "auth-profile": authChoice.authProfile } : {}),
|
|
1359
|
+
...(authChoice.noAuthState ? { "no-auth-state": true } : {}),
|
|
1360
|
+
};
|
|
1361
|
+
const localAuth = resolveLocalAuthStateForRun({ args: localAuthArgs, orgId, projectId });
|
|
1362
|
+
if (localAuth?.path) {
|
|
1363
|
+
// eslint-disable-next-line no-console
|
|
1364
|
+
console.log(`[qualty][auth] Reusing profile "${localAuth.profile}"`);
|
|
1365
|
+
}
|
|
729
1366
|
await runLocalCi({
|
|
730
|
-
apiUrl
|
|
731
|
-
token
|
|
732
|
-
orgId
|
|
733
|
-
projectId
|
|
734
|
-
suiteId
|
|
1367
|
+
apiUrl,
|
|
1368
|
+
token,
|
|
1369
|
+
orgId,
|
|
1370
|
+
projectId,
|
|
1371
|
+
suiteId,
|
|
735
1372
|
explicitIds: String(args.ids || "")
|
|
736
1373
|
.split(",")
|
|
737
1374
|
.map((id) => id.trim())
|
|
@@ -742,6 +1379,7 @@ async function main() {
|
|
|
742
1379
|
device: args.device || "1440x900",
|
|
743
1380
|
headless: !parseBoolean(args.headed, false),
|
|
744
1381
|
localConcurrency: resolveLocalConcurrency(args),
|
|
1382
|
+
storageStatePath: localAuth?.path,
|
|
745
1383
|
});
|
|
746
1384
|
return;
|
|
747
1385
|
}
|
|
@@ -752,7 +1390,13 @@ async function main() {
|
|
|
752
1390
|
await runResolve(args);
|
|
753
1391
|
return;
|
|
754
1392
|
}
|
|
755
|
-
if (
|
|
1393
|
+
if (
|
|
1394
|
+
command !== "setup" &&
|
|
1395
|
+
command !== "auth" &&
|
|
1396
|
+
command !== "connect" &&
|
|
1397
|
+
command !== "run" &&
|
|
1398
|
+
command !== "resolve"
|
|
1399
|
+
) {
|
|
756
1400
|
throw new Error(`Unknown command: ${command}`);
|
|
757
1401
|
}
|
|
758
1402
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qualty",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Qualty CLI for localhost and CI test runs",
|
|
5
5
|
"bin": {
|
|
6
6
|
"qualty": "bin/qualty.js"
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"bin"
|
|
12
12
|
],
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"playwright": "1.60.0"
|
|
14
|
+
"playwright": "1.60.0",
|
|
15
|
+
"rrweb": "^2.0.0-alpha.4"
|
|
15
16
|
}
|
|
16
17
|
}
|