qualty 0.1.20 → 0.1.22
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 +144 -15
- package/bin/qualty.js +523 -173
- package/bin/test-pw.mjs +4 -0
- package/package.json +1 -1
package/bin/local-runner.js
CHANGED
|
@@ -78,6 +78,25 @@ function localAuthStatePath({ orgId, projectId, profile }) {
|
|
|
78
78
|
return join(homedir(), ".qualty", "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
function jobUsesSavedLogin(job) {
|
|
82
|
+
if (!job) return false;
|
|
83
|
+
if (job.use_saved_login) return true;
|
|
84
|
+
if (job.saved_login_profile_id) return true;
|
|
85
|
+
const profile = job.saved_login_profile;
|
|
86
|
+
return Boolean(profile && typeof profile === "object" && (profile.id || profile.name));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function localProfileLabel(profile) {
|
|
90
|
+
if (!profile || typeof profile !== "object") return "";
|
|
91
|
+
return String(profile.local_profile_name || profile.name || "").trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function authSetupCommand(projectId, profileName) {
|
|
95
|
+
const pid = String(projectId || "").trim();
|
|
96
|
+
const name = String(profileName || "").trim();
|
|
97
|
+
return `qualty auth setup --project ${pid} --profile "${name}"`;
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
export function summarizeStorageStateFile(statePath) {
|
|
82
101
|
const path = String(statePath || "").trim();
|
|
83
102
|
if (!path || !existsSync(path)) {
|
|
@@ -233,6 +252,31 @@ export async function logAuthStateAfterInjection(tag, context, { testUrl = "" }
|
|
|
233
252
|
}
|
|
234
253
|
}
|
|
235
254
|
|
|
255
|
+
export async function logAuthStateAfterNavigation(tag, page) {
|
|
256
|
+
try {
|
|
257
|
+
const snapshot = await page.evaluate(() => ({
|
|
258
|
+
origin: window.location.origin,
|
|
259
|
+
href: window.location.href,
|
|
260
|
+
localStorageKeys: Object.keys(localStorage),
|
|
261
|
+
cookieCount: document.cookie ? document.cookie.split(";").filter(Boolean).length : 0,
|
|
262
|
+
}));
|
|
263
|
+
const keys = snapshot.localStorageKeys.length ? snapshot.localStorageKeys.join(", ") : "none";
|
|
264
|
+
// eslint-disable-next-line no-console
|
|
265
|
+
console.log(
|
|
266
|
+
`${tag} after navigation (${snapshot.origin}): localStorage keys [${keys}], document.cookie entries ~${snapshot.cookieCount}`
|
|
267
|
+
);
|
|
268
|
+
if (snapshot.localStorageKeys.length === 0 && snapshot.cookieCount === 0) {
|
|
269
|
+
// eslint-disable-next-line no-console
|
|
270
|
+
console.warn(
|
|
271
|
+
`${tag} warning: page has no localStorage keys and no cookies after navigation — app will likely appear logged out`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
// eslint-disable-next-line no-console
|
|
276
|
+
console.warn(`${tag} could not read auth state after navigation: ${err?.message || err}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
236
280
|
const localBindingSetupPromises = new Map();
|
|
237
281
|
|
|
238
282
|
async function promptYesNo(question, defaultYes = true) {
|
|
@@ -340,11 +384,21 @@ async function maybeSetupMissingLocalBinding({
|
|
|
340
384
|
? claim.saved_login_profile
|
|
341
385
|
: null;
|
|
342
386
|
if (!profile || !profile.name) return null;
|
|
343
|
-
if (profile.has_local_binding) return null;
|
|
344
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
|
|
345
|
-
|
|
346
387
|
const profileName = String(profile.name || "").trim();
|
|
347
388
|
if (!profileName) return null;
|
|
389
|
+
const localProfileName = localProfileLabel(profile) || profileName;
|
|
390
|
+
const statePath = localAuthStatePath({
|
|
391
|
+
orgId,
|
|
392
|
+
projectId: claim?.project_id,
|
|
393
|
+
profile: localProfileName,
|
|
394
|
+
});
|
|
395
|
+
if (existsSync(statePath)) return null;
|
|
396
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
397
|
+
throw new Error(
|
|
398
|
+
`Local login state missing for profile "${profileName}". Run: ${authSetupCommand(claim?.project_id, profileName)}`
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
348
402
|
const key = `${String(orgId || "")}:${String(claim?.project_id || "")}:${profileName}`;
|
|
349
403
|
if (localBindingSetupPromises.has(key)) {
|
|
350
404
|
return localBindingSetupPromises.get(key);
|
|
@@ -398,20 +452,21 @@ async function preflightMissingLocalBindings({
|
|
|
398
452
|
}) {
|
|
399
453
|
if (noAuthState) return;
|
|
400
454
|
if (explicitAuthProfile) return;
|
|
401
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
402
455
|
const missingByProfile = new Map();
|
|
403
456
|
for (const job of savedJobs || []) {
|
|
404
|
-
|
|
457
|
+
if (!jobUsesSavedLogin(job)) continue;
|
|
405
458
|
const profileName = String(job?.saved_login_profile?.name || "").trim();
|
|
406
|
-
if (!
|
|
459
|
+
if (!profileName) continue;
|
|
460
|
+
const localProfileName =
|
|
461
|
+
String(job?.saved_login_profile?.local_profile_name || "").trim() || profileName;
|
|
407
462
|
const statePath = localAuthStatePath({
|
|
408
463
|
orgId,
|
|
409
464
|
projectId: String(job?.project_id || projectId || "").trim(),
|
|
410
|
-
profile:
|
|
465
|
+
profile: localProfileName,
|
|
411
466
|
});
|
|
412
467
|
if (existsSync(statePath)) continue;
|
|
413
468
|
const entry = missingByProfile.get(profileName) || {
|
|
414
|
-
localProfileName
|
|
469
|
+
localProfileName,
|
|
415
470
|
projectId: String(job?.project_id || projectId || "").trim(),
|
|
416
471
|
startUrl: rewriteUrlForLocalRun(String(job?.url || "").trim(), localPort),
|
|
417
472
|
tests: [],
|
|
@@ -427,6 +482,13 @@ async function preflightMissingLocalBindings({
|
|
|
427
482
|
}
|
|
428
483
|
if (missingByProfile.size === 0) return;
|
|
429
484
|
|
|
485
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
486
|
+
const [profileName, entry] = missingByProfile.entries().next().value;
|
|
487
|
+
throw new Error(
|
|
488
|
+
`Local login state missing for profile "${profileName}". Run: ${authSetupCommand(entry.projectId, profileName)}`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
430
492
|
// eslint-disable-next-line no-console
|
|
431
493
|
console.log("[qualty][auth] Missing local login state for attached dashboard profiles:");
|
|
432
494
|
for (const [profileName, entry] of missingByProfile.entries()) {
|
|
@@ -1228,6 +1290,51 @@ export async function runLocalExecution({
|
|
|
1228
1290
|
authProfile,
|
|
1229
1291
|
noAuthState,
|
|
1230
1292
|
storageStatePath,
|
|
1293
|
+
}) {
|
|
1294
|
+
let overallSuccess = true;
|
|
1295
|
+
let lastError = null;
|
|
1296
|
+
let claimDevice = device || undefined;
|
|
1297
|
+
|
|
1298
|
+
while (true) {
|
|
1299
|
+
let once;
|
|
1300
|
+
try {
|
|
1301
|
+
once = await runLocalSingleCombination({
|
|
1302
|
+
apiRequest,
|
|
1303
|
+
apiUrl,
|
|
1304
|
+
token,
|
|
1305
|
+
orgId,
|
|
1306
|
+
executionId,
|
|
1307
|
+
device: claimDevice,
|
|
1308
|
+
headless,
|
|
1309
|
+
authProfile,
|
|
1310
|
+
noAuthState,
|
|
1311
|
+
storageStatePath,
|
|
1312
|
+
});
|
|
1313
|
+
} catch (err) {
|
|
1314
|
+
const msg = String(err?.message || err);
|
|
1315
|
+
if (msg.includes("No queued combinations")) break;
|
|
1316
|
+
throw err;
|
|
1317
|
+
}
|
|
1318
|
+
claimDevice = undefined;
|
|
1319
|
+
overallSuccess = overallSuccess && once.success;
|
|
1320
|
+
if (!once.success) lastError = once.error;
|
|
1321
|
+
if (device || !once.combinationsRemaining) break;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
return { success: overallSuccess, error: lastError };
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
async function runLocalSingleCombination({
|
|
1328
|
+
apiRequest,
|
|
1329
|
+
apiUrl,
|
|
1330
|
+
token,
|
|
1331
|
+
orgId,
|
|
1332
|
+
executionId,
|
|
1333
|
+
device,
|
|
1334
|
+
headless,
|
|
1335
|
+
authProfile,
|
|
1336
|
+
noAuthState,
|
|
1337
|
+
storageStatePath,
|
|
1231
1338
|
}) {
|
|
1232
1339
|
const { chromium } = await import("playwright");
|
|
1233
1340
|
|
|
@@ -1238,7 +1345,7 @@ export async function runLocalExecution({
|
|
|
1238
1345
|
orgId,
|
|
1239
1346
|
path: `/api/v1/cli/executions/${executionId}/claim`,
|
|
1240
1347
|
method: "POST",
|
|
1241
|
-
body: { device:
|
|
1348
|
+
body: device ? { device: String(device).trim() } : {},
|
|
1242
1349
|
});
|
|
1243
1350
|
const effectiveDevice = String(claim.device || runDevice).trim() || runDevice;
|
|
1244
1351
|
|
|
@@ -1267,7 +1374,7 @@ export async function runLocalExecution({
|
|
|
1267
1374
|
explicitAuthProfile: String(authProfile || "").trim(),
|
|
1268
1375
|
noAuthState: Boolean(noAuthState),
|
|
1269
1376
|
});
|
|
1270
|
-
const claimProfile =
|
|
1377
|
+
const claimProfile = localProfileLabel(claimSavedLogin);
|
|
1271
1378
|
const effectiveProfile = String(authProfile || "").trim() || setupFromClaim?.profile || claimProfile;
|
|
1272
1379
|
const resolvedStoragePath =
|
|
1273
1380
|
!noAuthState && effectiveProfile
|
|
@@ -1309,12 +1416,22 @@ export async function runLocalExecution({
|
|
|
1309
1416
|
});
|
|
1310
1417
|
|
|
1311
1418
|
if (!noAuthState && effectiveProfile && !finalStoragePath) {
|
|
1419
|
+
const profileName = String(claimSavedLogin?.name || effectiveProfile).trim();
|
|
1312
1420
|
const candidates = [storageStatePath, resolvedStoragePath].filter(Boolean);
|
|
1421
|
+
const setupCmd = authSetupCommand(claim.project_id, profileName);
|
|
1422
|
+
const detail =
|
|
1423
|
+
`Local login state missing for profile "${profileName}".` +
|
|
1424
|
+
(candidates.length ? ` Checked: ${candidates.join(", ")}.` : "") +
|
|
1425
|
+
` Run: ${setupCmd}`;
|
|
1426
|
+
const failLoud =
|
|
1427
|
+
!process.stdin.isTTY ||
|
|
1428
|
+
!process.stdout.isTTY ||
|
|
1429
|
+
(claimSavedLogin?.name && !String(authProfile || "").trim());
|
|
1430
|
+
if (failLoud) {
|
|
1431
|
+
throw new Error(detail);
|
|
1432
|
+
}
|
|
1313
1433
|
// eslint-disable-next-line no-console
|
|
1314
|
-
console.warn(
|
|
1315
|
-
`[qualty][auth] profile "${effectiveProfile}" was requested but no auth state file exists` +
|
|
1316
|
-
(candidates.length ? ` (checked: ${candidates.join(", ")})` : "")
|
|
1317
|
-
);
|
|
1434
|
+
console.warn(`[qualty][auth] ${detail}`);
|
|
1318
1435
|
}
|
|
1319
1436
|
|
|
1320
1437
|
const browserServer = await chromium.launchServer({
|
|
@@ -1379,6 +1496,9 @@ export async function runLocalExecution({
|
|
|
1379
1496
|
try {
|
|
1380
1497
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
1381
1498
|
await page.waitForTimeout(500);
|
|
1499
|
+
if (finalStoragePath) {
|
|
1500
|
+
await logAuthStateAfterNavigation("[qualty][auth]", page);
|
|
1501
|
+
}
|
|
1382
1502
|
|
|
1383
1503
|
for (let stepIndex = 0; stepIndex < totalSteps; stepIndex += 1) {
|
|
1384
1504
|
const step = steps[stepIndex] || {};
|
|
@@ -1544,7 +1664,12 @@ export async function runLocalExecution({
|
|
|
1544
1664
|
}),
|
|
1545
1665
|
});
|
|
1546
1666
|
|
|
1547
|
-
|
|
1667
|
+
if (completeResp && completeResp.status) {
|
|
1668
|
+
runSuccess = completeResp.status === "completed" || runSuccess;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
const combinationsRemaining = Number(completeResp?.combinations_remaining ?? 0);
|
|
1672
|
+
return { success: runSuccess, stepsJson, error: runError, combinationsRemaining, device: effectiveDevice };
|
|
1548
1673
|
}
|
|
1549
1674
|
|
|
1550
1675
|
async function fetchProjectLocalPort({ apiRequest, apiUrl, token, orgId, projectId }) {
|
|
@@ -1604,6 +1729,10 @@ export async function runLocalCi({
|
|
|
1604
1729
|
const started = runs.filter((r) => r.execution_job_id);
|
|
1605
1730
|
if (started.length === 0) throw new Error("Failed to start any local runs.");
|
|
1606
1731
|
|
|
1732
|
+
const executionIds = started.map((r) => r.execution_job_id);
|
|
1733
|
+
// eslint-disable-next-line no-console
|
|
1734
|
+
console.log(`QUALTY_EXECUTION_IDS=${JSON.stringify(executionIds)}`);
|
|
1735
|
+
|
|
1607
1736
|
await preflightMissingLocalBindings({
|
|
1608
1737
|
apiRequest,
|
|
1609
1738
|
apiUrl,
|
package/bin/qualty.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { gzipSync } from "node:zlib";
|
|
13
13
|
import { spawn, spawnSync } from "node:child_process";
|
|
14
14
|
import { homedir, hostname } from "node:os";
|
|
15
|
-
import { join } from "node:path";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
16
|
import { createInterface } from "node:readline/promises";
|
|
17
17
|
import process from "node:process";
|
|
18
18
|
import { rewriteUrlForLocalRun, runLocalCi, summarizeStorageStateFile, logAuthStateDiagnostics } from "./local-runner.js";
|
|
@@ -20,32 +20,23 @@ import { rewriteUrlForLocalRun, runLocalCi, summarizeStorageStateFile, logAuthSt
|
|
|
20
20
|
/** CLI backend URL is fixed by Qualty distribution. */
|
|
21
21
|
const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
|
|
22
22
|
|
|
23
|
-
const
|
|
24
|
-
const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
|
|
23
|
+
const PROJECT_CONFIG_FILENAME = ".qualty.json";
|
|
25
24
|
const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
|
|
26
25
|
const QUALTY_SHARED_CREDS_PATH = join(QUALTY_SHARED_CREDS_DIR, "credentials");
|
|
27
|
-
const QUALTY_PROJECT_CONFIG_PATH = join(process.cwd(), ".qualty.json");
|
|
28
26
|
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
36
|
-
|
|
37
|
-
return {};
|
|
27
|
+
function findProjectConfigPath() {
|
|
28
|
+
let current = process.cwd();
|
|
29
|
+
for (;;) {
|
|
30
|
+
const candidate = join(current, PROJECT_CONFIG_FILENAME);
|
|
31
|
+
if (existsSync(candidate)) return candidate;
|
|
32
|
+
const parent = dirname(current);
|
|
33
|
+
if (parent === current) return null;
|
|
34
|
+
current = parent;
|
|
38
35
|
}
|
|
39
36
|
}
|
|
40
37
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
writeFileSync(QUALTY_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
44
|
-
try {
|
|
45
|
-
chmodSync(QUALTY_CONFIG_PATH, 0o600);
|
|
46
|
-
} catch {
|
|
47
|
-
// best effort on non-posix systems
|
|
48
|
-
}
|
|
38
|
+
function getProjectConfigWritePath() {
|
|
39
|
+
return findProjectConfigPath() || join(process.cwd(), PROJECT_CONFIG_FILENAME);
|
|
49
40
|
}
|
|
50
41
|
|
|
51
42
|
function readSharedCredentials() {
|
|
@@ -70,10 +61,23 @@ function writeSharedCredentials(token) {
|
|
|
70
61
|
}
|
|
71
62
|
}
|
|
72
63
|
|
|
64
|
+
function projectIdFromConfig(projectCfg, fallback = "") {
|
|
65
|
+
return String(fallback || projectCfg.default_project_id || "").trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function orgIdFromConfig(projectCfg, fallback = "") {
|
|
69
|
+
return String(fallback || projectCfg.default_org_id || "").trim();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function authProfileFromConfig(projectCfg, fallback = "") {
|
|
73
|
+
return String(fallback || projectCfg.default_auth_profile || "").trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
73
76
|
function readProjectConfig() {
|
|
74
|
-
|
|
77
|
+
const configPath = findProjectConfigPath();
|
|
78
|
+
if (!configPath) return {};
|
|
75
79
|
try {
|
|
76
|
-
const raw = readFileSync(
|
|
80
|
+
const raw = readFileSync(configPath, "utf8");
|
|
77
81
|
const parsed = JSON.parse(raw);
|
|
78
82
|
if (!parsed || typeof parsed !== "object") return {};
|
|
79
83
|
return parsed;
|
|
@@ -83,29 +87,59 @@ function readProjectConfig() {
|
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
function writeProjectConfig(updates) {
|
|
90
|
+
const configPath = getProjectConfigWritePath();
|
|
86
91
|
const current = readProjectConfig();
|
|
87
92
|
const next = { ...current };
|
|
88
93
|
for (const [k, v] of Object.entries(updates || {})) {
|
|
89
94
|
if (v === undefined || v === null || v === "") delete next[k];
|
|
90
95
|
else next[k] = v;
|
|
91
96
|
}
|
|
92
|
-
writeFileSync(
|
|
97
|
+
writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
93
98
|
}
|
|
94
99
|
|
|
95
|
-
function
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
function normalizeApiUrl(raw, { label = "API URL" } = {}) {
|
|
101
|
+
const trimmed = String(raw || "").trim();
|
|
102
|
+
if (!trimmed) {
|
|
103
|
+
throw new Error(`${label} is empty.`);
|
|
104
|
+
}
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = new URL(trimmed);
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error(`${label} is not a valid URL: ${trimmed}`);
|
|
110
|
+
}
|
|
111
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
112
|
+
throw new Error(`${label} must use http or https (got ${parsed.protocol}).`);
|
|
113
|
+
}
|
|
114
|
+
return trimmed.replace(/\/$/, "");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function qualtyBaseUrlFromEnv() {
|
|
118
|
+
return String(process.env.QUALTY_BASE_URL || process.env.QUALTY_API_URL || "").trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolveApiUrl(args = {}) {
|
|
122
|
+
const fromFlag = String(args.url || args["api-url"] || "").trim();
|
|
123
|
+
const fromEnv = qualtyBaseUrlFromEnv();
|
|
124
|
+
const raw = fromFlag || fromEnv || DEFAULT_QUALTY_API_URL;
|
|
125
|
+
const label = fromFlag
|
|
126
|
+
? "--url"
|
|
127
|
+
: process.env.QUALTY_BASE_URL
|
|
128
|
+
? "QUALTY_BASE_URL"
|
|
129
|
+
: fromEnv
|
|
130
|
+
? "QUALTY_API_URL"
|
|
131
|
+
: "default API URL";
|
|
132
|
+
return normalizeApiUrl(raw, { label });
|
|
99
133
|
}
|
|
100
134
|
|
|
101
|
-
function resolveToken(args
|
|
135
|
+
function resolveToken(args = {}) {
|
|
102
136
|
const creds = readSharedCredentials();
|
|
103
|
-
return args.token || process.env.QUALTY_API_TOKEN || creds.api_token ||
|
|
137
|
+
return args.token || process.env.QUALTY_API_TOKEN || creds.api_token || "";
|
|
104
138
|
}
|
|
105
139
|
|
|
106
|
-
function resolveOrgId(args
|
|
140
|
+
function resolveOrgId(args = {}) {
|
|
107
141
|
const projectCfg = readProjectConfig();
|
|
108
|
-
return args.org
|
|
142
|
+
return orgIdFromConfig(projectCfg, args.org);
|
|
109
143
|
}
|
|
110
144
|
|
|
111
145
|
function maskSecret(secret) {
|
|
@@ -144,8 +178,11 @@ function usage() {
|
|
|
144
178
|
"Usage:",
|
|
145
179
|
" qualty setup [--token <bearer-token>] [--org <org-id>]",
|
|
146
180
|
" qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
|
|
147
|
-
" qualty auth export-ci --project <project-id> [--profile <profile-name>] [--org <org-id>]",
|
|
148
|
-
" [--
|
|
181
|
+
" qualty auth export-ci [--project <project-id>] [--profile <profile-name>] [--org <org-id>]",
|
|
182
|
+
" [--url <api-base-url>] [--suite-id <suite-uuid>] [--repo <owner/repo>]",
|
|
183
|
+
" [--output <path>] [--no-file] [--copy] [--gzip] [--include-token]",
|
|
184
|
+
" [--set-secret | --set-github] gh: secrets (token, API URL, auth) + variables (org, project, profile, …)",
|
|
185
|
+
" [--cli-repo <owner/repo>] [--cli-ref <branch>] Optional CI variables",
|
|
149
186
|
" qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
|
|
150
187
|
" qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
|
|
151
188
|
" [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
|
|
@@ -157,8 +194,11 @@ function usage() {
|
|
|
157
194
|
"",
|
|
158
195
|
"Env vars:",
|
|
159
196
|
" QUALTY_API_TOKEN Bearer token used for auth",
|
|
160
|
-
"
|
|
197
|
+
" QUALTY_BASE_URL API base URL when --url is not set (default: production)",
|
|
161
198
|
" QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
|
|
199
|
+
"",
|
|
200
|
+
"Config (.qualty.json): default_org_id, default_project_id, default_auth_profile",
|
|
201
|
+
" CLI flags (--org, --project, --profile, --url) override config values.",
|
|
162
202
|
].join("\n")
|
|
163
203
|
);
|
|
164
204
|
}
|
|
@@ -228,12 +268,11 @@ function startCloudflared(token, port) {
|
|
|
228
268
|
}
|
|
229
269
|
|
|
230
270
|
async function runConnect(args) {
|
|
231
|
-
const config = readQualtyConfig();
|
|
232
271
|
const projectId = args.project;
|
|
233
272
|
const port = Number(args.port || 3000);
|
|
234
|
-
const apiUrl = resolveApiUrl(args
|
|
235
|
-
const token = resolveToken(args
|
|
236
|
-
const orgId = resolveOrgId(args
|
|
273
|
+
const apiUrl = resolveApiUrl(args);
|
|
274
|
+
const token = resolveToken(args);
|
|
275
|
+
const orgId = resolveOrgId(args);
|
|
237
276
|
|
|
238
277
|
if (!projectId || !token) {
|
|
239
278
|
usage();
|
|
@@ -305,7 +344,6 @@ async function runConnect(args) {
|
|
|
305
344
|
}
|
|
306
345
|
|
|
307
346
|
async function runSetup(args) {
|
|
308
|
-
const existing = readQualtyConfig();
|
|
309
347
|
const sharedCreds = readSharedCredentials();
|
|
310
348
|
const projectCfg = readProjectConfig();
|
|
311
349
|
const rl = createInterface({
|
|
@@ -341,8 +379,10 @@ async function runSetup(args) {
|
|
|
341
379
|
};
|
|
342
380
|
|
|
343
381
|
try {
|
|
344
|
-
const currentOrgId = String(args.org ||
|
|
345
|
-
const currentToken = String(
|
|
382
|
+
const currentOrgId = String(args.org || projectCfg.default_org_id || "");
|
|
383
|
+
const currentToken = String(
|
|
384
|
+
args.token || process.env.QUALTY_API_TOKEN || sharedCreds.api_token || ""
|
|
385
|
+
);
|
|
346
386
|
|
|
347
387
|
let token = "";
|
|
348
388
|
if (args.token) {
|
|
@@ -366,7 +406,7 @@ async function runSetup(args) {
|
|
|
366
406
|
let currentOrgName = "";
|
|
367
407
|
try {
|
|
368
408
|
const orgResp = await apiRequest({
|
|
369
|
-
apiUrl: resolveApiUrl({}
|
|
409
|
+
apiUrl: resolveApiUrl({}),
|
|
370
410
|
token,
|
|
371
411
|
orgId: "",
|
|
372
412
|
path: "/api/v1/orgs",
|
|
@@ -435,23 +475,16 @@ async function runSetup(args) {
|
|
|
435
475
|
}
|
|
436
476
|
|
|
437
477
|
if (!orgId) {
|
|
438
|
-
throw new Error("Org ID is required. Re-run setup and
|
|
478
|
+
throw new Error("Org ID is required. Re-run setup and enter your org ID when prompted.");
|
|
439
479
|
}
|
|
440
480
|
|
|
441
|
-
const next = {
|
|
442
|
-
org_id: orgId,
|
|
443
|
-
};
|
|
444
|
-
// Keep token in the same place MCP uses.
|
|
445
481
|
writeSharedCredentials(token);
|
|
446
|
-
writeQualtyConfig(next);
|
|
447
|
-
// Keep org in MCP-compatible per-project config too.
|
|
448
482
|
writeProjectConfig({ default_org_id: orgId });
|
|
449
|
-
|
|
450
|
-
console.log(`[qualty] Saved config to ${QUALTY_CONFIG_PATH}`);
|
|
483
|
+
const projectConfigPath = getProjectConfigWritePath();
|
|
451
484
|
// eslint-disable-next-line no-console
|
|
452
485
|
console.log(`[qualty] Saved shared token to ${QUALTY_SHARED_CREDS_PATH} (used by MCP)`);
|
|
453
486
|
// eslint-disable-next-line no-console
|
|
454
|
-
console.log(`[qualty] Saved project
|
|
487
|
+
console.log(`[qualty] Saved project config to ${projectConfigPath} (MCP-compatible)`);
|
|
455
488
|
// eslint-disable-next-line no-console
|
|
456
489
|
console.log(`[qualty] Org: ${orgName || "configured"}`);
|
|
457
490
|
// eslint-disable-next-line no-console
|
|
@@ -524,8 +557,17 @@ function copyToClipboard(text) {
|
|
|
524
557
|
return false;
|
|
525
558
|
}
|
|
526
559
|
|
|
527
|
-
function
|
|
528
|
-
const
|
|
560
|
+
function ghRepoArgs(repo) {
|
|
561
|
+
const target = String(repo || "").trim();
|
|
562
|
+
return target ? ["--repo", target] : [];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function setGithubSecret(name, value, { repo } = {}) {
|
|
566
|
+
const result = spawnSync(
|
|
567
|
+
"gh",
|
|
568
|
+
["secret", "set", name, ...ghRepoArgs(repo)],
|
|
569
|
+
{ input: value, encoding: "utf8" }
|
|
570
|
+
);
|
|
529
571
|
if (result.error?.code === "ENOENT") {
|
|
530
572
|
return { ok: false, reason: "GitHub CLI (gh) is not installed." };
|
|
531
573
|
}
|
|
@@ -536,6 +578,234 @@ function setGithubSecret(name, value) {
|
|
|
536
578
|
return { ok: true };
|
|
537
579
|
}
|
|
538
580
|
|
|
581
|
+
function setGithubVariable(name, value, { repo } = {}) {
|
|
582
|
+
const result = spawnSync("gh", ["variable", "set", name, "--body", value, ...ghRepoArgs(repo)], {
|
|
583
|
+
encoding: "utf8",
|
|
584
|
+
});
|
|
585
|
+
if (result.error?.code === "ENOENT") {
|
|
586
|
+
return { ok: false, reason: "GitHub CLI (gh) is not installed." };
|
|
587
|
+
}
|
|
588
|
+
if (result.status !== 0) {
|
|
589
|
+
const detail = String(result.stderr || result.stdout || "").trim();
|
|
590
|
+
return { ok: false, reason: detail || `gh variable set failed (exit ${result.status})` };
|
|
591
|
+
}
|
|
592
|
+
return { ok: true };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Push Qualty CI secrets + repository variables via gh (run from target app repo root).
|
|
597
|
+
*/
|
|
598
|
+
function syncGithubCiConfig({
|
|
599
|
+
repo,
|
|
600
|
+
orgId,
|
|
601
|
+
projectId,
|
|
602
|
+
profile,
|
|
603
|
+
authPayload,
|
|
604
|
+
apiUrl,
|
|
605
|
+
token,
|
|
606
|
+
suiteId,
|
|
607
|
+
cliRepo,
|
|
608
|
+
cliRef,
|
|
609
|
+
}) {
|
|
610
|
+
const results = [];
|
|
611
|
+
const record = (kind, name, outcome) => {
|
|
612
|
+
results.push({ kind, name, ...outcome });
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
const authResult = setGithubSecret("QUALTY_AUTH_STATE_B64", authPayload, { repo });
|
|
616
|
+
record("secret", "QUALTY_AUTH_STATE_B64", authResult);
|
|
617
|
+
|
|
618
|
+
if (token) {
|
|
619
|
+
record("secret", "QUALTY_API_TOKEN", setGithubSecret("QUALTY_API_TOKEN", token, { repo }));
|
|
620
|
+
} else {
|
|
621
|
+
record("secret", "QUALTY_API_TOKEN", {
|
|
622
|
+
ok: false,
|
|
623
|
+
reason: "No token locally — run qualty setup or set QUALTY_API_TOKEN.",
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const apiUrlNorm = String(apiUrl || "").replace(/\/$/, "");
|
|
628
|
+
if (apiUrlNorm) {
|
|
629
|
+
record("secret", "QUALTY_BASE_URL", setGithubSecret("QUALTY_BASE_URL", apiUrlNorm, { repo }));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
record("variable", "QUALTY_ORG_ID", setGithubVariable("QUALTY_ORG_ID", orgId, { repo }));
|
|
633
|
+
record("variable", "QUALTY_PROJECT_ID", setGithubVariable("QUALTY_PROJECT_ID", String(projectId), { repo }));
|
|
634
|
+
record("variable", "QUALTY_AUTH_PROFILE", setGithubVariable("QUALTY_AUTH_PROFILE", profile, { repo }));
|
|
635
|
+
|
|
636
|
+
if (suiteId) {
|
|
637
|
+
record("variable", "QUALTY_SUITE_ID", setGithubVariable("QUALTY_SUITE_ID", String(suiteId), { repo }));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (cliRepo) {
|
|
641
|
+
record("variable", "QUALTY_CLI_REPO", setGithubVariable("QUALTY_CLI_REPO", cliRepo, { repo }));
|
|
642
|
+
}
|
|
643
|
+
if (cliRef) {
|
|
644
|
+
record("variable", "QUALTY_CLI_REF", setGithubVariable("QUALTY_CLI_REF", cliRef, { repo }));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return results;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function printGithubSyncResults(results, { repo }) {
|
|
651
|
+
const target = repo ? ` (repo ${repo})` : "";
|
|
652
|
+
const printGroup = (title, rows) => {
|
|
653
|
+
if (!rows.length) return;
|
|
654
|
+
// eslint-disable-next-line no-console
|
|
655
|
+
console.log(title);
|
|
656
|
+
for (const row of rows) {
|
|
657
|
+
if (row.ok) {
|
|
658
|
+
// eslint-disable-next-line no-console
|
|
659
|
+
console.log(` ✓ ${row.name}`);
|
|
660
|
+
} else {
|
|
661
|
+
// eslint-disable-next-line no-console
|
|
662
|
+
console.log(` ✗ ${row.name} — ${row.reason || "failed"}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// eslint-disable-next-line no-console
|
|
668
|
+
console.log(`GitHub sync${target} (gh secret set + gh variable set):`);
|
|
669
|
+
printGroup(
|
|
670
|
+
" Repository secrets:",
|
|
671
|
+
results.filter((r) => r.kind === "secret")
|
|
672
|
+
);
|
|
673
|
+
printGroup(
|
|
674
|
+
" Repository variables:",
|
|
675
|
+
results.filter((r) => r.kind === "variable")
|
|
676
|
+
);
|
|
677
|
+
// eslint-disable-next-line no-console
|
|
678
|
+
console.log("");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function readProjectConfigStrict() {
|
|
682
|
+
const configPath = findProjectConfigPath();
|
|
683
|
+
if (!configPath) {
|
|
684
|
+
return { configPath: null, config: {} };
|
|
685
|
+
}
|
|
686
|
+
let raw;
|
|
687
|
+
try {
|
|
688
|
+
raw = readFileSync(configPath, "utf8");
|
|
689
|
+
} catch (err) {
|
|
690
|
+
throw new Error(
|
|
691
|
+
`Could not read ${PROJECT_CONFIG_FILENAME} at ${configPath}: ${err.message}`
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
let parsed;
|
|
695
|
+
try {
|
|
696
|
+
parsed = JSON.parse(raw);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
[
|
|
700
|
+
`Invalid ${PROJECT_CONFIG_FILENAME} at ${configPath}: ${err.message}`,
|
|
701
|
+
"Fix the JSON syntax or pass --org, --project, and --profile on the command line.",
|
|
702
|
+
].join("\n")
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
[
|
|
708
|
+
`Invalid ${PROJECT_CONFIG_FILENAME} at ${configPath}: expected a JSON object.`,
|
|
709
|
+
"Fix the file or pass --org, --project, and --profile on the command line.",
|
|
710
|
+
].join("\n")
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
return { configPath, config: parsed };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function configStringValue(projectCfg, key, configPath) {
|
|
717
|
+
const value = projectCfg[key];
|
|
718
|
+
if (value === undefined || value === null || value === "") return "";
|
|
719
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
720
|
+
return String(value).trim();
|
|
721
|
+
}
|
|
722
|
+
const where = configPath
|
|
723
|
+
? `${PROJECT_CONFIG_FILENAME} at ${configPath}`
|
|
724
|
+
: PROJECT_CONFIG_FILENAME;
|
|
725
|
+
throw new Error(
|
|
726
|
+
`Invalid ${key} in ${where}: expected a string or number, got ${typeof value}.`
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function missingExportCiField(fieldLabel, { flag, configKey, configPath, hasFlag }) {
|
|
731
|
+
const configHint = configPath
|
|
732
|
+
? `${PROJECT_CONFIG_FILENAME} at ${configPath} (${configKey})`
|
|
733
|
+
: `${PROJECT_CONFIG_FILENAME} (not found walking up from ${process.cwd()})`;
|
|
734
|
+
if (hasFlag) {
|
|
735
|
+
return [
|
|
736
|
+
`Missing ${fieldLabel}: ${flag} was empty.`,
|
|
737
|
+
`Set ${configKey} in ${configHint} or pass a non-empty ${flag}.`,
|
|
738
|
+
].join(" ");
|
|
739
|
+
}
|
|
740
|
+
return [
|
|
741
|
+
`Missing ${fieldLabel}.`,
|
|
742
|
+
`Pass ${flag} or set ${configKey} in ${configHint}.`,
|
|
743
|
+
].join(" ");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function resolveExportCiConfig(args) {
|
|
747
|
+
const { configPath, config: projectCfg } = readProjectConfigStrict();
|
|
748
|
+
|
|
749
|
+
const orgFromFlag = String(args.org || "").trim();
|
|
750
|
+
const projectFromFlag = String(args.project || "").trim();
|
|
751
|
+
const profileFromFlag = String(args.profile || args["auth-profile"] || "").trim();
|
|
752
|
+
|
|
753
|
+
let orgId = orgFromFlag;
|
|
754
|
+
if (!orgId) {
|
|
755
|
+
orgId = configStringValue(projectCfg, "default_org_id", configPath);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
let projectId = projectFromFlag;
|
|
759
|
+
if (!projectId) {
|
|
760
|
+
projectId = configStringValue(projectCfg, "default_project_id", configPath);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
let profile = profileFromFlag;
|
|
764
|
+
if (!profile) {
|
|
765
|
+
profile = configStringValue(projectCfg, "default_auth_profile", configPath);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (!orgId) {
|
|
769
|
+
throw new Error(
|
|
770
|
+
missingExportCiField("org ID", {
|
|
771
|
+
flag: "--org",
|
|
772
|
+
configKey: "default_org_id",
|
|
773
|
+
configPath,
|
|
774
|
+
hasFlag: Boolean(orgFromFlag),
|
|
775
|
+
})
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
if (!projectId) {
|
|
779
|
+
throw new Error(
|
|
780
|
+
missingExportCiField("project ID", {
|
|
781
|
+
flag: "--project",
|
|
782
|
+
configKey: "default_project_id",
|
|
783
|
+
configPath,
|
|
784
|
+
hasFlag: Boolean(projectFromFlag),
|
|
785
|
+
})
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
if (!profile) {
|
|
789
|
+
throw new Error(
|
|
790
|
+
missingExportCiField("auth profile", {
|
|
791
|
+
flag: "--profile",
|
|
792
|
+
configKey: "default_auth_profile",
|
|
793
|
+
configPath,
|
|
794
|
+
hasFlag: Boolean(profileFromFlag),
|
|
795
|
+
})
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
let apiUrl;
|
|
800
|
+
try {
|
|
801
|
+
apiUrl = resolveApiUrl(args);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
throw new Error(`Invalid API URL: ${err.message}`);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
return { orgId, projectId, profile, apiUrl, configPath };
|
|
807
|
+
}
|
|
808
|
+
|
|
539
809
|
function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
|
|
540
810
|
const raw = readFileSync(statePath);
|
|
541
811
|
if (gzip) {
|
|
@@ -552,74 +822,81 @@ function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
|
|
|
552
822
|
};
|
|
553
823
|
}
|
|
554
824
|
|
|
555
|
-
function githubAuthRestoreShell({ orgId, projectId, profile
|
|
556
|
-
const orgSeg = safePathSegment(orgId, "default-org");
|
|
557
|
-
const projectSeg = safePathSegment(projectId, "default-project");
|
|
558
|
-
const profileSeg = safePathSegment(profile, "default");
|
|
559
|
-
const decodeCmd =
|
|
560
|
-
encoding === "gzip+base64"
|
|
561
|
-
? 'echo "$QUALTY_AUTH_STATE_B64" | base64 -d | gunzip'
|
|
562
|
-
: 'echo "$QUALTY_AUTH_STATE_B64" | base64 -d';
|
|
825
|
+
function githubAuthRestoreShell({ orgId, projectId, profile }) {
|
|
563
826
|
return [
|
|
564
827
|
`- name: Restore Qualty login state`,
|
|
565
828
|
` env:`,
|
|
566
829
|
` QUALTY_AUTH_STATE_B64: \${{ secrets.QUALTY_AUTH_STATE_B64 }}`,
|
|
567
830
|
` run: |`,
|
|
568
|
-
`
|
|
569
|
-
`
|
|
570
|
-
`
|
|
571
|
-
`
|
|
831
|
+
` set -euo pipefail`,
|
|
832
|
+
` slug_segment() {`,
|
|
833
|
+
` printf '%s' "$1" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g; s/[^a-zA-Z0-9._-]+/_/g; s/^_+|_+$//g'`,
|
|
834
|
+
` }`,
|
|
835
|
+
` org_seg=$(slug_segment "$QUALTY_ORG_ID"); [ -n "$org_seg" ] || org_seg="default-org"`,
|
|
836
|
+
` project_seg=$(slug_segment "$QUALTY_PROJECT_ID"); [ -n "$project_seg" ] || project_seg="default-project"`,
|
|
837
|
+
` profile_seg=$(slug_segment "$QUALTY_AUTH_PROFILE"); [ -n "$profile_seg" ] || profile_seg="default"`,
|
|
838
|
+
` auth_dir="$HOME/.qualty/local-contexts/\${org_seg}/\${project_seg}"`,
|
|
839
|
+
` auth_file="\${auth_dir}/\${profile_seg}.json"`,
|
|
840
|
+
` mkdir -p "$auth_dir"`,
|
|
841
|
+
` echo "$QUALTY_AUTH_STATE_B64" | base64 -d | gunzip > "$auth_file"`,
|
|
842
|
+
` test -s "$auth_file"`,
|
|
572
843
|
``,
|
|
573
|
-
`- name: Run Qualty (local)`,
|
|
844
|
+
`- name: Run Qualty tests (local)`,
|
|
574
845
|
` env:`,
|
|
575
846
|
` QUALTY_API_TOKEN: \${{ secrets.QUALTY_API_TOKEN }}`,
|
|
576
|
-
`
|
|
847
|
+
` QUALTY_BASE_URL: \${{ secrets.QUALTY_BASE_URL }}`,
|
|
577
848
|
` run: |`,
|
|
578
|
-
` npx qualty
|
|
579
|
-
` --
|
|
580
|
-
` --
|
|
581
|
-
` --
|
|
849
|
+
` npx --yes qualty run --local \\`,
|
|
850
|
+
` --org "$QUALTY_ORG_ID" \\`,
|
|
851
|
+
` --project "$QUALTY_PROJECT_ID" \\`,
|
|
852
|
+
` --suite-id "YOUR-SUITE-UUID" \\`,
|
|
853
|
+
` --auth-profile "$QUALTY_AUTH_PROFILE" \\`,
|
|
582
854
|
` --fail-on-failure true`,
|
|
583
855
|
].join("\n");
|
|
584
856
|
}
|
|
585
857
|
|
|
586
858
|
async function runAuthExportCi(args) {
|
|
587
|
-
const
|
|
588
|
-
const
|
|
589
|
-
const token = resolveToken(args, config);
|
|
590
|
-
const projectCfg = readProjectConfig();
|
|
591
|
-
const projectId = String(args.project || projectCfg.local_auth_project_id || "").trim();
|
|
592
|
-
const profile = String(args.profile || args["auth-profile"] || projectCfg.local_auth_profile || "").trim();
|
|
593
|
-
|
|
594
|
-
if (!orgId) {
|
|
595
|
-
throw new Error("Missing org. Pass --org, set QUALTY_ORG_ID, or add default_org_id to .qualty.json.");
|
|
596
|
-
}
|
|
597
|
-
if (!projectId) {
|
|
598
|
-
throw new Error("Missing project. Pass --project (or run qualty auth setup first).");
|
|
599
|
-
}
|
|
600
|
-
if (!profile) {
|
|
601
|
-
throw new Error('Missing profile. Pass --profile "your-profile" (or run qualty auth setup first).');
|
|
602
|
-
}
|
|
859
|
+
const { orgId, projectId, profile, apiUrl, configPath } = resolveExportCiConfig(args);
|
|
860
|
+
const token = resolveToken(args);
|
|
603
861
|
|
|
604
862
|
const statePath = localAuthStatePath({ orgId, projectId, profile });
|
|
605
863
|
if (!existsSync(statePath)) {
|
|
606
864
|
throw new Error(
|
|
607
865
|
[
|
|
608
|
-
|
|
866
|
+
"Local auth state not found:",
|
|
609
867
|
` ${statePath}`,
|
|
610
868
|
"",
|
|
611
869
|
"Create it first:",
|
|
612
870
|
` qualty auth setup --project ${projectId} --profile "${profile}"`,
|
|
613
|
-
|
|
871
|
+
configPath ? ` (org/project/profile from ${configPath})` : "",
|
|
872
|
+
]
|
|
873
|
+
.filter(Boolean)
|
|
874
|
+
.join("\n")
|
|
614
875
|
);
|
|
615
876
|
}
|
|
616
877
|
|
|
617
|
-
|
|
618
|
-
|
|
878
|
+
const setGithubFlag =
|
|
879
|
+
parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
|
|
880
|
+
// CI path: one format only — gzip then base64 in QUALTY_AUTH_STATE_B64 (no separate gzip flag).
|
|
881
|
+
let gzip = parseBoolean(args.gzip, false) || setGithubFlag;
|
|
882
|
+
let encoded;
|
|
883
|
+
try {
|
|
884
|
+
encoded = encodeAuthStateForGithubSecret(statePath, { gzip });
|
|
885
|
+
} catch (err) {
|
|
886
|
+
throw new Error(
|
|
887
|
+
`Could not read auth state at ${statePath}: ${err.message}`
|
|
888
|
+
);
|
|
889
|
+
}
|
|
619
890
|
let secretBytes = Buffer.byteLength(encoded.payload, "utf8");
|
|
620
891
|
if (!gzip && secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
|
|
621
892
|
gzip = true;
|
|
622
|
-
|
|
893
|
+
try {
|
|
894
|
+
encoded = encodeAuthStateForGithubSecret(statePath, { gzip: true });
|
|
895
|
+
} catch (err) {
|
|
896
|
+
throw new Error(
|
|
897
|
+
`Could not gzip auth state at ${statePath}: ${err.message}`
|
|
898
|
+
);
|
|
899
|
+
}
|
|
623
900
|
secretBytes = Buffer.byteLength(encoded.payload, "utf8");
|
|
624
901
|
}
|
|
625
902
|
if (secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
|
|
@@ -634,7 +911,11 @@ async function runAuthExportCi(args) {
|
|
|
634
911
|
? String(args.output || "").trim()
|
|
635
912
|
: String(args.output || "").trim() || authExportCiOutputPath(statePath);
|
|
636
913
|
const copyToClipboardFlag = parseBoolean(args.copy, false);
|
|
637
|
-
const
|
|
914
|
+
const githubRepo = String(args.repo || "").trim();
|
|
915
|
+
const suiteId = String(args["suite-id"] || args.suite || "").trim();
|
|
916
|
+
const cliRepo = String(args["cli-repo"] || "").trim();
|
|
917
|
+
const cliRef = String(args["cli-ref"] || "").trim();
|
|
918
|
+
|
|
638
919
|
if (outputPath) {
|
|
639
920
|
writeFileSync(outputPath, encoded.payload, "utf8");
|
|
640
921
|
try {
|
|
@@ -646,12 +927,12 @@ async function runAuthExportCi(args) {
|
|
|
646
927
|
|
|
647
928
|
const includeToken = parseBoolean(args["include-token"], false);
|
|
648
929
|
const profileSeg = safePathSegment(profile, "default");
|
|
649
|
-
const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag ||
|
|
930
|
+
const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setGithubFlag);
|
|
650
931
|
|
|
651
932
|
// eslint-disable-next-line no-console
|
|
652
933
|
console.log("");
|
|
653
934
|
// eslint-disable-next-line no-console
|
|
654
|
-
console.log("=== Qualty → GitHub Actions secrets ===");
|
|
935
|
+
console.log("=== Qualty → GitHub Actions (secrets & variables) ===");
|
|
655
936
|
// eslint-disable-next-line no-console
|
|
656
937
|
console.log("");
|
|
657
938
|
if (encoded.encoding === "gzip+base64") {
|
|
@@ -671,21 +952,27 @@ async function runAuthExportCi(args) {
|
|
|
671
952
|
// eslint-disable-next-line no-console
|
|
672
953
|
console.log("");
|
|
673
954
|
}
|
|
674
|
-
if (
|
|
675
|
-
const
|
|
676
|
-
|
|
955
|
+
if (setGithubFlag) {
|
|
956
|
+
const syncResults = syncGithubCiConfig({
|
|
957
|
+
repo: githubRepo,
|
|
958
|
+
orgId,
|
|
959
|
+
projectId,
|
|
960
|
+
profile,
|
|
961
|
+
authPayload: encoded.payload,
|
|
962
|
+
apiUrl,
|
|
963
|
+
token,
|
|
964
|
+
suiteId,
|
|
965
|
+
cliRepo,
|
|
966
|
+
cliRef,
|
|
967
|
+
});
|
|
968
|
+
printGithubSyncResults(syncResults, { repo: githubRepo });
|
|
969
|
+
const authRow = syncResults.find((r) => r.name === "QUALTY_AUTH_STATE_B64");
|
|
970
|
+
if (!authRow?.ok && outputPath) {
|
|
677
971
|
// eslint-disable-next-line no-console
|
|
678
|
-
console.log(
|
|
679
|
-
} else {
|
|
972
|
+
console.log(` Manual auth secret: gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
|
|
680
973
|
// eslint-disable-next-line no-console
|
|
681
|
-
console.log(
|
|
682
|
-
if (outputPath) {
|
|
683
|
-
// eslint-disable-next-line no-console
|
|
684
|
-
console.log(` gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
|
|
685
|
-
}
|
|
974
|
+
console.log("");
|
|
686
975
|
}
|
|
687
|
-
// eslint-disable-next-line no-console
|
|
688
|
-
console.log("");
|
|
689
976
|
}
|
|
690
977
|
if (copyToClipboardFlag) {
|
|
691
978
|
if (copyToClipboard(encoded.payload)) {
|
|
@@ -702,61 +989,124 @@ async function runAuthExportCi(args) {
|
|
|
702
989
|
// eslint-disable-next-line no-console
|
|
703
990
|
console.log("");
|
|
704
991
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
if (includeToken && token) {
|
|
992
|
+
if (setGithubFlag) {
|
|
993
|
+
// eslint-disable-next-line no-console
|
|
994
|
+
console.log(
|
|
995
|
+
"1. GitHub Actions config was pushed via gh (secrets + repository variables — see sync above). Fix any ✗ manually."
|
|
996
|
+
);
|
|
997
|
+
if (!suiteId) {
|
|
998
|
+
// eslint-disable-next-line no-console
|
|
999
|
+
console.log(
|
|
1000
|
+
" Tip: hardcode --suite-id or --ids in your workflow (see templates/github-actions/qualty-local-pr.yml)."
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
// eslint-disable-next-line no-console
|
|
1004
|
+
console.log("");
|
|
1005
|
+
} else {
|
|
1006
|
+
// eslint-disable-next-line no-console
|
|
1007
|
+
console.log(
|
|
1008
|
+
"1. Add repository variables (or re-run with --set-github from this repo's git root):"
|
|
1009
|
+
);
|
|
724
1010
|
// eslint-disable-next-line no-console
|
|
725
|
-
console.log(
|
|
726
|
-
} else if (token) {
|
|
1011
|
+
console.log("");
|
|
727
1012
|
// eslint-disable-next-line no-console
|
|
728
|
-
console.log(
|
|
1013
|
+
console.log("---");
|
|
729
1014
|
// eslint-disable-next-line no-console
|
|
730
|
-
console.log("
|
|
1015
|
+
console.log("Name: QUALTY_ORG_ID");
|
|
731
1016
|
// eslint-disable-next-line no-console
|
|
732
|
-
console.log("
|
|
733
|
-
|
|
1017
|
+
console.log("Value:");
|
|
1018
|
+
// eslint-disable-next-line no-console
|
|
1019
|
+
console.log(orgId);
|
|
1020
|
+
// eslint-disable-next-line no-console
|
|
1021
|
+
console.log(` gh variable set QUALTY_ORG_ID --body "${orgId}"`);
|
|
1022
|
+
// eslint-disable-next-line no-console
|
|
1023
|
+
console.log("---");
|
|
1024
|
+
// eslint-disable-next-line no-console
|
|
1025
|
+
console.log("Name: QUALTY_PROJECT_ID");
|
|
1026
|
+
// eslint-disable-next-line no-console
|
|
1027
|
+
console.log("Value:");
|
|
1028
|
+
// eslint-disable-next-line no-console
|
|
1029
|
+
console.log(projectId);
|
|
1030
|
+
// eslint-disable-next-line no-console
|
|
1031
|
+
console.log(` gh variable set QUALTY_PROJECT_ID --body "${projectId}"`);
|
|
1032
|
+
// eslint-disable-next-line no-console
|
|
1033
|
+
console.log("---");
|
|
734
1034
|
// eslint-disable-next-line no-console
|
|
735
|
-
console.log("
|
|
1035
|
+
console.log("Name: QUALTY_AUTH_PROFILE");
|
|
1036
|
+
// eslint-disable-next-line no-console
|
|
1037
|
+
console.log("Value:");
|
|
1038
|
+
// eslint-disable-next-line no-console
|
|
1039
|
+
console.log(profile);
|
|
1040
|
+
// eslint-disable-next-line no-console
|
|
1041
|
+
console.log(` gh variable set QUALTY_AUTH_PROFILE --body "${profile}"`);
|
|
1042
|
+
// eslint-disable-next-line no-console
|
|
1043
|
+
console.log("---");
|
|
1044
|
+
// eslint-disable-next-line no-console
|
|
1045
|
+
console.log("Name: QUALTY_SUITE_ID");
|
|
1046
|
+
// eslint-disable-next-line no-console
|
|
1047
|
+
console.log("Value:");
|
|
1048
|
+
// eslint-disable-next-line no-console
|
|
1049
|
+
console.log(suiteId || "(suite UUID from Qualty dashboard — pass --suite-id)");
|
|
1050
|
+
if (suiteId) {
|
|
1051
|
+
// eslint-disable-next-line no-console
|
|
1052
|
+
console.log(` gh variable set QUALTY_SUITE_ID --body "${suiteId}"`);
|
|
1053
|
+
}
|
|
1054
|
+
// eslint-disable-next-line no-console
|
|
1055
|
+
console.log("");
|
|
1056
|
+
// eslint-disable-next-line no-console
|
|
1057
|
+
console.log("2. Add these GitHub secrets:");
|
|
736
1058
|
}
|
|
737
|
-
|
|
738
|
-
console.log("---");
|
|
739
|
-
// eslint-disable-next-line no-console
|
|
740
|
-
console.log("Name: QUALTY_AUTH_STATE_B64");
|
|
741
|
-
if (skipPayloadPrint) {
|
|
1059
|
+
if (!setGithubFlag) {
|
|
742
1060
|
// eslint-disable-next-line no-console
|
|
743
|
-
console.log(
|
|
744
|
-
|
|
1061
|
+
console.log("");
|
|
1062
|
+
// eslint-disable-next-line no-console
|
|
1063
|
+
console.log("---");
|
|
1064
|
+
// eslint-disable-next-line no-console
|
|
1065
|
+
console.log("Name: QUALTY_API_TOKEN");
|
|
745
1066
|
// eslint-disable-next-line no-console
|
|
746
1067
|
console.log("Value:");
|
|
1068
|
+
if (includeToken && token) {
|
|
1069
|
+
// eslint-disable-next-line no-console
|
|
1070
|
+
console.log(token);
|
|
1071
|
+
} else if (token) {
|
|
1072
|
+
// eslint-disable-next-line no-console
|
|
1073
|
+
console.log(`(your qlty_ci_ token — already configured locally as ${maskSecret(token)})`);
|
|
1074
|
+
// eslint-disable-next-line no-console
|
|
1075
|
+
console.log("Paste the full token from Qualty → Account → API tokens.");
|
|
1076
|
+
// eslint-disable-next-line no-console
|
|
1077
|
+
console.log("Re-run with --set-github to push via gh (or --include-token to print).");
|
|
1078
|
+
} else {
|
|
1079
|
+
// eslint-disable-next-line no-console
|
|
1080
|
+
console.log("(create in Qualty → Account → API tokens; must start with qlty_ci_)");
|
|
1081
|
+
}
|
|
1082
|
+
// eslint-disable-next-line no-console
|
|
1083
|
+
console.log("---");
|
|
747
1084
|
// eslint-disable-next-line no-console
|
|
748
|
-
console.log(
|
|
1085
|
+
console.log("Name: QUALTY_AUTH_STATE_B64");
|
|
1086
|
+
if (skipPayloadPrint) {
|
|
1087
|
+
// eslint-disable-next-line no-console
|
|
1088
|
+
console.log(
|
|
1089
|
+
`Value: (see file above${copyToClipboardFlag ? " or clipboard" : ""})`
|
|
1090
|
+
);
|
|
1091
|
+
} else {
|
|
1092
|
+
// eslint-disable-next-line no-console
|
|
1093
|
+
console.log("Value:");
|
|
1094
|
+
// eslint-disable-next-line no-console
|
|
1095
|
+
console.log(encoded.payload);
|
|
1096
|
+
}
|
|
1097
|
+
// eslint-disable-next-line no-console
|
|
1098
|
+
console.log("---");
|
|
1099
|
+
// eslint-disable-next-line no-console
|
|
1100
|
+
console.log("");
|
|
749
1101
|
}
|
|
1102
|
+
|
|
1103
|
+
const stepLabel = setGithubFlag ? "2" : "3";
|
|
750
1104
|
// eslint-disable-next-line no-console
|
|
751
|
-
console.log(
|
|
752
|
-
// eslint-disable-next-line no-console
|
|
753
|
-
console.log("");
|
|
754
|
-
// eslint-disable-next-line no-console
|
|
755
|
-
console.log("3. Paste this workflow snippet before qualty run:");
|
|
1105
|
+
console.log(`${stepLabel}. Workflow snippet (see templates/github-actions/qualty-local-pr.yml):`);
|
|
756
1106
|
// eslint-disable-next-line no-console
|
|
757
1107
|
console.log("");
|
|
758
1108
|
// eslint-disable-next-line no-console
|
|
759
|
-
console.log(githubAuthRestoreShell({ orgId, projectId, profile
|
|
1109
|
+
console.log(githubAuthRestoreShell({ orgId, projectId, profile }));
|
|
760
1110
|
// eslint-disable-next-line no-console
|
|
761
1111
|
console.log("");
|
|
762
1112
|
// eslint-disable-next-line no-console
|
|
@@ -1113,10 +1463,9 @@ function printQualtyViewLogsReport(executionId, status) {
|
|
|
1113
1463
|
}
|
|
1114
1464
|
|
|
1115
1465
|
async function runCi(args) {
|
|
1116
|
-
const
|
|
1117
|
-
const
|
|
1118
|
-
const
|
|
1119
|
-
const orgId = resolveOrgId(args, config);
|
|
1466
|
+
const apiUrl = resolveApiUrl(args);
|
|
1467
|
+
const token = resolveToken(args);
|
|
1468
|
+
const orgId = resolveOrgId(args);
|
|
1120
1469
|
const projectId = args.project;
|
|
1121
1470
|
const suiteId = args["suite-id"];
|
|
1122
1471
|
const explicitIds = String(args.ids || "")
|
|
@@ -1460,10 +1809,9 @@ async function runLocalSuiteWithSequences({
|
|
|
1460
1809
|
}
|
|
1461
1810
|
|
|
1462
1811
|
async function runResolve(args) {
|
|
1463
|
-
const
|
|
1464
|
-
const
|
|
1465
|
-
const
|
|
1466
|
-
const orgId = resolveOrgId(args, config);
|
|
1812
|
+
const apiUrl = resolveApiUrl(args);
|
|
1813
|
+
const token = resolveToken(args);
|
|
1814
|
+
const orgId = resolveOrgId(args);
|
|
1467
1815
|
const projectId = args.project;
|
|
1468
1816
|
const suiteId = args["suite-id"];
|
|
1469
1817
|
const explicitIds = String(args.ids || "")
|
|
@@ -1626,8 +1974,8 @@ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
|
|
|
1626
1974
|
|
|
1627
1975
|
function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
|
|
1628
1976
|
if (parseBoolean(args["no-auth-state"], false)) return null;
|
|
1629
|
-
const
|
|
1630
|
-
const profile =
|
|
1977
|
+
const projectCfg = readProjectConfig();
|
|
1978
|
+
const profile = authProfileFromConfig(projectCfg, args["auth-profile"]);
|
|
1631
1979
|
if (!profile) return null;
|
|
1632
1980
|
const path = localAuthStatePath({ orgId, projectId, profile });
|
|
1633
1981
|
const summary = summarizeStorageStateFile(path);
|
|
@@ -1640,17 +1988,21 @@ function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
|
|
|
1640
1988
|
}
|
|
1641
1989
|
|
|
1642
1990
|
async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
|
|
1643
|
-
const
|
|
1991
|
+
const projectCfg = readProjectConfig();
|
|
1992
|
+
const requestedProfile = authProfileFromConfig(projectCfg, args["auth-profile"]);
|
|
1644
1993
|
if (requestedProfile) return { authProfile: requestedProfile, noAuthState: false };
|
|
1645
1994
|
if (parseBoolean(args["no-auth-state"], false)) return { authProfile: "", noAuthState: true };
|
|
1646
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
1995
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1996
|
+
const defaultProfile = authProfileFromConfig(projectCfg);
|
|
1997
|
+
if (defaultProfile) return { authProfile: defaultProfile, noAuthState: false };
|
|
1998
|
+
return { authProfile: "", noAuthState: false };
|
|
1999
|
+
}
|
|
1647
2000
|
if (!projectId) return { authProfile: "", noAuthState: false };
|
|
1648
2001
|
|
|
1649
2002
|
const profiles = listLocalAuthProfiles({ orgId, projectId });
|
|
1650
2003
|
if (profiles.length === 0) return { authProfile: "", noAuthState: false };
|
|
1651
2004
|
|
|
1652
|
-
const
|
|
1653
|
-
const defaultProfile = String(projectCfg.local_auth_profile || "").trim();
|
|
2005
|
+
const defaultProfile = authProfileFromConfig(projectCfg);
|
|
1654
2006
|
const defaultIndex = profiles.findIndex((p) => p === defaultProfile);
|
|
1655
2007
|
|
|
1656
2008
|
// eslint-disable-next-line no-console
|
|
@@ -1692,10 +2044,9 @@ async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
|
|
|
1692
2044
|
}
|
|
1693
2045
|
|
|
1694
2046
|
async function runAuthSetup(args) {
|
|
1695
|
-
const
|
|
1696
|
-
const
|
|
1697
|
-
const
|
|
1698
|
-
const orgId = resolveOrgId(args, config);
|
|
2047
|
+
const apiUrl = resolveApiUrl(args);
|
|
2048
|
+
const token = resolveToken(args);
|
|
2049
|
+
const orgId = resolveOrgId(args);
|
|
1699
2050
|
if (!token) {
|
|
1700
2051
|
throw new Error("Missing auth token. Run `qualty setup` first.");
|
|
1701
2052
|
}
|
|
@@ -1849,9 +2200,9 @@ async function runAuthSetup(args) {
|
|
|
1849
2200
|
await browser.close();
|
|
1850
2201
|
|
|
1851
2202
|
writeProjectConfig({
|
|
1852
|
-
local_auth_project_id: project.id,
|
|
1853
|
-
local_auth_profile: profile,
|
|
1854
2203
|
default_org_id: orgId,
|
|
2204
|
+
default_project_id: project.id,
|
|
2205
|
+
default_auth_profile: profile,
|
|
1855
2206
|
});
|
|
1856
2207
|
try {
|
|
1857
2208
|
const synced = await registerLocalLoginProfileBinding({
|
|
@@ -1914,11 +2265,10 @@ async function main() {
|
|
|
1914
2265
|
throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth export-ci");
|
|
1915
2266
|
}
|
|
1916
2267
|
if (command === "run") {
|
|
1917
|
-
const config = readQualtyConfig();
|
|
1918
2268
|
if (parseBoolean(args.local, false)) {
|
|
1919
|
-
const apiUrl = resolveApiUrl(args
|
|
1920
|
-
const token = resolveToken(args
|
|
1921
|
-
const orgId = resolveOrgId(args
|
|
2269
|
+
const apiUrl = resolveApiUrl(args);
|
|
2270
|
+
const token = resolveToken(args);
|
|
2271
|
+
const orgId = resolveOrgId(args);
|
|
1922
2272
|
const projectId =
|
|
1923
2273
|
(await maybePromptProjectIdForLocalRun({
|
|
1924
2274
|
args,
|
package/bin/test-pw.mjs
ADDED