qualty 0.1.18 → 0.1.21
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 +333 -14
- package/bin/qualty.js +340 -151
- package/bin/test-pw.mjs +4 -0
- package/package.json +1 -1
package/bin/local-runner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, mkdtempSync, existsSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, writeFileSync, mkdtempSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
@@ -78,6 +78,205 @@ 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
|
+
|
|
100
|
+
export function summarizeStorageStateFile(statePath) {
|
|
101
|
+
const path = String(statePath || "").trim();
|
|
102
|
+
if (!path || !existsSync(path)) {
|
|
103
|
+
return { exists: false, path };
|
|
104
|
+
}
|
|
105
|
+
const stat = statSync(path);
|
|
106
|
+
let parsed = null;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return {
|
|
111
|
+
exists: true,
|
|
112
|
+
path,
|
|
113
|
+
bytes: stat.size,
|
|
114
|
+
parseError: String(err?.message || err),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const cookies = Array.isArray(parsed.cookies) ? parsed.cookies : [];
|
|
118
|
+
const origins = Array.isArray(parsed.origins) ? parsed.origins : [];
|
|
119
|
+
return {
|
|
120
|
+
exists: true,
|
|
121
|
+
path,
|
|
122
|
+
bytes: stat.size,
|
|
123
|
+
cookieCount: cookies.length,
|
|
124
|
+
cookieDomains: [...new Set(cookies.map((c) => c.domain || c.url || "?"))],
|
|
125
|
+
storageOrigins: origins.map((entry) => ({
|
|
126
|
+
origin: entry.origin || "?",
|
|
127
|
+
localStorageKeys: Array.isArray(entry.localStorage)
|
|
128
|
+
? entry.localStorage.map((item) => String(item?.name || "").trim()).filter(Boolean)
|
|
129
|
+
: [],
|
|
130
|
+
})),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function hostFromUrl(rawUrl) {
|
|
135
|
+
try {
|
|
136
|
+
return new URL(String(rawUrl || "").trim()).host;
|
|
137
|
+
} catch {
|
|
138
|
+
return "";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function logAuthStateDiagnostics(prefix, summary, details = {}) {
|
|
143
|
+
const tag = prefix || "[qualty][auth]";
|
|
144
|
+
const {
|
|
145
|
+
profile = "",
|
|
146
|
+
source = "",
|
|
147
|
+
claimProfileName = "",
|
|
148
|
+
claimLocalProfileName = "",
|
|
149
|
+
testUrl = "",
|
|
150
|
+
noAuthState = false,
|
|
151
|
+
} = details;
|
|
152
|
+
|
|
153
|
+
if (noAuthState) {
|
|
154
|
+
// eslint-disable-next-line no-console
|
|
155
|
+
console.log(`${tag} skipped (--no-auth-state)`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (source) {
|
|
160
|
+
// eslint-disable-next-line no-console
|
|
161
|
+
console.log(`${tag} resolution: ${source}`);
|
|
162
|
+
}
|
|
163
|
+
if (profile) {
|
|
164
|
+
// eslint-disable-next-line no-console
|
|
165
|
+
console.log(`${tag} profile: "${profile}"`);
|
|
166
|
+
}
|
|
167
|
+
if (claimProfileName) {
|
|
168
|
+
// eslint-disable-next-line no-console
|
|
169
|
+
console.log(`${tag} dashboard saved login: "${claimProfileName}"`);
|
|
170
|
+
}
|
|
171
|
+
if (claimLocalProfileName && claimLocalProfileName !== profile) {
|
|
172
|
+
// eslint-disable-next-line no-console
|
|
173
|
+
console.log(`${tag} dashboard local_profile_name: "${claimLocalProfileName}"`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!summary?.path) {
|
|
177
|
+
// eslint-disable-next-line no-console
|
|
178
|
+
console.log(`${tag} no auth state path resolved`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!summary.exists) {
|
|
183
|
+
// eslint-disable-next-line no-console
|
|
184
|
+
console.log(`${tag} auth state file missing: ${summary.path}`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (summary.parseError) {
|
|
189
|
+
// eslint-disable-next-line no-console
|
|
190
|
+
console.log(
|
|
191
|
+
`${tag} auth state file is not valid JSON (${summary.bytes} bytes): ${summary.parseError}`
|
|
192
|
+
);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// eslint-disable-next-line no-console
|
|
197
|
+
console.log(`${tag} auth state file found (${summary.bytes} bytes): ${summary.path}`);
|
|
198
|
+
// eslint-disable-next-line no-console
|
|
199
|
+
console.log(
|
|
200
|
+
`${tag} contents: ${summary.cookieCount} cookie(s)` +
|
|
201
|
+
(summary.cookieDomains.length ? ` [${summary.cookieDomains.join(", ")}]` : "")
|
|
202
|
+
);
|
|
203
|
+
for (const originEntry of summary.storageOrigins || []) {
|
|
204
|
+
const keys = originEntry.localStorageKeys.length
|
|
205
|
+
? originEntry.localStorageKeys.join(", ")
|
|
206
|
+
: "none";
|
|
207
|
+
// eslint-disable-next-line no-console
|
|
208
|
+
console.log(`${tag} origin ${originEntry.origin}: localStorage keys [${keys}]`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (testUrl) {
|
|
212
|
+
const testHost = hostFromUrl(testUrl);
|
|
213
|
+
const storageHosts = new Set(
|
|
214
|
+
(summary.storageOrigins || [])
|
|
215
|
+
.map((entry) => hostFromUrl(entry.origin))
|
|
216
|
+
.filter(Boolean)
|
|
217
|
+
);
|
|
218
|
+
for (const cookieDomain of summary.cookieDomains || []) {
|
|
219
|
+
if (cookieDomain && cookieDomain !== "?") storageHosts.add(String(cookieDomain).replace(/^\./, ""));
|
|
220
|
+
}
|
|
221
|
+
// eslint-disable-next-line no-console
|
|
222
|
+
console.log(`${tag} test URL: ${testUrl}`);
|
|
223
|
+
if (testHost && storageHosts.size > 0 && !storageHosts.has(testHost)) {
|
|
224
|
+
// eslint-disable-next-line no-console
|
|
225
|
+
console.warn(
|
|
226
|
+
`${tag} warning: test host "${testHost}" does not match saved auth origins/cookies (${[...storageHosts].join(", ")}). localStorage will not apply until origins match.`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const emptyStorage =
|
|
232
|
+
summary.cookieCount === 0 &&
|
|
233
|
+
(summary.storageOrigins || []).every((entry) => entry.localStorageKeys.length === 0);
|
|
234
|
+
if (emptyStorage) {
|
|
235
|
+
// eslint-disable-next-line no-console
|
|
236
|
+
console.warn(`${tag} warning: auth state file appears empty (no cookies or localStorage)`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function logAuthStateAfterInjection(tag, context, { testUrl = "" } = {}) {
|
|
241
|
+
try {
|
|
242
|
+
const cookies = await context.cookies();
|
|
243
|
+
// eslint-disable-next-line no-console
|
|
244
|
+
console.log(`${tag} injected into browser context: ${cookies.length} cookie(s) loaded`);
|
|
245
|
+
if (testUrl) {
|
|
246
|
+
// eslint-disable-next-line no-console
|
|
247
|
+
console.log(`${tag} localStorage applies after navigation to a matching origin (${testUrl})`);
|
|
248
|
+
}
|
|
249
|
+
} catch (err) {
|
|
250
|
+
// eslint-disable-next-line no-console
|
|
251
|
+
console.warn(`${tag} could not verify injected auth state: ${err?.message || err}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
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
|
+
|
|
81
280
|
const localBindingSetupPromises = new Map();
|
|
82
281
|
|
|
83
282
|
async function promptYesNo(question, defaultYes = true) {
|
|
@@ -185,11 +384,21 @@ async function maybeSetupMissingLocalBinding({
|
|
|
185
384
|
? claim.saved_login_profile
|
|
186
385
|
: null;
|
|
187
386
|
if (!profile || !profile.name) return null;
|
|
188
|
-
if (profile.has_local_binding) return null;
|
|
189
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
|
|
190
|
-
|
|
191
387
|
const profileName = String(profile.name || "").trim();
|
|
192
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
|
+
|
|
193
402
|
const key = `${String(orgId || "")}:${String(claim?.project_id || "")}:${profileName}`;
|
|
194
403
|
if (localBindingSetupPromises.has(key)) {
|
|
195
404
|
return localBindingSetupPromises.get(key);
|
|
@@ -243,20 +452,21 @@ async function preflightMissingLocalBindings({
|
|
|
243
452
|
}) {
|
|
244
453
|
if (noAuthState) return;
|
|
245
454
|
if (explicitAuthProfile) return;
|
|
246
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
247
455
|
const missingByProfile = new Map();
|
|
248
456
|
for (const job of savedJobs || []) {
|
|
249
|
-
|
|
457
|
+
if (!jobUsesSavedLogin(job)) continue;
|
|
250
458
|
const profileName = String(job?.saved_login_profile?.name || "").trim();
|
|
251
|
-
if (!
|
|
459
|
+
if (!profileName) continue;
|
|
460
|
+
const localProfileName =
|
|
461
|
+
String(job?.saved_login_profile?.local_profile_name || "").trim() || profileName;
|
|
252
462
|
const statePath = localAuthStatePath({
|
|
253
463
|
orgId,
|
|
254
464
|
projectId: String(job?.project_id || projectId || "").trim(),
|
|
255
|
-
profile:
|
|
465
|
+
profile: localProfileName,
|
|
256
466
|
});
|
|
257
467
|
if (existsSync(statePath)) continue;
|
|
258
468
|
const entry = missingByProfile.get(profileName) || {
|
|
259
|
-
localProfileName
|
|
469
|
+
localProfileName,
|
|
260
470
|
projectId: String(job?.project_id || projectId || "").trim(),
|
|
261
471
|
startUrl: rewriteUrlForLocalRun(String(job?.url || "").trim(), localPort),
|
|
262
472
|
tests: [],
|
|
@@ -272,6 +482,13 @@ async function preflightMissingLocalBindings({
|
|
|
272
482
|
}
|
|
273
483
|
if (missingByProfile.size === 0) return;
|
|
274
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
|
+
|
|
275
492
|
// eslint-disable-next-line no-console
|
|
276
493
|
console.log("[qualty][auth] Missing local login state for attached dashboard profiles:");
|
|
277
494
|
for (const [profileName, entry] of missingByProfile.entries()) {
|
|
@@ -1073,6 +1290,51 @@ export async function runLocalExecution({
|
|
|
1073
1290
|
authProfile,
|
|
1074
1291
|
noAuthState,
|
|
1075
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,
|
|
1076
1338
|
}) {
|
|
1077
1339
|
const { chromium } = await import("playwright");
|
|
1078
1340
|
|
|
@@ -1083,7 +1345,7 @@ export async function runLocalExecution({
|
|
|
1083
1345
|
orgId,
|
|
1084
1346
|
path: `/api/v1/cli/executions/${executionId}/claim`,
|
|
1085
1347
|
method: "POST",
|
|
1086
|
-
body: { device:
|
|
1348
|
+
body: device ? { device: String(device).trim() } : {},
|
|
1087
1349
|
});
|
|
1088
1350
|
const effectiveDevice = String(claim.device || runDevice).trim() || runDevice;
|
|
1089
1351
|
|
|
@@ -1112,7 +1374,7 @@ export async function runLocalExecution({
|
|
|
1112
1374
|
explicitAuthProfile: String(authProfile || "").trim(),
|
|
1113
1375
|
noAuthState: Boolean(noAuthState),
|
|
1114
1376
|
});
|
|
1115
|
-
const claimProfile =
|
|
1377
|
+
const claimProfile = localProfileLabel(claimSavedLogin);
|
|
1116
1378
|
const effectiveProfile = String(authProfile || "").trim() || setupFromClaim?.profile || claimProfile;
|
|
1117
1379
|
const resolvedStoragePath =
|
|
1118
1380
|
!noAuthState && effectiveProfile
|
|
@@ -1131,6 +1393,47 @@ export async function runLocalExecution({
|
|
|
1131
1393
|
? resolvedStoragePath
|
|
1132
1394
|
: null;
|
|
1133
1395
|
|
|
1396
|
+
let authSource = "none";
|
|
1397
|
+
if (!noAuthState && finalStoragePath) {
|
|
1398
|
+
if (setupFromClaim?.path && finalStoragePath === setupFromClaim.path) {
|
|
1399
|
+
authSource = "interactive setup during run";
|
|
1400
|
+
} else if (storageStatePath && finalStoragePath === storageStatePath) {
|
|
1401
|
+
authSource = "--auth-profile flag";
|
|
1402
|
+
} else if (resolvedStoragePath && finalStoragePath === resolvedStoragePath) {
|
|
1403
|
+
authSource = claimProfile ? "dashboard saved login profile" : "resolved profile path";
|
|
1404
|
+
} else {
|
|
1405
|
+
authSource = "resolved profile path";
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
logAuthStateDiagnostics("[qualty][auth]", summarizeStorageStateFile(finalStoragePath), {
|
|
1410
|
+
profile: effectiveProfile || "",
|
|
1411
|
+
source: authSource,
|
|
1412
|
+
claimProfileName: String(claimSavedLogin?.name || "").trim(),
|
|
1413
|
+
claimLocalProfileName: claimProfile,
|
|
1414
|
+
testUrl: url,
|
|
1415
|
+
noAuthState: Boolean(noAuthState),
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
if (!noAuthState && effectiveProfile && !finalStoragePath) {
|
|
1419
|
+
const profileName = String(claimSavedLogin?.name || effectiveProfile).trim();
|
|
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
|
+
}
|
|
1433
|
+
// eslint-disable-next-line no-console
|
|
1434
|
+
console.warn(`[qualty][auth] ${detail}`);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1134
1437
|
const browserServer = await chromium.launchServer({
|
|
1135
1438
|
headless: headless !== false,
|
|
1136
1439
|
host: "127.0.0.1",
|
|
@@ -1142,10 +1445,14 @@ export async function runLocalExecution({
|
|
|
1142
1445
|
};
|
|
1143
1446
|
if (finalStoragePath) {
|
|
1144
1447
|
contextOptions.storageState = finalStoragePath;
|
|
1145
|
-
const logProfile = effectiveProfile || "default";
|
|
1146
|
-
console.log(`[qualty][auth] using saved context "${logProfile}": ${finalStoragePath}`);
|
|
1147
1448
|
}
|
|
1148
1449
|
const context = await browser.newContext(contextOptions);
|
|
1450
|
+
if (finalStoragePath) {
|
|
1451
|
+
await logAuthStateAfterInjection("[qualty][auth]", context, { testUrl: url });
|
|
1452
|
+
} else if (!noAuthState && (effectiveProfile || claimSavedLogin?.name)) {
|
|
1453
|
+
// eslint-disable-next-line no-console
|
|
1454
|
+
console.log("[qualty][auth] running without saved login state injection");
|
|
1455
|
+
}
|
|
1149
1456
|
const page = await context.newPage();
|
|
1150
1457
|
const networkCollector = attachNetworkCollector(page);
|
|
1151
1458
|
const attachmentSpecs = Array.isArray(claim.file_attachments) ? claim.file_attachments : [];
|
|
@@ -1189,6 +1496,9 @@ export async function runLocalExecution({
|
|
|
1189
1496
|
try {
|
|
1190
1497
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60000 });
|
|
1191
1498
|
await page.waitForTimeout(500);
|
|
1499
|
+
if (finalStoragePath) {
|
|
1500
|
+
await logAuthStateAfterNavigation("[qualty][auth]", page);
|
|
1501
|
+
}
|
|
1192
1502
|
|
|
1193
1503
|
for (let stepIndex = 0; stepIndex < totalSteps; stepIndex += 1) {
|
|
1194
1504
|
const step = steps[stepIndex] || {};
|
|
@@ -1354,7 +1664,12 @@ export async function runLocalExecution({
|
|
|
1354
1664
|
}),
|
|
1355
1665
|
});
|
|
1356
1666
|
|
|
1357
|
-
|
|
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 };
|
|
1358
1673
|
}
|
|
1359
1674
|
|
|
1360
1675
|
async function fetchProjectLocalPort({ apiRequest, apiUrl, token, orgId, projectId }) {
|
|
@@ -1414,6 +1729,10 @@ export async function runLocalCi({
|
|
|
1414
1729
|
const started = runs.filter((r) => r.execution_job_id);
|
|
1415
1730
|
if (started.length === 0) throw new Error("Failed to start any local runs.");
|
|
1416
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
|
+
|
|
1417
1736
|
await preflightMissingLocalBindings({
|
|
1418
1737
|
apiRequest,
|
|
1419
1738
|
apiUrl,
|
package/bin/qualty.js
CHANGED
|
@@ -12,40 +12,32 @@ 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
|
-
import { rewriteUrlForLocalRun, runLocalCi } from "./local-runner.js";
|
|
18
|
+
import { rewriteUrlForLocalRun, runLocalCi, summarizeStorageStateFile, logAuthStateDiagnostics } from "./local-runner.js";
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
|
|
20
|
+
/** Default Qualty API base URL (no trailing slash). Override with QUALTY_API_URL env. */
|
|
21
|
+
//const DEFAULT_QUALTY_API_URL = "https://qualty-api-production.up.railway.app";
|
|
22
|
+
const DEFAULT_QUALTY_API_URL = "http://127.0.0.1:8000";
|
|
22
23
|
|
|
23
|
-
const
|
|
24
|
-
const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
|
|
24
|
+
const PROJECT_CONFIG_FILENAME = ".qualty.json";
|
|
25
25
|
const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
|
|
26
26
|
const QUALTY_SHARED_CREDS_PATH = join(QUALTY_SHARED_CREDS_DIR, "credentials");
|
|
27
|
-
const QUALTY_PROJECT_CONFIG_PATH = join(process.cwd(), ".qualty.json");
|
|
28
27
|
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
36
|
-
|
|
37
|
-
return {};
|
|
28
|
+
function findProjectConfigPath() {
|
|
29
|
+
let current = process.cwd();
|
|
30
|
+
for (;;) {
|
|
31
|
+
const candidate = join(current, PROJECT_CONFIG_FILENAME);
|
|
32
|
+
if (existsSync(candidate)) return candidate;
|
|
33
|
+
const parent = dirname(current);
|
|
34
|
+
if (parent === current) return null;
|
|
35
|
+
current = parent;
|
|
38
36
|
}
|
|
39
37
|
}
|
|
40
38
|
|
|
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
|
-
}
|
|
39
|
+
function getProjectConfigWritePath() {
|
|
40
|
+
return findProjectConfigPath() || join(process.cwd(), PROJECT_CONFIG_FILENAME);
|
|
49
41
|
}
|
|
50
42
|
|
|
51
43
|
function readSharedCredentials() {
|
|
@@ -70,10 +62,19 @@ function writeSharedCredentials(token) {
|
|
|
70
62
|
}
|
|
71
63
|
}
|
|
72
64
|
|
|
65
|
+
function projectIdFromConfig(projectCfg, fallback = "") {
|
|
66
|
+
return String(fallback || projectCfg.default_project_id || "").trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function authProfileFromConfig(projectCfg, fallback = "") {
|
|
70
|
+
return String(fallback || projectCfg.default_auth_profile || "").trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
73
|
function readProjectConfig() {
|
|
74
|
-
|
|
74
|
+
const configPath = findProjectConfigPath();
|
|
75
|
+
if (!configPath) return {};
|
|
75
76
|
try {
|
|
76
|
-
const raw = readFileSync(
|
|
77
|
+
const raw = readFileSync(configPath, "utf8");
|
|
77
78
|
const parsed = JSON.parse(raw);
|
|
78
79
|
if (!parsed || typeof parsed !== "object") return {};
|
|
79
80
|
return parsed;
|
|
@@ -83,29 +84,29 @@ function readProjectConfig() {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
function writeProjectConfig(updates) {
|
|
87
|
+
const configPath = getProjectConfigWritePath();
|
|
86
88
|
const current = readProjectConfig();
|
|
87
89
|
const next = { ...current };
|
|
88
90
|
for (const [k, v] of Object.entries(updates || {})) {
|
|
89
91
|
if (v === undefined || v === null || v === "") delete next[k];
|
|
90
92
|
else next[k] = v;
|
|
91
93
|
}
|
|
92
|
-
writeFileSync(
|
|
94
|
+
writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
function resolveApiUrl(args
|
|
97
|
+
function resolveApiUrl(args = {}) {
|
|
96
98
|
void args;
|
|
97
|
-
|
|
98
|
-
return String(DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
|
|
99
|
+
return String(process.env.QUALTY_API_URL || DEFAULT_QUALTY_API_URL).replace(/\/$/, "");
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
function resolveToken(args
|
|
102
|
+
function resolveToken(args = {}) {
|
|
102
103
|
const creds = readSharedCredentials();
|
|
103
|
-
return args.token || process.env.QUALTY_API_TOKEN || creds.api_token ||
|
|
104
|
+
return args.token || process.env.QUALTY_API_TOKEN || creds.api_token || "";
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
function resolveOrgId(args
|
|
107
|
+
function resolveOrgId(args = {}) {
|
|
107
108
|
const projectCfg = readProjectConfig();
|
|
108
|
-
return args.org || process.env.QUALTY_ORG_ID ||
|
|
109
|
+
return args.org || process.env.QUALTY_ORG_ID || projectCfg.default_org_id || "";
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
function maskSecret(secret) {
|
|
@@ -145,7 +146,10 @@ function usage() {
|
|
|
145
146
|
" qualty setup [--token <bearer-token>] [--org <org-id>]",
|
|
146
147
|
" qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
|
|
147
148
|
" qualty auth export-ci --project <project-id> [--profile <profile-name>] [--org <org-id>]",
|
|
148
|
-
" [--
|
|
149
|
+
" [--suite-id <suite-uuid>] [--repo <owner/repo>]",
|
|
150
|
+
" [--output <path>] [--no-file] [--copy] [--gzip] [--include-token]",
|
|
151
|
+
" [--set-secret | --set-github] gh: secrets (token, API URL, auth) + variables (org, project, profile, …)",
|
|
152
|
+
" [--cli-repo <owner/repo>] [--cli-ref <branch>] Optional CI variables",
|
|
149
153
|
" qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
|
|
150
154
|
" qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
|
|
151
155
|
" [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
|
|
@@ -157,7 +161,8 @@ function usage() {
|
|
|
157
161
|
"",
|
|
158
162
|
"Env vars:",
|
|
159
163
|
" QUALTY_API_TOKEN Bearer token used for auth",
|
|
160
|
-
"
|
|
164
|
+
" QUALTY_API_URL API base URL (default: bundled CLI default)",
|
|
165
|
+
" QUALTY_ORG_ID Org context override (default: .qualty.json default_org_id)",
|
|
161
166
|
" QUALTY_LOCAL_CONCURRENCY Local parallel workers for --local runs (default: 4)",
|
|
162
167
|
].join("\n")
|
|
163
168
|
);
|
|
@@ -228,12 +233,11 @@ function startCloudflared(token, port) {
|
|
|
228
233
|
}
|
|
229
234
|
|
|
230
235
|
async function runConnect(args) {
|
|
231
|
-
const config = readQualtyConfig();
|
|
232
236
|
const projectId = args.project;
|
|
233
237
|
const port = Number(args.port || 3000);
|
|
234
|
-
const apiUrl = resolveApiUrl(args
|
|
235
|
-
const token = resolveToken(args
|
|
236
|
-
const orgId = resolveOrgId(args
|
|
238
|
+
const apiUrl = resolveApiUrl(args);
|
|
239
|
+
const token = resolveToken(args);
|
|
240
|
+
const orgId = resolveOrgId(args);
|
|
237
241
|
|
|
238
242
|
if (!projectId || !token) {
|
|
239
243
|
usage();
|
|
@@ -305,7 +309,6 @@ async function runConnect(args) {
|
|
|
305
309
|
}
|
|
306
310
|
|
|
307
311
|
async function runSetup(args) {
|
|
308
|
-
const existing = readQualtyConfig();
|
|
309
312
|
const sharedCreds = readSharedCredentials();
|
|
310
313
|
const projectCfg = readProjectConfig();
|
|
311
314
|
const rl = createInterface({
|
|
@@ -341,8 +344,10 @@ async function runSetup(args) {
|
|
|
341
344
|
};
|
|
342
345
|
|
|
343
346
|
try {
|
|
344
|
-
const currentOrgId = String(args.org ||
|
|
345
|
-
const currentToken = String(
|
|
347
|
+
const currentOrgId = String(args.org || projectCfg.default_org_id || "");
|
|
348
|
+
const currentToken = String(
|
|
349
|
+
args.token || process.env.QUALTY_API_TOKEN || sharedCreds.api_token || ""
|
|
350
|
+
);
|
|
346
351
|
|
|
347
352
|
let token = "";
|
|
348
353
|
if (args.token) {
|
|
@@ -438,20 +443,13 @@ async function runSetup(args) {
|
|
|
438
443
|
throw new Error("Org ID is required. Re-run setup and provide QUALTY_ORG_ID.");
|
|
439
444
|
}
|
|
440
445
|
|
|
441
|
-
const next = {
|
|
442
|
-
org_id: orgId,
|
|
443
|
-
};
|
|
444
|
-
// Keep token in the same place MCP uses.
|
|
445
446
|
writeSharedCredentials(token);
|
|
446
|
-
writeQualtyConfig(next);
|
|
447
|
-
// Keep org in MCP-compatible per-project config too.
|
|
448
447
|
writeProjectConfig({ default_org_id: orgId });
|
|
449
|
-
|
|
450
|
-
console.log(`[qualty] Saved config to ${QUALTY_CONFIG_PATH}`);
|
|
448
|
+
const projectConfigPath = getProjectConfigWritePath();
|
|
451
449
|
// eslint-disable-next-line no-console
|
|
452
450
|
console.log(`[qualty] Saved shared token to ${QUALTY_SHARED_CREDS_PATH} (used by MCP)`);
|
|
453
451
|
// eslint-disable-next-line no-console
|
|
454
|
-
console.log(`[qualty] Saved project
|
|
452
|
+
console.log(`[qualty] Saved project config to ${projectConfigPath} (MCP-compatible)`);
|
|
455
453
|
// eslint-disable-next-line no-console
|
|
456
454
|
console.log(`[qualty] Org: ${orgName || "configured"}`);
|
|
457
455
|
// eslint-disable-next-line no-console
|
|
@@ -524,8 +522,17 @@ function copyToClipboard(text) {
|
|
|
524
522
|
return false;
|
|
525
523
|
}
|
|
526
524
|
|
|
527
|
-
function
|
|
528
|
-
const
|
|
525
|
+
function ghRepoArgs(repo) {
|
|
526
|
+
const target = String(repo || "").trim();
|
|
527
|
+
return target ? ["--repo", target] : [];
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function setGithubSecret(name, value, { repo } = {}) {
|
|
531
|
+
const result = spawnSync(
|
|
532
|
+
"gh",
|
|
533
|
+
["secret", "set", name, ...ghRepoArgs(repo)],
|
|
534
|
+
{ input: value, encoding: "utf8" }
|
|
535
|
+
);
|
|
529
536
|
if (result.error?.code === "ENOENT") {
|
|
530
537
|
return { ok: false, reason: "GitHub CLI (gh) is not installed." };
|
|
531
538
|
}
|
|
@@ -536,6 +543,112 @@ function setGithubSecret(name, value) {
|
|
|
536
543
|
return { ok: true };
|
|
537
544
|
}
|
|
538
545
|
|
|
546
|
+
function setGithubVariable(name, value, { repo } = {}) {
|
|
547
|
+
const result = spawnSync("gh", ["variable", "set", name, "--body", value, ...ghRepoArgs(repo)], {
|
|
548
|
+
encoding: "utf8",
|
|
549
|
+
});
|
|
550
|
+
if (result.error?.code === "ENOENT") {
|
|
551
|
+
return { ok: false, reason: "GitHub CLI (gh) is not installed." };
|
|
552
|
+
}
|
|
553
|
+
if (result.status !== 0) {
|
|
554
|
+
const detail = String(result.stderr || result.stdout || "").trim();
|
|
555
|
+
return { ok: false, reason: detail || `gh variable set failed (exit ${result.status})` };
|
|
556
|
+
}
|
|
557
|
+
return { ok: true };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Push Qualty CI secrets + repository variables via gh (run from target app repo root).
|
|
562
|
+
*/
|
|
563
|
+
function syncGithubCiConfig({
|
|
564
|
+
repo,
|
|
565
|
+
orgId,
|
|
566
|
+
projectId,
|
|
567
|
+
profile,
|
|
568
|
+
authPayload,
|
|
569
|
+
apiUrl,
|
|
570
|
+
token,
|
|
571
|
+
suiteId,
|
|
572
|
+
cliRepo,
|
|
573
|
+
cliRef,
|
|
574
|
+
}) {
|
|
575
|
+
const results = [];
|
|
576
|
+
const record = (kind, name, outcome) => {
|
|
577
|
+
results.push({ kind, name, ...outcome });
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const authResult = setGithubSecret("QUALTY_AUTH_STATE_B64", authPayload, { repo });
|
|
581
|
+
record("secret", "QUALTY_AUTH_STATE_B64", authResult);
|
|
582
|
+
|
|
583
|
+
if (token) {
|
|
584
|
+
record("secret", "QUALTY_API_TOKEN", setGithubSecret("QUALTY_API_TOKEN", token, { repo }));
|
|
585
|
+
} else {
|
|
586
|
+
record("secret", "QUALTY_API_TOKEN", {
|
|
587
|
+
ok: false,
|
|
588
|
+
reason: "No token locally — run qualty setup or set QUALTY_API_TOKEN.",
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const apiUrlNorm = String(apiUrl || "").replace(/\/$/, "");
|
|
593
|
+
const isLocalDefault = apiUrlNorm === "http://127.0.0.1:8000";
|
|
594
|
+
if (apiUrlNorm && !isLocalDefault) {
|
|
595
|
+
record("secret", "QUALTY_API_URL", setGithubSecret("QUALTY_API_URL", apiUrlNorm, { repo }));
|
|
596
|
+
} else {
|
|
597
|
+
record("secret", "QUALTY_API_URL", {
|
|
598
|
+
ok: false,
|
|
599
|
+
reason: "Set QUALTY_API_URL to your dev/prod API (not localhost) before export-ci.",
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
record("variable", "QUALTY_ORG_ID", setGithubVariable("QUALTY_ORG_ID", orgId, { repo }));
|
|
604
|
+
record("variable", "QUALTY_PROJECT_ID", setGithubVariable("QUALTY_PROJECT_ID", String(projectId), { repo }));
|
|
605
|
+
record("variable", "QUALTY_AUTH_PROFILE", setGithubVariable("QUALTY_AUTH_PROFILE", profile, { repo }));
|
|
606
|
+
|
|
607
|
+
if (suiteId) {
|
|
608
|
+
record("variable", "QUALTY_SUITE_ID", setGithubVariable("QUALTY_SUITE_ID", String(suiteId), { repo }));
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (cliRepo) {
|
|
612
|
+
record("variable", "QUALTY_CLI_REPO", setGithubVariable("QUALTY_CLI_REPO", cliRepo, { repo }));
|
|
613
|
+
}
|
|
614
|
+
if (cliRef) {
|
|
615
|
+
record("variable", "QUALTY_CLI_REF", setGithubVariable("QUALTY_CLI_REF", cliRef, { repo }));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return results;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function printGithubSyncResults(results, { repo }) {
|
|
622
|
+
const target = repo ? ` (repo ${repo})` : "";
|
|
623
|
+
const printGroup = (title, rows) => {
|
|
624
|
+
if (!rows.length) return;
|
|
625
|
+
// eslint-disable-next-line no-console
|
|
626
|
+
console.log(title);
|
|
627
|
+
for (const row of rows) {
|
|
628
|
+
if (row.ok) {
|
|
629
|
+
// eslint-disable-next-line no-console
|
|
630
|
+
console.log(` ✓ ${row.name}`);
|
|
631
|
+
} else {
|
|
632
|
+
// eslint-disable-next-line no-console
|
|
633
|
+
console.log(` ✗ ${row.name} — ${row.reason || "failed"}`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
// eslint-disable-next-line no-console
|
|
639
|
+
console.log(`GitHub sync${target} (gh secret set + gh variable set):`);
|
|
640
|
+
printGroup(
|
|
641
|
+
" Repository secrets:",
|
|
642
|
+
results.filter((r) => r.kind === "secret")
|
|
643
|
+
);
|
|
644
|
+
printGroup(
|
|
645
|
+
" Repository variables:",
|
|
646
|
+
results.filter((r) => r.kind === "variable")
|
|
647
|
+
);
|
|
648
|
+
// eslint-disable-next-line no-console
|
|
649
|
+
console.log("");
|
|
650
|
+
}
|
|
651
|
+
|
|
539
652
|
function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
|
|
540
653
|
const raw = readFileSync(statePath);
|
|
541
654
|
if (gzip) {
|
|
@@ -552,14 +665,10 @@ function encodeAuthStateForGithubSecret(statePath, { gzip = false } = {}) {
|
|
|
552
665
|
};
|
|
553
666
|
}
|
|
554
667
|
|
|
555
|
-
function githubAuthRestoreShell({ orgId, projectId, profile
|
|
668
|
+
function githubAuthRestoreShell({ orgId, projectId, profile }) {
|
|
556
669
|
const orgSeg = safePathSegment(orgId, "default-org");
|
|
557
670
|
const projectSeg = safePathSegment(projectId, "default-project");
|
|
558
671
|
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';
|
|
563
672
|
return [
|
|
564
673
|
`- name: Restore Qualty login state`,
|
|
565
674
|
` env:`,
|
|
@@ -567,13 +676,13 @@ function githubAuthRestoreShell({ orgId, projectId, profile, encoding }) {
|
|
|
567
676
|
` run: |`,
|
|
568
677
|
` AUTH_DIR="$HOME/.qualty/local-contexts/${orgSeg}/${projectSeg}"`,
|
|
569
678
|
` mkdir -p "$AUTH_DIR"`,
|
|
570
|
-
` $
|
|
679
|
+
` echo "$QUALTY_AUTH_STATE_B64" | base64 -d | gunzip > "$AUTH_DIR/${profileSeg}.json"`,
|
|
571
680
|
` test -s "$AUTH_DIR/${profileSeg}.json"`,
|
|
572
681
|
``,
|
|
573
682
|
`- name: Run Qualty (local)`,
|
|
574
683
|
` env:`,
|
|
575
684
|
` QUALTY_API_TOKEN: \${{ secrets.QUALTY_API_TOKEN }}`,
|
|
576
|
-
` QUALTY_ORG_ID: \${{
|
|
685
|
+
` QUALTY_ORG_ID: \${{ vars.QUALTY_ORG_ID }}`,
|
|
577
686
|
` run: |`,
|
|
578
687
|
` npx qualty@latest run --local \\`,
|
|
579
688
|
` --project ${projectId} \\`,
|
|
@@ -584,12 +693,14 @@ function githubAuthRestoreShell({ orgId, projectId, profile, encoding }) {
|
|
|
584
693
|
}
|
|
585
694
|
|
|
586
695
|
async function runAuthExportCi(args) {
|
|
587
|
-
const
|
|
588
|
-
const
|
|
589
|
-
const token = resolveToken(args, config);
|
|
696
|
+
const orgId = resolveOrgId(args);
|
|
697
|
+
const token = resolveToken(args);
|
|
590
698
|
const projectCfg = readProjectConfig();
|
|
591
|
-
const projectId =
|
|
592
|
-
const profile =
|
|
699
|
+
const projectId = projectIdFromConfig(projectCfg, args.project);
|
|
700
|
+
const profile = authProfileFromConfig(
|
|
701
|
+
projectCfg,
|
|
702
|
+
args.profile || args["auth-profile"]
|
|
703
|
+
);
|
|
593
704
|
|
|
594
705
|
if (!orgId) {
|
|
595
706
|
throw new Error("Missing org. Pass --org, set QUALTY_ORG_ID, or add default_org_id to .qualty.json.");
|
|
@@ -614,7 +725,10 @@ async function runAuthExportCi(args) {
|
|
|
614
725
|
);
|
|
615
726
|
}
|
|
616
727
|
|
|
617
|
-
|
|
728
|
+
const setGithubFlag =
|
|
729
|
+
parseBoolean(args["set-github"], false) || parseBoolean(args["set-secret"], false);
|
|
730
|
+
// CI path: one format only — gzip then base64 in QUALTY_AUTH_STATE_B64 (no separate gzip flag).
|
|
731
|
+
let gzip = parseBoolean(args.gzip, false) || setGithubFlag;
|
|
618
732
|
let encoded = encodeAuthStateForGithubSecret(statePath, { gzip });
|
|
619
733
|
let secretBytes = Buffer.byteLength(encoded.payload, "utf8");
|
|
620
734
|
if (!gzip && secretBytes > GITHUB_SECRET_MAX_BYTES - 1024) {
|
|
@@ -634,7 +748,12 @@ async function runAuthExportCi(args) {
|
|
|
634
748
|
? String(args.output || "").trim()
|
|
635
749
|
: String(args.output || "").trim() || authExportCiOutputPath(statePath);
|
|
636
750
|
const copyToClipboardFlag = parseBoolean(args.copy, false);
|
|
637
|
-
const
|
|
751
|
+
const githubRepo = String(args.repo || "").trim();
|
|
752
|
+
const suiteId = String(args["suite-id"] || args.suite || "").trim();
|
|
753
|
+
const cliRepo = String(args["cli-repo"] || "").trim();
|
|
754
|
+
const cliRef = String(args["cli-ref"] || "").trim();
|
|
755
|
+
const apiUrl = resolveApiUrl(args);
|
|
756
|
+
|
|
638
757
|
if (outputPath) {
|
|
639
758
|
writeFileSync(outputPath, encoded.payload, "utf8");
|
|
640
759
|
try {
|
|
@@ -646,12 +765,12 @@ async function runAuthExportCi(args) {
|
|
|
646
765
|
|
|
647
766
|
const includeToken = parseBoolean(args["include-token"], false);
|
|
648
767
|
const profileSeg = safePathSegment(profile, "default");
|
|
649
|
-
const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag ||
|
|
768
|
+
const skipPayloadPrint = Boolean(outputPath || copyToClipboardFlag || setGithubFlag);
|
|
650
769
|
|
|
651
770
|
// eslint-disable-next-line no-console
|
|
652
771
|
console.log("");
|
|
653
772
|
// eslint-disable-next-line no-console
|
|
654
|
-
console.log("=== Qualty → GitHub Actions secrets ===");
|
|
773
|
+
console.log("=== Qualty → GitHub Actions (secrets & variables) ===");
|
|
655
774
|
// eslint-disable-next-line no-console
|
|
656
775
|
console.log("");
|
|
657
776
|
if (encoded.encoding === "gzip+base64") {
|
|
@@ -671,21 +790,27 @@ async function runAuthExportCi(args) {
|
|
|
671
790
|
// eslint-disable-next-line no-console
|
|
672
791
|
console.log("");
|
|
673
792
|
}
|
|
674
|
-
if (
|
|
675
|
-
const
|
|
676
|
-
|
|
793
|
+
if (setGithubFlag) {
|
|
794
|
+
const syncResults = syncGithubCiConfig({
|
|
795
|
+
repo: githubRepo,
|
|
796
|
+
orgId,
|
|
797
|
+
projectId,
|
|
798
|
+
profile,
|
|
799
|
+
authPayload: encoded.payload,
|
|
800
|
+
apiUrl,
|
|
801
|
+
token,
|
|
802
|
+
suiteId,
|
|
803
|
+
cliRepo,
|
|
804
|
+
cliRef,
|
|
805
|
+
});
|
|
806
|
+
printGithubSyncResults(syncResults, { repo: githubRepo });
|
|
807
|
+
const authRow = syncResults.find((r) => r.name === "QUALTY_AUTH_STATE_B64");
|
|
808
|
+
if (!authRow?.ok && outputPath) {
|
|
677
809
|
// eslint-disable-next-line no-console
|
|
678
|
-
console.log(
|
|
679
|
-
} else {
|
|
810
|
+
console.log(` Manual auth secret: gh secret set QUALTY_AUTH_STATE_B64 < ${outputPath}`);
|
|
680
811
|
// 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
|
-
}
|
|
812
|
+
console.log("");
|
|
686
813
|
}
|
|
687
|
-
// eslint-disable-next-line no-console
|
|
688
|
-
console.log("");
|
|
689
814
|
}
|
|
690
815
|
if (copyToClipboardFlag) {
|
|
691
816
|
if (copyToClipboard(encoded.payload)) {
|
|
@@ -702,61 +827,124 @@ async function runAuthExportCi(args) {
|
|
|
702
827
|
// eslint-disable-next-line no-console
|
|
703
828
|
console.log("");
|
|
704
829
|
}
|
|
705
|
-
|
|
706
|
-
console.log("1. Add these GitHub secrets (Settings → Secrets and variables → Actions):");
|
|
707
|
-
// eslint-disable-next-line no-console
|
|
708
|
-
console.log("");
|
|
709
|
-
// eslint-disable-next-line no-console
|
|
710
|
-
console.log("---");
|
|
711
|
-
// eslint-disable-next-line no-console
|
|
712
|
-
console.log("Name: QUALTY_ORG_ID");
|
|
713
|
-
// eslint-disable-next-line no-console
|
|
714
|
-
console.log("Value:");
|
|
715
|
-
// eslint-disable-next-line no-console
|
|
716
|
-
console.log(orgId);
|
|
717
|
-
// eslint-disable-next-line no-console
|
|
718
|
-
console.log("---");
|
|
719
|
-
// eslint-disable-next-line no-console
|
|
720
|
-
console.log("Name: QUALTY_API_TOKEN");
|
|
721
|
-
// eslint-disable-next-line no-console
|
|
722
|
-
console.log("Value:");
|
|
723
|
-
if (includeToken && token) {
|
|
830
|
+
if (setGithubFlag) {
|
|
724
831
|
// eslint-disable-next-line no-console
|
|
725
|
-
console.log(
|
|
726
|
-
|
|
832
|
+
console.log(
|
|
833
|
+
"1. GitHub Actions config was pushed via gh (secrets + repository variables — see sync above). Fix any ✗ manually."
|
|
834
|
+
);
|
|
835
|
+
if (!suiteId) {
|
|
836
|
+
// eslint-disable-next-line no-console
|
|
837
|
+
console.log(
|
|
838
|
+
" Tip: re-run with --suite-id <uuid> to set QUALTY_SUITE_ID (or set it in the GitHub UI)."
|
|
839
|
+
);
|
|
840
|
+
}
|
|
727
841
|
// eslint-disable-next-line no-console
|
|
728
|
-
console.log(
|
|
842
|
+
console.log("");
|
|
843
|
+
} else {
|
|
729
844
|
// eslint-disable-next-line no-console
|
|
730
|
-
console.log(
|
|
845
|
+
console.log(
|
|
846
|
+
"1. Add repository variables (or re-run with --set-github from this repo's git root):"
|
|
847
|
+
);
|
|
731
848
|
// eslint-disable-next-line no-console
|
|
732
|
-
console.log("
|
|
733
|
-
|
|
849
|
+
console.log("");
|
|
850
|
+
// eslint-disable-next-line no-console
|
|
851
|
+
console.log("---");
|
|
852
|
+
// eslint-disable-next-line no-console
|
|
853
|
+
console.log("Name: QUALTY_ORG_ID");
|
|
854
|
+
// eslint-disable-next-line no-console
|
|
855
|
+
console.log("Value:");
|
|
856
|
+
// eslint-disable-next-line no-console
|
|
857
|
+
console.log(orgId);
|
|
858
|
+
// eslint-disable-next-line no-console
|
|
859
|
+
console.log(` gh variable set QUALTY_ORG_ID --body "${orgId}"`);
|
|
860
|
+
// eslint-disable-next-line no-console
|
|
861
|
+
console.log("---");
|
|
862
|
+
// eslint-disable-next-line no-console
|
|
863
|
+
console.log("Name: QUALTY_PROJECT_ID");
|
|
864
|
+
// eslint-disable-next-line no-console
|
|
865
|
+
console.log("Value:");
|
|
866
|
+
// eslint-disable-next-line no-console
|
|
867
|
+
console.log(projectId);
|
|
868
|
+
// eslint-disable-next-line no-console
|
|
869
|
+
console.log(` gh variable set QUALTY_PROJECT_ID --body "${projectId}"`);
|
|
870
|
+
// eslint-disable-next-line no-console
|
|
871
|
+
console.log("---");
|
|
872
|
+
// eslint-disable-next-line no-console
|
|
873
|
+
console.log("Name: QUALTY_AUTH_PROFILE");
|
|
874
|
+
// eslint-disable-next-line no-console
|
|
875
|
+
console.log("Value:");
|
|
876
|
+
// eslint-disable-next-line no-console
|
|
877
|
+
console.log(profile);
|
|
878
|
+
// eslint-disable-next-line no-console
|
|
879
|
+
console.log(` gh variable set QUALTY_AUTH_PROFILE --body "${profile}"`);
|
|
880
|
+
// eslint-disable-next-line no-console
|
|
881
|
+
console.log("---");
|
|
882
|
+
// eslint-disable-next-line no-console
|
|
883
|
+
console.log("Name: QUALTY_SUITE_ID");
|
|
734
884
|
// eslint-disable-next-line no-console
|
|
735
|
-
console.log("
|
|
885
|
+
console.log("Value:");
|
|
886
|
+
// eslint-disable-next-line no-console
|
|
887
|
+
console.log(suiteId || "(suite UUID from Qualty dashboard — pass --suite-id)");
|
|
888
|
+
if (suiteId) {
|
|
889
|
+
// eslint-disable-next-line no-console
|
|
890
|
+
console.log(` gh variable set QUALTY_SUITE_ID --body "${suiteId}"`);
|
|
891
|
+
}
|
|
892
|
+
// eslint-disable-next-line no-console
|
|
893
|
+
console.log("");
|
|
894
|
+
// eslint-disable-next-line no-console
|
|
895
|
+
console.log("2. Add these GitHub secrets:");
|
|
736
896
|
}
|
|
737
|
-
|
|
738
|
-
console.log("---");
|
|
739
|
-
// eslint-disable-next-line no-console
|
|
740
|
-
console.log("Name: QUALTY_AUTH_STATE_B64");
|
|
741
|
-
if (skipPayloadPrint) {
|
|
897
|
+
if (!setGithubFlag) {
|
|
742
898
|
// eslint-disable-next-line no-console
|
|
743
|
-
console.log(
|
|
744
|
-
|
|
899
|
+
console.log("");
|
|
900
|
+
// eslint-disable-next-line no-console
|
|
901
|
+
console.log("---");
|
|
902
|
+
// eslint-disable-next-line no-console
|
|
903
|
+
console.log("Name: QUALTY_API_TOKEN");
|
|
745
904
|
// eslint-disable-next-line no-console
|
|
746
905
|
console.log("Value:");
|
|
906
|
+
if (includeToken && token) {
|
|
907
|
+
// eslint-disable-next-line no-console
|
|
908
|
+
console.log(token);
|
|
909
|
+
} else if (token) {
|
|
910
|
+
// eslint-disable-next-line no-console
|
|
911
|
+
console.log(`(your qlty_ci_ token — already configured locally as ${maskSecret(token)})`);
|
|
912
|
+
// eslint-disable-next-line no-console
|
|
913
|
+
console.log("Paste the full token from Qualty → Account → API tokens.");
|
|
914
|
+
// eslint-disable-next-line no-console
|
|
915
|
+
console.log("Re-run with --set-github to push via gh (or --include-token to print).");
|
|
916
|
+
} else {
|
|
917
|
+
// eslint-disable-next-line no-console
|
|
918
|
+
console.log("(create in Qualty → Account → API tokens; must start with qlty_ci_)");
|
|
919
|
+
}
|
|
747
920
|
// eslint-disable-next-line no-console
|
|
748
|
-
console.log(
|
|
921
|
+
console.log("---");
|
|
922
|
+
// eslint-disable-next-line no-console
|
|
923
|
+
console.log("Name: QUALTY_AUTH_STATE_B64");
|
|
924
|
+
if (skipPayloadPrint) {
|
|
925
|
+
// eslint-disable-next-line no-console
|
|
926
|
+
console.log(
|
|
927
|
+
`Value: (see file above${copyToClipboardFlag ? " or clipboard" : ""})`
|
|
928
|
+
);
|
|
929
|
+
} else {
|
|
930
|
+
// eslint-disable-next-line no-console
|
|
931
|
+
console.log("Value:");
|
|
932
|
+
// eslint-disable-next-line no-console
|
|
933
|
+
console.log(encoded.payload);
|
|
934
|
+
}
|
|
935
|
+
// eslint-disable-next-line no-console
|
|
936
|
+
console.log("---");
|
|
937
|
+
// eslint-disable-next-line no-console
|
|
938
|
+
console.log("");
|
|
749
939
|
}
|
|
940
|
+
|
|
941
|
+
const stepLabel = setGithubFlag ? "2" : "3";
|
|
750
942
|
// 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:");
|
|
943
|
+
console.log(`${stepLabel}. Workflow snippet (see templates/github-actions/qualty-local-pr.yml):`);
|
|
756
944
|
// eslint-disable-next-line no-console
|
|
757
945
|
console.log("");
|
|
758
946
|
// eslint-disable-next-line no-console
|
|
759
|
-
console.log(githubAuthRestoreShell({ orgId, projectId, profile
|
|
947
|
+
console.log(githubAuthRestoreShell({ orgId, projectId, profile }));
|
|
760
948
|
// eslint-disable-next-line no-console
|
|
761
949
|
console.log("");
|
|
762
950
|
// eslint-disable-next-line no-console
|
|
@@ -1113,10 +1301,9 @@ function printQualtyViewLogsReport(executionId, status) {
|
|
|
1113
1301
|
}
|
|
1114
1302
|
|
|
1115
1303
|
async function runCi(args) {
|
|
1116
|
-
const
|
|
1117
|
-
const
|
|
1118
|
-
const
|
|
1119
|
-
const orgId = resolveOrgId(args, config);
|
|
1304
|
+
const apiUrl = resolveApiUrl(args);
|
|
1305
|
+
const token = resolveToken(args);
|
|
1306
|
+
const orgId = resolveOrgId(args);
|
|
1120
1307
|
const projectId = args.project;
|
|
1121
1308
|
const suiteId = args["suite-id"];
|
|
1122
1309
|
const explicitIds = String(args.ids || "")
|
|
@@ -1460,10 +1647,9 @@ async function runLocalSuiteWithSequences({
|
|
|
1460
1647
|
}
|
|
1461
1648
|
|
|
1462
1649
|
async function runResolve(args) {
|
|
1463
|
-
const
|
|
1464
|
-
const
|
|
1465
|
-
const
|
|
1466
|
-
const orgId = resolveOrgId(args, config);
|
|
1650
|
+
const apiUrl = resolveApiUrl(args);
|
|
1651
|
+
const token = resolveToken(args);
|
|
1652
|
+
const orgId = resolveOrgId(args);
|
|
1467
1653
|
const projectId = args.project;
|
|
1468
1654
|
const suiteId = args["suite-id"];
|
|
1469
1655
|
const explicitIds = String(args.ids || "")
|
|
@@ -1626,26 +1812,35 @@ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
|
|
|
1626
1812
|
|
|
1627
1813
|
function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
|
|
1628
1814
|
if (parseBoolean(args["no-auth-state"], false)) return null;
|
|
1629
|
-
const
|
|
1630
|
-
const profile =
|
|
1815
|
+
const projectCfg = readProjectConfig();
|
|
1816
|
+
const profile = authProfileFromConfig(projectCfg, args["auth-profile"]);
|
|
1631
1817
|
if (!profile) return null;
|
|
1632
1818
|
const path = localAuthStatePath({ orgId, projectId, profile });
|
|
1633
|
-
|
|
1819
|
+
const summary = summarizeStorageStateFile(path);
|
|
1820
|
+
logAuthStateDiagnostics("[qualty][auth]", summary, {
|
|
1821
|
+
profile,
|
|
1822
|
+
source: "pre-run --auth-profile check",
|
|
1823
|
+
});
|
|
1824
|
+
if (!summary.exists) return null;
|
|
1634
1825
|
return { profile, path };
|
|
1635
1826
|
}
|
|
1636
1827
|
|
|
1637
1828
|
async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
|
|
1638
|
-
const
|
|
1829
|
+
const projectCfg = readProjectConfig();
|
|
1830
|
+
const requestedProfile = authProfileFromConfig(projectCfg, args["auth-profile"]);
|
|
1639
1831
|
if (requestedProfile) return { authProfile: requestedProfile, noAuthState: false };
|
|
1640
1832
|
if (parseBoolean(args["no-auth-state"], false)) return { authProfile: "", noAuthState: true };
|
|
1641
|
-
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
1833
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1834
|
+
const defaultProfile = authProfileFromConfig(projectCfg);
|
|
1835
|
+
if (defaultProfile) return { authProfile: defaultProfile, noAuthState: false };
|
|
1836
|
+
return { authProfile: "", noAuthState: false };
|
|
1837
|
+
}
|
|
1642
1838
|
if (!projectId) return { authProfile: "", noAuthState: false };
|
|
1643
1839
|
|
|
1644
1840
|
const profiles = listLocalAuthProfiles({ orgId, projectId });
|
|
1645
1841
|
if (profiles.length === 0) return { authProfile: "", noAuthState: false };
|
|
1646
1842
|
|
|
1647
|
-
const
|
|
1648
|
-
const defaultProfile = String(projectCfg.local_auth_profile || "").trim();
|
|
1843
|
+
const defaultProfile = authProfileFromConfig(projectCfg);
|
|
1649
1844
|
const defaultIndex = profiles.findIndex((p) => p === defaultProfile);
|
|
1650
1845
|
|
|
1651
1846
|
// eslint-disable-next-line no-console
|
|
@@ -1687,10 +1882,9 @@ async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
|
|
|
1687
1882
|
}
|
|
1688
1883
|
|
|
1689
1884
|
async function runAuthSetup(args) {
|
|
1690
|
-
const
|
|
1691
|
-
const
|
|
1692
|
-
const
|
|
1693
|
-
const orgId = resolveOrgId(args, config);
|
|
1885
|
+
const apiUrl = resolveApiUrl(args);
|
|
1886
|
+
const token = resolveToken(args);
|
|
1887
|
+
const orgId = resolveOrgId(args);
|
|
1694
1888
|
if (!token) {
|
|
1695
1889
|
throw new Error("Missing auth token. Run `qualty setup` first.");
|
|
1696
1890
|
}
|
|
@@ -1844,9 +2038,9 @@ async function runAuthSetup(args) {
|
|
|
1844
2038
|
await browser.close();
|
|
1845
2039
|
|
|
1846
2040
|
writeProjectConfig({
|
|
1847
|
-
local_auth_project_id: project.id,
|
|
1848
|
-
local_auth_profile: profile,
|
|
1849
2041
|
default_org_id: orgId,
|
|
2042
|
+
default_project_id: project.id,
|
|
2043
|
+
default_auth_profile: profile,
|
|
1850
2044
|
});
|
|
1851
2045
|
try {
|
|
1852
2046
|
const synced = await registerLocalLoginProfileBinding({
|
|
@@ -1909,11 +2103,10 @@ async function main() {
|
|
|
1909
2103
|
throw new Error("Unknown auth subcommand. Use: qualty auth setup | qualty auth export-ci");
|
|
1910
2104
|
}
|
|
1911
2105
|
if (command === "run") {
|
|
1912
|
-
const config = readQualtyConfig();
|
|
1913
2106
|
if (parseBoolean(args.local, false)) {
|
|
1914
|
-
const apiUrl = resolveApiUrl(args
|
|
1915
|
-
const token = resolveToken(args
|
|
1916
|
-
const orgId = resolveOrgId(args
|
|
2107
|
+
const apiUrl = resolveApiUrl(args);
|
|
2108
|
+
const token = resolveToken(args);
|
|
2109
|
+
const orgId = resolveOrgId(args);
|
|
1917
2110
|
const projectId =
|
|
1918
2111
|
(await maybePromptProjectIdForLocalRun({
|
|
1919
2112
|
args,
|
|
@@ -1949,10 +2142,6 @@ async function main() {
|
|
|
1949
2142
|
...(authChoice.noAuthState ? { "no-auth-state": true } : {}),
|
|
1950
2143
|
};
|
|
1951
2144
|
const localAuth = resolveLocalAuthStateForRun({ args: localAuthArgs, orgId, projectId });
|
|
1952
|
-
if (localAuth?.path) {
|
|
1953
|
-
// eslint-disable-next-line no-console
|
|
1954
|
-
console.log(`[qualty][auth] Reusing profile "${localAuth.profile}"`);
|
|
1955
|
-
}
|
|
1956
2145
|
const localRunArgs = {
|
|
1957
2146
|
apiUrl,
|
|
1958
2147
|
token,
|
package/bin/test-pw.mjs
ADDED