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/worker.js ADDED
@@ -0,0 +1,309 @@
1
+ import { n as normalizeStorageState, s as summarizeStorageState, r as redactStorageState, b as applyStorageState, e as extractStorageState, i as isAuthorized, A as AUTH_HEADER } from "./secret-B4HOopFT.js";
2
+ const KEY_STATE = "storage-state";
3
+ function json(body, status = 200) {
4
+ return new Response(JSON.stringify(body, null, 2), {
5
+ status,
6
+ headers: { "content-type": "application/json; charset=utf-8" }
7
+ });
8
+ }
9
+ async function handleRequest(request, env) {
10
+ if (!env.TEST_AUTH_SECRET) {
11
+ return json(
12
+ {
13
+ error: "not_configured",
14
+ message: "TEST_AUTH_SECRET is not set, so this Worker refuses every request. Set it with `wrangler secret put TEST_AUTH_SECRET`."
15
+ },
16
+ 503
17
+ );
18
+ }
19
+ if (!isAuthorized(request.headers, env.TEST_AUTH_SECRET)) {
20
+ return new Response("Unauthorized", { status: 401, headers: { "x-required-header": AUTH_HEADER } });
21
+ }
22
+ const url = new URL(request.url);
23
+ if (url.pathname === "/health") return json({ ok: true });
24
+ const session = url.searchParams.get("session") || "default";
25
+ const stub = env.BROWSER_SESSION.get(env.BROWSER_SESSION.idFromName(session));
26
+ return stub.fetch(request);
27
+ }
28
+ const worker = {
29
+ fetch: handleRequest
30
+ };
31
+ let puppeteerOverride;
32
+ function setPuppeteer(implementation) {
33
+ puppeteerOverride = implementation;
34
+ }
35
+ async function getPuppeteer() {
36
+ if (puppeteerOverride) return puppeteerOverride;
37
+ const loaded = await import("@cloudflare/puppeteer");
38
+ const implementation = loaded.default ?? loaded;
39
+ if (typeof implementation?.launch !== "function") {
40
+ throw new TypeError(
41
+ "Could not load @cloudflare/puppeteer. Install it in the Worker that deploys this, or call setPuppeteer() with your own implementation."
42
+ );
43
+ }
44
+ return implementation;
45
+ }
46
+ class GoogleLoginBrowser {
47
+ storage;
48
+ env;
49
+ browser;
50
+ constructor(state, env) {
51
+ this.storage = state.storage;
52
+ this.env = env;
53
+ }
54
+ get idleMs() {
55
+ const minutes = Number.parseFloat(this.env.BROWSER_IDLE_MINUTES ?? "3");
56
+ return (Number.isFinite(minutes) && minutes > 0 ? minutes : 3) * 6e4;
57
+ }
58
+ async fetch(request) {
59
+ const url = new URL(request.url);
60
+ try {
61
+ switch (`${request.method} ${url.pathname}`) {
62
+ case "POST /state":
63
+ return await this.putState(request);
64
+ case "GET /state":
65
+ return await this.describeState();
66
+ case "DELETE /state":
67
+ return await this.clearState();
68
+ case "POST /check":
69
+ return await this.check(request);
70
+ case "POST /login":
71
+ return await this.login(request);
72
+ case "POST /close":
73
+ await this.closeBrowser();
74
+ return json({ closed: true });
75
+ default:
76
+ return json({ error: "not_found", message: `No route for ${request.method} ${url.pathname}` }, 404);
77
+ }
78
+ } catch (error) {
79
+ return json({ error: "failed", message: error.message }, 500);
80
+ }
81
+ }
82
+ /**
83
+ * Store a storage state for this session.
84
+ *
85
+ * Validated on the way in, so a malformed upload fails here with a precise
86
+ * reason rather than as an opaque CDP error during the next check.
87
+ */
88
+ async putState(request) {
89
+ const state = normalizeStorageState(await request.json());
90
+ await this.storage.put(KEY_STATE, state);
91
+ return json({ stored: true, summary: summarizeStorageState(state) });
92
+ }
93
+ /**
94
+ * Describe the stored state — redacted, always.
95
+ *
96
+ * There is no route that returns the live values. That is the point: this Worker
97
+ * can be handed to CI without handing CI a way to read the session back out.
98
+ */
99
+ async describeState() {
100
+ const state = await this.storage.get(KEY_STATE);
101
+ if (!state) return json({ present: false }, 404);
102
+ return json({
103
+ present: true,
104
+ summary: summarizeStorageState(state),
105
+ redacted: redactStorageState(state)
106
+ });
107
+ }
108
+ async clearState() {
109
+ const existed = await this.storage.delete(KEY_STATE);
110
+ await this.closeBrowser();
111
+ return json({ cleared: existed });
112
+ }
113
+ /**
114
+ * Replay the stored session against a URL and report whether it is signed in.
115
+ *
116
+ * "Signed in" is whatever you say it is: `expectSelector` is checked in the page
117
+ * and `rejectUrl` catches the redirect-to-login case, which is the failure that
118
+ * otherwise looks like a passing 200.
119
+ */
120
+ async check(request) {
121
+ const body = await request.json();
122
+ if (!body.url) return json({ error: "bad_request", message: "`url` is required" }, 400);
123
+ const state = await this.storage.get(KEY_STATE);
124
+ if (!state) {
125
+ return json(
126
+ {
127
+ error: "no_state",
128
+ message: "No stored session for this name. POST /state with a Playwright storage state first."
129
+ },
130
+ 409
131
+ );
132
+ }
133
+ const browser = await this.ensureBrowser();
134
+ const page = await browser.newPage();
135
+ try {
136
+ const applied = await applyStorageState(page, state, { origins: body.origins });
137
+ const response = await page.goto(body.url, { waitUntil: "domcontentloaded" });
138
+ const landedUrl = page.url();
139
+ const title = await page.title();
140
+ let authenticated = true;
141
+ let reason = "navigated without being redirected away";
142
+ if (body.rejectUrl && landedUrl.includes(body.rejectUrl)) {
143
+ authenticated = false;
144
+ reason = `redirected to ${body.rejectUrl} — the session was not accepted`;
145
+ } else if (body.expectSelector) {
146
+ const found = await page.evaluate(
147
+ ((selector) => document.querySelector(selector) !== null),
148
+ body.expectSelector
149
+ );
150
+ authenticated = found === true;
151
+ reason = found ? `found ${body.expectSelector}` : `${body.expectSelector} is not on the page — probably signed out`;
152
+ }
153
+ const result = {
154
+ authenticated,
155
+ status: typeof response?.status === "function" ? response.status() : null,
156
+ url: landedUrl,
157
+ title,
158
+ reason,
159
+ applied
160
+ };
161
+ if (body.screenshot) {
162
+ const shot = await page.screenshot({ type: "png" });
163
+ result.screenshot = toBase64(shot);
164
+ }
165
+ return json(result);
166
+ } finally {
167
+ await page.close();
168
+ await this.touch();
169
+ }
170
+ }
171
+ /**
172
+ * Sign in to Google for real, in the Worker, and store the resulting session.
173
+ *
174
+ * Gated twice — the flag and the credentials — and still the path you should
175
+ * reach for last. Read {@link signInWithGoogle}'s notes in `google-login.ts`:
176
+ * they apply here in full, with the extra wrinkle that a datacentre IP makes
177
+ * Google's risk checks more likely, not less.
178
+ */
179
+ async login(request) {
180
+ if (this.env.ALLOW_REAL_GOOGLE_LOGIN !== "true") {
181
+ return json(
182
+ {
183
+ error: "disabled",
184
+ message: 'Real Google sign-in is disabled. Set ALLOW_REAL_GOOGLE_LOGIN="true" and the GOOGLE_TEST_EMAIL / GOOGLE_TEST_PASSWORD secrets to enable POST /login — and prefer capturing the session locally with `playwright codegen` instead.'
185
+ },
186
+ 403
187
+ );
188
+ }
189
+ const email = this.env.GOOGLE_TEST_EMAIL;
190
+ const password = this.env.GOOGLE_TEST_PASSWORD;
191
+ if (!email || !password) {
192
+ return json(
193
+ { error: "disabled", message: "GOOGLE_TEST_EMAIL and GOOGLE_TEST_PASSWORD must both be set." },
194
+ 403
195
+ );
196
+ }
197
+ const body = await request.json();
198
+ if (!body.loginUrl) return json({ error: "bad_request", message: "`loginUrl` is required" }, 400);
199
+ if (!body.expectUrl) {
200
+ return json(
201
+ {
202
+ error: "bad_request",
203
+ message: '`expectUrl` is required — a substring of the URL you land on once signed in, e.g. "/dashboard". It is not defaulted to your origin, because the login page is on that origin too and the wait would pass before sign-in even started.'
204
+ },
205
+ 400
206
+ );
207
+ }
208
+ const browser = await this.ensureBrowser();
209
+ const page = await browser.newPage();
210
+ try {
211
+ await page.goto(body.loginUrl, { waitUntil: "domcontentloaded" });
212
+ await clickSelector(page, body.signInSelector ?? 'a[href*="google"], button[data-provider="google"]');
213
+ await typeInto(page, 'input[type="email"]', email);
214
+ await clickSelector(page, "#identifierNext button, #identifierNext");
215
+ await typeInto(page, 'input[type="password"]', password);
216
+ await clickSelector(page, "#passwordNext button, #passwordNext");
217
+ await waitForUrlContaining(
218
+ page,
219
+ body.expectUrl,
220
+ // Capped rather than trusted: an unbounded wait pins a billed browser
221
+ // session open for as long as the caller likes.
222
+ Math.min(Math.max(body.timeoutMs ?? 3e4, 250), 12e4)
223
+ );
224
+ const state = await extractStorageState(page, { origins: body.origins });
225
+ await this.storage.put(KEY_STATE, state);
226
+ return json({ stored: true, summary: summarizeStorageState(state) });
227
+ } finally {
228
+ await page.close();
229
+ await this.touch();
230
+ }
231
+ }
232
+ async ensureBrowser() {
233
+ if (this.browser?.isConnected?.() === false) this.browser = void 0;
234
+ if (!this.browser) {
235
+ const puppeteer = await getPuppeteer();
236
+ this.browser = await puppeteer.launch(this.env.BROWSER);
237
+ }
238
+ await this.touch();
239
+ return this.browser;
240
+ }
241
+ /** Push the idle alarm out. Called after every request that used the browser. */
242
+ async touch() {
243
+ await this.storage.setAlarm(Date.now() + this.idleMs);
244
+ }
245
+ /**
246
+ * Close the browser when the session goes quiet.
247
+ *
248
+ * Not an optimisation — a Browser Rendering session left open is billed and
249
+ * counts against the account's concurrency limit, so the alarm is the difference
250
+ * between a test tool and a surprise invoice.
251
+ */
252
+ async alarm() {
253
+ await this.closeBrowser();
254
+ }
255
+ async closeBrowser() {
256
+ const browser = this.browser;
257
+ this.browser = void 0;
258
+ if (!browser) return;
259
+ try {
260
+ await browser.close();
261
+ } catch {
262
+ }
263
+ }
264
+ }
265
+ async function clickSelector(page, selector) {
266
+ await page.evaluate(
267
+ ((css) => {
268
+ const element = document.querySelector(css);
269
+ if (!element) throw new Error(`No element matched ${css}`);
270
+ element.click();
271
+ }),
272
+ selector
273
+ );
274
+ }
275
+ async function typeInto(page, selector, value) {
276
+ await page.evaluate(
277
+ (([css, text]) => {
278
+ const element = document.querySelector(css);
279
+ if (!element) throw new Error(`No element matched ${css}`);
280
+ element.focus();
281
+ element.value = text;
282
+ element.dispatchEvent(new Event("input", { bubbles: true }));
283
+ element.dispatchEvent(new Event("change", { bubbles: true }));
284
+ }),
285
+ [selector, value]
286
+ );
287
+ }
288
+ async function waitForUrlContaining(page, fragment, timeoutMs = 3e4) {
289
+ const deadline = Date.now() + timeoutMs;
290
+ while (Date.now() < deadline) {
291
+ if (page.url().includes(fragment)) return;
292
+ await new Promise((resolve) => setTimeout(resolve, 250));
293
+ }
294
+ throw new Error(
295
+ `Timed out waiting for a URL containing "${fragment}" — currently ${page.url()}. A consent screen, a device check or MFA looks like this, and none of them should be automated: capture the session by hand instead.`
296
+ );
297
+ }
298
+ function toBase64(bytes) {
299
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
300
+ let binary = "";
301
+ for (const byte of view) binary += String.fromCharCode(byte);
302
+ return btoa(binary);
303
+ }
304
+ export {
305
+ GoogleLoginBrowser,
306
+ worker as default,
307
+ handleRequest,
308
+ setPuppeteer
309
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The setup project. Pick one of the two bodies below.
3
+ *
4
+ * **Verify a hand-captured session** (the default, and what a laptop should do):
5
+ * no credentials anywhere, no Google traffic, and a sub-millisecond check.
6
+ *
7
+ * **Mint your app's own session** (what CI should do): your app creates the same
8
+ * session a successful Google callback creates, so the run holds one secret of
9
+ * yours instead of a Google password.
10
+ */
11
+ import { test as setup } from "@playwright/test";
12
+ import { bootstrapAppSession, requireStoredSession } from "test-google-login";
13
+
14
+ const SECRET = process.env.E2E_TEST_AUTH_SECRET;
15
+
16
+ setup("an authenticated session is available", async ({ page, context, request }) => {
17
+ if (SECRET) {
18
+ // CI path. Regenerates state every run, so nothing persists between jobs.
19
+ const file = await bootstrapAppSession({
20
+ request,
21
+ page,
22
+ context,
23
+ secret: SECRET,
24
+ endpoint: process.env.E2E_TEST_AUTH_ENDPOINT ?? "/api/test-auth/google-user",
25
+ landingPath: "/dashboard",
26
+ });
27
+
28
+ console.log(`minted a fresh app session → ${file}`);
29
+ return;
30
+ }
31
+
32
+ // Local path. Throws with the exact `playwright codegen` command on the first
33
+ // run, which is the whole onboarding story for a new contributor.
34
+ const summary = requireStoredSession({ baseUrl: process.env.E2E_BASE_URL });
35
+
36
+ console.log(
37
+ `session ok — ${summary.cookieCount} cookies across ${summary.domains.join(", ")}` +
38
+ (summary.expiresInSeconds === null
39
+ ? " (no expiry; dies with the browser)"
40
+ : `, earliest expiry in ${Math.round(summary.expiresInSeconds / 60)} min`),
41
+ );
42
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What the tests look like once the setup project has done its work: nothing
3
+ * about auth, because the browser is already signed in.
4
+ */
5
+ import { expect, test } from "@playwright/test";
6
+
7
+ test("an authenticated user can open the dashboard", async ({ page }) => {
8
+ await page.goto("/dashboard");
9
+
10
+ await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
11
+ });
12
+
13
+ test("the session survives a reload", async ({ page }) => {
14
+ await page.goto("/dashboard");
15
+ await page.reload();
16
+
17
+ // The assertion worth making explicitly: a storageState that restores cookies
18
+ // but not localStorage often passes the first navigation and fails this one.
19
+ await expect(page).not.toHaveURL(/\/login/);
20
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The setup-project layout: capture (or verify) the session once, then every
3
+ * browser project starts signed in.
4
+ *
5
+ * Copy this next to your tests. The two projects matter — `chromium` declares
6
+ * `dependencies: ["auth-setup"]`, so Playwright runs the setup once per run
7
+ * rather than once per test file, and a failure there fails fast with a message
8
+ * that says what to do instead of timing out on a login form.
9
+ */
10
+ import { defineConfig, devices } from "@playwright/test";
11
+ import { DEFAULT_AUTH_FILE } from "test-google-login";
12
+
13
+ export default defineConfig({
14
+ testDir: "./tests/e2e",
15
+ timeout: 30_000,
16
+
17
+ use: {
18
+ baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000",
19
+ trace: "on-first-retry",
20
+ },
21
+
22
+ projects: [
23
+ {
24
+ name: "auth-setup",
25
+ testMatch: /.*\.setup\.ts/,
26
+ },
27
+ {
28
+ name: "chromium",
29
+ dependencies: ["auth-setup"],
30
+ use: {
31
+ ...devices["Desktop Chrome"],
32
+ // Imported rather than written out, so the config and the CLI cannot
33
+ // drift onto different paths — which looks exactly like an expired session.
34
+ storageState: DEFAULT_AUTH_FILE,
35
+ },
36
+ },
37
+ ],
38
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The test-only endpoint `bootstrapAppSession` calls — the piece that lives in
3
+ * *your* app, not in this package.
4
+ *
5
+ * Written for a Next.js route handler; the shape is the same anywhere. Four
6
+ * things are not optional:
7
+ *
8
+ * 1. **It must not exist in production.** The 404 below is the floor, not the
9
+ * ceiling — prefer excluding the file from the production build entirely, and
10
+ * write a deployment test that asserts the route 404s in prod.
11
+ * 2. **It must require a strong secret**, compared in constant time.
12
+ * 3. **It must create the same session** a real Google callback creates: same
13
+ * format, same claims, same roles, same cookie attributes. A subtly different
14
+ * session turns your whole suite into a test of a code path that never ships.
15
+ * 4. **It must not accept a user id from the request** beyond what you are
16
+ * willing to let any caller with the secret create.
17
+ */
18
+ import { constantTimeEqual } from "test-google-login";
19
+
20
+ // Stand-ins for your app's own modules.
21
+ declare const db: {
22
+ user: {
23
+ upsert(args: Record<string, unknown>): Promise<{ id: string }>;
24
+ };
25
+ };
26
+ declare function createSessionForUser(userId: string): Promise<{ id: string }>;
27
+ declare function buildSessionCookie(session: { id: string }): string;
28
+
29
+ export async function POST(request: Request): Promise<Response> {
30
+ // Defence in depth. The real protection is this file not being in the bundle.
31
+ if (process.env.NODE_ENV === "production") {
32
+ return new Response("Not found", { status: 404 });
33
+ }
34
+
35
+ if (!constantTimeEqual(request.headers.get("x-e2e-auth-secret"), process.env.E2E_TEST_AUTH_SECRET)) {
36
+ return new Response("Unauthorized", { status: 401 });
37
+ }
38
+
39
+ const user = await db.user.upsert({
40
+ where: { email: "google-e2e@example.test" },
41
+ create: {
42
+ email: "google-e2e@example.test",
43
+ name: "Google E2E User",
44
+ googleSubject: "google-e2e-subject-001",
45
+ emailVerified: true,
46
+ },
47
+ update: {},
48
+ });
49
+
50
+ const session = await createSessionForUser(user.id);
51
+
52
+ return new Response(JSON.stringify({ ok: true }), {
53
+ headers: {
54
+ "content-type": "application/json",
55
+ "set-cookie": buildSessionCookie(session),
56
+ },
57
+ });
58
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Driving the Durable Object from a test, so one captured session can be checked
3
+ * against a deployed environment from Cloudflare's network.
4
+ *
5
+ * Worth doing when "does the session work?" depends on where the request comes
6
+ * from — geo-routed auth, Cloudflare Access in front of the app, a WAF rule that
7
+ * treats your CI runner's IP differently from a real visitor.
8
+ */
9
+ import { AUTH_HEADER, loadStorageStateFor } from "test-google-login";
10
+
11
+ const WORKER = process.env.TGL_WORKER_URL ?? "https://test-google-login.example.workers.dev";
12
+ const SECRET = process.env.TGL_WORKER_SECRET;
13
+
14
+ if (!SECRET) throw new Error("TGL_WORKER_SECRET is required.");
15
+
16
+ async function worker(path: string, body?: unknown) {
17
+ const response = await fetch(`${WORKER}${path}`, {
18
+ method: body === undefined ? "GET" : "POST",
19
+ headers: { [AUTH_HEADER]: SECRET!, "content-type": "application/json" },
20
+ body: body === undefined ? undefined : JSON.stringify(body),
21
+ });
22
+
23
+ if (!response.ok) throw new Error(`${path} → ${response.status}: ${await response.text()}`);
24
+ return response.json();
25
+ }
26
+
27
+ // Upload the session captured locally. This is the one request that carries live
28
+ // cookies, so it goes over HTTPS to a Worker that only accepts the shared secret —
29
+ // and there is deliberately no route that reads them back out.
30
+ await worker("/state?session=signed-in", loadStorageStateFor());
31
+
32
+ const result = (await worker("/check?session=signed-in", {
33
+ url: "https://app.example.test/dashboard",
34
+ expectSelector: "[data-testid=user-menu]",
35
+ rejectUrl: "/login",
36
+ // Narrow the origins: restoring accounts.google.com storage means navigating a
37
+ // datacentre browser to Google, which is slow and trips its risk checks.
38
+ origins: ["https://app.example.test"],
39
+ screenshot: true,
40
+ })) as { authenticated: boolean; reason: string; screenshot?: string };
41
+
42
+ console.log(result.authenticated ? `signed in — ${result.reason}` : `NOT signed in — ${result.reason}`);
43
+
44
+ // GET /state returns a redacted summary, never the values. Safe to log in CI.
45
+ console.log(await worker("/state?session=signed-in"));
46
+
47
+ // Close the browser rather than waiting for the idle alarm: an open Browser
48
+ // Rendering session is billed.
49
+ await worker("/close?session=signed-in", {});
package/package.json ADDED
@@ -0,0 +1,82 @@
1
+ {
2
+ "name": "automate-google-login-scraper",
3
+ "version": "0.1.9",
4
+ "description": "End-to-end test harness for Google sign-in: persist a Playwright storageState once, reuse it everywhere, and replay it from a Cloudflare Workers Browser Rendering Durable Object",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./worker": {
15
+ "types": "./dist/worker.d.ts",
16
+ "import": "./dist/worker.js"
17
+ }
18
+ },
19
+ "bin": {
20
+ "test-google-login": "./bin/cli.js"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "bin",
25
+ "examples"
26
+ ],
27
+ "scripts": {
28
+ "build": "vite build",
29
+ "prepublishOnly": "bun run build",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest",
33
+ "test:ci": "vitest run --reporter=default --reporter=junit --outputFile=junit.xml --coverage",
34
+ "coverage": "vitest run --coverage",
35
+ "deploy": "wrangler deploy",
36
+ "dev:worker": "wrangler dev",
37
+ "ship": "npx standard-version --release-as patch; npm publish"
38
+ },
39
+ "keywords": [
40
+ "playwright",
41
+ "puppeteer",
42
+ "google-login",
43
+ "oauth",
44
+ "storage-state",
45
+ "e2e",
46
+ "testing",
47
+ "cloudflare-workers",
48
+ "browser-rendering",
49
+ "durable-objects"
50
+ ],
51
+ "author": "vtempest",
52
+ "license": "MIT",
53
+ "devDependencies": {
54
+ "@cloudflare/puppeteer": "^1.4.0",
55
+ "@cloudflare/workers-types": "^4.20260507.1",
56
+ "@types/node": "^22.10.0",
57
+ "@vitest/coverage-v8": "^4.1.0",
58
+ "typescript": "^5.9.3",
59
+ "vite": "^6.3.5",
60
+ "vite-plugin-dts": "^4.5.4",
61
+ "vitest": "^4.1.0",
62
+ "wrangler": "^4.24.0"
63
+ },
64
+ "peerDependencies": {
65
+ "@cloudflare/puppeteer": "*",
66
+ "@playwright/test": "*"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "@playwright/test": {
70
+ "optional": true
71
+ },
72
+ "@cloudflare/puppeteer": {
73
+ "optional": true
74
+ }
75
+ },
76
+ "repository": {
77
+ "type": "git",
78
+ "url": "git+https://github.com/OpenSourceAGI/dev-tools-starter-agent.git",
79
+ "directory": "packages/test-google-login"
80
+ },
81
+ "homepage": "https://github.com/OpenSourceAGI/dev-tools-starter-agent/tree/master/packages/test-google-login"
82
+ }