qualty 0.1.10 → 0.1.12
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 +299 -5
- package/bin/qualty.js +105 -16
- package/package.json +1 -1
package/bin/local-runner.js
CHANGED
|
@@ -1,6 +1,252 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync, mkdtempSync, existsSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
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,8 @@ export async function runLocalExecution({
|
|
|
754
1000
|
executionId,
|
|
755
1001
|
device,
|
|
756
1002
|
headless,
|
|
1003
|
+
authProfile,
|
|
1004
|
+
noAuthState,
|
|
757
1005
|
storageStatePath,
|
|
758
1006
|
}) {
|
|
759
1007
|
const { chromium } = await import("playwright");
|
|
@@ -771,6 +1019,36 @@ export async function runLocalExecution({
|
|
|
771
1019
|
const viewport = claim.viewport || { width: 1440, height: 900 };
|
|
772
1020
|
const steps = Array.isArray(claim.steps) ? claim.steps : [];
|
|
773
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;
|
|
774
1052
|
|
|
775
1053
|
const browserServer = await chromium.launchServer({
|
|
776
1054
|
headless: headless !== false,
|
|
@@ -781,9 +1059,10 @@ export async function runLocalExecution({
|
|
|
781
1059
|
const contextOptions = {
|
|
782
1060
|
viewport: { width: viewport.width || 1440, height: viewport.height || 900 },
|
|
783
1061
|
};
|
|
784
|
-
if (
|
|
785
|
-
contextOptions.storageState =
|
|
786
|
-
|
|
1062
|
+
if (finalStoragePath) {
|
|
1063
|
+
contextOptions.storageState = finalStoragePath;
|
|
1064
|
+
const logProfile = effectiveProfile || "default";
|
|
1065
|
+
console.log(`[qualty][auth] using saved context "${logProfile}": ${finalStoragePath}`);
|
|
787
1066
|
}
|
|
788
1067
|
const context = await browser.newContext(contextOptions);
|
|
789
1068
|
const page = await context.newPage();
|
|
@@ -991,6 +1270,8 @@ export async function runLocalCi({
|
|
|
991
1270
|
device,
|
|
992
1271
|
headless,
|
|
993
1272
|
localConcurrency = 4,
|
|
1273
|
+
authProfile,
|
|
1274
|
+
noAuthState,
|
|
994
1275
|
storageStatePath,
|
|
995
1276
|
}) {
|
|
996
1277
|
const savedJobs = await resolveSavedJobs({ apiUrl, token, orgId, projectId, suiteId, explicitIds });
|
|
@@ -1016,6 +1297,17 @@ export async function runLocalCi({
|
|
|
1016
1297
|
const started = runs.filter((r) => r.execution_job_id);
|
|
1017
1298
|
if (started.length === 0) throw new Error("Failed to start any local runs.");
|
|
1018
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
|
+
|
|
1019
1311
|
const concurrency = Math.max(1, Number(localConcurrency) || 4);
|
|
1020
1312
|
const workerCount = Math.min(concurrency, started.length);
|
|
1021
1313
|
|
|
@@ -1047,6 +1339,8 @@ export async function runLocalCi({
|
|
|
1047
1339
|
executionId: id,
|
|
1048
1340
|
device,
|
|
1049
1341
|
headless,
|
|
1342
|
+
authProfile,
|
|
1343
|
+
noAuthState,
|
|
1050
1344
|
storageStatePath,
|
|
1051
1345
|
});
|
|
1052
1346
|
if (result.success) {
|
package/bin/qualty.js
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
|
|
3
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");
|
|
@@ -137,7 +139,7 @@ function usage() {
|
|
|
137
139
|
" qualty connect --project <project-id> [--port 3000] [--token <bearer-token>] [--org <org-id>]",
|
|
138
140
|
" qualty run --project <project-id> [--suite-id <suite-id>] [--ids <id1,id2>] [--local] [--token <bearer-token>] [--org <org-id>]",
|
|
139
141
|
" [--poll-interval 5] [--timeout 30] [--fail-on-failure true] [--no-view-logs]",
|
|
140
|
-
" [--local] [--device 1440x900] [--headed] [--local-concurrency 4] [--auth-profile <profile>] [--no-auth-state]",
|
|
142
|
+
" [--local] [--device 1440x900] [--headed] [--local-concurrency 4] [--auth-profile <profile>] [--no-auth-state] [--prompt-auth-profile]",
|
|
141
143
|
"",
|
|
142
144
|
" After each run, logs include step results, explanation, and final evaluator output (dashboard \"View logs\" data).",
|
|
143
145
|
" Pass --no-view-logs for a short log only (e.g. very large evaluator text).",
|
|
@@ -170,6 +172,29 @@ async function apiRequest({ apiUrl, token, orgId, path, method = "GET", body })
|
|
|
170
172
|
return data;
|
|
171
173
|
}
|
|
172
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
|
+
|
|
173
198
|
function startCloudflared(token, port) {
|
|
174
199
|
const baseArgs = [
|
|
175
200
|
"tunnel",
|
|
@@ -1122,13 +1147,8 @@ async function maybePromptProjectIdForLocalRun({ args, apiUrl, token, orgId }) {
|
|
|
1122
1147
|
|
|
1123
1148
|
function resolveLocalAuthStateForRun({ args, orgId, projectId }) {
|
|
1124
1149
|
if (parseBoolean(args["no-auth-state"], false)) return null;
|
|
1125
|
-
const projectCfg = readProjectConfig();
|
|
1126
1150
|
const requestedProfile = String(args["auth-profile"] || "").trim();
|
|
1127
|
-
const
|
|
1128
|
-
const savedProfile = String(projectCfg.local_auth_profile || "").trim();
|
|
1129
|
-
const profile =
|
|
1130
|
-
requestedProfile ||
|
|
1131
|
-
(savedProjectId && String(projectId || "").trim() === savedProjectId ? savedProfile : "");
|
|
1151
|
+
const profile = requestedProfile;
|
|
1132
1152
|
if (!profile) return null;
|
|
1133
1153
|
const path = localAuthStatePath({ orgId, projectId, profile });
|
|
1134
1154
|
if (!existsSync(path)) return null;
|
|
@@ -1249,8 +1269,52 @@ async function runAuthSetup(args) {
|
|
|
1249
1269
|
|
|
1250
1270
|
let profile = String(args.profile || "").trim();
|
|
1251
1271
|
if (!profile) {
|
|
1252
|
-
const
|
|
1253
|
-
|
|
1272
|
+
const existingProfilesPayload = await apiRequest({
|
|
1273
|
+
apiUrl,
|
|
1274
|
+
token,
|
|
1275
|
+
orgId,
|
|
1276
|
+
path: `/api/v1/projects/${encodeURIComponent(project.id)}/saved-login-profiles`,
|
|
1277
|
+
}).catch(() => []);
|
|
1278
|
+
const existingProfileNames = (Array.isArray(existingProfilesPayload) ? existingProfilesPayload : [])
|
|
1279
|
+
.map((p) => String(p?.name || "").trim())
|
|
1280
|
+
.filter(Boolean);
|
|
1281
|
+
|
|
1282
|
+
const suggestedName = "default";
|
|
1283
|
+
if (existingProfileNames.length > 0) {
|
|
1284
|
+
// eslint-disable-next-line no-console
|
|
1285
|
+
console.log("[qualty][auth] Found existing saved login profiles on dashboard:");
|
|
1286
|
+
// eslint-disable-next-line no-console
|
|
1287
|
+
console.log(" 0) Create new profile");
|
|
1288
|
+
for (let i = 0; i < existingProfileNames.length; i += 1) {
|
|
1289
|
+
const name = existingProfileNames[i];
|
|
1290
|
+
// eslint-disable-next-line no-console
|
|
1291
|
+
console.log(` ${i + 1}) ${name}`);
|
|
1292
|
+
}
|
|
1293
|
+
while (!profile) {
|
|
1294
|
+
const raw = String(
|
|
1295
|
+
await rl.question(`Select profile [0-${existingProfileNames.length}]: `)
|
|
1296
|
+
).trim();
|
|
1297
|
+
const picked = Number(raw);
|
|
1298
|
+
if (!Number.isInteger(picked) || picked < 0 || picked > existingProfileNames.length) {
|
|
1299
|
+
// eslint-disable-next-line no-console
|
|
1300
|
+
console.log(`[qualty] Please enter a number from 0 to ${existingProfileNames.length}.`);
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
1303
|
+
if (picked === 0) {
|
|
1304
|
+
const proposed = String(await rl.question(`New profile name [${suggestedName}]: `)).trim() || suggestedName;
|
|
1305
|
+
if (!proposed) {
|
|
1306
|
+
// eslint-disable-next-line no-console
|
|
1307
|
+
console.log("[qualty] Profile name cannot be empty.");
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
profile = proposed;
|
|
1311
|
+
} else {
|
|
1312
|
+
profile = existingProfileNames[picked - 1];
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
} else {
|
|
1316
|
+
profile = String(await rl.question(`Profile name [${suggestedName}]: `)).trim() || suggestedName;
|
|
1317
|
+
}
|
|
1254
1318
|
}
|
|
1255
1319
|
const statePath = localAuthStatePath({
|
|
1256
1320
|
orgId,
|
|
@@ -1296,6 +1360,23 @@ async function runAuthSetup(args) {
|
|
|
1296
1360
|
local_auth_profile: profile,
|
|
1297
1361
|
default_org_id: orgId,
|
|
1298
1362
|
});
|
|
1363
|
+
try {
|
|
1364
|
+
const synced = await registerLocalLoginProfileBinding({
|
|
1365
|
+
apiUrl,
|
|
1366
|
+
token,
|
|
1367
|
+
orgId,
|
|
1368
|
+
projectId: project.id,
|
|
1369
|
+
profileName: profile,
|
|
1370
|
+
localProfileName: profile,
|
|
1371
|
+
});
|
|
1372
|
+
if (synced?.name) {
|
|
1373
|
+
// eslint-disable-next-line no-console
|
|
1374
|
+
console.log(`[qualty][auth] Synced profile reference "${synced.name}" to dashboard.`);
|
|
1375
|
+
}
|
|
1376
|
+
} catch (syncErr) {
|
|
1377
|
+
// eslint-disable-next-line no-console
|
|
1378
|
+
console.warn(`[qualty][auth] Failed to sync profile reference to backend: ${syncErr?.message || syncErr}`);
|
|
1379
|
+
}
|
|
1299
1380
|
// eslint-disable-next-line no-console
|
|
1300
1381
|
console.log(`[qualty][auth] Saved local context profile "${profile}" for project "${project.name}".`);
|
|
1301
1382
|
// eslint-disable-next-line no-console
|
|
@@ -1348,11 +1429,17 @@ async function main() {
|
|
|
1348
1429
|
token,
|
|
1349
1430
|
orgId,
|
|
1350
1431
|
})) || args["suite-id"];
|
|
1351
|
-
const
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1432
|
+
const shouldPromptAuthProfile = parseBoolean(args["prompt-auth-profile"], false);
|
|
1433
|
+
const authChoice = shouldPromptAuthProfile
|
|
1434
|
+
? await maybePromptAuthProfileForLocalRun({
|
|
1435
|
+
args,
|
|
1436
|
+
orgId,
|
|
1437
|
+
projectId,
|
|
1438
|
+
})
|
|
1439
|
+
: {
|
|
1440
|
+
authProfile: String(args["auth-profile"] || "").trim(),
|
|
1441
|
+
noAuthState: parseBoolean(args["no-auth-state"], false),
|
|
1442
|
+
};
|
|
1356
1443
|
const localAuthArgs = {
|
|
1357
1444
|
...args,
|
|
1358
1445
|
...(authChoice.authProfile ? { "auth-profile": authChoice.authProfile } : {}),
|
|
@@ -1379,6 +1466,8 @@ async function main() {
|
|
|
1379
1466
|
device: args.device || "1440x900",
|
|
1380
1467
|
headless: !parseBoolean(args.headed, false),
|
|
1381
1468
|
localConcurrency: resolveLocalConcurrency(args),
|
|
1469
|
+
authProfile: localAuth?.profile || authChoice.authProfile || "",
|
|
1470
|
+
noAuthState: Boolean(authChoice.noAuthState || parseBoolean(args["no-auth-state"], false)),
|
|
1382
1471
|
storageStatePath: localAuth?.path,
|
|
1383
1472
|
});
|
|
1384
1473
|
return;
|