qualty 0.1.9 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/local-runner.js +11 -3
- package/bin/qualty.js +251 -3
- package/package.json +1 -1
package/bin/local-runner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, mkdtempSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, writeFileSync, mkdtempSync, existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
|
|
@@ -754,6 +754,7 @@ export async function runLocalExecution({
|
|
|
754
754
|
executionId,
|
|
755
755
|
device,
|
|
756
756
|
headless,
|
|
757
|
+
storageStatePath,
|
|
757
758
|
}) {
|
|
758
759
|
const { chromium } = await import("playwright");
|
|
759
760
|
|
|
@@ -777,9 +778,14 @@ export async function runLocalExecution({
|
|
|
777
778
|
});
|
|
778
779
|
const cdpEndpoint = browserServer.wsEndpoint();
|
|
779
780
|
const browser = await chromium.connect(cdpEndpoint);
|
|
780
|
-
const
|
|
781
|
+
const contextOptions = {
|
|
781
782
|
viewport: { width: viewport.width || 1440, height: viewport.height || 900 },
|
|
782
|
-
}
|
|
783
|
+
};
|
|
784
|
+
if (storageStatePath && existsSync(storageStatePath)) {
|
|
785
|
+
contextOptions.storageState = storageStatePath;
|
|
786
|
+
console.log(`[qualty][auth] using saved context: ${storageStatePath}`);
|
|
787
|
+
}
|
|
788
|
+
const context = await browser.newContext(contextOptions);
|
|
783
789
|
const page = await context.newPage();
|
|
784
790
|
const networkCollector = attachNetworkCollector(page);
|
|
785
791
|
const labelToPath = await downloadProjectAttachments({
|
|
@@ -985,6 +991,7 @@ export async function runLocalCi({
|
|
|
985
991
|
device,
|
|
986
992
|
headless,
|
|
987
993
|
localConcurrency = 4,
|
|
994
|
+
storageStatePath,
|
|
988
995
|
}) {
|
|
989
996
|
const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
|
|
990
997
|
if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
|
|
@@ -1040,6 +1047,7 @@ export async function runLocalCi({
|
|
|
1040
1047
|
executionId: id,
|
|
1041
1048
|
device,
|
|
1042
1049
|
headless,
|
|
1050
|
+
storageStatePath,
|
|
1043
1051
|
});
|
|
1044
1052
|
if (result.success) {
|
|
1045
1053
|
passed += 1;
|
package/bin/qualty.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
@@ -133,10 +133,11 @@ function usage() {
|
|
|
133
133
|
[
|
|
134
134
|
"Usage:",
|
|
135
135
|
" qualty setup [--token <bearer-token>] [--org <org-id>]",
|
|
136
|
+
" qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
|
|
136
137
|
" qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
|
|
137
138
|
" qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
|
|
138
139
|
" [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
|
|
139
|
-
" [--local] [--device 1440x900] [--headed] [--local-concurrency 4]",
|
|
140
|
+
" [--local] [--device 1440x900] [--headed] [--local-concurrency 4] [--auth-profile <profile>] [--no-auth-state]",
|
|
140
141
|
"",
|
|
141
142
|
" After each run, logs include step results, explanation, and final evaluator output (dashboard \"View logs\" data).",
|
|
142
143
|
" Pass --no-view-logs for a short log only (e.g. very large evaluator text).",
|
|
@@ -445,6 +446,38 @@ function resolveLocalConcurrency(args) {
|
|
|
445
446
|
return n;
|
|
446
447
|
}
|
|
447
448
|
|
|
449
|
+
function safePathSegment(value, fallback = "default") {
|
|
450
|
+
const out = String(value || "")
|
|
451
|
+
.trim()
|
|
452
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
|
453
|
+
.replace(/^_+|_+$/g, "");
|
|
454
|
+
return out || fallback;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function localAuthStatePath({ orgId, projectId, profile }) {
|
|
458
|
+
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
459
|
+
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
460
|
+
const profileSeg = safePathSegment(profile || "default", "default");
|
|
461
|
+
return join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function listLocalAuthProfiles({ orgId, projectId }) {
|
|
465
|
+
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
466
|
+
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
467
|
+
const dir = join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg);
|
|
468
|
+
if (!existsSync(dir)) return [];
|
|
469
|
+
try {
|
|
470
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
471
|
+
return entries
|
|
472
|
+
.filter((e) => e.isFile() && e.name.endsWith(".json"))
|
|
473
|
+
.map((e) => e.name.replace(/\.json$/i, ""))
|
|
474
|
+
.filter(Boolean)
|
|
475
|
+
.sort((a, b) => a.localeCompare(b));
|
|
476
|
+
} catch {
|
|
477
|
+
return [];
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
448
481
|
function sleep(ms) {
|
|
449
482
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
450
483
|
}
|
|
@@ -1087,6 +1120,191 @@ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
|
|
|
1087
1120
|
}
|
|
1088
1121
|
}
|
|
1089
1122
|
|
|
1123
|
+
function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
|
|
1124
|
+
if (parseBoolean(args["no-auth-state"], false)) return null;
|
|
1125
|
+
const projectCfg = readProjectConfig();
|
|
1126
|
+
const requestedProfile = String(args["auth-profile"] || "").trim();
|
|
1127
|
+
const savedProjectId = String(projectCfg.local_auth_project_id || "").trim();
|
|
1128
|
+
const savedProfile = String(projectCfg.local_auth_profile || "").trim();
|
|
1129
|
+
const profile =
|
|
1130
|
+
requestedProfile ||
|
|
1131
|
+
(savedProjectId && String(projectId || "").trim() === savedProjectId ? savedProfile : "");
|
|
1132
|
+
if (!profile) return null;
|
|
1133
|
+
const path = localAuthStatePath({ orgId, projectId, profile });
|
|
1134
|
+
if (!existsSync(path)) return null;
|
|
1135
|
+
return { profile, path };
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
|
|
1139
|
+
const requestedProfile = String(args["auth-profile"] || "").trim();
|
|
1140
|
+
if (requestedProfile) return { authProfile: requestedProfile, noAuthState: false };
|
|
1141
|
+
if (parseBoolean(args["no-auth-state"], false)) return { authProfile: "", noAuthState: true };
|
|
1142
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return { authProfile: "", noAuthState: false };
|
|
1143
|
+
if (!projectId) return { authProfile: "", noAuthState: false };
|
|
1144
|
+
|
|
1145
|
+
const profiles = listLocalAuthProfiles({ orgId, projectId });
|
|
1146
|
+
if (profiles.length === 0) return { authProfile: "", noAuthState: false };
|
|
1147
|
+
|
|
1148
|
+
const projectCfg = readProjectConfig();
|
|
1149
|
+
const defaultProfile = String(projectCfg.local_auth_profile || "").trim();
|
|
1150
|
+
const defaultIndex = profiles.findIndex((p) => p === defaultProfile);
|
|
1151
|
+
|
|
1152
|
+
// eslint-disable-next-line no-console
|
|
1153
|
+
console.log("[qualty][auth] Choose saved login state:");
|
|
1154
|
+
// eslint-disable-next-line no-console
|
|
1155
|
+
console.log(" 0) Run without saved login state");
|
|
1156
|
+
for (let i = 0; i < profiles.length; i += 1) {
|
|
1157
|
+
const isDefault = defaultIndex === i ? " (default)" : "";
|
|
1158
|
+
// eslint-disable-next-line no-console
|
|
1159
|
+
console.log(` ${i + 1}) ${profiles[i]}${isDefault}`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
const defaultChoice = defaultIndex >= 0 ? defaultIndex + 1 : 0;
|
|
1163
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1164
|
+
try {
|
|
1165
|
+
while (true) {
|
|
1166
|
+
const raw = String(
|
|
1167
|
+
await rl.question(`Select login state [0-${profiles.length}] (default ${defaultChoice}): `)
|
|
1168
|
+
).trim();
|
|
1169
|
+
const picked = raw ? Number(raw) : defaultChoice;
|
|
1170
|
+
if (!Number.isInteger(picked) || picked < 0 || picked > profiles.length) {
|
|
1171
|
+
// eslint-disable-next-line no-console
|
|
1172
|
+
console.log(`[qualty] Please enter a number from 0 to ${profiles.length}.`);
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
if (picked === 0) {
|
|
1176
|
+
// eslint-disable-next-line no-console
|
|
1177
|
+
console.log("[qualty][auth] Running without saved login state.");
|
|
1178
|
+
return { authProfile: "", noAuthState: true };
|
|
1179
|
+
}
|
|
1180
|
+
const profile = profiles[picked - 1];
|
|
1181
|
+
// eslint-disable-next-line no-console
|
|
1182
|
+
console.log(`[qualty][auth] Selected profile "${profile}"`);
|
|
1183
|
+
return { authProfile: profile, noAuthState: false };
|
|
1184
|
+
}
|
|
1185
|
+
} finally {
|
|
1186
|
+
rl.close();
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async function runAuthSetup(args) {
|
|
1191
|
+
const config = readQualtyConfig();
|
|
1192
|
+
const apiUrl = resolveApiUrl(args, config);
|
|
1193
|
+
const token = resolveToken(args, config);
|
|
1194
|
+
const orgId = resolveOrgId(args, config);
|
|
1195
|
+
if (!token) {
|
|
1196
|
+
throw new Error("Missing auth token. Run `qualty setup` first.");
|
|
1197
|
+
}
|
|
1198
|
+
if (!orgId) {
|
|
1199
|
+
throw new Error("Missing org. Run `qualty setup` first.");
|
|
1200
|
+
}
|
|
1201
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1202
|
+
throw new Error("Interactive auth setup requires a TTY terminal.");
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
const projectsPayload = await apiRequest({
|
|
1206
|
+
apiUrl,
|
|
1207
|
+
token,
|
|
1208
|
+
orgId,
|
|
1209
|
+
path: "/api/v1/projects",
|
|
1210
|
+
});
|
|
1211
|
+
const projects = (Array.isArray(projectsPayload) ? projectsPayload : [])
|
|
1212
|
+
.map((p) => ({
|
|
1213
|
+
id: String(p?.id || "").trim(),
|
|
1214
|
+
name: String(p?.name || "").trim() || "Unnamed project",
|
|
1215
|
+
url: String(p?.url || "").trim(),
|
|
1216
|
+
}))
|
|
1217
|
+
.filter((p) => p.id);
|
|
1218
|
+
if (projects.length === 0) {
|
|
1219
|
+
throw new Error("No projects found. Create a project first.");
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1223
|
+
let project = null;
|
|
1224
|
+
try {
|
|
1225
|
+
const requestedProjectId = String(args.project || "").trim();
|
|
1226
|
+
if (requestedProjectId) {
|
|
1227
|
+
project = projects.find((p) => p.id === requestedProjectId) || null;
|
|
1228
|
+
if (!project) {
|
|
1229
|
+
throw new Error(`Project ${requestedProjectId} not found.`);
|
|
1230
|
+
}
|
|
1231
|
+
} else {
|
|
1232
|
+
// eslint-disable-next-line no-console
|
|
1233
|
+
console.log("[qualty] Choose project for saved local auth:");
|
|
1234
|
+
for (let i = 0; i < projects.length; i += 1) {
|
|
1235
|
+
// eslint-disable-next-line no-console
|
|
1236
|
+
console.log(` ${i + 1}) ${projects[i].name}`);
|
|
1237
|
+
}
|
|
1238
|
+
while (!project) {
|
|
1239
|
+
const raw = String(await rl.question(`Select project [1-${projects.length}] (default 1): `)).trim();
|
|
1240
|
+
const picked = raw ? Number(raw) : 1;
|
|
1241
|
+
if (Number.isInteger(picked) && picked >= 1 && picked <= projects.length) {
|
|
1242
|
+
project = projects[picked - 1];
|
|
1243
|
+
} else {
|
|
1244
|
+
// eslint-disable-next-line no-console
|
|
1245
|
+
console.log(`[qualty] Please enter a number from 1 to ${projects.length}.`);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
let profile = String(args.profile || "").trim();
|
|
1251
|
+
if (!profile) {
|
|
1252
|
+
const current = String(readProjectConfig().local_auth_profile || "default").trim() || "default";
|
|
1253
|
+
profile = String(await rl.question(`Profile name [${current}]: `)).trim() || current;
|
|
1254
|
+
}
|
|
1255
|
+
const statePath = localAuthStatePath({
|
|
1256
|
+
orgId,
|
|
1257
|
+
projectId: project.id,
|
|
1258
|
+
profile,
|
|
1259
|
+
});
|
|
1260
|
+
mkdirSync(join(statePath, ".."), { recursive: true });
|
|
1261
|
+
|
|
1262
|
+
const { chromium } = await import("playwright");
|
|
1263
|
+
const headed = parseBoolean(args.headed, true);
|
|
1264
|
+
let browser = null;
|
|
1265
|
+
try {
|
|
1266
|
+
browser = await chromium.launch({
|
|
1267
|
+
headless: !headed,
|
|
1268
|
+
channel: "chrome",
|
|
1269
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
1270
|
+
});
|
|
1271
|
+
// eslint-disable-next-line no-console
|
|
1272
|
+
console.log("[qualty][auth] Using local Chrome channel for login setup.");
|
|
1273
|
+
} catch (err) {
|
|
1274
|
+
// eslint-disable-next-line no-console
|
|
1275
|
+
console.log(`[qualty][auth] Chrome channel unavailable, falling back to Playwright Chromium (${err?.message || err}).`);
|
|
1276
|
+
browser = await chromium.launch({
|
|
1277
|
+
headless: !headed,
|
|
1278
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
1279
|
+
});
|
|
1280
|
+
}
|
|
1281
|
+
const context = await browser.newContext();
|
|
1282
|
+
const page = await context.newPage();
|
|
1283
|
+
const startUrl = project.url || "about:blank";
|
|
1284
|
+
await page.goto(startUrl, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
|
|
1285
|
+
// eslint-disable-next-line no-console
|
|
1286
|
+
console.log(`[qualty][auth] Browser opened for project "${project.name}".`);
|
|
1287
|
+
// eslint-disable-next-line no-console
|
|
1288
|
+
console.log("[qualty][auth] Complete login in the browser, then press Enter here to save context.");
|
|
1289
|
+
await rl.question("Press Enter to save local auth context...");
|
|
1290
|
+
await context.storageState({ path: statePath });
|
|
1291
|
+
await context.close();
|
|
1292
|
+
await browser.close();
|
|
1293
|
+
|
|
1294
|
+
writeProjectConfig({
|
|
1295
|
+
local_auth_project_id: project.id,
|
|
1296
|
+
local_auth_profile: profile,
|
|
1297
|
+
default_org_id: orgId,
|
|
1298
|
+
});
|
|
1299
|
+
// eslint-disable-next-line no-console
|
|
1300
|
+
console.log(`[qualty][auth] Saved local context profile "${profile}" for project "${project.name}".`);
|
|
1301
|
+
// eslint-disable-next-line no-console
|
|
1302
|
+
console.log(`[qualty][auth] Path: ${statePath}`);
|
|
1303
|
+
} finally {
|
|
1304
|
+
rl.close();
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1090
1308
|
async function main() {
|
|
1091
1309
|
const args = parseArgs(process.argv);
|
|
1092
1310
|
const command = args._[0];
|
|
@@ -1102,6 +1320,14 @@ async function main() {
|
|
|
1102
1320
|
await runSetup(args);
|
|
1103
1321
|
return;
|
|
1104
1322
|
}
|
|
1323
|
+
if (command === "auth") {
|
|
1324
|
+
const sub = args._[1];
|
|
1325
|
+
if (sub === "setup") {
|
|
1326
|
+
await runAuthSetup(args);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
throw new Error("Unknown auth subcommand. Use: qualty auth setup");
|
|
1330
|
+
}
|
|
1105
1331
|
if (command === "run") {
|
|
1106
1332
|
const config = readQualtyConfig();
|
|
1107
1333
|
if (parseBoolean(args.local, false)) {
|
|
@@ -1122,6 +1348,21 @@ async function main() {
|
|
|
1122
1348
|
token,
|
|
1123
1349
|
orgId,
|
|
1124
1350
|
})) || args["suite-id"];
|
|
1351
|
+
const authChoice = await maybePromptAuthProfileForLocalRun({
|
|
1352
|
+
args,
|
|
1353
|
+
orgId,
|
|
1354
|
+
projectId,
|
|
1355
|
+
});
|
|
1356
|
+
const localAuthArgs = {
|
|
1357
|
+
...args,
|
|
1358
|
+
...(authChoice.authProfile ? { "auth-profile": authChoice.authProfile } : {}),
|
|
1359
|
+
...(authChoice.noAuthState ? { "no-auth-state": true } : {}),
|
|
1360
|
+
};
|
|
1361
|
+
const localAuth = resolveLocalAuthStateForRun({ args: localAuthArgs, orgId, projectId });
|
|
1362
|
+
if (localAuth?.path) {
|
|
1363
|
+
// eslint-disable-next-line no-console
|
|
1364
|
+
console.log(`[qualty][auth] Reusing profile "${localAuth.profile}"`);
|
|
1365
|
+
}
|
|
1125
1366
|
await runLocalCi({
|
|
1126
1367
|
apiUrl,
|
|
1127
1368
|
token,
|
|
@@ -1138,6 +1379,7 @@ async function main() {
|
|
|
1138
1379
|
device: args.device || "1440x900",
|
|
1139
1380
|
headless: !parseBoolean(args.headed, false),
|
|
1140
1381
|
localConcurrency: resolveLocalConcurrency(args),
|
|
1382
|
+
storageStatePath: localAuth?.path,
|
|
1141
1383
|
});
|
|
1142
1384
|
return;
|
|
1143
1385
|
}
|
|
@@ -1148,7 +1390,13 @@ async function main() {
|
|
|
1148
1390
|
await runResolve(args);
|
|
1149
1391
|
return;
|
|
1150
1392
|
}
|
|
1151
|
-
if (
|
|
1393
|
+
if (
|
|
1394
|
+
command !== "setup" &&
|
|
1395
|
+
command !== "auth" &&
|
|
1396
|
+
command !== "connect" &&
|
|
1397
|
+
command !== "run" &&
|
|
1398
|
+
command !== "resolve"
|
|
1399
|
+
) {
|
|
1152
1400
|
throw new Error(`Unknown command: ${command}`);
|
|
1153
1401
|
}
|
|
1154
1402
|
}
|