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.
@@ -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
+ }
@@ -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 = String(authProfile || "").trim() || setupFromClaim?.profile || claimProfile;
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 && storageStatePath && existsSync(storageStatePath)
791
- ? storageStatePath
792
- : !noAuthState && resolvedStoragePath && existsSync(resolvedStoragePath)
793
- ? resolvedStoragePath
794
- : null;
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({