qualty 0.1.9 → 0.1.11
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 +307 -5
- package/bin/qualty.js +298 -5
- package/package.json +1 -1
package/bin/local-runner.js
CHANGED
|
@@ -1,6 +1,252 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync, mkdtempSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
1
|
+
import { mkdirSync, writeFileSync, mkdtempSync, existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import process from "node:process";
|
|
6
|
+
|
|
7
|
+
function safePathSegment(value, fallback) {
|
|
8
|
+
const normalized = String(value || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
9
|
+
return normalized || fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function localAuthStatePath({ orgId, projectId, profile }) {
|
|
13
|
+
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
14
|
+
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
15
|
+
const profileSeg = safePathSegment(profile || "default", "default");
|
|
16
|
+
return join(homedir(), ".qualty", "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const localBindingSetupPromises = new Map();
|
|
20
|
+
|
|
21
|
+
async function promptYesNo(question, defaultYes = true) {
|
|
22
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultYes;
|
|
23
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
24
|
+
try {
|
|
25
|
+
const suffix = defaultYes ? " [Y/n]: " : " [y/N]: ";
|
|
26
|
+
const raw = String(await rl.question(`${question}${suffix}`)).trim().toLowerCase();
|
|
27
|
+
if (!raw) return defaultYes;
|
|
28
|
+
if (raw === "y" || raw === "yes") return true;
|
|
29
|
+
if (raw === "n" || raw === "no") return false;
|
|
30
|
+
return defaultYes;
|
|
31
|
+
} finally {
|
|
32
|
+
rl.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function runInteractiveLocalBindingSetup({
|
|
37
|
+
apiRequest,
|
|
38
|
+
apiUrl,
|
|
39
|
+
token,
|
|
40
|
+
orgId,
|
|
41
|
+
projectId,
|
|
42
|
+
profileName,
|
|
43
|
+
localProfileName,
|
|
44
|
+
startUrl,
|
|
45
|
+
}) {
|
|
46
|
+
const statePath = localAuthStatePath({
|
|
47
|
+
orgId,
|
|
48
|
+
projectId,
|
|
49
|
+
profile: localProfileName,
|
|
50
|
+
});
|
|
51
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
52
|
+
|
|
53
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
54
|
+
try {
|
|
55
|
+
const { chromium } = await import("playwright");
|
|
56
|
+
let browser = null;
|
|
57
|
+
try {
|
|
58
|
+
browser = await chromium.launch({
|
|
59
|
+
headless: false,
|
|
60
|
+
channel: "chrome",
|
|
61
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
62
|
+
});
|
|
63
|
+
// eslint-disable-next-line no-console
|
|
64
|
+
console.log("[qualty][auth] Using local Chrome channel for login setup.");
|
|
65
|
+
} catch (err) {
|
|
66
|
+
// eslint-disable-next-line no-console
|
|
67
|
+
console.log(
|
|
68
|
+
`[qualty][auth] Chrome channel unavailable, falling back to Playwright Chromium (${err?.message || err}).`
|
|
69
|
+
);
|
|
70
|
+
browser = await chromium.launch({
|
|
71
|
+
headless: false,
|
|
72
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const context = await browser.newContext();
|
|
77
|
+
const page = await context.newPage();
|
|
78
|
+
const safeStartUrl = String(startUrl || "about:blank").trim() || "about:blank";
|
|
79
|
+
await page.goto(safeStartUrl, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
|
|
80
|
+
// eslint-disable-next-line no-console
|
|
81
|
+
console.log(
|
|
82
|
+
`[qualty][auth] Browser opened. Complete login for profile "${profileName}", then press Enter here.`
|
|
83
|
+
);
|
|
84
|
+
await rl.question("Press Enter to save local login state...");
|
|
85
|
+
await context.storageState({ path: statePath });
|
|
86
|
+
await context.close();
|
|
87
|
+
await browser.close();
|
|
88
|
+
} finally {
|
|
89
|
+
rl.close();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await apiRequest({
|
|
94
|
+
apiUrl,
|
|
95
|
+
token,
|
|
96
|
+
orgId,
|
|
97
|
+
path: `/api/v1/projects/${projectId}/saved-login-profiles/local-binding`,
|
|
98
|
+
method: "POST",
|
|
99
|
+
body: {
|
|
100
|
+
profile_name: profileName,
|
|
101
|
+
local_profile_name: localProfileName,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
} catch (syncErr) {
|
|
105
|
+
// eslint-disable-next-line no-console
|
|
106
|
+
console.warn(`[qualty][auth] Saved locally, but backend sync failed: ${syncErr?.message || syncErr}`);
|
|
107
|
+
}
|
|
108
|
+
return { profile: localProfileName, path: statePath };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function maybeSetupMissingLocalBinding({
|
|
112
|
+
apiRequest,
|
|
113
|
+
apiUrl,
|
|
114
|
+
token,
|
|
115
|
+
orgId,
|
|
116
|
+
claim,
|
|
117
|
+
explicitAuthProfile,
|
|
118
|
+
noAuthState,
|
|
119
|
+
}) {
|
|
120
|
+
if (noAuthState) return null;
|
|
121
|
+
if (explicitAuthProfile) return null;
|
|
122
|
+
const profile = claim?.saved_login_profile && typeof claim.saved_login_profile === "object"
|
|
123
|
+
? claim.saved_login_profile
|
|
124
|
+
: null;
|
|
125
|
+
if (!profile || !profile.name) return null;
|
|
126
|
+
if (profile.has_local_binding) return null;
|
|
127
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
|
|
128
|
+
|
|
129
|
+
const profileName = String(profile.name || "").trim();
|
|
130
|
+
if (!profileName) return null;
|
|
131
|
+
const key = `${String(orgId || "")}:${String(claim?.project_id || "")}:${profileName}`;
|
|
132
|
+
if (localBindingSetupPromises.has(key)) {
|
|
133
|
+
return localBindingSetupPromises.get(key);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const task = (async () => {
|
|
137
|
+
// eslint-disable-next-line no-console
|
|
138
|
+
console.log(
|
|
139
|
+
`[qualty][auth] Saved login profile "${profileName}" is attached to this test, but no local login state is configured.`
|
|
140
|
+
);
|
|
141
|
+
const shouldSetup = await promptYesNo("[qualty][auth] Create local login state now?", true);
|
|
142
|
+
if (!shouldSetup) {
|
|
143
|
+
// eslint-disable-next-line no-console
|
|
144
|
+
console.log("[qualty][auth] Continuing without local login state.");
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
const localProfileName = String(profile.local_profile_name || "").trim() || profileName;
|
|
148
|
+
const setupResult = await runInteractiveLocalBindingSetup({
|
|
149
|
+
apiRequest,
|
|
150
|
+
apiUrl,
|
|
151
|
+
token,
|
|
152
|
+
orgId,
|
|
153
|
+
projectId: claim.project_id,
|
|
154
|
+
profileName,
|
|
155
|
+
localProfileName,
|
|
156
|
+
startUrl: claim?.url,
|
|
157
|
+
});
|
|
158
|
+
// eslint-disable-next-line no-console
|
|
159
|
+
console.log(`[qualty][auth] Saved local login state for "${profileName}".`);
|
|
160
|
+
return setupResult;
|
|
161
|
+
})();
|
|
162
|
+
|
|
163
|
+
localBindingSetupPromises.set(key, task);
|
|
164
|
+
try {
|
|
165
|
+
return await task;
|
|
166
|
+
} finally {
|
|
167
|
+
localBindingSetupPromises.delete(key);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function preflightMissingLocalBindings({
|
|
172
|
+
apiRequest,
|
|
173
|
+
apiUrl,
|
|
174
|
+
token,
|
|
175
|
+
orgId,
|
|
176
|
+
projectId,
|
|
177
|
+
savedJobs,
|
|
178
|
+
explicitAuthProfile,
|
|
179
|
+
noAuthState,
|
|
180
|
+
}) {
|
|
181
|
+
if (noAuthState) return;
|
|
182
|
+
if (explicitAuthProfile) return;
|
|
183
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
184
|
+
const missingByProfile = new Map();
|
|
185
|
+
for (const job of savedJobs || []) {
|
|
186
|
+
const useSaved = Boolean(job?.use_saved_login);
|
|
187
|
+
const profileName = String(job?.saved_login_profile?.name || "").trim();
|
|
188
|
+
if (!useSaved || !profileName) continue;
|
|
189
|
+
const statePath = localAuthStatePath({
|
|
190
|
+
orgId,
|
|
191
|
+
projectId: String(job?.project_id || projectId || "").trim(),
|
|
192
|
+
profile: profileName,
|
|
193
|
+
});
|
|
194
|
+
if (existsSync(statePath)) continue;
|
|
195
|
+
const entry = missingByProfile.get(profileName) || {
|
|
196
|
+
localProfileName: profileName,
|
|
197
|
+
projectId: String(job?.project_id || projectId || "").trim(),
|
|
198
|
+
startUrl: String(job?.url || "").trim(),
|
|
199
|
+
tests: [],
|
|
200
|
+
};
|
|
201
|
+
entry.tests.push({
|
|
202
|
+
id: String(job?.id || "").trim(),
|
|
203
|
+
name: String(job?.name || job?.id || "Unnamed test").trim(),
|
|
204
|
+
});
|
|
205
|
+
if (!entry.startUrl) entry.startUrl = String(job?.url || "").trim();
|
|
206
|
+
missingByProfile.set(profileName, entry);
|
|
207
|
+
}
|
|
208
|
+
if (missingByProfile.size === 0) return;
|
|
209
|
+
|
|
210
|
+
// eslint-disable-next-line no-console
|
|
211
|
+
console.log("[qualty][auth] Missing local login state for attached dashboard profiles:");
|
|
212
|
+
for (const [profileName, entry] of missingByProfile.entries()) {
|
|
213
|
+
// eslint-disable-next-line no-console
|
|
214
|
+
console.log(` - Profile "${profileName}" is required by:`);
|
|
215
|
+
for (const test of entry.tests) {
|
|
216
|
+
// eslint-disable-next-line no-console
|
|
217
|
+
console.log(` - ${test.name} (${test.id})`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (const [profileName, entry] of missingByProfile.entries()) {
|
|
222
|
+
const shouldSetup = await promptYesNo(
|
|
223
|
+
`[qualty][auth] Set up local login now for profile "${profileName}"?`,
|
|
224
|
+
true
|
|
225
|
+
);
|
|
226
|
+
if (!shouldSetup) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Cancelled: local login state is required for attached profile "${profileName}".`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
await runInteractiveLocalBindingSetup({
|
|
232
|
+
apiRequest,
|
|
233
|
+
apiUrl,
|
|
234
|
+
token,
|
|
235
|
+
orgId,
|
|
236
|
+
projectId: entry.projectId,
|
|
237
|
+
profileName,
|
|
238
|
+
localProfileName: entry.localProfileName,
|
|
239
|
+
startUrl: entry.startUrl,
|
|
240
|
+
});
|
|
241
|
+
// eslint-disable-next-line no-console
|
|
242
|
+
console.log(`[qualty][auth] Saved local login state for "${profileName}".`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const confirmed = await promptYesNo("[qualty] Login setup complete. Start all local runs now?", true);
|
|
246
|
+
if (!confirmed) {
|
|
247
|
+
throw new Error("Run cancelled by user before starting tests.");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
4
250
|
|
|
5
251
|
async function downloadProjectAttachments({ apiUrl, token, orgId, projectId, specs }) {
|
|
6
252
|
const labelToPath = {};
|
|
@@ -754,6 +1000,9 @@ export async function runLocalExecution({
|
|
|
754
1000
|
executionId,
|
|
755
1001
|
device,
|
|
756
1002
|
headless,
|
|
1003
|
+
authProfile,
|
|
1004
|
+
noAuthState,
|
|
1005
|
+
storageStatePath,
|
|
757
1006
|
}) {
|
|
758
1007
|
const { chromium } = await import("playwright");
|
|
759
1008
|
|
|
@@ -770,6 +1019,36 @@ export async function runLocalExecution({
|
|
|
770
1019
|
const viewport = claim.viewport || { width: 1440, height: 900 };
|
|
771
1020
|
const steps = Array.isArray(claim.steps) ? claim.steps : [];
|
|
772
1021
|
const totalSteps = claim.total_steps || steps.length;
|
|
1022
|
+
const claimSavedLogin = claim.saved_login_profile && typeof claim.saved_login_profile === "object"
|
|
1023
|
+
? claim.saved_login_profile
|
|
1024
|
+
: null;
|
|
1025
|
+
const setupFromClaim = await maybeSetupMissingLocalBinding({
|
|
1026
|
+
apiRequest,
|
|
1027
|
+
apiUrl,
|
|
1028
|
+
token,
|
|
1029
|
+
orgId,
|
|
1030
|
+
claim,
|
|
1031
|
+
explicitAuthProfile: String(authProfile || "").trim(),
|
|
1032
|
+
noAuthState: Boolean(noAuthState),
|
|
1033
|
+
});
|
|
1034
|
+
const claimProfile = String(claimSavedLogin?.local_profile_name || "").trim();
|
|
1035
|
+
const effectiveProfile = String(authProfile || "").trim() || setupFromClaim?.profile || claimProfile;
|
|
1036
|
+
const resolvedStoragePath =
|
|
1037
|
+
!noAuthState && effectiveProfile
|
|
1038
|
+
? localAuthStatePath({
|
|
1039
|
+
orgId,
|
|
1040
|
+
projectId: claim.project_id,
|
|
1041
|
+
profile: effectiveProfile,
|
|
1042
|
+
})
|
|
1043
|
+
: null;
|
|
1044
|
+
const finalStoragePath =
|
|
1045
|
+
!noAuthState && setupFromClaim?.path && existsSync(setupFromClaim.path)
|
|
1046
|
+
? setupFromClaim.path
|
|
1047
|
+
: !noAuthState && storageStatePath && existsSync(storageStatePath)
|
|
1048
|
+
? storageStatePath
|
|
1049
|
+
: !noAuthState && resolvedStoragePath && existsSync(resolvedStoragePath)
|
|
1050
|
+
? resolvedStoragePath
|
|
1051
|
+
: null;
|
|
773
1052
|
|
|
774
1053
|
const browserServer = await chromium.launchServer({
|
|
775
1054
|
headless: headless !== false,
|
|
@@ -777,9 +1056,15 @@ export async function runLocalExecution({
|
|
|
777
1056
|
});
|
|
778
1057
|
const cdpEndpoint = browserServer.wsEndpoint();
|
|
779
1058
|
const browser = await chromium.connect(cdpEndpoint);
|
|
780
|
-
const
|
|
1059
|
+
const contextOptions = {
|
|
781
1060
|
viewport: { width: viewport.width || 1440, height: viewport.height || 900 },
|
|
782
|
-
}
|
|
1061
|
+
};
|
|
1062
|
+
if (finalStoragePath) {
|
|
1063
|
+
contextOptions.storageState = finalStoragePath;
|
|
1064
|
+
const logProfile = effectiveProfile || "default";
|
|
1065
|
+
console.log(`[qualty][auth] using saved context "${logProfile}": ${finalStoragePath}`);
|
|
1066
|
+
}
|
|
1067
|
+
const context = await browser.newContext(contextOptions);
|
|
783
1068
|
const page = await context.newPage();
|
|
784
1069
|
const networkCollector = attachNetworkCollector(page);
|
|
785
1070
|
const labelToPath = await downloadProjectAttachments({
|
|
@@ -985,6 +1270,9 @@ export async function runLocalCi({
|
|
|
985
1270
|
device,
|
|
986
1271
|
headless,
|
|
987
1272
|
localConcurrency = 4,
|
|
1273
|
+
authProfile,
|
|
1274
|
+
noAuthState,
|
|
1275
|
+
storageStatePath,
|
|
988
1276
|
}) {
|
|
989
1277
|
const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
|
|
990
1278
|
if (!Array.isArray(savedJobs) || savedJobs.length === 0) {
|
|
@@ -1009,6 +1297,17 @@ export async function runLocalCi({
|
|
|
1009
1297
|
const started = runs.filter((r) => r.execution_job_id);
|
|
1010
1298
|
if (started.length === 0) throw new Error("Failed to start any local runs.");
|
|
1011
1299
|
|
|
1300
|
+
await preflightMissingLocalBindings({
|
|
1301
|
+
apiRequest,
|
|
1302
|
+
apiUrl,
|
|
1303
|
+
token,
|
|
1304
|
+
orgId,
|
|
1305
|
+
projectId,
|
|
1306
|
+
savedJobs,
|
|
1307
|
+
explicitAuthProfile: String(authProfile || "").trim(),
|
|
1308
|
+
noAuthState: Boolean(noAuthState),
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1012
1311
|
const concurrency = Math.max(1, Number(localConcurrency) || 4);
|
|
1013
1312
|
const workerCount = Math.min(concurrency, started.length);
|
|
1014
1313
|
|
|
@@ -1040,6 +1339,9 @@ export async function runLocalCi({
|
|
|
1040
1339
|
executionId: id,
|
|
1041
1340
|
device,
|
|
1042
1341
|
headless,
|
|
1342
|
+
authProfile,
|
|
1343
|
+
noAuthState,
|
|
1344
|
+
storageStatePath,
|
|
1043
1345
|
});
|
|
1044
1346
|
if (result.success) {
|
|
1045
1347
|
passed += 1;
|
package/bin/qualty.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
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
|
-
import { homedir } from "node:os";
|
|
5
|
+
import { homedir, hostname } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { createInterface } from "node:readline/promises";
|
|
8
8
|
import process from "node:process";
|
|
9
9
|
import { runLocalCi } from "./local-runner.js";
|
|
10
10
|
|
|
11
11
|
/** CLI backend URL is fixed by Qualty distribution. */
|
|
12
|
-
const DEFAULT_QUALTY_API_URL = "https://qualty-api-development.up.railway.app";
|
|
12
|
+
// const DEFAULT_QUALTY_API_URL = "https://qualty-api-development.up.railway.app";
|
|
13
|
+
const DEFAULT_QUALTY_API_URL = "http://localhost:8000";
|
|
14
|
+
|
|
13
15
|
const QUALTY_CONFIG_DIR = join(homedir(), ".config", "qualty");
|
|
14
16
|
const QUALTY_CONFIG_PATH = join(QUALTY_CONFIG_DIR, "config.json");
|
|
15
17
|
const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
|
|
@@ -133,10 +135,11 @@ function usage() {
|
|
|
133
135
|
[
|
|
134
136
|
"Usage:",
|
|
135
137
|
" qualty setup [--token <bearer-token>] [--org <org-id>]",
|
|
138
|
+
" qualty auth setup [--project <project-id>] [--profile <profile-name>] [--headed]",
|
|
136
139
|
" qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
|
|
137
140
|
" qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
|
|
138
141
|
" [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
|
|
139
|
-
" [--local] [--device 1440x900] [--headed] [--local-concurrency 4]",
|
|
142
|
+
" [--local] [--device 1440x900] [--headed] [--local-concurrency 4] [--auth-profile <profile>] [--no-auth-state] [--prompt-auth-profile]",
|
|
140
143
|
"",
|
|
141
144
|
" After each run, logs include step results, explanation, and final evaluator output (dashboard \"View logs\" data).",
|
|
142
145
|
" Pass --no-view-logs for a short log only (e.g. very large evaluator text).",
|
|
@@ -169,6 +172,29 @@ async function apiRequest({ apiUrl, token, orgId, path, method = "GET", body })
|
|
|
169
172
|
return data;
|
|
170
173
|
}
|
|
171
174
|
|
|
175
|
+
async function registerLocalLoginProfileBinding({
|
|
176
|
+
apiUrl,
|
|
177
|
+
token,
|
|
178
|
+
orgId,
|
|
179
|
+
projectId,
|
|
180
|
+
profileName,
|
|
181
|
+
localProfileName,
|
|
182
|
+
}) {
|
|
183
|
+
if (!projectId || !profileName) return null;
|
|
184
|
+
return apiRequest({
|
|
185
|
+
apiUrl,
|
|
186
|
+
token,
|
|
187
|
+
orgId,
|
|
188
|
+
path: `/api/v1/projects/${projectId}/saved-login-profiles/local-binding`,
|
|
189
|
+
method: "POST",
|
|
190
|
+
body: {
|
|
191
|
+
profile_name: profileName,
|
|
192
|
+
local_profile_name: localProfileName || profileName,
|
|
193
|
+
machine_id: hostname(),
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
172
198
|
function startCloudflared(token, port) {
|
|
173
199
|
const baseArgs = [
|
|
174
200
|
"tunnel",
|
|
@@ -445,6 +471,38 @@ function resolveLocalConcurrency(args) {
|
|
|
445
471
|
return n;
|
|
446
472
|
}
|
|
447
473
|
|
|
474
|
+
function safePathSegment(value, fallback = "default") {
|
|
475
|
+
const out = String(value || "")
|
|
476
|
+
.trim()
|
|
477
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
|
478
|
+
.replace(/^_+|_+$/g, "");
|
|
479
|
+
return out || fallback;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function localAuthStatePath({ orgId, projectId, profile }) {
|
|
483
|
+
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
484
|
+
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
485
|
+
const profileSeg = safePathSegment(profile || "default", "default");
|
|
486
|
+
return join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function listLocalAuthProfiles({ orgId, projectId }) {
|
|
490
|
+
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
491
|
+
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
492
|
+
const dir = join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg);
|
|
493
|
+
if (!existsSync(dir)) return [];
|
|
494
|
+
try {
|
|
495
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
496
|
+
return entries
|
|
497
|
+
.filter((e) => e.isFile() && e.name.endsWith(".json"))
|
|
498
|
+
.map((e) => e.name.replace(/\.json$/i, ""))
|
|
499
|
+
.filter(Boolean)
|
|
500
|
+
.sort((a, b) => a.localeCompare(b));
|
|
501
|
+
} catch {
|
|
502
|
+
return [];
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
448
506
|
function sleep(ms) {
|
|
449
507
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
450
508
|
}
|
|
@@ -1087,6 +1145,203 @@ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
|
|
|
1087
1145
|
}
|
|
1088
1146
|
}
|
|
1089
1147
|
|
|
1148
|
+
function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
|
|
1149
|
+
if (parseBoolean(args["no-auth-state"], false)) return null;
|
|
1150
|
+
const requestedProfile = String(args["auth-profile"] || "").trim();
|
|
1151
|
+
const profile = requestedProfile;
|
|
1152
|
+
if (!profile) return null;
|
|
1153
|
+
const path = localAuthStatePath({ orgId, projectId, profile });
|
|
1154
|
+
if (!existsSync(path)) return null;
|
|
1155
|
+
return { profile, path };
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async function maybePromptAuthProfileForLocalRun({ args, orgId, projectId }) {
|
|
1159
|
+
const requestedProfile = String(args["auth-profile"] || "").trim();
|
|
1160
|
+
if (requestedProfile) return { authProfile: requestedProfile, noAuthState: false };
|
|
1161
|
+
if (parseBoolean(args["no-auth-state"], false)) return { authProfile: "", noAuthState: true };
|
|
1162
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return { authProfile: "", noAuthState: false };
|
|
1163
|
+
if (!projectId) return { authProfile: "", noAuthState: false };
|
|
1164
|
+
|
|
1165
|
+
const profiles = listLocalAuthProfiles({ orgId, projectId });
|
|
1166
|
+
if (profiles.length === 0) return { authProfile: "", noAuthState: false };
|
|
1167
|
+
|
|
1168
|
+
const projectCfg = readProjectConfig();
|
|
1169
|
+
const defaultProfile = String(projectCfg.local_auth_profile || "").trim();
|
|
1170
|
+
const defaultIndex = profiles.findIndex((p) => p === defaultProfile);
|
|
1171
|
+
|
|
1172
|
+
// eslint-disable-next-line no-console
|
|
1173
|
+
console.log("[qualty][auth] Choose saved login state:");
|
|
1174
|
+
// eslint-disable-next-line no-console
|
|
1175
|
+
console.log(" 0) Run without saved login state");
|
|
1176
|
+
for (let i = 0; i < profiles.length; i += 1) {
|
|
1177
|
+
const isDefault = defaultIndex === i ? " (default)" : "";
|
|
1178
|
+
// eslint-disable-next-line no-console
|
|
1179
|
+
console.log(` ${i + 1}) ${profiles[i]}${isDefault}`);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
const defaultChoice = defaultIndex >= 0 ? defaultIndex + 1 : 0;
|
|
1183
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1184
|
+
try {
|
|
1185
|
+
while (true) {
|
|
1186
|
+
const raw = String(
|
|
1187
|
+
await rl.question(`Select login state [0-${profiles.length}] (default ${defaultChoice}): `)
|
|
1188
|
+
).trim();
|
|
1189
|
+
const picked = raw ? Number(raw) : defaultChoice;
|
|
1190
|
+
if (!Number.isInteger(picked) || picked < 0 || picked > profiles.length) {
|
|
1191
|
+
// eslint-disable-next-line no-console
|
|
1192
|
+
console.log(`[qualty] Please enter a number from 0 to ${profiles.length}.`);
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
if (picked === 0) {
|
|
1196
|
+
// eslint-disable-next-line no-console
|
|
1197
|
+
console.log("[qualty][auth] Running without saved login state.");
|
|
1198
|
+
return { authProfile: "", noAuthState: true };
|
|
1199
|
+
}
|
|
1200
|
+
const profile = profiles[picked - 1];
|
|
1201
|
+
// eslint-disable-next-line no-console
|
|
1202
|
+
console.log(`[qualty][auth] Selected profile "${profile}"`);
|
|
1203
|
+
return { authProfile: profile, noAuthState: false };
|
|
1204
|
+
}
|
|
1205
|
+
} finally {
|
|
1206
|
+
rl.close();
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async function runAuthSetup(args) {
|
|
1211
|
+
const config = readQualtyConfig();
|
|
1212
|
+
const apiUrl = resolveApiUrl(args, config);
|
|
1213
|
+
const token = resolveToken(args, config);
|
|
1214
|
+
const orgId = resolveOrgId(args, config);
|
|
1215
|
+
if (!token) {
|
|
1216
|
+
throw new Error("Missing auth token. Run `qualty setup` first.");
|
|
1217
|
+
}
|
|
1218
|
+
if (!orgId) {
|
|
1219
|
+
throw new Error("Missing org. Run `qualty setup` first.");
|
|
1220
|
+
}
|
|
1221
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1222
|
+
throw new Error("Interactive auth setup requires a TTY terminal.");
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const projectsPayload = await apiRequest({
|
|
1226
|
+
apiUrl,
|
|
1227
|
+
token,
|
|
1228
|
+
orgId,
|
|
1229
|
+
path: "/api/v1/projects",
|
|
1230
|
+
});
|
|
1231
|
+
const projects = (Array.isArray(projectsPayload) ? projectsPayload : [])
|
|
1232
|
+
.map((p) => ({
|
|
1233
|
+
id: String(p?.id || "").trim(),
|
|
1234
|
+
name: String(p?.name || "").trim() || "Unnamed project",
|
|
1235
|
+
url: String(p?.url || "").trim(),
|
|
1236
|
+
}))
|
|
1237
|
+
.filter((p) => p.id);
|
|
1238
|
+
if (projects.length === 0) {
|
|
1239
|
+
throw new Error("No projects found. Create a project first.");
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1243
|
+
let project = null;
|
|
1244
|
+
try {
|
|
1245
|
+
const requestedProjectId = String(args.project || "").trim();
|
|
1246
|
+
if (requestedProjectId) {
|
|
1247
|
+
project = projects.find((p) => p.id === requestedProjectId) || null;
|
|
1248
|
+
if (!project) {
|
|
1249
|
+
throw new Error(`Project ${requestedProjectId} not found.`);
|
|
1250
|
+
}
|
|
1251
|
+
} else {
|
|
1252
|
+
// eslint-disable-next-line no-console
|
|
1253
|
+
console.log("[qualty] Choose project for saved local auth:");
|
|
1254
|
+
for (let i = 0; i < projects.length; i += 1) {
|
|
1255
|
+
// eslint-disable-next-line no-console
|
|
1256
|
+
console.log(` ${i + 1}) ${projects[i].name}`);
|
|
1257
|
+
}
|
|
1258
|
+
while (!project) {
|
|
1259
|
+
const raw = String(await rl.question(`Select project [1-${projects.length}] (default 1): `)).trim();
|
|
1260
|
+
const picked = raw ? Number(raw) : 1;
|
|
1261
|
+
if (Number.isInteger(picked) && picked >= 1 && picked <= projects.length) {
|
|
1262
|
+
project = projects[picked - 1];
|
|
1263
|
+
} else {
|
|
1264
|
+
// eslint-disable-next-line no-console
|
|
1265
|
+
console.log(`[qualty] Please enter a number from 1 to ${projects.length}.`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
let profile = String(args.profile || "").trim();
|
|
1271
|
+
if (!profile) {
|
|
1272
|
+
const current = String(readProjectConfig().local_auth_profile || "default").trim() || "default";
|
|
1273
|
+
profile = String(await rl.question(`Profile name [${current}]: `)).trim() || current;
|
|
1274
|
+
}
|
|
1275
|
+
const statePath = localAuthStatePath({
|
|
1276
|
+
orgId,
|
|
1277
|
+
projectId: project.id,
|
|
1278
|
+
profile,
|
|
1279
|
+
});
|
|
1280
|
+
mkdirSync(join(statePath, ".."), { recursive: true });
|
|
1281
|
+
|
|
1282
|
+
const { chromium } = await import("playwright");
|
|
1283
|
+
const headed = parseBoolean(args.headed, true);
|
|
1284
|
+
let browser = null;
|
|
1285
|
+
try {
|
|
1286
|
+
browser = await chromium.launch({
|
|
1287
|
+
headless: !headed,
|
|
1288
|
+
channel: "chrome",
|
|
1289
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
1290
|
+
});
|
|
1291
|
+
// eslint-disable-next-line no-console
|
|
1292
|
+
console.log("[qualty][auth] Using local Chrome channel for login setup.");
|
|
1293
|
+
} catch (err) {
|
|
1294
|
+
// eslint-disable-next-line no-console
|
|
1295
|
+
console.log(`[qualty][auth] Chrome channel unavailable, falling back to Playwright Chromium (${err?.message || err}).`);
|
|
1296
|
+
browser = await chromium.launch({
|
|
1297
|
+
headless: !headed,
|
|
1298
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
const context = await browser.newContext();
|
|
1302
|
+
const page = await context.newPage();
|
|
1303
|
+
const startUrl = project.url || "about:blank";
|
|
1304
|
+
await page.goto(startUrl, { waitUntil: "domcontentloaded", timeout: 60000 }).catch(() => {});
|
|
1305
|
+
// eslint-disable-next-line no-console
|
|
1306
|
+
console.log(`[qualty][auth] Browser opened for project "${project.name}".`);
|
|
1307
|
+
// eslint-disable-next-line no-console
|
|
1308
|
+
console.log("[qualty][auth] Complete login in the browser, then press Enter here to save context.");
|
|
1309
|
+
await rl.question("Press Enter to save local auth context...");
|
|
1310
|
+
await context.storageState({ path: statePath });
|
|
1311
|
+
await context.close();
|
|
1312
|
+
await browser.close();
|
|
1313
|
+
|
|
1314
|
+
writeProjectConfig({
|
|
1315
|
+
local_auth_project_id: project.id,
|
|
1316
|
+
local_auth_profile: profile,
|
|
1317
|
+
default_org_id: orgId,
|
|
1318
|
+
});
|
|
1319
|
+
try {
|
|
1320
|
+
const synced = await registerLocalLoginProfileBinding({
|
|
1321
|
+
apiUrl,
|
|
1322
|
+
token,
|
|
1323
|
+
orgId,
|
|
1324
|
+
projectId: project.id,
|
|
1325
|
+
profileName: profile,
|
|
1326
|
+
localProfileName: profile,
|
|
1327
|
+
});
|
|
1328
|
+
if (synced?.name) {
|
|
1329
|
+
// eslint-disable-next-line no-console
|
|
1330
|
+
console.log(`[qualty][auth] Synced profile reference "${synced.name}" to dashboard.`);
|
|
1331
|
+
}
|
|
1332
|
+
} catch (syncErr) {
|
|
1333
|
+
// eslint-disable-next-line no-console
|
|
1334
|
+
console.warn(`[qualty][auth] Failed to sync profile reference to backend: ${syncErr?.message || syncErr}`);
|
|
1335
|
+
}
|
|
1336
|
+
// eslint-disable-next-line no-console
|
|
1337
|
+
console.log(`[qualty][auth] Saved local context profile "${profile}" for project "${project.name}".`);
|
|
1338
|
+
// eslint-disable-next-line no-console
|
|
1339
|
+
console.log(`[qualty][auth] Path: ${statePath}`);
|
|
1340
|
+
} finally {
|
|
1341
|
+
rl.close();
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1090
1345
|
async function main() {
|
|
1091
1346
|
const args = parseArgs(process.argv);
|
|
1092
1347
|
const command = args._[0];
|
|
@@ -1102,6 +1357,14 @@ async function main() {
|
|
|
1102
1357
|
await runSetup(args);
|
|
1103
1358
|
return;
|
|
1104
1359
|
}
|
|
1360
|
+
if (command === "auth") {
|
|
1361
|
+
const sub = args._[1];
|
|
1362
|
+
if (sub === "setup") {
|
|
1363
|
+
await runAuthSetup(args);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
throw new Error("Unknown auth subcommand. Use: qualty auth setup");
|
|
1367
|
+
}
|
|
1105
1368
|
if (command === "run") {
|
|
1106
1369
|
const config = readQualtyConfig();
|
|
1107
1370
|
if (parseBoolean(args.local, false)) {
|
|
@@ -1122,6 +1385,27 @@ async function main() {
|
|
|
1122
1385
|
token,
|
|
1123
1386
|
orgId,
|
|
1124
1387
|
})) || args["suite-id"];
|
|
1388
|
+
const shouldPromptAuthProfile = parseBoolean(args["prompt-auth-profile"], false);
|
|
1389
|
+
const authChoice = shouldPromptAuthProfile
|
|
1390
|
+
? await maybePromptAuthProfileForLocalRun({
|
|
1391
|
+
args,
|
|
1392
|
+
orgId,
|
|
1393
|
+
projectId,
|
|
1394
|
+
})
|
|
1395
|
+
: {
|
|
1396
|
+
authProfile: String(args["auth-profile"] || "").trim(),
|
|
1397
|
+
noAuthState: parseBoolean(args["no-auth-state"], false),
|
|
1398
|
+
};
|
|
1399
|
+
const localAuthArgs = {
|
|
1400
|
+
...args,
|
|
1401
|
+
...(authChoice.authProfile ? { "auth-profile": authChoice.authProfile } : {}),
|
|
1402
|
+
...(authChoice.noAuthState ? { "no-auth-state": true } : {}),
|
|
1403
|
+
};
|
|
1404
|
+
const localAuth = resolveLocalAuthStateForRun({ args: localAuthArgs, orgId, projectId });
|
|
1405
|
+
if (localAuth?.path) {
|
|
1406
|
+
// eslint-disable-next-line no-console
|
|
1407
|
+
console.log(`[qualty][auth] Reusing profile "${localAuth.profile}"`);
|
|
1408
|
+
}
|
|
1125
1409
|
await runLocalCi({
|
|
1126
1410
|
apiUrl,
|
|
1127
1411
|
token,
|
|
@@ -1138,6 +1422,9 @@ async function main() {
|
|
|
1138
1422
|
device: args.device || "1440x900",
|
|
1139
1423
|
headless: !parseBoolean(args.headed, false),
|
|
1140
1424
|
localConcurrency: resolveLocalConcurrency(args),
|
|
1425
|
+
authProfile: localAuth?.profile || authChoice.authProfile || "",
|
|
1426
|
+
noAuthState: Boolean(authChoice.noAuthState || parseBoolean(args["no-auth-state"], false)),
|
|
1427
|
+
storageStatePath: localAuth?.path,
|
|
1141
1428
|
});
|
|
1142
1429
|
return;
|
|
1143
1430
|
}
|
|
@@ -1148,7 +1435,13 @@ async function main() {
|
|
|
1148
1435
|
await runResolve(args);
|
|
1149
1436
|
return;
|
|
1150
1437
|
}
|
|
1151
|
-
if (
|
|
1438
|
+
if (
|
|
1439
|
+
command !== "setup" &&
|
|
1440
|
+
command !== "auth" &&
|
|
1441
|
+
command !== "connect" &&
|
|
1442
|
+
command !== "run" &&
|
|
1443
|
+
command !== "resolve"
|
|
1444
|
+
) {
|
|
1152
1445
|
throw new Error(`Unknown command: ${command}`);
|
|
1153
1446
|
}
|
|
1154
1447
|
}
|