qualty 0.1.30 → 0.1.32
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/auth-ci-bundle.js +348 -0
- package/bin/local-runner.js +31 -7
- package/bin/qualty.js +721 -155
- package/package.json +1 -1
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const AUTH_BUNDLE_VERSION = 2;
|
|
7
|
+
export const GITHUB_SECRET_MAX_BYTES = 64 * 1024;
|
|
8
|
+
export const GITHUB_SECRET_SAFE_BYTES = GITHUB_SECRET_MAX_BYTES - 1024;
|
|
9
|
+
export const AUTH_SECRET_PRIMARY = "QUALTY_AUTH_STATE_B64";
|
|
10
|
+
export const AUTH_SHARDS_VARIABLE = "QUALTY_AUTH_SHARDS";
|
|
11
|
+
export const AUTH_PROFILES_VARIABLE = "QUALTY_AUTH_PROFILES";
|
|
12
|
+
export const MAX_AUTH_SHARDS = 10;
|
|
13
|
+
|
|
14
|
+
const QUALTY_SHARED_CREDS_DIR = join(homedir(), ".qualty");
|
|
15
|
+
|
|
16
|
+
export function safePathSegment(value, fallback = "default") {
|
|
17
|
+
const out = String(value || "")
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/[^a-zA-Z0-9._-]+/g, "_")
|
|
20
|
+
.replace(/^_+|_+$/g, "");
|
|
21
|
+
return out || fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function localAuthStatePath({ orgId, projectId, profile }) {
|
|
25
|
+
const orgSeg = safePathSegment(orgId || "default-org", "default-org");
|
|
26
|
+
const projectSeg = safePathSegment(projectId || "default-project", "default-project");
|
|
27
|
+
const profileSeg = safePathSegment(profile || "default", "default");
|
|
28
|
+
return join(QUALTY_SHARED_CREDS_DIR, "local-contexts", orgSeg, projectSeg, `${profileSeg}.json`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function authShardSecretName(shardIndex) {
|
|
32
|
+
const index = Number(shardIndex);
|
|
33
|
+
if (!Number.isInteger(index) || index < 1) {
|
|
34
|
+
throw new Error(`Invalid auth shard index: ${shardIndex}`);
|
|
35
|
+
}
|
|
36
|
+
return index === 1 ? AUTH_SECRET_PRIMARY : `${AUTH_SECRET_PRIMARY}_${index}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function loadStorageStateFromPath(statePath) {
|
|
40
|
+
const raw = readFileSync(statePath, "utf8");
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(raw);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new Error(`Invalid auth JSON at ${statePath}: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
48
|
+
throw new Error(`Invalid auth JSON at ${statePath}: expected an object`);
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildAuthBundleManifest({ orgId, projectId, profileEntries }) {
|
|
54
|
+
const profiles = {};
|
|
55
|
+
for (const entry of profileEntries) {
|
|
56
|
+
const name = String(entry.name || "").trim();
|
|
57
|
+
if (!name) continue;
|
|
58
|
+
const localProfileName =
|
|
59
|
+
String(entry.localProfileName || entry.local_profile_name || name).trim() || name;
|
|
60
|
+
const state =
|
|
61
|
+
entry.state && typeof entry.state === "object"
|
|
62
|
+
? entry.state
|
|
63
|
+
: loadStorageStateFromPath(entry.statePath);
|
|
64
|
+
profiles[name] = {
|
|
65
|
+
local_profile_name: localProfileName,
|
|
66
|
+
state,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (Object.keys(profiles).length === 0) {
|
|
70
|
+
throw new Error("No auth profiles to export.");
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
version: AUTH_BUNDLE_VERSION,
|
|
74
|
+
org_id: String(orgId || "").trim(),
|
|
75
|
+
project_id: String(projectId || "").trim(),
|
|
76
|
+
profiles,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function encodeAuthBundleManifest(manifest) {
|
|
81
|
+
const raw = Buffer.from(JSON.stringify(manifest), "utf8");
|
|
82
|
+
return {
|
|
83
|
+
encoding: "gzip+base64",
|
|
84
|
+
payload: gzipSync(raw).toString("base64"),
|
|
85
|
+
rawBytes: raw.length,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isPlaywrightStorageState(value) {
|
|
90
|
+
return (
|
|
91
|
+
value &&
|
|
92
|
+
typeof value === "object" &&
|
|
93
|
+
!Array.isArray(value) &&
|
|
94
|
+
(Array.isArray(value.cookies) || Array.isArray(value.origins))
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function decodeAuthPayload(payloadB64) {
|
|
99
|
+
const trimmed = String(payloadB64 || "").trim();
|
|
100
|
+
if (!trimmed) {
|
|
101
|
+
throw new Error("Empty auth payload.");
|
|
102
|
+
}
|
|
103
|
+
let decoded;
|
|
104
|
+
try {
|
|
105
|
+
decoded = Buffer.from(trimmed, "base64");
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw new Error(`Invalid base64 auth payload: ${err.message}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let jsonText;
|
|
111
|
+
try {
|
|
112
|
+
jsonText = gunzipSync(decoded).toString("utf8");
|
|
113
|
+
} catch {
|
|
114
|
+
jsonText = decoded.toString("utf8");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let parsed;
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(jsonText);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
throw new Error(`Auth payload is not valid JSON: ${err.message}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (parsed?.version === AUTH_BUNDLE_VERSION && parsed?.profiles && typeof parsed.profiles === "object") {
|
|
125
|
+
return { format: "bundle", manifest: parsed };
|
|
126
|
+
}
|
|
127
|
+
if (isPlaywrightStorageState(parsed)) {
|
|
128
|
+
return { format: "legacy-single", state: parsed };
|
|
129
|
+
}
|
|
130
|
+
throw new Error("Unrecognized auth payload format.");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function manifestSubset(manifest, profileNames) {
|
|
134
|
+
const profiles = {};
|
|
135
|
+
for (const name of profileNames) {
|
|
136
|
+
if (manifest.profiles?.[name]) {
|
|
137
|
+
profiles[name] = manifest.profiles[name];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
version: AUTH_BUNDLE_VERSION,
|
|
142
|
+
org_id: manifest.org_id,
|
|
143
|
+
project_id: manifest.project_id,
|
|
144
|
+
profiles,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function shardAuthBundleManifest(manifest) {
|
|
149
|
+
const profileNames = Object.keys(manifest.profiles || {});
|
|
150
|
+
if (profileNames.length === 0) {
|
|
151
|
+
throw new Error("Auth bundle has no profiles.");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const singles = profileNames.map((name) => {
|
|
155
|
+
const encoded = encodeAuthBundleManifest(manifestSubset(manifest, [name]));
|
|
156
|
+
return { name, encoded, bytes: Buffer.byteLength(encoded.payload, "utf8") };
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const allEncoded = encodeAuthBundleManifest(manifest);
|
|
160
|
+
if (Buffer.byteLength(allEncoded.payload, "utf8") <= GITHUB_SECRET_SAFE_BYTES) {
|
|
161
|
+
return [
|
|
162
|
+
{
|
|
163
|
+
secretName: AUTH_SECRET_PRIMARY,
|
|
164
|
+
payload: allEncoded.payload,
|
|
165
|
+
encoding: allEncoded.encoding,
|
|
166
|
+
rawBytes: allEncoded.rawBytes,
|
|
167
|
+
profileNames,
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const shards = [];
|
|
173
|
+
let currentNames = [];
|
|
174
|
+
let currentBytes = 0;
|
|
175
|
+
|
|
176
|
+
const flush = () => {
|
|
177
|
+
if (currentNames.length === 0) return;
|
|
178
|
+
const encoded = encodeAuthBundleManifest(manifestSubset(manifest, currentNames));
|
|
179
|
+
const bytes = Buffer.byteLength(encoded.payload, "utf8");
|
|
180
|
+
if (bytes > GITHUB_SECRET_SAFE_BYTES) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Profile "${currentNames.join(", ")}" is too large for a GitHub secret on its own ` +
|
|
183
|
+
`(${bytes} bytes; limit ~${GITHUB_SECRET_SAFE_BYTES}). Try a shorter-lived session.`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
shards.push({
|
|
187
|
+
secretName: authShardSecretName(shards.length + 1),
|
|
188
|
+
payload: encoded.payload,
|
|
189
|
+
encoding: encoded.encoding,
|
|
190
|
+
rawBytes: encoded.rawBytes,
|
|
191
|
+
profileNames: [...currentNames],
|
|
192
|
+
});
|
|
193
|
+
currentNames = [];
|
|
194
|
+
currentBytes = 0;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
for (const row of singles) {
|
|
198
|
+
if (row.bytes > GITHUB_SECRET_SAFE_BYTES) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Profile "${row.name}" is too large for a GitHub secret (${row.bytes} bytes; limit ~${GITHUB_SECRET_SAFE_BYTES}).`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const nextNames = [...currentNames, row.name];
|
|
204
|
+
const nextEncoded = encodeAuthBundleManifest(manifestSubset(manifest, nextNames));
|
|
205
|
+
const nextBytes = Buffer.byteLength(nextEncoded.payload, "utf8");
|
|
206
|
+
if (nextBytes > GITHUB_SECRET_SAFE_BYTES && currentNames.length > 0) {
|
|
207
|
+
flush();
|
|
208
|
+
}
|
|
209
|
+
currentNames.push(row.name);
|
|
210
|
+
currentBytes = Buffer.byteLength(
|
|
211
|
+
encodeAuthBundleManifest(manifestSubset(manifest, currentNames)).payload,
|
|
212
|
+
"utf8"
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
flush();
|
|
216
|
+
|
|
217
|
+
if (shards.length > MAX_AUTH_SHARDS) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`Auth bundle requires ${shards.length} GitHub secrets (max ${MAX_AUTH_SHARDS}). ` +
|
|
220
|
+
"Export fewer profiles with --profiles or --suite-id / --ids."
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return shards;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function collectAuthShardPayloadsFromEnv(env = process.env) {
|
|
227
|
+
const payloads = [];
|
|
228
|
+
for (let index = 1; index <= MAX_AUTH_SHARDS; index += 1) {
|
|
229
|
+
const secretName = authShardSecretName(index);
|
|
230
|
+
const value = String(env[secretName] || "").trim();
|
|
231
|
+
if (!value) break;
|
|
232
|
+
payloads.push({ secretName, payload: value });
|
|
233
|
+
}
|
|
234
|
+
return payloads;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function mergeAuthManifests(manifests) {
|
|
238
|
+
const mergedProfiles = {};
|
|
239
|
+
let orgId = "";
|
|
240
|
+
let projectId = "";
|
|
241
|
+
for (const manifest of manifests) {
|
|
242
|
+
orgId = orgId || String(manifest.org_id || "").trim();
|
|
243
|
+
projectId = projectId || String(manifest.project_id || "").trim();
|
|
244
|
+
for (const [name, entry] of Object.entries(manifest.profiles || {})) {
|
|
245
|
+
mergedProfiles[name] = entry;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
version: AUTH_BUNDLE_VERSION,
|
|
250
|
+
org_id: orgId,
|
|
251
|
+
project_id: projectId,
|
|
252
|
+
profiles: mergedProfiles,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function restoreAuthBundleToDisk({
|
|
257
|
+
orgId,
|
|
258
|
+
projectId,
|
|
259
|
+
manifest,
|
|
260
|
+
fallbackProfile = "",
|
|
261
|
+
}) {
|
|
262
|
+
const restored = [];
|
|
263
|
+
for (const [name, entry] of Object.entries(manifest.profiles || {})) {
|
|
264
|
+
const localProfileName =
|
|
265
|
+
String(entry?.local_profile_name || name).trim() || name;
|
|
266
|
+
const state = entry?.state;
|
|
267
|
+
if (!isPlaywrightStorageState(state)) {
|
|
268
|
+
throw new Error(`Profile "${name}" has invalid storage state in auth bundle.`);
|
|
269
|
+
}
|
|
270
|
+
const authFile = localAuthStatePath({
|
|
271
|
+
orgId,
|
|
272
|
+
projectId,
|
|
273
|
+
profile: localProfileName,
|
|
274
|
+
});
|
|
275
|
+
mkdirSync(join(authFile, ".."), { recursive: true });
|
|
276
|
+
writeFileSync(authFile, `${JSON.stringify(state)}\n`, "utf8");
|
|
277
|
+
try {
|
|
278
|
+
chmodSync(authFile, 0o600);
|
|
279
|
+
} catch {
|
|
280
|
+
// ignore
|
|
281
|
+
}
|
|
282
|
+
restored.push({ name, localProfileName, path: authFile });
|
|
283
|
+
}
|
|
284
|
+
return restored;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function restoreLegacySingleAuthToDisk({ orgId, projectId, state, fallbackProfile }) {
|
|
288
|
+
const profile = String(fallbackProfile || "").trim();
|
|
289
|
+
if (!profile) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
"Legacy single-profile auth payload requires QUALTY_AUTH_PROFILE (or --profile)."
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const authFile = localAuthStatePath({ orgId, projectId, profile });
|
|
295
|
+
mkdirSync(join(authFile, ".."), { recursive: true });
|
|
296
|
+
writeFileSync(authFile, `${JSON.stringify(state)}\n`, "utf8");
|
|
297
|
+
try {
|
|
298
|
+
chmodSync(authFile, 0o600);
|
|
299
|
+
} catch {
|
|
300
|
+
// ignore
|
|
301
|
+
}
|
|
302
|
+
return [{ name: profile, localProfileName: profile, path: authFile }];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function restoreAuthPayloadsToDisk({
|
|
306
|
+
payloads,
|
|
307
|
+
orgId,
|
|
308
|
+
projectId,
|
|
309
|
+
fallbackProfile = "",
|
|
310
|
+
}) {
|
|
311
|
+
const manifests = [];
|
|
312
|
+
let legacySingle = null;
|
|
313
|
+
|
|
314
|
+
for (const row of payloads) {
|
|
315
|
+
const decoded = decodeAuthPayload(row.payload);
|
|
316
|
+
if (decoded.format === "bundle") {
|
|
317
|
+
manifests.push(decoded.manifest);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (legacySingle) {
|
|
321
|
+
throw new Error("Multiple legacy single-profile auth payloads are not supported.");
|
|
322
|
+
}
|
|
323
|
+
legacySingle = decoded.state;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (manifests.length > 0) {
|
|
327
|
+
const merged = mergeAuthManifests(manifests);
|
|
328
|
+
const effectiveOrgId = String(orgId || merged.org_id || "").trim();
|
|
329
|
+
const effectiveProjectId = String(projectId || merged.project_id || "").trim();
|
|
330
|
+
return restoreAuthBundleToDisk({
|
|
331
|
+
orgId: effectiveOrgId,
|
|
332
|
+
projectId: effectiveProjectId,
|
|
333
|
+
manifest: merged,
|
|
334
|
+
fallbackProfile,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (legacySingle) {
|
|
339
|
+
return restoreLegacySingleAuthToDisk({
|
|
340
|
+
orgId,
|
|
341
|
+
projectId,
|
|
342
|
+
state: legacySingle,
|
|
343
|
+
fallbackProfile,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
throw new Error("No auth payloads to restore.");
|
|
348
|
+
}
|
package/bin/local-runner.js
CHANGED
|
@@ -289,6 +289,19 @@ function authSetupCommand(projectId, profileName) {
|
|
|
289
289
|
return `qualty auth setup --project ${pid} --profile "${name}"`;
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
function loadStorageStateForBackend(statePath) {
|
|
293
|
+
if (!statePath) return null;
|
|
294
|
+
try {
|
|
295
|
+
const parsed = JSON.parse(readFileSync(statePath, "utf8"));
|
|
296
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
297
|
+
return parsed;
|
|
298
|
+
} catch (err) {
|
|
299
|
+
// eslint-disable-next-line no-console
|
|
300
|
+
console.warn(`[qualty][auth] could not read local auth state for backend injection: ${err?.message || err}`);
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
292
305
|
export function summarizeStorageStateFile(statePath) {
|
|
293
306
|
const path = String(statePath || "").trim();
|
|
294
307
|
if (!path || !existsSync(path)) {
|
|
@@ -774,8 +787,9 @@ async function runLocalSingleCombination({
|
|
|
774
787
|
explicitAuthProfile: String(authProfile || "").trim(),
|
|
775
788
|
noAuthState: Boolean(noAuthState),
|
|
776
789
|
});
|
|
790
|
+
const explicitAuthProfile = String(authProfile || "").trim();
|
|
777
791
|
const claimProfile = localProfileLabel(claimSavedLogin);
|
|
778
|
-
const effectiveProfile =
|
|
792
|
+
const effectiveProfile = explicitAuthProfile || setupFromClaim?.profile || claimProfile;
|
|
779
793
|
const resolvedStoragePath =
|
|
780
794
|
!noAuthState && effectiveProfile
|
|
781
795
|
? localAuthStatePath({
|
|
@@ -787,17 +801,20 @@ async function runLocalSingleCombination({
|
|
|
787
801
|
const finalStoragePath =
|
|
788
802
|
!noAuthState && setupFromClaim?.path && existsSync(setupFromClaim.path)
|
|
789
803
|
? setupFromClaim.path
|
|
790
|
-
: !noAuthState &&
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
804
|
+
: !noAuthState &&
|
|
805
|
+
explicitAuthProfile &&
|
|
806
|
+
storageStatePath &&
|
|
807
|
+
existsSync(storageStatePath)
|
|
808
|
+
? storageStatePath
|
|
809
|
+
: !noAuthState && resolvedStoragePath && existsSync(resolvedStoragePath)
|
|
810
|
+
? resolvedStoragePath
|
|
811
|
+
: null;
|
|
795
812
|
|
|
796
813
|
let authSource = "none";
|
|
797
814
|
if (!noAuthState && finalStoragePath) {
|
|
798
815
|
if (setupFromClaim?.path && finalStoragePath === setupFromClaim.path) {
|
|
799
816
|
authSource = "interactive setup during run";
|
|
800
|
-
} else if (storageStatePath && finalStoragePath === storageStatePath) {
|
|
817
|
+
} else if (explicitAuthProfile && storageStatePath && finalStoragePath === storageStatePath) {
|
|
801
818
|
authSource = "--auth-profile flag";
|
|
802
819
|
} else if (resolvedStoragePath && finalStoragePath === resolvedStoragePath) {
|
|
803
820
|
authSource = claimProfile ? "dashboard saved login profile" : "resolved profile path";
|
|
@@ -842,6 +859,8 @@ async function runLocalSingleCombination({
|
|
|
842
859
|
|
|
843
860
|
let cloudflared = null;
|
|
844
861
|
try {
|
|
862
|
+
const storageStateForBackend = loadStorageStateForBackend(finalStoragePath);
|
|
863
|
+
|
|
845
864
|
const wsParts = parseWsEndpoint(cdpEndpoint);
|
|
846
865
|
if (!Number.isFinite(wsParts.port) || wsParts.port < 1 || wsParts.port > 65535) {
|
|
847
866
|
throw new Error(`Could not parse local browser WebSocket port from ${cdpEndpoint}`);
|
|
@@ -874,8 +893,13 @@ async function runLocalSingleCombination({
|
|
|
874
893
|
connect_url: publicConnectUrl,
|
|
875
894
|
connect_kind: "playwright",
|
|
876
895
|
start_url: url,
|
|
896
|
+
...(storageStateForBackend ? { storage_state: storageStateForBackend } : {}),
|
|
877
897
|
}),
|
|
878
898
|
});
|
|
899
|
+
if (storageStateForBackend) {
|
|
900
|
+
// eslint-disable-next-line no-console
|
|
901
|
+
console.log("[qualty][auth] sent local auth state to backend for browser context injection");
|
|
902
|
+
}
|
|
879
903
|
// eslint-disable-next-line no-console
|
|
880
904
|
console.log(`[qualty] Registered local browser tunnel for ${effectiveDevice}`);
|
|
881
905
|
const result = await waitForCombinationDone({
|