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/README.md +378 -0
- package/bin/cli.js +6 -0
- package/dist/auth-file.d.ts +91 -0
- package/dist/cli.d.ts +19 -0
- package/dist/google-login.d.ts +145 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +455 -0
- package/dist/puppeteer-state.d.ts +80 -0
- package/dist/secret-B4HOopFT.js +276 -0
- package/dist/secret.d.ts +31 -0
- package/dist/state-core.d.ts +50 -0
- package/dist/storage-state.d.ts +50 -0
- package/dist/types.d.ts +99 -0
- package/dist/worker.d.ts +138 -0
- package/dist/worker.js +309 -0
- package/examples/auth.setup.ts +42 -0
- package/examples/dashboard.spec.ts +20 -0
- package/examples/playwright.config.ts +38 -0
- package/examples/test-auth-endpoint.ts +58 -0
- package/examples/worker-check.ts +49 -0
- package/package.json +82 -0
package/README.md
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
<!-- template-git-repo:badges:start -->
|
|
2
|
+
<p align="center">
|
|
3
|
+
<a href="https://starterdocs.vtempest.workers.dev/docs/packages/test-google-login"><img src="https://img.shields.io/badge/Docs-blue?logo=ReadTheDocs&logoColor=white" alt="Documentation" /></a>
|
|
4
|
+
<br />
|
|
5
|
+
<a href="https://github.com/OpenSourceAGI/dev-tools-starter-agent/stargazers"><img src="https://img.shields.io/github/stars/OpenSourceAGI/dev-tools-starter-agent" alt="GitHub Stars" /></a>
|
|
6
|
+
<a href="https://www.npmjs.com/package/test-google-login"><img src="https://img.shields.io/npm/dm/test-google-login.svg" alt="NPM Monthly Downloads" /></a>
|
|
7
|
+
<a href="https://www.npmjs.com/package/test-google-login"><img src="https://img.shields.io/npm/v/test-google-login.svg" alt="npm version" /></a>
|
|
8
|
+
<a href="https://www.npmjs.com/package/test-google-login"><img src="https://img.shields.io/npm/dt/test-google-login.svg" alt="NPM Total Downloads" /></a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/test-google-login"><img src="https://img.shields.io/npm/types/test-google-login" alt="TypeScript types" /></a>
|
|
10
|
+
<a href="https://packagephobia.com/result?p=test-google-login"><img src="https://packagephobia.com/badge?p=test-google-login" alt="Install size" /></a>
|
|
11
|
+
<a href="https://app.codecov.io/gh/OpenSourceAGI/dev-tools-starter-agent/flags"><img src="https://img.shields.io/codecov/c/github/OpenSourceAGI/dev-tools-starter-agent?flag=test-google-login&label=test-google-login%20coverage&logo=codecov&logoColor=white" alt="Coverage" /></a>
|
|
12
|
+
<br />
|
|
13
|
+
<a href="https://github.com/OpenSourceAGI/dev-tools-starter-agent/issues"><img src="https://img.shields.io/github/issues/OpenSourceAGI/dev-tools-starter-agent?logo=github" alt="GitHub Issues" /></a>
|
|
14
|
+
<a href="https://github.com/OpenSourceAGI/dev-tools-starter-agent/pulls"><img src="https://img.shields.io/github/issues-pr/OpenSourceAGI/dev-tools-starter-agent?logo=github&label=PRs" alt="Open Pull Requests" /></a>
|
|
15
|
+
<a href="https://github.com/OpenSourceAGI/dev-tools-starter-agent/pulls?q=is%3Apr+is%3Aclosed"><img src="https://img.shields.io/github/issues-pr-closed/OpenSourceAGI/dev-tools-starter-agent?logo=github&label=PRs%20merged&color=8957e5" alt="Merged Pull Requests" /></a>
|
|
16
|
+
<a href="https://github.com/OpenSourceAGI/dev-tools-starter-agent/discussions"><img src="https://img.shields.io/github/discussions/OpenSourceAGI/dev-tools-starter-agent" alt="GitHub Discussions" /></a>
|
|
17
|
+
<a href="https://github.com/OpenSourceAGI/dev-tools-starter-agent/commits/master/"><img src="https://img.shields.io/github/last-commit/OpenSourceAGI/dev-tools-starter-agent.svg" alt="GitHub last commit" /></a>
|
|
18
|
+
<br />
|
|
19
|
+
<a href="https://stackblitz.com/github/OpenSourceAGI/dev-tools-starter-agent/tree/master/packages/test-google-login"><img height="20px" src="https://developer.stackblitz.com/img/open_in_stackblitz.svg" alt="Open in StackBlitz" /></a>
|
|
20
|
+
<img src="https://img.shields.io/badge/Bun-14151A?logo=bun&logoColor=white" alt="Bun" /> <img src="https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white" alt="TypeScript" /> <img src="https://img.shields.io/badge/Cloudflare%20Workers-F38020?logo=cloudflareworkers&logoColor=white" alt="Cloudflare Workers" /> <img src="https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white" alt="Vite" /> <img src="https://img.shields.io/badge/Vitest-6E9F18?logo=vitest&logoColor=white" alt="Vitest" /> <img src="https://img.shields.io/badge/Playwright-2EAD33?logo=playwright&logoColor=white" alt="Playwright" />
|
|
21
|
+
</p>
|
|
22
|
+
<!-- template-git-repo:badges:end -->
|
|
23
|
+
|
|
24
|
+
<!-- skills:install:start -->
|
|
25
|
+
**🤖 Agent skill** — `npx skills@latest add https://github.com/OpenSourceAGI/dev-tools-starter-agent --skill test-google-login` ([what it covers](../../skills/test-google-login/SKILL.md))
|
|
26
|
+
<!-- skills:install:end -->
|
|
27
|
+
|
|
28
|
+
# Test Google Login
|
|
29
|
+
|
|
30
|
+
Sign in with Google **once**, by hand, and let every test after that start
|
|
31
|
+
already signed in — locally, in CI, and from a Cloudflare Worker.
|
|
32
|
+
|
|
33
|
+
Two halves, one session file:
|
|
34
|
+
|
|
35
|
+
- **Playwright** (`test-google-login`) — captures, validates, inspects and
|
|
36
|
+
redacts a `storageState`, and guards a setup project so the first run tells a
|
|
37
|
+
new contributor exactly what to do instead of timing out on a login form.
|
|
38
|
+
- **Cloudflare Workers** (`test-google-login/worker`) — a Browser Rendering
|
|
39
|
+
Durable Object that replays the same session against a deployed app, keeping
|
|
40
|
+
one browser alive across a whole suite.
|
|
41
|
+
|
|
42
|
+
It has **no runtime dependencies**. Playwright and `@cloudflare/puppeteer` are
|
|
43
|
+
optional peers: every function takes the `page`, `context` or `request` it needs
|
|
44
|
+
as an argument, which is also why the whole package is unit-tested without ever
|
|
45
|
+
launching a browser.
|
|
46
|
+
|
|
47
|
+
## ⚠The security boundary — read this first
|
|
48
|
+
|
|
49
|
+
Putting `GOOGLE_TEST_PASSWORD` in `.env` protects nothing if you then write:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
playwright/.auth/google-test-user.json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**That file is a live credential.** It can contain your app's session cookie,
|
|
56
|
+
Google's cookies for `accounts.google.com`, OAuth tokens in `localStorage` or
|
|
57
|
+
IndexedDB, and — depending on your auth library — something refresh-capable.
|
|
58
|
+
Anyone holding it is signed in as the test account.
|
|
59
|
+
|
|
60
|
+
So:
|
|
61
|
+
|
|
62
|
+
- Use a **dedicated Google account** for testing, with nothing sensitive on it.
|
|
63
|
+
- **Never commit it.** `test-google-login init` adds the rules, twice.
|
|
64
|
+
- **Never upload it as a public CI artifact**, and never paste it into a log,
|
|
65
|
+
an issue or a chat. Use `test-google-login redact` when you need to show it.
|
|
66
|
+
- **Regenerate it** when it stops working, rather than trying to repair it.
|
|
67
|
+
- Prefer testing **your app's own session**. Keep real Google sign-in as a
|
|
68
|
+
narrow, occasional smoke test.
|
|
69
|
+
|
|
70
|
+
This package is built around that last point. Everything that touches Google
|
|
71
|
+
is opt-in and off by default; everything that touches the file is 0600.
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bun add -d test-google-login
|
|
77
|
+
npm install --save-dev test-google-login
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Quick start
|
|
81
|
+
|
|
82
|
+
### 1. Set up the directory and the gitignore rules
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npx test-google-login init
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
Created playwright/.auth/ (mode 0700)
|
|
90
|
+
Wrote playwright/.auth/.gitignore — ignores everything in that directory
|
|
91
|
+
Added to .gitignore: playwright/.auth/, .env, .env.*, !.env.example
|
|
92
|
+
|
|
93
|
+
Now capture a session by hand — this avoids automating Google's password form,
|
|
94
|
+
which is what MFA, CAPTCHA and device checks all break:
|
|
95
|
+
|
|
96
|
+
npx playwright codegen --save-storage=playwright/.auth/google-test-user.json http://localhost:3000
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Two gitignores on purpose: the root one, and `playwright/.auth/.gitignore`
|
|
100
|
+
containing `*`. The root file gets reverted, reformatted and replaced by tooling;
|
|
101
|
+
the nested one travels with the directory.
|
|
102
|
+
|
|
103
|
+
### 2. Capture a session, by hand, once
|
|
104
|
+
|
|
105
|
+
Run the command `init` printed. In the window that opens: click **Sign in with
|
|
106
|
+
Google**, use the dedicated test account, finish the redirect back to *your* app,
|
|
107
|
+
confirm you are on an authenticated page, then close the window — Playwright
|
|
108
|
+
writes the state file on exit.
|
|
109
|
+
|
|
110
|
+
This is manual on purpose. Google may ask for MFA, a CAPTCHA or a device check,
|
|
111
|
+
and none of those should be automated around.
|
|
112
|
+
|
|
113
|
+
### 3. Reuse it
|
|
114
|
+
|
|
115
|
+
`playwright.config.ts`:
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
119
|
+
import { DEFAULT_AUTH_FILE } from "test-google-login";
|
|
120
|
+
|
|
121
|
+
export default defineConfig({
|
|
122
|
+
testDir: "./tests/e2e",
|
|
123
|
+
use: { baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000" },
|
|
124
|
+
projects: [
|
|
125
|
+
{ name: "auth-setup", testMatch: /.*\.setup\.ts/ },
|
|
126
|
+
{
|
|
127
|
+
name: "chromium",
|
|
128
|
+
dependencies: ["auth-setup"],
|
|
129
|
+
use: { ...devices["Desktop Chrome"], storageState: DEFAULT_AUTH_FILE },
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`tests/auth.setup.ts`:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { test as setup } from "@playwright/test";
|
|
139
|
+
import { requireStoredSession } from "test-google-login";
|
|
140
|
+
|
|
141
|
+
setup("an authenticated session is available", () => {
|
|
142
|
+
const summary = requireStoredSession({ baseUrl: process.env.E2E_BASE_URL });
|
|
143
|
+
console.log(`session ok — ${summary.cookieCount} cookies, ${summary.domains.join(", ")}`);
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Your tests now start signed in:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
test("an authenticated user can open the dashboard", async ({ page }) => {
|
|
151
|
+
await page.goto("/dashboard");
|
|
152
|
+
await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`requireStoredSession` is the part that earns its keep. A missing file throws the
|
|
157
|
+
`codegen` command; an expired one throws *how long ago* it lapsed:
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
The persisted session is no longer usable — it expired 380 minutes ago.
|
|
161
|
+
|
|
162
|
+
Re-capture it:
|
|
163
|
+
npx playwright codegen --save-storage=playwright/.auth/google-test-user.json http://localhost:3000
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Without that, an expired session presents as a selector timeout on an
|
|
167
|
+
unauthenticated page, which reads like a broken test rather than a stale cookie.
|
|
168
|
+
|
|
169
|
+
## For CI: let your app mint the session
|
|
170
|
+
|
|
171
|
+
Do **not** make a Google password a repository secret every fork's pull request
|
|
172
|
+
can reach. Instead, add a test-only endpoint to your own staging environment that
|
|
173
|
+
creates the same session a successful Google callback creates, and hold one secret
|
|
174
|
+
of yours.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { bootstrapAppSession } from "test-google-login";
|
|
178
|
+
|
|
179
|
+
setup("create authenticated test session", async ({ page, context, request }) => {
|
|
180
|
+
await bootstrapAppSession({
|
|
181
|
+
request,
|
|
182
|
+
page,
|
|
183
|
+
context,
|
|
184
|
+
secret: process.env.E2E_TEST_AUTH_SECRET, // required; never defaulted
|
|
185
|
+
endpoint: "/api/test-auth/google-user",
|
|
186
|
+
landingPath: "/dashboard",
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
State is regenerated each run, no Google traffic is involved, and a PR check can
|
|
192
|
+
never fail because Google showed a consent screen. See
|
|
193
|
+
[`examples/test-auth-endpoint.ts`](examples/test-auth-endpoint.ts) for the
|
|
194
|
+
endpoint — it must 404 in production, require a strong secret compared in
|
|
195
|
+
constant time, and create a session identical to the real callback's.
|
|
196
|
+
|
|
197
|
+
### Which strategy where
|
|
198
|
+
|
|
199
|
+
| Strategy | Use for | Secrets held |
|
|
200
|
+
| --- | --- | --- |
|
|
201
|
+
| `bootstrapAppSession` — your app mints it | every PR check | `E2E_TEST_AUTH_SECRET` |
|
|
202
|
+
| `requireStoredSession` — captured by hand | local development | none |
|
|
203
|
+
| Encrypted stored state | private, controlled CI | the encryption key; delete state after the run |
|
|
204
|
+
| `signInWithGoogle` — real Google | occasional manual smoke test | a dedicated account's credentials, environment-scoped |
|
|
205
|
+
| Mock the OAuth callback | unit and integration tests | none |
|
|
206
|
+
|
|
207
|
+
## Inspecting a session without opening it
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
npx test-google-login check
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
```text
|
|
214
|
+
State file playwright/.auth/google-test-user.json
|
|
215
|
+
Cookies 3 (1 session-only)
|
|
216
|
+
Domains .google.com, app.example.test
|
|
217
|
+
Origins 1
|
|
218
|
+
Google cookies present: yes
|
|
219
|
+
Expiry earliest in 718 minutes
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Exit codes are meant for CI: `0` healthy, `1` missing or expired, `2` present but
|
|
223
|
+
malformed. `--json` for machine-readable output, `--within 30` to warn about
|
|
224
|
+
cookies expiring inside a window.
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
npx test-google-login redact # safe to paste into an issue
|
|
228
|
+
npx test-google-login clear # delete the session
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`redact` keeps the shape — names, domains, flags, expiries — and replaces every
|
|
232
|
+
value with `«redacted 21 chars»`. Nothing in this package ever prints a value:
|
|
233
|
+
`summarizeStorageState` returns names and domains only, and the Worker's
|
|
234
|
+
`GET /state` returns a redacted copy.
|
|
235
|
+
|
|
236
|
+
## The Cloudflare Workers half
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
import { GoogleLoginBrowser, handleRequest } from "test-google-login/worker";
|
|
240
|
+
|
|
241
|
+
export { GoogleLoginBrowser };
|
|
242
|
+
export default { fetch: handleRequest };
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
wrangler secret put TEST_AUTH_SECRET
|
|
247
|
+
wrangler deploy
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
A Durable Object per named session, holding one Browser Rendering browser. Why a
|
|
251
|
+
DO rather than `puppeteer.launch()` in the Worker: a launch costs seconds and is
|
|
252
|
+
billed, so one browser serves a whole suite; and Browser Rendering allows only a
|
|
253
|
+
few concurrent sessions per account, which a parallel test run would otherwise
|
|
254
|
+
trip. An alarm closes the browser once idle — an *unclosed* session is billed too.
|
|
255
|
+
|
|
256
|
+
| Route | Does |
|
|
257
|
+
| --- | --- |
|
|
258
|
+
| `POST /state` | Store a Playwright storage state. Validated on the way in; the response summarises it and never echoes it |
|
|
259
|
+
| `GET /state` | A redacted summary. **There is no route that returns live cookies** |
|
|
260
|
+
| `DELETE /state` | Forget the session and close the browser |
|
|
261
|
+
| `POST /check` | Replay the session against a URL; reports `authenticated`, the landing URL, title, and optionally a screenshot |
|
|
262
|
+
| `POST /login` | Real Google sign-in, off unless `ALLOW_REAL_GOOGLE_LOGIN="true"`. Needs `loginUrl` **and** `expectUrl` |
|
|
263
|
+
| `POST /close` | Close the browser now rather than waiting for the alarm |
|
|
264
|
+
|
|
265
|
+
Every request must carry `x-test-auth-secret`. If `TEST_AUTH_SECRET` is not set
|
|
266
|
+
the Worker returns `503` to everything rather than serving an anonymous browser to
|
|
267
|
+
whoever finds the URL.
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
curl -X POST "$WORKER/check?session=signed-in" \
|
|
271
|
+
-H "x-test-auth-secret: $TEST_AUTH_SECRET" \
|
|
272
|
+
-H 'content-type: application/json' \
|
|
273
|
+
-d '{"url":"https://app.example.test/dashboard","expectSelector":"[data-testid=user-menu]","rejectUrl":"/login"}'
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`rejectUrl` matters: a redirect to `/login` returns `200`, so without it a
|
|
277
|
+
signed-out check reads as a pass.
|
|
278
|
+
|
|
279
|
+
See [`examples/worker-check.ts`](examples/worker-check.ts) for the whole loop.
|
|
280
|
+
|
|
281
|
+
### Carrying a session between Playwright and Puppeteer
|
|
282
|
+
|
|
283
|
+
`applyStorageState` / `extractStorageState` convert in both directions, and the
|
|
284
|
+
three differences between the formats are each a way to lose a session silently:
|
|
285
|
+
|
|
286
|
+
- **Session cookies.** Playwright writes `expires: -1`; CDP reads `-1` as an
|
|
287
|
+
expiry in 1969 and drops the cookie. The key must be *omitted*.
|
|
288
|
+
- **`sameSite`.** CDP wants exactly `Strict`, `Lax` or `None`. One lowercase
|
|
289
|
+
value rejects the whole batch.
|
|
290
|
+
- **`localStorage` needs a document.** There is no blind write — the page has to
|
|
291
|
+
be on the origin first, so restoring it costs one navigation per origin.
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
import { applyStorageState } from "test-google-login";
|
|
295
|
+
|
|
296
|
+
await applyStorageState(page, state, { origins: ["https://app.example.test"] });
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
Narrow `origins` to your own app. A state captured through a real Google sign-in
|
|
300
|
+
also holds `accounts.google.com` storage, and navigating a datacentre browser to
|
|
301
|
+
Google to restore it is slow and is exactly the traffic its risk checks look for.
|
|
302
|
+
A cookie-only state costs **no** navigation at all.
|
|
303
|
+
|
|
304
|
+
## If you automate Google anyway
|
|
305
|
+
|
|
306
|
+
```ts
|
|
307
|
+
import { signInWithGoogle } from "test-google-login";
|
|
308
|
+
|
|
309
|
+
setup("real Google OAuth smoke bootstrap", async ({ page, context }) => {
|
|
310
|
+
await signInWithGoogle({ page, context, loginPath: "/login" });
|
|
311
|
+
});
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
It refuses to run unless `TEST_GOOGLE_LOGIN_ALLOW_REAL=1` **and** both
|
|
315
|
+
`GOOGLE_TEST_EMAIL` and `GOOGLE_TEST_PASSWORD` are set. Restrict that workflow to
|
|
316
|
+
a protected branch with environment-scoped secrets, trigger it by hand, and expect
|
|
317
|
+
failures that are not your app's fault — Google changes its UI, its labels and its
|
|
318
|
+
language, and a datacentre IP makes its risk checks *more* likely.
|
|
319
|
+
|
|
320
|
+
It waits for your own origin rather than clicking through whatever appears. If a
|
|
321
|
+
consent screen, a device check or MFA shows up, the wait times out, which is the
|
|
322
|
+
correct outcome: those are the controls protecting the account.
|
|
323
|
+
|
|
324
|
+
## Refreshing an expired session
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
npx test-google-login clear
|
|
328
|
+
npx playwright codegen --save-storage=playwright/.auth/google-test-user.json http://localhost:3000
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
With the test-only endpoint, just re-run the setup project — it regenerates state
|
|
332
|
+
on its own.
|
|
333
|
+
|
|
334
|
+
Note that `storageState` covers cookies and `localStorage` (and IndexedDB when you
|
|
335
|
+
pass `indexedDB: true`), but **not `sessionStorage`**. If your app keeps anything
|
|
336
|
+
there, restore it yourself with an init script.
|
|
337
|
+
|
|
338
|
+
## API
|
|
339
|
+
|
|
340
|
+
| Export | Does |
|
|
341
|
+
| --- | --- |
|
|
342
|
+
| `requireStoredSession(options)` | Assert a usable session exists; returns a safe summary, throws with the fix |
|
|
343
|
+
| `bootstrapAppSession(options)` | Mint a session through your test-only endpoint and persist it |
|
|
344
|
+
| `signInWithGoogle(options)` | Drive Google's real form. Off unless opted in |
|
|
345
|
+
| `loadStorageStateFor(options)` | Read the state for `browser.newContext({ storageState })` |
|
|
346
|
+
| `summarizeStorageState(state)` | Counts, domains, names, expiry — no values |
|
|
347
|
+
| `redactStorageState(state)` | The same state with every value replaced by its length |
|
|
348
|
+
| `findExpiringCookies(state, o)` | Cookies expiring inside a window — the warning before the failure |
|
|
349
|
+
| `assertStorageStateUsable(state, o)` | Throw unless still usable, with how long ago it lapsed |
|
|
350
|
+
| `readStorageState` / `writeStorageState` | Read; write 0600 |
|
|
351
|
+
| `hardenStorageStateFile(file)` | Chmod + validate a file Playwright wrote, preserving its extra keys |
|
|
352
|
+
| `applyStorageState(page, state, o)` | Restore a whole session into a Puppeteer page |
|
|
353
|
+
| `extractStorageState(page, o)` | Capture one back out, in Playwright's format |
|
|
354
|
+
| `to/fromPuppeteerCookies` | Cookie-format conversion, both directions |
|
|
355
|
+
| `ensureGitignored` / `writeAuthDirGitignore` | The two gitignore rules |
|
|
356
|
+
| `resolveAuthFile` / `codegenCommand` | Path resolution and the capture command |
|
|
357
|
+
| `constantTimeEqual` / `isAuthorized` | Secret comparison, usable in a Worker |
|
|
358
|
+
| `GoogleLoginBrowser` / `handleRequest` | The Durable Object and the Worker entry |
|
|
359
|
+
|
|
360
|
+
Full details in the agent skill:
|
|
361
|
+
[`skills/test-google-login`](../../skills/test-google-login/SKILL.md).
|
|
362
|
+
|
|
363
|
+
## Development
|
|
364
|
+
|
|
365
|
+
```bash
|
|
366
|
+
cd packages/test-google-login
|
|
367
|
+
bun run test # no browser is launched
|
|
368
|
+
bun run build # two entries: index (Node) and worker (Cloudflare)
|
|
369
|
+
bun run typecheck
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
The suite drives fakes for the page, the browser and the DO's storage, so it runs
|
|
373
|
+
anywhere in under a second. The assertions that matter most are the negative ones:
|
|
374
|
+
that no value ever reaches a log, a response or a summary.
|
|
375
|
+
|
|
376
|
+
## License
|
|
377
|
+
|
|
378
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Thin wrapper: the implementation is `runCli` in src/cli.ts, which returns an
|
|
3
|
+
// exit code instead of calling process.exit so it can be tested directly.
|
|
4
|
+
import { runCli } from "../dist/index.js";
|
|
5
|
+
|
|
6
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Playwright's documented directory for persisted auth state. */
|
|
2
|
+
export declare const DEFAULT_AUTH_DIR = "playwright/.auth";
|
|
3
|
+
/** The state file this package reads and writes unless told otherwise. */
|
|
4
|
+
export declare const DEFAULT_AUTH_FILE = "playwright/.auth/google-test-user.json";
|
|
5
|
+
/** Env var that overrides the state file path, for CI layouts that differ. */
|
|
6
|
+
export declare const STATE_PATH_ENV = "TEST_GOOGLE_LOGIN_STATE";
|
|
7
|
+
/**
|
|
8
|
+
* The lines `init` adds to `.gitignore`.
|
|
9
|
+
*
|
|
10
|
+
* `.env` is here too. Not scope creep: the same bootstrap that produces a state
|
|
11
|
+
* file is the one that puts `GOOGLE_TEST_PASSWORD` in a local `.env`, and a repo
|
|
12
|
+
* that ignores one but not the other is still one `git add -A` from a leak.
|
|
13
|
+
*/
|
|
14
|
+
export declare const GITIGNORE_LINES: readonly ["playwright/.auth/", ".env", ".env.*", "!.env.example"];
|
|
15
|
+
export interface AuthFileOptions {
|
|
16
|
+
/** Project root. Defaults to `process.cwd()`. */
|
|
17
|
+
cwd?: string;
|
|
18
|
+
/** Explicit path, absolute or relative to `cwd`. */
|
|
19
|
+
file?: string;
|
|
20
|
+
/** Environment to read `TEST_GOOGLE_LOGIN_STATE` from. Defaults to `process.env`. */
|
|
21
|
+
env?: Record<string, string | undefined>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The absolute path of the state file, resolving explicit argument → env var →
|
|
25
|
+
* default, in that order.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* resolveAuthFile() // <cwd>/playwright/.auth/google-test-user.json
|
|
29
|
+
* resolveAuthFile({ file: "ci/state.json" }) // <cwd>/ci/state.json
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveAuthFile(options?: AuthFileOptions): string;
|
|
32
|
+
/**
|
|
33
|
+
* Create the directory a state file goes in, owner-only.
|
|
34
|
+
*
|
|
35
|
+
* `0o700` rather than the default `0o755`: on a shared CI box or a multi-user
|
|
36
|
+
* machine, a world-readable directory holding session cookies is the leak, and
|
|
37
|
+
* nobody notices because the file itself looks fine.
|
|
38
|
+
*
|
|
39
|
+
* @returns the absolute directory path
|
|
40
|
+
*/
|
|
41
|
+
export declare function ensureAuthDir(file: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* A second line of defence inside the auth directory itself: `playwright/.auth/.gitignore`
|
|
44
|
+
* containing `*`.
|
|
45
|
+
*
|
|
46
|
+
* The root `.gitignore` can be reverted, reformatted or replaced wholesale by a
|
|
47
|
+
* tool; a `.gitignore` living in the directory travels with it and keeps the
|
|
48
|
+
* state file ignored even if someone adds `!playwright/**` upstream of it.
|
|
49
|
+
*/
|
|
50
|
+
export declare function writeAuthDirGitignore(file: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Does `.gitignore` already cover this pattern?
|
|
53
|
+
*
|
|
54
|
+
* Compares ignore *lines*, not paths: a real match check would mean
|
|
55
|
+
* reimplementing gitignore globbing, and being over-eager here means wrongly
|
|
56
|
+
* concluding a repo is safe. So `playwright/.auth/` counts as covered by
|
|
57
|
+
* `playwright/.auth`, `playwright/.auth/`, `/playwright/.auth/` or `playwright/`,
|
|
58
|
+
* and nothing cleverer. A pattern we fail to recognise costs a duplicate line,
|
|
59
|
+
* which is harmless.
|
|
60
|
+
*/
|
|
61
|
+
export declare function isIgnored(gitignore: string, pattern: string): boolean;
|
|
62
|
+
export interface GitignoreResult {
|
|
63
|
+
/** Absolute path of the `.gitignore` written. */
|
|
64
|
+
file: string;
|
|
65
|
+
/** Patterns appended by this call. */
|
|
66
|
+
added: string[];
|
|
67
|
+
/** Patterns some existing line already covered. */
|
|
68
|
+
alreadyIgnored: string[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Append whichever of {@link GITIGNORE_LINES} the repo does not already ignore.
|
|
72
|
+
*
|
|
73
|
+
* Idempotent, and never rewrites or reorders what is already there — a
|
|
74
|
+
* `.gitignore` is hand-maintained and a tool that reformats it gets turned off.
|
|
75
|
+
*/
|
|
76
|
+
export declare function ensureGitignored(options?: {
|
|
77
|
+
cwd?: string;
|
|
78
|
+
patterns?: readonly string[];
|
|
79
|
+
}): GitignoreResult;
|
|
80
|
+
/**
|
|
81
|
+
* The `playwright codegen` command that captures a session by hand.
|
|
82
|
+
*
|
|
83
|
+
* Generated rather than pasted into prose, because it carries the state path —
|
|
84
|
+
* and a README telling you to save state to a path the suite does not read is a
|
|
85
|
+
* half-hour of confusion every time.
|
|
86
|
+
*/
|
|
87
|
+
export declare function codegenCommand(options?: {
|
|
88
|
+
file?: string;
|
|
89
|
+
baseUrl?: string;
|
|
90
|
+
cwd?: string;
|
|
91
|
+
}): string;
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Parse `--flag value` and `--flag=value`, collecting the rest positionally. */
|
|
2
|
+
export declare function parseArgs(argv: string[]): {
|
|
3
|
+
command: string;
|
|
4
|
+
flags: Record<string, string | true>;
|
|
5
|
+
};
|
|
6
|
+
type Writer = (line: string) => void;
|
|
7
|
+
export interface CliIo {
|
|
8
|
+
out?: Writer;
|
|
9
|
+
err?: Writer;
|
|
10
|
+
cwd?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Run the CLI.
|
|
14
|
+
*
|
|
15
|
+
* Returns an exit code rather than calling `process.exit`, and takes its writers
|
|
16
|
+
* as arguments, so the whole surface is testable without spawning anything.
|
|
17
|
+
*/
|
|
18
|
+
export declare function runCli(argv: string[], io?: CliIo): Promise<number>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { StorageState, StorageStateSummary } from './types.js';
|
|
2
|
+
/** Env var that must be set before {@link signInWithGoogle} will run. */
|
|
3
|
+
export declare const ALLOW_REAL_LOGIN_ENV = "TEST_GOOGLE_LOGIN_ALLOW_REAL";
|
|
4
|
+
/** Header the test-only session endpoint authenticates with. */
|
|
5
|
+
export declare const BOOTSTRAP_HEADER = "x-e2e-auth-secret";
|
|
6
|
+
/** The subset of Playwright's `BrowserContext` used here. */
|
|
7
|
+
export interface ContextLike {
|
|
8
|
+
storageState(options?: {
|
|
9
|
+
path?: string;
|
|
10
|
+
indexedDB?: boolean;
|
|
11
|
+
}): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
/** The subset of Playwright's `APIRequestContext` used here. */
|
|
14
|
+
export interface RequestLike {
|
|
15
|
+
post(url: string, options?: {
|
|
16
|
+
headers?: Record<string, string>;
|
|
17
|
+
data?: unknown;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
ok(): boolean;
|
|
20
|
+
status(): number;
|
|
21
|
+
text(): Promise<string>;
|
|
22
|
+
}>;
|
|
23
|
+
}
|
|
24
|
+
/** The subset of Playwright's `Locator` used here. */
|
|
25
|
+
export interface LocatorLike {
|
|
26
|
+
click(options?: Record<string, unknown>): Promise<void>;
|
|
27
|
+
fill(value: string, options?: Record<string, unknown>): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/** The subset of Playwright's `Page` used here. */
|
|
30
|
+
export interface PlaywrightPageLike {
|
|
31
|
+
goto(url: string, options?: Record<string, unknown>): Promise<unknown>;
|
|
32
|
+
waitForURL(url: string | RegExp, options?: Record<string, unknown>): Promise<void>;
|
|
33
|
+
url(): string;
|
|
34
|
+
getByRole(role: string, options?: {
|
|
35
|
+
name?: string | RegExp;
|
|
36
|
+
}): LocatorLike;
|
|
37
|
+
getByLabel(text: string | RegExp, options?: Record<string, unknown>): LocatorLike;
|
|
38
|
+
}
|
|
39
|
+
export interface StoredSessionOptions {
|
|
40
|
+
/** State file path. Defaults to `playwright/.auth/google-test-user.json`. */
|
|
41
|
+
file?: string;
|
|
42
|
+
/** Project root for relative paths and for the hint in error messages. */
|
|
43
|
+
cwd?: string;
|
|
44
|
+
/** App URL used in the `playwright codegen` hint. */
|
|
45
|
+
baseUrl?: string;
|
|
46
|
+
/** Injectable clock, so expiry assertions are deterministic in tests. */
|
|
47
|
+
now?: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Assert that a usable persisted session exists, and describe it.
|
|
51
|
+
*
|
|
52
|
+
* This is the whole body of a Playwright setup project. It deliberately does not
|
|
53
|
+
* *create* the session: scripting Google's password form is the brittle part, so
|
|
54
|
+
* the first run tells you how to do it by hand and every run after that is a
|
|
55
|
+
* sub-millisecond file check.
|
|
56
|
+
*
|
|
57
|
+
* @returns a summary safe to print — no cookie value is in it
|
|
58
|
+
* @throws when the file is missing, malformed, or expired, in each case with the
|
|
59
|
+
* command that fixes it
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* // tests/auth.setup.ts
|
|
63
|
+
* setup("Google test-user session exists", () => {
|
|
64
|
+
* const summary = requireStoredSession({ baseUrl: process.env.E2E_BASE_URL });
|
|
65
|
+
* console.log(`session ok — ${summary.cookieCount} cookies, expires in ${summary.expiresInSeconds}s`);
|
|
66
|
+
* });
|
|
67
|
+
*/
|
|
68
|
+
export declare function requireStoredSession(options?: StoredSessionOptions): StorageStateSummary;
|
|
69
|
+
export interface BootstrapAppSessionOptions extends StoredSessionOptions {
|
|
70
|
+
/** Playwright's `request` fixture. */
|
|
71
|
+
request: RequestLike;
|
|
72
|
+
/** Playwright's `page` fixture — needed to put the app's origin in the context. */
|
|
73
|
+
page: PlaywrightPageLike;
|
|
74
|
+
/** Playwright's `context` fixture, which the state is read off. */
|
|
75
|
+
context: ContextLike;
|
|
76
|
+
/**
|
|
77
|
+
* The test-only endpoint that creates a session. Must 404 in production, and be
|
|
78
|
+
* covered by a test that proves it does.
|
|
79
|
+
*/
|
|
80
|
+
endpoint?: string;
|
|
81
|
+
/** The shared secret the endpoint checks. Required; never defaulted. */
|
|
82
|
+
secret?: string;
|
|
83
|
+
/** Where to land afterwards, so the app's cookies are in the context. */
|
|
84
|
+
landingPath?: string;
|
|
85
|
+
/** Body posted to the endpoint — which user to create, what roles to give it. */
|
|
86
|
+
user?: Record<string, unknown>;
|
|
87
|
+
/** Capture IndexedDB too. Needed when your auth library keeps tokens there. */
|
|
88
|
+
indexedDB?: boolean;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Ask your own app for a session that looks exactly like a successful Google
|
|
92
|
+
* callback, then persist the browser state.
|
|
93
|
+
*
|
|
94
|
+
* The recommended path for CI. The only secret involved is yours, it is scoped to
|
|
95
|
+
* your own staging environment, and nothing in the run touches Google — so a PR
|
|
96
|
+
* check cannot fail because Google showed a consent screen.
|
|
97
|
+
*
|
|
98
|
+
* The endpoint on your side must create the *same* session format, claims, roles
|
|
99
|
+
* and cookies as the real callback. An endpoint that mints a subtly different
|
|
100
|
+
* session turns the whole suite into a test of a code path that does not ship.
|
|
101
|
+
*
|
|
102
|
+
* @throws when `secret` is absent — an unauthenticated session-minting endpoint
|
|
103
|
+
* is a far worse outcome than a failed test, so this never falls back
|
|
104
|
+
*/
|
|
105
|
+
export declare function bootstrapAppSession(options: BootstrapAppSessionOptions): Promise<string>;
|
|
106
|
+
/** Is real Google sign-in opted into, and are the credentials present? */
|
|
107
|
+
export declare function realGoogleLoginEnabled(env?: Record<string, string | undefined>): boolean;
|
|
108
|
+
export interface GoogleSignInOptions extends StoredSessionOptions {
|
|
109
|
+
page: PlaywrightPageLike;
|
|
110
|
+
context: ContextLike;
|
|
111
|
+
/** Environment the opt-in flag and credentials are read from. */
|
|
112
|
+
env?: Record<string, string | undefined>;
|
|
113
|
+
/** Your app's login page. */
|
|
114
|
+
loginPath?: string;
|
|
115
|
+
/** Accessible name of your app's "sign in with Google" button. */
|
|
116
|
+
signInButton?: string | RegExp;
|
|
117
|
+
/** URL the flow must land on before the state is captured. */
|
|
118
|
+
expectUrl?: RegExp;
|
|
119
|
+
indexedDB?: boolean;
|
|
120
|
+
/** Google's own field and button labels, overridable because Google changes them. */
|
|
121
|
+
googleSelectors?: {
|
|
122
|
+
email?: RegExp;
|
|
123
|
+
password?: RegExp;
|
|
124
|
+
next?: RegExp;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Drive Google's real sign-in form and persist the result.
|
|
129
|
+
*
|
|
130
|
+
* Use this once, in a workflow a human triggers, to confirm your OAuth client is
|
|
131
|
+
* configured — not in a PR check. The selectors below are illustrative: Google
|
|
132
|
+
* changes its UI, its labels and its language, and none of that is something this
|
|
133
|
+
* package can keep up with.
|
|
134
|
+
*
|
|
135
|
+
* It will not attempt to get past MFA, CAPTCHA, a suspicious-sign-in prompt or
|
|
136
|
+
* device verification, and you should not add that: those are the controls
|
|
137
|
+
* protecting the account, and automating around them is how a test account
|
|
138
|
+
* becomes a compromised one. If you hit them, capture the session by hand with
|
|
139
|
+
* `playwright codegen` instead.
|
|
140
|
+
*
|
|
141
|
+
* @throws when {@link ALLOW_REAL_LOGIN_ENV} is not set or credentials are missing
|
|
142
|
+
*/
|
|
143
|
+
export declare function signInWithGoogle(options: GoogleSignInOptions): Promise<string>;
|
|
144
|
+
/** Load a state file for passing to `browser.newContext({ storageState })`. */
|
|
145
|
+
export declare function loadStorageStateFor(options?: StoredSessionOptions): StorageState;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* test-google-login — persist a Google sign-in once, reuse it everywhere.
|
|
3
|
+
*
|
|
4
|
+
* The Node/Playwright half of the package. The Cloudflare Workers half is a
|
|
5
|
+
* separate entry point, `test-google-login/worker`, so that importing this one
|
|
6
|
+
* never drags a Worker runtime into your test process (and vice versa).
|
|
7
|
+
*
|
|
8
|
+
* Start with {@link requireStoredSession} in a Playwright setup project, or
|
|
9
|
+
* {@link bootstrapAppSession} if your app can mint its own test sessions — which
|
|
10
|
+
* is the one to use in CI.
|
|
11
|
+
*
|
|
12
|
+
* @see README.md for the security boundary around the state file. It is short,
|
|
13
|
+
* and it is the most important part of this package.
|
|
14
|
+
*/
|
|
15
|
+
export type { ApplyStorageStateOptions, ApplyStorageStateResult, } from './puppeteer-state.js';
|
|
16
|
+
export type { PageLike, PuppeteerCookie, SameSite, StorageState, StorageStateCookie, StorageStateOrigin, StorageStateSummary, } from './types.js';
|
|
17
|
+
export type { BootstrapAppSessionOptions, ContextLike, GoogleSignInOptions, LocatorLike, PlaywrightPageLike, RequestLike, StoredSessionOptions, } from './google-login.js';
|
|
18
|
+
export type { CliIo } from './cli.js';
|
|
19
|
+
export type { GitignoreResult, AuthFileOptions } from './auth-file.js';
|
|
20
|
+
export { DEFAULT_AUTH_DIR, DEFAULT_AUTH_FILE, GITIGNORE_LINES, STATE_PATH_ENV, codegenCommand, ensureAuthDir, ensureGitignored, isIgnored, resolveAuthFile, writeAuthDirGitignore, } from './auth-file.js';
|
|
21
|
+
export { InvalidStorageStateError, MissingStorageStateError, SESSION_COOKIE_EXPIRES, assertStorageStateUsable, findExpiringCookies, hardenStorageStateFile, isGoogleDomain, normalizeStorageState, parseStorageState, readStorageState, redactStorageState, summarizeStorageState, writeStorageState, } from './storage-state.js';
|
|
22
|
+
export { ALLOW_REAL_LOGIN_ENV, BOOTSTRAP_HEADER, bootstrapAppSession, loadStorageStateFor, realGoogleLoginEnabled, requireStoredSession, signInWithGoogle, } from './google-login.js';
|
|
23
|
+
export { applyLocalStorage, applyStorageState, extractStorageState, fromPuppeteerCookies, storageStateOrigins, toPuppeteerCookies, } from './puppeteer-state.js';
|
|
24
|
+
export { AUTH_HEADER, constantTimeEqual, isAuthorized } from './secret.js';
|
|
25
|
+
export { parseArgs, runCli } from './cli.js';
|