automate-google-login-scraper 0.1.9

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/dist/index.js ADDED
@@ -0,0 +1,455 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { s as summarizeStorageState, p as parseStorageState, r as redactStorageState, f as findExpiringCookies } from "./secret-B4HOopFT.js";
4
+ import { A, I, S, a, b, c, e, d, i, g, n, h, t } from "./secret-B4HOopFT.js";
5
+ const DEFAULT_AUTH_DIR = "playwright/.auth";
6
+ const DEFAULT_AUTH_FILE = `${DEFAULT_AUTH_DIR}/google-test-user.json`;
7
+ const STATE_PATH_ENV = "TEST_GOOGLE_LOGIN_STATE";
8
+ const GITIGNORE_LINES = [
9
+ "playwright/.auth/",
10
+ ".env",
11
+ ".env.*",
12
+ "!.env.example"
13
+ ];
14
+ function resolveAuthFile(options = {}) {
15
+ const cwd = options.cwd ?? process.cwd();
16
+ const env = options.env ?? process.env;
17
+ const file = options.file || env[STATE_PATH_ENV] || DEFAULT_AUTH_FILE;
18
+ return path.isAbsolute(file) ? file : path.resolve(cwd, file);
19
+ }
20
+ function ensureAuthDir(file) {
21
+ const dir = path.dirname(file);
22
+ fs.mkdirSync(dir, { recursive: true, mode: 448 });
23
+ try {
24
+ fs.chmodSync(dir, 448);
25
+ } catch {
26
+ }
27
+ return dir;
28
+ }
29
+ function writeAuthDirGitignore(file) {
30
+ const dir = ensureAuthDir(file);
31
+ const target = path.join(dir, ".gitignore");
32
+ fs.writeFileSync(
33
+ target,
34
+ [
35
+ "# Everything in this directory is a live browser session.",
36
+ "# Nothing here is ever committed — see test-google-login's README.",
37
+ "*",
38
+ ""
39
+ ].join("\n")
40
+ );
41
+ return target;
42
+ }
43
+ function isIgnored(gitignore, pattern) {
44
+ const normalize = (line) => line.trim().replace(/^\/+/, "").replace(/\/+$/, "");
45
+ const wanted = normalize(pattern);
46
+ if (!wanted) return true;
47
+ for (const raw of gitignore.split(/\r?\n/)) {
48
+ const line = raw.trim();
49
+ if (!line || line.startsWith("#")) continue;
50
+ const candidate = normalize(line);
51
+ if (candidate === wanted) return true;
52
+ if (line.startsWith("!")) continue;
53
+ if (!candidate.includes("*") && wanted.startsWith(`${candidate}/`)) return true;
54
+ }
55
+ return false;
56
+ }
57
+ function ensureGitignored(options = {}) {
58
+ const cwd = options.cwd ?? process.cwd();
59
+ const patterns = options.patterns ?? GITIGNORE_LINES;
60
+ const file = path.join(cwd, ".gitignore");
61
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
62
+ const added = [];
63
+ const alreadyIgnored = [];
64
+ for (const pattern of patterns) {
65
+ const covered = pattern.startsWith("!") ? existing.split(/\r?\n/).some((line) => line.trim() === pattern) : isIgnored(existing, pattern);
66
+ (covered ? alreadyIgnored : added).push(pattern);
67
+ }
68
+ if (added.length > 0) {
69
+ const parts = [];
70
+ if (existing.length > 0) {
71
+ parts.push(existing.endsWith("\n") ? existing : `${existing}
72
+ `);
73
+ parts.push("\n");
74
+ }
75
+ parts.push("# test-google-login: persisted browser sessions are credentials\n");
76
+ for (const pattern of added) parts.push(`${pattern}
77
+ `);
78
+ fs.writeFileSync(file, parts.join(""));
79
+ }
80
+ return { file, added, alreadyIgnored };
81
+ }
82
+ function codegenCommand(options = {}) {
83
+ const cwd = options.cwd ?? process.cwd();
84
+ const absolute = resolveAuthFile({ cwd, file: options.file });
85
+ const relative = path.relative(cwd, absolute) || absolute;
86
+ const baseUrl = options.baseUrl || "http://localhost:3000";
87
+ return `npx playwright codegen --save-storage=${relative} ${baseUrl}`;
88
+ }
89
+ class MissingStorageStateError extends Error {
90
+ file;
91
+ constructor(file, hint) {
92
+ super(
93
+ [
94
+ `Missing persisted auth state: ${file}`,
95
+ "",
96
+ "Create it locally with:",
97
+ ` ${hint}`,
98
+ "",
99
+ "Then complete Google sign-in manually with the dedicated test account and",
100
+ "close the browser window — Playwright writes the state file on exit."
101
+ ].join("\n")
102
+ );
103
+ this.name = "MissingStorageStateError";
104
+ this.file = file;
105
+ }
106
+ }
107
+ function readStorageState(file, options = {}) {
108
+ let text;
109
+ try {
110
+ text = fs.readFileSync(file, "utf8");
111
+ } catch (error) {
112
+ if (error.code === "ENOENT") {
113
+ throw new MissingStorageStateError(file, codegenCommand({ file, ...options }));
114
+ }
115
+ throw error;
116
+ }
117
+ return parseStorageState(text);
118
+ }
119
+ function writeStorageState(file, state) {
120
+ ensureAuthDir(file);
121
+ fs.writeFileSync(file, `${JSON.stringify(state, null, 2)}
122
+ `, { mode: 384 });
123
+ try {
124
+ fs.chmodSync(file, 384);
125
+ } catch {
126
+ }
127
+ return path.resolve(file);
128
+ }
129
+ function hardenStorageStateFile(file) {
130
+ try {
131
+ fs.chmodSync(file, 384);
132
+ } catch {
133
+ }
134
+ return summarizeStorageState(parseStorageState(fs.readFileSync(file, "utf8")));
135
+ }
136
+ function assertStorageStateUsable(state, options = {}) {
137
+ const summary = summarizeStorageState(state, options);
138
+ if (summary.usable && !summary.expired) return summary;
139
+ const ago = summary.expiresInSeconds === null ? "it carries no usable cookie" : `it expired ${Math.abs(Math.round(summary.expiresInSeconds / 60))} minutes ago`;
140
+ throw new Error(
141
+ [
142
+ `The persisted session is no longer usable — ${ago}.`,
143
+ "",
144
+ "Re-capture it:",
145
+ ` ${codegenCommand(options)}`
146
+ ].join("\n")
147
+ );
148
+ }
149
+ const ALLOW_REAL_LOGIN_ENV = "TEST_GOOGLE_LOGIN_ALLOW_REAL";
150
+ const BOOTSTRAP_HEADER = "x-e2e-auth-secret";
151
+ function requireStoredSession(options = {}) {
152
+ const file = resolveAuthFile(options);
153
+ const state = readStorageState(file, options);
154
+ return assertStorageStateUsable(state, { ...options, file });
155
+ }
156
+ async function bootstrapAppSession(options) {
157
+ const {
158
+ request,
159
+ page,
160
+ context,
161
+ endpoint = "/api/test-auth/google-user",
162
+ secret,
163
+ landingPath = "/dashboard",
164
+ user,
165
+ indexedDB = true
166
+ } = options;
167
+ if (!secret) {
168
+ throw new Error(
169
+ "bootstrapAppSession() needs the shared secret for the test-only auth endpoint. Set E2E_TEST_AUTH_SECRET and pass it as `secret` — it is never defaulted, because an endpoint that mints sessions without one is a production incident waiting to happen."
170
+ );
171
+ }
172
+ const response = await request.post(endpoint, {
173
+ headers: { [BOOTSTRAP_HEADER]: secret },
174
+ data: user ?? {}
175
+ });
176
+ if (!response.ok()) {
177
+ const body = await response.text().catch(() => "");
178
+ throw new Error(
179
+ [
180
+ `Test-auth endpoint ${endpoint} returned ${response.status()}.`,
181
+ body ? `Body: ${body.slice(0, 400)}` : "",
182
+ "",
183
+ "A 401 means the secret does not match; a 404 means the endpoint is disabled for this",
184
+ "environment — which is correct in production and a misconfiguration anywhere else."
185
+ ].filter(Boolean).join("\n")
186
+ );
187
+ }
188
+ await page.goto(landingPath);
189
+ const file = resolveAuthFile(options);
190
+ await context.storageState({ path: file, indexedDB });
191
+ hardenStorageStateFile(file);
192
+ return file;
193
+ }
194
+ function realGoogleLoginEnabled(env = process.env) {
195
+ const flag = env[ALLOW_REAL_LOGIN_ENV];
196
+ const optedIn = flag === "1" || flag?.toLowerCase() === "true";
197
+ return Boolean(optedIn && env.GOOGLE_TEST_EMAIL && env.GOOGLE_TEST_PASSWORD);
198
+ }
199
+ async function signInWithGoogle(options) {
200
+ const {
201
+ page,
202
+ context,
203
+ env = process.env,
204
+ loginPath = "/login",
205
+ signInButton = /continue with google|sign in with google/i,
206
+ expectUrl = /dashboard|app/,
207
+ indexedDB = true,
208
+ googleSelectors = {}
209
+ } = options;
210
+ if (!realGoogleLoginEnabled(env)) {
211
+ throw new Error(
212
+ [
213
+ "Real Google sign-in is not enabled.",
214
+ "",
215
+ `Set ${ALLOW_REAL_LOGIN_ENV}=1 together with GOOGLE_TEST_EMAIL and GOOGLE_TEST_PASSWORD,`,
216
+ "and only in a manually triggered workflow on a protected branch — never where a fork's",
217
+ "pull request can reach the secrets.",
218
+ "",
219
+ "For everything else, prefer a session captured once by hand:",
220
+ ` ${codegenCommand(options)}`
221
+ ].join("\n")
222
+ );
223
+ }
224
+ const email = env.GOOGLE_TEST_EMAIL;
225
+ const password = env.GOOGLE_TEST_PASSWORD;
226
+ const emailField = googleSelectors.email ?? /email or phone/i;
227
+ const passwordField = googleSelectors.password ?? /enter your password/i;
228
+ const next = googleSelectors.next ?? /^next$/i;
229
+ await page.goto(loginPath);
230
+ await page.getByRole("button", { name: signInButton }).click();
231
+ await page.getByLabel(emailField).fill(email);
232
+ await page.getByRole("button", { name: next }).click();
233
+ await page.getByLabel(passwordField).fill(password);
234
+ await page.getByRole("button", { name: next }).click();
235
+ await page.waitForURL(expectUrl);
236
+ const file = resolveAuthFile(options);
237
+ await context.storageState({ path: file, indexedDB });
238
+ hardenStorageStateFile(file);
239
+ return file;
240
+ }
241
+ function loadStorageStateFor(options = {}) {
242
+ return readStorageState(resolveAuthFile(options), options);
243
+ }
244
+ const HELP = `test-google-login — persist a Google test session for Playwright, safely
245
+
246
+ Usage
247
+ test-google-login init [--file <path>] [--url <baseUrl>]
248
+ Create the auth directory (0700), add the gitignore rules, and print the
249
+ playwright codegen command that captures a session.
250
+
251
+ test-google-login check [--file <path>] [--within <minutes>]
252
+ Report what the stored session holds and whether it is still usable.
253
+ Exits 1 when it is missing or expired, so CI can fail early and clearly.
254
+
255
+ test-google-login redact [--file <path>]
256
+ Print the state with every cookie and localStorage value replaced by its
257
+ length. This is the only form that is safe to share.
258
+
259
+ test-google-login clear [--file <path>]
260
+ Delete the stored session.
261
+
262
+ Options
263
+ --file <path> State file. Default: ${DEFAULT_AUTH_FILE}
264
+ (or $TEST_GOOGLE_LOGIN_STATE)
265
+ --url <baseUrl> App URL used in the codegen command. Default: http://localhost:3000
266
+ --within <mins> 'check' warns about cookies expiring within this window. Default: 60
267
+ --json Machine-readable output for 'check'.
268
+
269
+ The state file contains live cookies and tokens. Treat it exactly like the
270
+ password that produced it: dedicated Google test account only, never committed,
271
+ never a public CI artifact, never pasted into a log.
272
+ `;
273
+ function parseArgs(argv) {
274
+ const flags = {};
275
+ const positional = [];
276
+ for (let i2 = 0; i2 < argv.length; i2 += 1) {
277
+ const arg = argv[i2];
278
+ if (!arg.startsWith("--")) {
279
+ positional.push(arg);
280
+ continue;
281
+ }
282
+ const [name, inline] = arg.slice(2).split("=", 2);
283
+ if (inline !== void 0) {
284
+ flags[name] = inline;
285
+ continue;
286
+ }
287
+ const next = argv[i2 + 1];
288
+ if (next && !next.startsWith("--")) {
289
+ flags[name] = next;
290
+ i2 += 1;
291
+ } else {
292
+ flags[name] = true;
293
+ }
294
+ }
295
+ return { command: positional[0] ?? "help", flags };
296
+ }
297
+ async function runCli(argv, io = {}) {
298
+ const out = io.out ?? ((line) => console.log(line));
299
+ const err = io.err ?? ((line) => console.error(line));
300
+ const cwd = io.cwd ?? process.cwd();
301
+ const { command, flags } = parseArgs(argv);
302
+ const file = resolveAuthFile({ cwd, file: typeof flags.file === "string" ? flags.file : void 0 });
303
+ const baseUrl = typeof flags.url === "string" ? flags.url : void 0;
304
+ switch (command) {
305
+ case "init":
306
+ return init({ cwd, file, baseUrl, out });
307
+ case "check":
308
+ return check({ cwd, file, baseUrl, flags, out, err });
309
+ case "redact":
310
+ return redact({ file, out, err });
311
+ case "clear":
312
+ return clear({ cwd, file, out });
313
+ case "help":
314
+ case "--help":
315
+ case "-h":
316
+ out(HELP);
317
+ return 0;
318
+ default:
319
+ err(`Unknown command: ${command}
320
+ `);
321
+ err(HELP);
322
+ return 1;
323
+ }
324
+ }
325
+ function init(options) {
326
+ const { cwd, file, baseUrl, out } = options;
327
+ const dir = ensureAuthDir(file);
328
+ const nested = writeAuthDirGitignore(file);
329
+ const gitignore = ensureGitignored({ cwd });
330
+ out(`Created ${path.relative(cwd, dir) || dir}/ (mode 0700)`);
331
+ out(`Wrote ${path.relative(cwd, nested)} — ignores everything in that directory`);
332
+ if (gitignore.added.length > 0) {
333
+ out(`Added to .gitignore: ${gitignore.added.join(", ")}`);
334
+ } else {
335
+ out(`.gitignore already covers: ${GITIGNORE_LINES.join(", ")}`);
336
+ }
337
+ out("");
338
+ out("Now capture a session by hand — this avoids automating Google's password form,");
339
+ out("which is what MFA, CAPTCHA and device checks all break:");
340
+ out("");
341
+ out(` ${codegenCommand({ cwd, file, baseUrl })}`);
342
+ out("");
343
+ out("In the window that opens: click your app's 'Sign in with Google', sign in with");
344
+ out("the dedicated test account, finish the redirect back to your app, confirm you");
345
+ out("are on an authenticated page, then close the window. Playwright writes the");
346
+ out("state file on exit.");
347
+ return 0;
348
+ }
349
+ function check(options) {
350
+ const { cwd, file, baseUrl, flags, out, err } = options;
351
+ let state;
352
+ try {
353
+ state = readStorageState(file, { cwd, baseUrl });
354
+ } catch (error) {
355
+ err(error.message);
356
+ return error instanceof MissingStorageStateError ? 1 : 2;
357
+ }
358
+ const summary = summarizeStorageState(state);
359
+ const withinMinutes = Number.parseFloat(String(flags.within ?? "60"));
360
+ const expiring = findExpiringCookies(state, { withinMs: withinMinutes * 6e4 });
361
+ if (flags.json) {
362
+ out(JSON.stringify({ file, summary, expiringSoon: expiring.map((cookie) => cookie.name) }, null, 2));
363
+ } else {
364
+ out(`State file ${path.relative(cwd, file) || file}`);
365
+ out(`Cookies ${summary.cookieCount} (${summary.sessionCookies} session-only)`);
366
+ out(`Domains ${summary.domains.join(", ") || "none"}`);
367
+ out(`Origins ${summary.originCount}`);
368
+ out(`Google cookies present: ${summary.hasGoogleCookies ? "yes" : "no"}`);
369
+ if (summary.expiresInSeconds === null) {
370
+ out("Expiry no cookie carries one — this session dies with the browser");
371
+ } else {
372
+ const minutes = Math.round(summary.expiresInSeconds / 60);
373
+ out(
374
+ summary.expiresInSeconds > 0 ? `Expiry earliest in ${minutes} minutes` : `Expiry lapsed ${Math.abs(minutes)} minutes ago`
375
+ );
376
+ }
377
+ if (expiring.length > 0 && summary.usable) {
378
+ out("");
379
+ out(
380
+ `⚠ ${expiring.length} cookie(s) expire within ${withinMinutes} minutes: ${expiring.map((cookie) => cookie.name).join(", ")}`
381
+ );
382
+ out(` Re-capture before a long run: ${codegenCommand({ cwd, file, baseUrl })}`);
383
+ }
384
+ }
385
+ if (!summary.usable || summary.expired) {
386
+ err("");
387
+ err("This session is no longer usable. Re-capture it:");
388
+ err(` ${codegenCommand({ cwd, file, baseUrl })}`);
389
+ return 1;
390
+ }
391
+ return 0;
392
+ }
393
+ function redact(options) {
394
+ try {
395
+ const state = readStorageState(options.file);
396
+ options.out(JSON.stringify(redactStorageState(state), null, 2));
397
+ return 0;
398
+ } catch (error) {
399
+ options.err(error.message);
400
+ return 1;
401
+ }
402
+ }
403
+ function clear(options) {
404
+ const { cwd, file, out } = options;
405
+ if (!fs.existsSync(file)) {
406
+ out(`Nothing to clear — ${path.relative(cwd, file) || file} does not exist.`);
407
+ return 0;
408
+ }
409
+ fs.rmSync(file);
410
+ out(`Deleted ${path.relative(cwd, file) || file}`);
411
+ return 0;
412
+ }
413
+ export {
414
+ ALLOW_REAL_LOGIN_ENV,
415
+ A as AUTH_HEADER,
416
+ BOOTSTRAP_HEADER,
417
+ DEFAULT_AUTH_DIR,
418
+ DEFAULT_AUTH_FILE,
419
+ GITIGNORE_LINES,
420
+ I as InvalidStorageStateError,
421
+ MissingStorageStateError,
422
+ S as SESSION_COOKIE_EXPIRES,
423
+ STATE_PATH_ENV,
424
+ a as applyLocalStorage,
425
+ b as applyStorageState,
426
+ assertStorageStateUsable,
427
+ bootstrapAppSession,
428
+ codegenCommand,
429
+ c as constantTimeEqual,
430
+ ensureAuthDir,
431
+ ensureGitignored,
432
+ e as extractStorageState,
433
+ findExpiringCookies,
434
+ d as fromPuppeteerCookies,
435
+ hardenStorageStateFile,
436
+ i as isAuthorized,
437
+ g as isGoogleDomain,
438
+ isIgnored,
439
+ loadStorageStateFor,
440
+ n as normalizeStorageState,
441
+ parseArgs,
442
+ parseStorageState,
443
+ readStorageState,
444
+ realGoogleLoginEnabled,
445
+ redactStorageState,
446
+ requireStoredSession,
447
+ resolveAuthFile,
448
+ runCli,
449
+ signInWithGoogle,
450
+ h as storageStateOrigins,
451
+ summarizeStorageState,
452
+ t as toPuppeteerCookies,
453
+ writeAuthDirGitignore,
454
+ writeStorageState
455
+ };
@@ -0,0 +1,80 @@
1
+ import { PageLike, PuppeteerCookie, StorageState, StorageStateCookie } from './types.js';
2
+ /**
3
+ * Playwright cookies → Puppeteer/CDP cookies.
4
+ *
5
+ * The one transformation that matters: a session cookie loses its `expires` key
6
+ * entirely rather than carrying `-1`.
7
+ */
8
+ export declare function toPuppeteerCookies(state: StorageState): PuppeteerCookie[];
9
+ /**
10
+ * Puppeteer/CDP cookies → Playwright cookies.
11
+ *
12
+ * The inverse, plus the normalizations a CDP read needs: `session: true` or a
13
+ * missing expiry becomes `-1`, and `sameSite` is re-capitalised (CDP has been
14
+ * known to return `"unspecified"`, which is not one of the three).
15
+ */
16
+ export declare function fromPuppeteerCookies(cookies: PuppeteerCookie[]): StorageStateCookie[];
17
+ /** The origins a state file carries `localStorage` for, in file order. */
18
+ export declare function storageStateOrigins(state: StorageState): string[];
19
+ /**
20
+ * Write one origin's `localStorage` into a page that is already on that origin.
21
+ *
22
+ * Exported because it is the half that can fail for reasons worth surfacing: a
23
+ * page on `about:blank` or a cross-origin page throws `SecurityError`, and a
24
+ * browser with storage disabled throws `QuotaExceededError`. Both are far easier
25
+ * to read here than three steps later as a login screen.
26
+ */
27
+ export declare function applyLocalStorage(page: PageLike, entries: {
28
+ name: string;
29
+ value: string;
30
+ }[]): Promise<void>;
31
+ export interface ApplyStorageStateOptions {
32
+ /**
33
+ * Only restore `localStorage` for these origins. Defaults to every origin in
34
+ * the state.
35
+ *
36
+ * Worth narrowing: the state captured during a real Google sign-in also holds
37
+ * `accounts.google.com` storage, and navigating a test browser to Google to
38
+ * restore it is both slow and a way to trip Google's own bot checks. Pass your
39
+ * app's origin and nothing else for the common case.
40
+ */
41
+ origins?: string[];
42
+ /** Passed through to `page.goto` for each origin navigation. */
43
+ gotoOptions?: Record<string, unknown>;
44
+ }
45
+ export interface ApplyStorageStateResult {
46
+ cookiesSet: number;
47
+ /** Origins whose `localStorage` was restored. */
48
+ originsRestored: string[];
49
+ /** Origins skipped, and why — a navigation that failed is not fatal on its own. */
50
+ skipped: {
51
+ origin: string;
52
+ reason: string;
53
+ }[];
54
+ }
55
+ /**
56
+ * Restore a whole captured session into a Puppeteer page.
57
+ *
58
+ * Cookies go in first and in one call — `setCookie` is variadic and a
59
+ * cookie-at-a-time loop is a round trip each. Then one navigation per origin to
60
+ * get a document that `localStorage` can be written through.
61
+ *
62
+ * A state file with no `origins` therefore costs no navigation at all, which is
63
+ * the case for most apps: if your session lives in a cookie, this is a single
64
+ * CDP call and you can `goto` wherever you actually wanted to go.
65
+ */
66
+ export declare function applyStorageState(page: PageLike, state: StorageState, options?: ApplyStorageStateOptions): Promise<ApplyStorageStateResult>;
67
+ /**
68
+ * Capture the current session out of a Puppeteer page, in Playwright's format.
69
+ *
70
+ * The mirror of {@link applyStorageState}: what a Worker produces here can be
71
+ * dropped into `playwright/.auth/` and used by the local suite, and vice versa.
72
+ *
73
+ * `origins` must be given explicitly — `localStorage` can only be read from the
74
+ * origin that owns it, so there is nothing to enumerate from. Defaults to the
75
+ * page's current origin, which is the common case.
76
+ */
77
+ export declare function extractStorageState(page: PageLike, options?: {
78
+ origins?: string[];
79
+ gotoOptions?: Record<string, unknown>;
80
+ }): Promise<StorageState>;