scenescout 3.13.0 → 3.14.0
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/CHANGELOG.md +29 -0
- package/README.md +34 -6
- package/dist/browsers.js +13 -0
- package/dist/check-run.js +2 -0
- package/dist/ci-run.js +134 -16
- package/dist/cli.js +72 -1
- package/dist/engine/authloss.js +149 -8
- package/dist/engine/brief.js +7 -1
- package/dist/engine/browser.js +615 -74
- package/dist/engine/capture.js +121 -0
- package/dist/engine/check.js +18 -0
- package/dist/engine/ci.js +117 -6
- package/dist/engine/dedup.js +50 -22
- package/dist/engine/expiry.js +254 -0
- package/dist/engine/fingerprint.js +2 -2
- package/dist/engine/flow.js +0 -2
- package/dist/engine/limits.js +144 -0
- package/dist/engine/live-page.js +57 -4
- package/dist/engine/oracles.js +8 -0
- package/dist/engine/png.js +238 -0
- package/dist/engine/postmessage.js +282 -0
- package/dist/engine/probes.js +4 -4
- package/dist/engine/profiles.js +392 -0
- package/dist/engine/refresh.js +383 -0
- package/dist/engine/scripted-login.js +400 -0
- package/dist/engine/teardown.js +41 -0
- package/dist/login-run.js +444 -0
- package/dist/mcp-server.js +136 -20
- package/package.json +7 -2
- package/skills/scenescout/SKILL.md +9 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f4c1acf: The time an action on the page and a page load may take are now settings. An action (click, typing, hover, pick, upload, and a saved flow step's wait) keeps its 5 s default and a page load its 20 s (15 s for a crawled page or a flow's navigate step, 10 s for going back). Raise them with `actionTimeoutMs` and `navTimeoutMs` on `scout_attach`, with `SCENESCOUT_ACTION_TIMEOUT_MS` and `SCENESCOUT_NAV_TIMEOUT_MS` in the environment, or with `--action-timeout-ms` and `--nav-timeout-ms` on `scenescout check` and `scenescout ci` (and the matching inputs on both GitHub Actions). An option wins over the variable, and the variable over the default; a value out of bounds refuses the attach with a sentence naming it. A timeout now says which limit ran out, how long it was, and how to raise it, so a slow machine is not mistaken for a slow app.
|
|
8
|
+
- 52ebb78: Add `scenescout login <url> --role <name>`: sign in once in a visible browser (SSO, MFA, anything the app asks), press Enter, and the session is saved as that role's profile in `.scenescout/auth/<name>.json`, readable by your account only and kept out of git. `scout_attach` takes a new `role` argument that builds the session's own browser from that profile, so any number of sessions can run as the same role from one login. A role with no saved login is refused with the command to run, and `role` with `storageStatePath` is refused as ambiguous. Lane briefs tell each lane to attach by role when the planner signed in that way.
|
|
9
|
+
- 6b8746c: A new oracle, `postmessage_token`, reports a page that calls `postMessage` with targetOrigin `"*"` on a message carrying a token: a JWT, a `Bearer` value, or an opaque value under a key such as `access_token`, including inside a JSON string. Any origin the receiving window holds can read such a message. The finding names the path inside the message, the shape and the token's first four characters and length, never the token. It is filed at high severity, and `scenescout check` reports it under the new `postmessage-token` rule, which fails the default gate.
|
|
10
|
+
- 3a39d7f: `scenescout login` now says how long the saved sign-in will last, read from its cookies' expiry dates and the `exp` of any JWT in a cookie or in localStorage (decoded for that claim only, never verified or printed). `scout_lane_brief` checks the planner's saved role before splitting the app: it takes `runMinutes` (default 60) and `expiryMarginMinutes` (default 10), refuses when every credential in the profile is dated, none was set for another host, and the last ends before the run does, and names the `scenescout login` command to run again. A profile whose first credential expires inside the run, or that has no expiry in it at all, is a warning at the top of the brief rather than a refusal.
|
|
11
|
+
- 77501be: Sign-in profiles saved by `scenescout login` now keep sessionStorage and IndexedDB as well as cookies and localStorage, so an app whose sign-in library keeps its token in either still comes back signed in when a session attaches by `role`. sessionStorage is restored before the app's own code runs, only on the origin it was saved from and once per tab, so a session that signs out stays signed out. The line printed after saving counts origins with session storage and IndexedDB databases. Logins saved by an earlier version load as before; record them again to pick up the new storage.
|
|
12
|
+
- 36665f5: `/scenescout qa` can now be opened to more than a list of usernames: `SCENESCOUT_QA_ALLOWED_ROLES` allows commenters by their association with the repository (`OWNER`, `MEMBER`, `COLLABORATOR`), and `SCENESCOUT_QA_ALLOWED_TEAMS` allows active members of the organization's teams (`org/team-slug`), read with a separate `SCENESCOUT_QA_TEAM_TOKEN` secret that only the gate job receives. The lists combine with `SCENESCOUT_QA_ALLOWED`; unset, the default stays the repository's owners. An unknown role fails the gate, and a team lookup with no token, a refused token or a team of another organization refuses the commenter and says why in the job's log.
|
|
13
|
+
- 2089e9e: Add `/scenescout qa show <element>` and `/scenescout qa compare <element>`: the QA comment captures one element of the pull request's preview with a real browser screenshot (its bounds plus a margin), and `compare` captures the same element on a base URL (`SCENESCOUT_QA_BASE_URL`, else the base branch's newest successful deployment) and adds a diff picture with the share of pixels changed. The reply shows the pictures inline: a new keyless `shots` job pushes them to the `scenescout-shots` branch, one folder per run, and the reply links only images whose URLs the workflow builds itself. Underneath, `scenescout ci` takes `--show "<words>"` and `--compare-url <url>` (and the ci action `show` and `compare-url`), and a new `scout_capture` tool saves a PNG of one element by its ref. The QA template now pins v3.14.0.
|
|
14
|
+
- e665fcc: A session attached by role now answers its first `SESSION AUTH LOST` by re-attaching once from that role's latest saved profile, read from disk at that moment, and going back to the page it asked for. The tool result says `SESSION RE-ATTACHED` and lists the routes the loss bounced; a crawl visits them again itself. A second loss in the same session, or a profile that no longer signs in, is reported as before. A lane that re-attached is named when its report is folded. Sessions attached with a storage-state file or none behave as before.
|
|
15
|
+
- fdbd77c: Add `scenescout login <url> --role <name> --script`, a sign-in for CI with no one at the keyboard. It runs headless, fills the sign-in form from `SCENESCOUT_LOGIN_USERNAME` and `SCENESCOUT_LOGIN_PASSWORD`, types an RFC 6238 code from `SCENESCOUT_LOGIN_TOTP_SECRET` when the form asks for one, follows forms that ask for the password after "Next", and saves the session as the role's profile, as the manual login does. Fields are found by autocomplete, type and label, with CSS selectors as a fallback, and success by leaving the sign-in fields behind or by a configured URL or selector. Missing configuration is reported before a browser starts, a refused sign-in exits 1, and no credential value appears in anything it prints, even when the page echoes it. The CI docs gain a section on signing in: the options (a test tenant's user, a test-only endpoint, a saved session as a secret) and the rules for the credentials.
|
|
16
|
+
- feb605c: Sessions attached by the same role no longer present the same refresh token. When a session's page is about to send a refresh token from the role's saved profile, it takes a lock beside the profile; holding it, it loads the profile again and, if another session has already rotated the token, sends the current one in place of the spent one. Once the page has stored the rotation it is written back over the profile. An app that revokes a whole token family on reuse keeps every session of the role signed in. Sessions in separate processes share the lock through the file. Token values are never printed or logged. `SCENESCOUT_REFRESH_BROKER=off` turns it off.
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- 031c34c: Closing a session now always closes its browser. When closing took longer than the 8-second cap, the close returned and cleared its references before teardown had reached the browser, so the browser kept running under the process and could stop it from exiting. The browser is now closed past the cap as well, without waiting on a page or context that has not closed.
|
|
21
|
+
- cd73ece: In Firefox, a page the engine closes (a popup of another site, or every page when a session ends) now waits until the write policy has answered what the page sent as it was left. A write refused by the policy, such as a delete sent by beacon on `pagehide`, could previously reach the server when the page closed first.
|
|
22
|
+
- d0cdf80: The live view's close-up keeps its session's stream in step with the rest of the page: pressing "Stream all" while a close-up is open holds for that session after the close-up closes, and opening another session's close-up from the keyboard hands the first one back as it was; a card's own Stream toggle pressed behind the close-up is a choice that outlasts it. Its Stream button disappears with a session that closes under it, and a still that fails to capture once stays on screen instead of blanking the picture until the next refresh.
|
|
23
|
+
- 3b869fc: A role session that re-attaches after losing its sign-in now also gets back the latest profile's sessionStorage, so an app that keeps its token there is signed in again rather than left signed out. Each tab is seeded once more, only for the origins the profile holds, and a sign-out after that stands.
|
|
24
|
+
- 6b27dd9: Page-state fingerprints and finding ids are now derived with SHA-256 instead of SHA-1. They are identifiers, not a security control, but the ids change: on the first run after upgrading, findings stored by an earlier version may show as new once, and ones marked resolved may be reported again.
|
|
25
|
+
|
|
26
|
+
## 3.13.1
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- ab8b9f9: The `ci` GitHub Action takes a `cache` input (default `true`). `cache: false` skips restoring and saving the browser in the actions cache, for a job that checks out a ref chosen by an input and must not write to the cache.
|
|
31
|
+
|
|
3
32
|
## 3.13.0
|
|
4
33
|
|
|
5
34
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -179,7 +179,25 @@ In Claude Code the skill gives you a command with flags for the same thing:
|
|
|
179
179
|
|
|
180
180
|
The agent scans the project (if there is one), attaches read-only, explores, and writes findings to `.scenescout/report.md`. That's it.
|
|
181
181
|
|
|
182
|
-
**Common flags** — `--level minimal|medium|extensive` · `--url <app>` · `--role <name\|path>` (
|
|
182
|
+
**Common flags** — `--level minimal|medium|extensive` · `--url <app>` · `--role <name\|path>` (who to explore as: a login saved with `scenescout login`, a storage state found by the scan, or a path to a Playwright storage-state JSON) · `--observe` / `--safe-write` / `--allow-destructive`.
|
|
183
|
+
|
|
184
|
+
### 🔑 Signing in as a role
|
|
185
|
+
|
|
186
|
+
For an app behind SSO or MFA, sign in once yourself and let every session reuse it:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
scenescout login http://localhost:3000 --role admin
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
A browser window opens at the URL. Sign in however the app asks, then come back to the terminal and press **Enter**: the session is saved as `.scenescout/auth/admin.json` in the project. Closing the window or pressing Ctrl+C saves nothing. The file is readable by your account only, `.scenescout/` keeps itself out of git, and the command prints where it saved, how many cookies, origins and databases it holds, never what they are, and how long it will last: read from each cookie's expiry and the `exp` of any JWT in a cookie or in localStorage (the payload is decoded for that one claim, never verified, never printed). The profile keeps cookies, localStorage, IndexedDB and sessionStorage, so an app whose sign-in library keeps its token in sessionStorage or IndexedDB still comes back signed in; sessionStorage is put back only on the origin it came from, once per tab, so a lane that signs out stays signed out. A login saved by an earlier version has no sessionStorage or IndexedDB: record it again if the app keeps its token there. `--project <dir>` saves into another project; `--browser firefox|webkit` records in another browser.
|
|
193
|
+
|
|
194
|
+
Then `/scenescout --role admin`, or `scout_attach { role: "admin" }` from any agent. Every session attached with the same role gets its own browser built from that one login, so parallel lanes can all run as `admin`. A role with no saved login is refused with the command to run. `role` and `storageStatePath` are alternatives: pass one.
|
|
195
|
+
|
|
196
|
+
Sessions of one role share one saved login, so they share its refresh token too. An app that rotates refresh tokens and treats a second use of a spent one as theft would revoke the whole token family, and sign every session of that role out, the moment two of them refreshed with the same token. SceneScout stops that for a session attached by role. When the page is about to send a refresh token from the role's profile, the session first takes a lock beside the profile (`.scenescout/auth/<role>.json.lock`, owner-only, taken over if its holder has not touched it in 30 seconds). Holding the lock, it re-reads the profile: if another session has rotated the token in the meantime, it loads that profile into its own browser and sends the current token in place of the spent one. Once the page has stored the rotated token, the session writes its state back over the profile and releases the lock. Sessions in separate processes share the lock through the file. A refresh token is recognised by name (a cookie, a storage key, or a field inside a JSON storage value whose name contains `refresh`) and is never printed or logged. An app whose sign-in renews through the identity provider's own session cookie needs none of this, since no refresh token is shared. `SCENESCOUT_REFRESH_BROKER=off` turns the broker off.
|
|
197
|
+
|
|
198
|
+
In CI, where nobody can type, `--script` signs in headless as a test user from `SCENESCOUT_LOGIN_USERNAME`, `SCENESCOUT_LOGIN_PASSWORD` and, for a one-time code, `SCENESCOUT_LOGIN_TOTP_SECRET`, and saves the same profile. No credential value is ever printed. See [signing in from CI](docs/ci.md#signing-in-from-ci) for the options and the rules: a test tenant's user, never production or a real person's account.
|
|
199
|
+
|
|
200
|
+
Before a parallel run, `scout_lane_brief` checks that the planner's saved login will outlast it: `runMinutes` (default 60) plus `expiryMarginMinutes` (default 10). It refuses only when it is sure, meaning every credential in the profile has a date, none was set for another host, and the last of them ends before the run does, and then names the `scenescout login` command to run again. A profile holds cookies other than the sign-in (analytics, preferences), so the first one to expire is reported as a warning rather than a reason to refuse, and a profile with undated credentials in it (a session cookie, or a refresh token with no expiry) is a warning that its lifetime is unknown.
|
|
183
201
|
|
|
184
202
|
---
|
|
185
203
|
|
|
@@ -279,7 +297,7 @@ Snapshots are cheap: re-snapshotting a route returns only *what changed*, with s
|
|
|
279
297
|
|---|---|---|
|
|
280
298
|
| **Set up** | `scout_playbook` `scout_scan` `scout_attach` `scout_session` | Hand the testing method to an agent that has no skill loaded; discover routes; launch a browser in a write-mode; keep several authenticated roles alive at once |
|
|
281
299
|
| **Explore** | `scout_crawl` `scout_coverage` | Sweep every route in one call; ask what's still untested |
|
|
282
|
-
| **Look** | `scout_snapshot` `scout_hover` `scout_screenshot` | Read the structured scene (diffed); reveal tooltips/hover cards; capture pixels only when needed |
|
|
300
|
+
| **Look** | `scout_snapshot` `scout_hover` `scout_screenshot` `scout_capture` | Read the structured scene (diffed); reveal tooltips/hover cards; capture pixels only when needed; save one element as a PNG to show someone |
|
|
283
301
|
| **Ask the server** | `scout_request` | Call the app's own API as this session, with the UI bypassed — the check that turns a hidden button into a proven refusal |
|
|
284
302
|
| **Act** | `scout_click` `scout_type` `scout_select` `scout_upload` `scout_press` `scout_scroll` `scout_navigate` `scout_back` `scout_run_plan` | Drive the UI like a user; `scout_run_plan` batches a whole mechanical sequence into one call |
|
|
285
303
|
| **Assess** | `scout_design_audit` `scout_journey` | Score a page's craft/a11y/consistency; measure how hard a task is to complete |
|
|
@@ -297,7 +315,7 @@ A few that punch above their weight:
|
|
|
297
315
|
- **`scout_click {clicks: 2}`** — the impatient-user probe: states whether a double-click fired the same state-changing request twice (the classic double-submit bug).
|
|
298
316
|
- **`scout_request`** — calls the app's own API as the session, so "the button is hidden" becomes "the server refuses it" (or doesn't).
|
|
299
317
|
|
|
300
|
-
Beyond crashes and HTTP errors, two oracles catch a page **contradicting the server**: `refused_empty` (a list request was refused and the page shows its empty state with no error) and `false_success` (a save was refused and the page says it worked). A third, `dom_injection`, reports a typed markup value coming back as an element on any page any session opens.
|
|
318
|
+
Beyond crashes and HTTP errors, two oracles catch a page **contradicting the server**: `refused_empty` (a list request was refused and the page shows its empty state with no error) and `false_success` (a save was refused and the page says it worked). A third, `dom_injection`, reports a typed markup value coming back as an element on any page any session opens. A fourth, `postmessage_token`, reports a page calling `postMessage` with targetOrigin `"*"` on a message that carries a token (a JWT, a `Bearer` value, or an opaque value under a key such as `access_token`): the report names where in the message it was, its shape and its first four characters, never the token.
|
|
301
319
|
|
|
302
320
|
---
|
|
303
321
|
|
|
@@ -432,7 +450,7 @@ Run `npx -y scenescout doctor` first — it checks every setup item below (every
|
|
|
432
450
|
| Installed as a plugin, and the tools fail with *"Executable not found in $PATH: npx"* | A plugin starts the server with a bare `npx`, which Claude Code can only find if it was launched from an environment that has Node on its `PATH`. Under nvm or fnm that means starting Claude Code from a terminal, not from a dock or launcher. Or use `npx -y scenescout install` instead, which registers the absolute path of `npx`. |
|
|
433
451
|
| *"… build has not been downloaded yet"* on attach | The browser download was skipped or failed, or the run asked for a browser you did not install. Run the command the message names, for example `npx -y scenescout install --browser-only --browsers firefox`. On Linux, system libraries may be missing too: `npx playwright install --with-deps chromium`. |
|
|
434
452
|
| Tools broke after moving the folder or changing node version | The registration stores absolute paths. `npx -y scenescout install` refreshes them. |
|
|
435
|
-
| Attach fails or every route lands on the login page | Your app isn't running at `--url`, or the `--role`
|
|
453
|
+
| Attach fails or every route lands on the login page | Your app isn't running at `--url`, or the `--role` session has expired. For a saved login, run `scenescout login <url> --role <name>` again; for a storage-state file, regenerate it the way your project's Playwright setup does. |
|
|
436
454
|
|
|
437
455
|
### ⬆️ Upgrading from an older version
|
|
438
456
|
|
|
@@ -481,6 +499,10 @@ To drive another browser, pass `browser` when attaching (`scout_attach { browser
|
|
|
481
499
|
|
|
482
500
|
In every browser, pages are not given shared workers unless the mode is `destructive`: a request a shared worker sends cannot be intercepted anywhere, so the app is made to do that work on the page, where the policy sees it.
|
|
483
501
|
|
|
502
|
+
### Time limits
|
|
503
|
+
|
|
504
|
+
An action on the page (a click, typing, a hover, a pick from a list) may take 5 s, and a page 20 s to load (15 s for a page the crawl opens). On a loaded machine these can run out while the app is fine; the timeout then says which limit ran out and how to raise it. Raise them per session with `scout_attach { actionTimeoutMs: 15000, navTimeoutMs: 60000 }`, for every session with `SCENESCOUT_ACTION_TIMEOUT_MS` and `SCENESCOUT_NAV_TIMEOUT_MS` in the server's environment, or on `scenescout check` and `scenescout ci` with `--action-timeout-ms` and `--nav-timeout-ms`. An option wins over the variable, and the variable over the default. The action limit takes 1000 to 120000 ms and the page-load limit 1000 to 300000 ms; anything else refuses the attach with a sentence naming the value to fix. Saving a login profile with `scenescout login` honours the two variables as well, and otherwise keeps its own longer waits (30 s for the page, 10 s for a field or the submit).
|
|
505
|
+
|
|
484
506
|
## 🔌 Other MCP clients
|
|
485
507
|
|
|
486
508
|
The engine is a plain MCP server over stdio, so any client can drive it, and the testing method reaches the agent through the server itself (see the end of this section). `install` can register it for you:
|
|
@@ -615,6 +637,7 @@ The CLI is also useful on its own:
|
|
|
615
637
|
npx -y scenescout scan <path> # project discovery: framework, routes, saved logins
|
|
616
638
|
npx -y scenescout status <path> # what every session of a running engine is doing right now
|
|
617
639
|
npx -y scenescout watch <path> # the same, live in your browser, with each session's page
|
|
640
|
+
npx -y scenescout login <url> --role admin # sign in once in a visible browser; sessions attach with role: "admin"
|
|
618
641
|
```
|
|
619
642
|
|
|
620
643
|
---
|
|
@@ -625,9 +648,10 @@ npx -y scenescout watch <path> # the same, live in your browser, with each
|
|
|
625
648
|
src/
|
|
626
649
|
mcp-server.ts the 29 tools + per-session dispatch
|
|
627
650
|
scan.ts project discovery (framework, routes, auth)
|
|
628
|
-
cli.ts scan · serve · install · doctor · check · ci · status
|
|
651
|
+
cli.ts scan · serve · install · doctor · check · ci · login · status · watch
|
|
629
652
|
check-run.ts drives a check: attach, crawl every route, collect what was measured
|
|
630
653
|
ci-run.ts drives a CI run: the MCP server as a child, the model's API, the agent loop
|
|
654
|
+
login-run.ts drives `scenescout login`: a visible browser, Enter to save the role's profile; or --script, headless from the environment
|
|
631
655
|
installer.ts setup logic (skill link, MCP registration, diagnostics)
|
|
632
656
|
engine/
|
|
633
657
|
browser.ts the engine class: attach, snapshot, actions, crawl, plans
|
|
@@ -648,6 +672,10 @@ src/
|
|
|
648
672
|
journey.ts task-ease measurement from the action log
|
|
649
673
|
design.ts the design audit + page scoring
|
|
650
674
|
memory.ts cross-run storage + finding dedup
|
|
675
|
+
profiles.ts saved sign-ins: role names, where a profile lives, owner-only files, attach by role, sessionStorage restore
|
|
676
|
+
refresh.ts the refresh broker: which values are a role's refresh tokens, the lock beside the profile, swapping a spent token
|
|
677
|
+
scripted-login.ts a CI sign-in: env and flags, TOTP (RFC 6238), which field is which, redaction
|
|
678
|
+
expiry.ts how long a saved sign-in lasts: cookie dates and JWT exp, checked before lanes start
|
|
651
679
|
report.ts the gap ledger + report generation
|
|
652
680
|
check.ts the check's rules, gate, report and SARIF
|
|
653
681
|
ci.ts a CI run's options, provider choice, caps, key redaction, tools and files
|
|
@@ -726,7 +754,7 @@ Found a way past the write policy, or another security problem? Please report it
|
|
|
726
754
|
- **Design audit with page scores.** Two tiers (⚠ measurable defects / → craft suggestions incl. AI-slop tells), per-page 0–100 score persisted per route, plus an automatic overlay/modal probe on every snapshot. Shared shell scored once, separately.
|
|
727
755
|
- **Scrolls like a user — and notices when it can't.** Reports `SCROLL LOCKED` for a leaked modal scroll-lock, finds the real inner scroll pane on app-shell layouts, and flags `UNREACHABLE` controls clipped inside `overflow:hidden`.
|
|
728
756
|
- **Uploads like a user.** Answers a styled file-chooser or sets a hidden input directly, with a valid in-memory fixture; `filePath` is fenced to the project under test; files violating `accept` are flagged at selection.
|
|
729
|
-
- **Auth via Playwright storage states.** Expired tokens caught at attach; repeated login-bounces raise `SESSION AUTH LOST
|
|
757
|
+
- **Auth via Playwright storage states.** Expired tokens caught at attach; repeated login-bounces raise `SESSION AUTH LOST` (a session attached by role first re-attaches once from its role's latest saved profile and carries on); a bounced route is recorded as *not* covered — a dead session can't certify routes it never reached.
|
|
730
758
|
- **A trustworthy gap ledger.** Entries must be actionable (a search box or wizard sub-step isn't "form filled but never submitted"); API/download URLs never enter the route contract.
|
|
731
759
|
- **Honest reporting.** Shared chrome counted once, stale scores marked, role matrix compares only roles that actually attempted a route.
|
|
732
760
|
- **Cross-run written knowledge.** `scout_note` curates `.scenescout/ASSUMPTIONS.md` — app model, personas, constraints, risks — in prose.
|
package/dist/browsers.js
CHANGED
|
@@ -225,6 +225,19 @@ export function allowedUnloadWritesMayBeLost(engine) {
|
|
|
225
225
|
export function frameUnloadWritesMayGoUnissued(engine) {
|
|
226
226
|
return engine === "firefox";
|
|
227
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Whether a page must wait, after it is left, for the write policy to answer
|
|
230
|
+
* what it sent on its way out before it is closed. Firefox can hand a beacon
|
|
231
|
+
* sent on pagehide to the route handler after the navigation away has
|
|
232
|
+
* returned, and a request still waiting for its answer when its page closes
|
|
233
|
+
* goes out unjudged: 9 of 80 foreign popups left and closed at once let their
|
|
234
|
+
* refused beacon reach the server. Chromium's browser-level interception
|
|
235
|
+
* judges it whatever the page's state, and WebKit met none in the same loop,
|
|
236
|
+
* so neither pays for the wait.
|
|
237
|
+
*/
|
|
238
|
+
export function closeWaitsForLeavingWrites(engine) {
|
|
239
|
+
return engine === "firefox";
|
|
240
|
+
}
|
|
228
241
|
/**
|
|
229
242
|
* Whether a write carried on by a redirect (a 307 or 308 keeps the method and
|
|
230
243
|
* the body) is judged at its new address. The route handler sees only the
|
package/dist/check-run.js
CHANGED
|
@@ -58,6 +58,8 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
58
58
|
mode: options.mode,
|
|
59
59
|
storageStatePath: options.storageStatePath,
|
|
60
60
|
...(options.browser ? { browser: options.browser } : {}),
|
|
61
|
+
actionTimeoutMs: options.actionTimeoutMs,
|
|
62
|
+
navTimeoutMs: options.navTimeoutMs,
|
|
61
63
|
objective: "Deterministic check: visit every route and measure it",
|
|
62
64
|
task: "Checking every route",
|
|
63
65
|
});
|
package/dist/ci-run.js
CHANGED
|
@@ -17,8 +17,11 @@ import path from "node:path";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
19
19
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
20
|
-
import {
|
|
20
|
+
import { CAPTURE_MARGIN, parseCaptureResult, rebaseUrl, SHOT_FILES, SHOTS_DIRNAME } from "./engine/capture.js";
|
|
21
|
+
import { addUsage, wallLeftMs, guardToolArgs, capReached, CAPTURE_TOOLS, childEnv, ciCaptureKickoff, ciCaptureSystemPrompt, CI_DIRNAME, ciExitCode, ciKickoff, ciSarif, ciSummaryJson, ciSummaryMarkdown, ciSystemPrompt, ciToolArgs, ciTools, describeStop, findingsThisRun, NO_USAGE, readFindings, redactKeys, toolResultText, usageLine, } from "./engine/ci.js";
|
|
22
|
+
import { resolveTimeLimits } from "./engine/limits.js";
|
|
21
23
|
import { MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
|
|
24
|
+
import { decodePng, diffImages, encodePng } from "./engine/png.js";
|
|
22
25
|
import { AnthropicConversation, backoffMs, errorMessage, MalformedReply, OpenAIConversation, retryable, retryAfterMs, } from "./engine/provider.js";
|
|
23
26
|
import { loadPlaybook } from "./playbook.js";
|
|
24
27
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -199,9 +202,10 @@ export async function agentLoop(o) {
|
|
|
199
202
|
if (turn.resume)
|
|
200
203
|
continue;
|
|
201
204
|
if (turn.calls.length === 0) {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
+
const said = turn.text.trim();
|
|
206
|
+
if (said)
|
|
207
|
+
o.log(` model: ${said.slice(0, 600)}`);
|
|
208
|
+
return { stop: "done", ...(turn.note ? { stopDetail: turn.note } : {}), spend, ...(said ? { finalText: said.slice(0, 600) } : {}) };
|
|
205
209
|
}
|
|
206
210
|
o.log(` turn ${spend.turns}: ${turn.calls.map((c) => c.name || "(unnamed)").join(", ")} — ${(spend.usage.input + spend.usage.output).toLocaleString("en-US")} tokens so far`);
|
|
207
211
|
const results = [];
|
|
@@ -232,6 +236,7 @@ export async function agentLoop(o) {
|
|
|
232
236
|
try {
|
|
233
237
|
const r = await o.host.call(call.name, guarded.args, Math.min(600_000, left));
|
|
234
238
|
results.push({ id: call.id, isError: r.isError, text: r.text });
|
|
239
|
+
o.onResult?.(call.name, guarded.args, r);
|
|
235
240
|
}
|
|
236
241
|
catch (err) {
|
|
237
242
|
results.push({ id: call.id, isError: true, text: `${call.name} failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
@@ -240,6 +245,97 @@ export async function agentLoop(o) {
|
|
|
240
245
|
o.client.addResults(results);
|
|
241
246
|
}
|
|
242
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* After the model has picked and captured the element: copy its picture into
|
|
250
|
+
* the run's output, and for a comparison capture the same element (by its
|
|
251
|
+
* key, not a ref the model chose) on the same page of the base URL, in a
|
|
252
|
+
* session of its own, then diff the two. Only the engine takes pictures, and
|
|
253
|
+
* the file names are fixed (SHOT_FILES).
|
|
254
|
+
*/
|
|
255
|
+
export async function captureShots(o) {
|
|
256
|
+
const what = o.options.show ?? "";
|
|
257
|
+
const shots = path.join(o.outDir, SHOTS_DIRNAME);
|
|
258
|
+
if (!o.captured)
|
|
259
|
+
return { what, status: "not-found", ...(o.finalText ? { detail: o.finalText.slice(0, 300) } : {}) };
|
|
260
|
+
const pathOf = (url) => {
|
|
261
|
+
try {
|
|
262
|
+
const u = new URL(url);
|
|
263
|
+
return `${u.pathname}${u.search}`;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return url;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
let previewPng;
|
|
270
|
+
try {
|
|
271
|
+
previewPng = fs.readFileSync(o.captured.file);
|
|
272
|
+
fs.mkdirSync(shots, { recursive: true });
|
|
273
|
+
fs.writeFileSync(path.join(shots, SHOT_FILES.preview), previewPng);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
return { what, status: "failed", detail: `the picture could not be kept: ${err instanceof Error ? err.message : String(err)}` };
|
|
277
|
+
}
|
|
278
|
+
const outcome = {
|
|
279
|
+
what,
|
|
280
|
+
status: "captured",
|
|
281
|
+
preview: {
|
|
282
|
+
file: `${SHOTS_DIRNAME}/${SHOT_FILES.preview}`,
|
|
283
|
+
key: o.captured.key,
|
|
284
|
+
label: o.captured.label,
|
|
285
|
+
path: pathOf(o.captured.url),
|
|
286
|
+
width: o.captured.width,
|
|
287
|
+
height: o.captured.height,
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
o.log(`Captured ${o.captured.label || o.captured.key} on ${o.captured.url}.`);
|
|
291
|
+
if (!o.options.compareUrl)
|
|
292
|
+
return outcome;
|
|
293
|
+
try {
|
|
294
|
+
const target = rebaseUrl(o.captured.url, o.options.url, o.options.compareUrl);
|
|
295
|
+
if (!target)
|
|
296
|
+
return { ...outcome, detail: `the page it is on (${pathOf(o.captured.url)}) has no place on the base URL, so it was not compared` };
|
|
297
|
+
const attached = await o.host.call("scout_attach", {
|
|
298
|
+
url: target,
|
|
299
|
+
projectPath: o.options.projectDir,
|
|
300
|
+
mode: o.options.mode,
|
|
301
|
+
session: "base",
|
|
302
|
+
objective: "CI run: capture the same element on the base URL",
|
|
303
|
+
...(o.options.browser ? { browser: o.options.browser } : {}),
|
|
304
|
+
}, Math.min(ATTACH_MS, o.timeLeft()));
|
|
305
|
+
if (attached.isError || /^ERROR:/.test(attached.text))
|
|
306
|
+
return { ...outcome, detail: `the base URL could not be opened: ${attached.text.replace(/^ERROR:\s*/, "").slice(0, 200)}` };
|
|
307
|
+
const base = await o.host.call("scout_capture", { key: o.captured.key, name: "base", margin: CAPTURE_MARGIN, session: "base" }, o.timeLeft());
|
|
308
|
+
const baseInfo = base.isError ? null : parseCaptureResult(base.text);
|
|
309
|
+
if (!baseInfo)
|
|
310
|
+
return { ...outcome, detail: `the same element was not captured on the base URL: ${base.text.replace(/^ERROR:\s*/, "").slice(0, 200)}` };
|
|
311
|
+
try {
|
|
312
|
+
const basePng = fs.readFileSync(baseInfo.file);
|
|
313
|
+
fs.writeFileSync(path.join(shots, SHOT_FILES.base), basePng);
|
|
314
|
+
const diff = diffImages(decodePng(basePng), decodePng(previewPng));
|
|
315
|
+
fs.writeFileSync(path.join(shots, SHOT_FILES.diff), encodePng(diff.image));
|
|
316
|
+
o.log(`Compared with ${target}: ${diff.percent}% of pixels changed${diff.sizeChanged ? ", and the size changed" : ""}.`);
|
|
317
|
+
return {
|
|
318
|
+
...outcome,
|
|
319
|
+
base: { file: `${SHOTS_DIRNAME}/${SHOT_FILES.base}`, path: pathOf(baseInfo.url), width: baseInfo.width, height: baseInfo.height },
|
|
320
|
+
diff: {
|
|
321
|
+
file: `${SHOTS_DIRNAME}/${SHOT_FILES.diff}`,
|
|
322
|
+
changedPixels: diff.changed,
|
|
323
|
+
totalPixels: diff.total,
|
|
324
|
+
percent: diff.percent,
|
|
325
|
+
sizeChanged: diff.sizeChanged,
|
|
326
|
+
box: diff.box,
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
return { ...outcome, detail: `the two pictures could not be compared: ${err instanceof Error ? err.message : String(err)}` };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
// A slow or failing base must not cost the preview's picture, nor turn the reply into an exploration's.
|
|
336
|
+
return { ...outcome, detail: `the base URL could not be captured: ${err instanceof Error ? err.message : String(err)}` };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
243
339
|
/**
|
|
244
340
|
* The whole run. `makeClient` is how the model is reached: the HTTP client in
|
|
245
341
|
* the CLI, a scripted one in the tests. Everything logged or written passes
|
|
@@ -252,14 +348,19 @@ export async function runCi(options, resolved, deps) {
|
|
|
252
348
|
const startedAt = now();
|
|
253
349
|
const outDir = options.outDir ?? path.join(options.projectDir, MEMORY_DIRNAME, CI_DIRNAME);
|
|
254
350
|
const before = readMemoryFindings(options.projectDir);
|
|
351
|
+
// Pictures an earlier run left in the same output are not this run's: they must never be uploaded as its.
|
|
352
|
+
fs.rmSync(path.join(outDir, SHOTS_DIRNAME), { recursive: true, force: true });
|
|
255
353
|
let outcome = { stop: "could-not-start", spend: { turns: 0, usage: { ...NO_USAGE }, startedAt } };
|
|
256
354
|
let contractMet = false;
|
|
257
355
|
let reportWritten = false;
|
|
356
|
+
let capture;
|
|
258
357
|
let host = null;
|
|
259
358
|
// Set when the exploration ends (or never starts): the report and the close share FINISH_MS from then.
|
|
260
359
|
let finishBy = 0;
|
|
261
360
|
const finishLeft = () => Math.max(1_000, finishBy - now());
|
|
262
361
|
try {
|
|
362
|
+
// The page-load limit may be longer than the usual attach budget; the attach gets that limit and a minute to launch.
|
|
363
|
+
const attachMs = Math.max(ATTACH_MS, resolveTimeLimits(options, process.env).navMs + 60_000);
|
|
263
364
|
host = await startServer(log);
|
|
264
365
|
const attached = await host.call("scout_attach", {
|
|
265
366
|
url: options.url,
|
|
@@ -269,16 +370,19 @@ export async function runCi(options, resolved, deps) {
|
|
|
269
370
|
task: "Starting the CI run",
|
|
270
371
|
...(options.storageStatePath ? { storageStatePath: options.storageStatePath } : {}),
|
|
271
372
|
...(options.browser ? { browser: options.browser } : {}),
|
|
272
|
-
|
|
373
|
+
...(options.actionTimeoutMs !== undefined ? { actionTimeoutMs: options.actionTimeoutMs } : {}),
|
|
374
|
+
...(options.navTimeoutMs !== undefined ? { navTimeoutMs: options.navTimeoutMs } : {}),
|
|
375
|
+
}, Math.min(attachMs, options.caps.wallMs));
|
|
273
376
|
const authFailed = attached.text.split("\n").find((l) => l.startsWith("⚠ AUTH FAILED"));
|
|
274
377
|
if (attached.isError || /^ERROR:/.test(attached.text) || authFailed) {
|
|
275
378
|
outcome = { ...outcome, stopDetail: (authFailed ?? attached.text).replace(/^ERROR:\s*/, "").slice(0, 400) };
|
|
276
379
|
}
|
|
277
380
|
else {
|
|
278
381
|
log(`Attached to ${options.url} in ${options.mode} mode.`);
|
|
279
|
-
const tools = ciTools(await host.tools());
|
|
280
|
-
const system = ciSystemPrompt(loadPlaybook(packageRoot), options);
|
|
281
|
-
const kickoff = ciKickoff(options);
|
|
382
|
+
const tools = ciTools(await host.tools(), options.show ? CAPTURE_TOOLS : undefined);
|
|
383
|
+
const system = options.show ? ciCaptureSystemPrompt() : ciSystemPrompt(loadPlaybook(packageRoot), options);
|
|
384
|
+
const kickoff = options.show ? ciCaptureKickoff({ url: options.url, show: options.show }) : ciKickoff(options);
|
|
385
|
+
let captured = null;
|
|
282
386
|
outcome = await agentLoop({
|
|
283
387
|
client: deps.makeClient(system, tools, kickoff),
|
|
284
388
|
host,
|
|
@@ -288,17 +392,27 @@ export async function runCi(options, resolved, deps) {
|
|
|
288
392
|
now,
|
|
289
393
|
startedAt,
|
|
290
394
|
projectDir: options.projectDir,
|
|
395
|
+
onResult: (name, _args, r) => {
|
|
396
|
+
if (name === "scout_capture" && !r.isError)
|
|
397
|
+
captured = parseCaptureResult(r.text) ?? captured;
|
|
398
|
+
},
|
|
291
399
|
});
|
|
292
400
|
log(`Run ended: ${describeStop(outcome.stop, options.caps, outcome.stopDetail)}.`);
|
|
293
401
|
finishBy = now() + FINISH_MS;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
402
|
+
if (options.show) {
|
|
403
|
+
// A run asked to show an element writes pictures, not a report: it did not explore.
|
|
404
|
+
capture = await captureShots({ host, options, captured, finalText: outcome.finalText, outDir, timeLeft: finishLeft, log });
|
|
405
|
+
}
|
|
406
|
+
else {
|
|
407
|
+
// The report is written whatever ended the run. A forced report still prints its gaps.
|
|
408
|
+
let report = await host.call("scout_report", { level: options.level }, finishLeft());
|
|
409
|
+
contractMet = !report.isError && !/NOT GENERATED/.test(report.text);
|
|
410
|
+
if (!contractMet && !report.isError)
|
|
411
|
+
report = await host.call("scout_report", { level: options.level, force: true }, finishLeft());
|
|
412
|
+
reportWritten = !report.isError && !/^ERROR:/.test(report.text) && fs.existsSync(path.join(options.projectDir, MEMORY_DIRNAME, "report.md"));
|
|
413
|
+
if (!reportWritten)
|
|
414
|
+
log(`The report could not be generated: ${report.text.slice(0, 400)}`);
|
|
415
|
+
}
|
|
302
416
|
}
|
|
303
417
|
}
|
|
304
418
|
catch (err) {
|
|
@@ -334,6 +448,7 @@ export async function runCi(options, resolved, deps) {
|
|
|
334
448
|
spend: outcome.spend,
|
|
335
449
|
endedAt,
|
|
336
450
|
findings: findingsThisRun(before, readMemoryFindings(options.projectDir)),
|
|
451
|
+
...(capture ? { capture } : {}),
|
|
337
452
|
};
|
|
338
453
|
const written = [];
|
|
339
454
|
try {
|
|
@@ -354,6 +469,9 @@ export async function runCi(options, resolved, deps) {
|
|
|
354
469
|
write("summary.md", summary);
|
|
355
470
|
write("ci.json", JSON.stringify(ciSummaryJson(result, deps.version, secrets), null, 2) + "\n");
|
|
356
471
|
write("ci.sarif", JSON.stringify(ciSarif(result, deps.version, secrets), null, 2) + "\n");
|
|
472
|
+
// A capture run's outcome is its pictures and ci.json: it wrote no report by design.
|
|
473
|
+
if (options.show)
|
|
474
|
+
reportWritten = true;
|
|
357
475
|
if (process.env.GITHUB_STEP_SUMMARY)
|
|
358
476
|
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, redactKeys(summary, secrets));
|
|
359
477
|
}
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* scenescout doctor Check every piece of the setup and say how to fix what is missing
|
|
9
9
|
* scenescout check <url> Visit every route, measure it, and pass or fail (no model involved)
|
|
10
10
|
* scenescout ci <url> An exploratory run driven by a model's API, unattended, that reports
|
|
11
|
+
* scenescout login <url> --role r Sign in once in a visible browser and save it as a named role
|
|
11
12
|
*/
|
|
12
13
|
import { spawnSync } from "node:child_process";
|
|
13
14
|
import fs from "node:fs";
|
|
@@ -20,6 +21,9 @@ import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithC
|
|
|
20
21
|
import { CLI_NAME, diagnose, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
|
|
21
22
|
import { defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
|
|
22
23
|
import { httpClient, runCi } from "./ci-run.js";
|
|
24
|
+
import { runLogin, runScriptedLogin, savedLine } from "./login-run.js";
|
|
25
|
+
import { credentialRedactor, LOGIN_ENV, readScriptedLogin } from "./engine/scripted-login.js";
|
|
26
|
+
import { parseLoginArgs } from "./engine/profiles.js";
|
|
23
27
|
import { detectProvider, EXIT_CI, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
|
|
24
28
|
import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason } from "./engine/check.js";
|
|
25
29
|
import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
|
|
@@ -57,6 +61,9 @@ Usage:
|
|
|
57
61
|
--ignore rule,rule; --storage-state file to check signed in;
|
|
58
62
|
--project dir (default: here); --out dir (default: .scenescout/check);
|
|
59
63
|
--browser chromium|firefox|webkit;
|
|
64
|
+
--action-timeout-ms N (default 5000), --nav-timeout-ms N (default 20000;
|
|
65
|
+
15000 per crawled route): raise on a loaded runner, or set
|
|
66
|
+
SCENESCOUT_ACTION_TIMEOUT_MS / SCENESCOUT_NAV_TIMEOUT_MS;
|
|
60
67
|
--flows dir|off: replay the flows saved there (default: .scenescout/flows);
|
|
61
68
|
--retest on|off: re-test open findings a page load reproduces (default on);
|
|
62
69
|
--flow-writes never|allow: never (default) replays flows under observe's
|
|
@@ -84,8 +91,28 @@ Usage:
|
|
|
84
91
|
destructive only with --allow-destructive as well); --level minimal|medium|
|
|
85
92
|
extensive (default medium); --focus "an area or flow";
|
|
86
93
|
--storage-state file; --browser chromium|firefox|webkit;
|
|
87
|
-
--
|
|
94
|
+
--action-timeout-ms N, --nav-timeout-ms N: as for check;
|
|
95
|
+
--project dir (default: here); --out dir (default: .scenescout/ci);
|
|
96
|
+
--show "the Save button": instead of exploring, capture that element as a PNG
|
|
97
|
+
under shots/; --compare-url https://…: with --show, capture it there too and
|
|
98
|
+
write a diff picture)
|
|
88
99
|
Exit code: 0 the run ran (findings never change it), 2 could not run.
|
|
100
|
+
scenescout login <url> --role <name>
|
|
101
|
+
Open a visible browser at the URL, sign in there (SSO, MFA, anything), then
|
|
102
|
+
press Enter in this terminal to save the session as that role's profile, in
|
|
103
|
+
.scenescout/auth/<name>.json (owner-only; never printed, never committed).
|
|
104
|
+
Closing the window or Ctrl+C saves nothing. Agents then attach with
|
|
105
|
+
scout_attach { role: "<name>" }, as many sessions as they like from one login.
|
|
106
|
+
(--project dir (default: here); --browser chromium|firefox|webkit)
|
|
107
|
+
scenescout login <url> --role <name> --script
|
|
108
|
+
For CI: sign in headless from SCENESCOUT_LOGIN_USERNAME, SCENESCOUT_LOGIN_PASSWORD
|
|
109
|
+
and, if the form asks for a code, SCENESCOUT_LOGIN_TOTP_SECRET (base32 or an
|
|
110
|
+
otpauth:// URI), then save the profile as above. A test user only. No value
|
|
111
|
+
is ever printed. Exit 0 signed in and saved, 1 not.
|
|
112
|
+
(--success-url text|url; --success-selector css; --username-selector,
|
|
113
|
+
--password-selector, --otp-selector, --submit-selector css; each of these also
|
|
114
|
+
from SCENESCOUT_LOGIN_<FLAG>, e.g. SCENESCOUT_LOGIN_SUCCESS_URL;
|
|
115
|
+
--timeout seconds (default 60))
|
|
89
116
|
scenescout status [projectPath] What is the engine doing right now? (every session + recent actions)
|
|
90
117
|
scenescout watch [projectPath] Open the live view in a browser: what each session is doing, a thumbnail
|
|
91
118
|
of its page, and a live stream you can switch on per session
|
|
@@ -598,6 +625,46 @@ async function ci(args) {
|
|
|
598
625
|
}
|
|
599
626
|
process.exit(exitCode);
|
|
600
627
|
}
|
|
628
|
+
/** `scenescout login`: exit 0 saved, 1 nothing saved. */
|
|
629
|
+
async function login(args) {
|
|
630
|
+
if (args.includes("--help") || args.includes("-h"))
|
|
631
|
+
usage(0);
|
|
632
|
+
const parsed = parseLoginArgs(args, process.cwd());
|
|
633
|
+
if (!parsed.ok) {
|
|
634
|
+
console.error(`scenescout login: ${parsed.error}`);
|
|
635
|
+
process.exit(1);
|
|
636
|
+
}
|
|
637
|
+
const options = parsed.options;
|
|
638
|
+
if (options.script) {
|
|
639
|
+
// Everything is checked before a browser launches; each problem names a variable, never a value.
|
|
640
|
+
const read = readScriptedLogin(options.script, process.env);
|
|
641
|
+
if (!read.ok) {
|
|
642
|
+
for (const e of read.errors)
|
|
643
|
+
console.error(`scenescout login: ${e}`);
|
|
644
|
+
process.exit(1);
|
|
645
|
+
}
|
|
646
|
+
const redactor = credentialRedactor(read.config, process.env[LOGIN_ENV.totpSecret]);
|
|
647
|
+
try {
|
|
648
|
+
const saved = await runScriptedLogin(options, read.config, redactor, (line) => console.log(line));
|
|
649
|
+
console.log(redactor.redact(savedLine(options, saved)));
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
// Redacted again here: an error thrown by the browser itself has not been through the run's redaction.
|
|
653
|
+
console.error(redactor.redact(`scenescout login: ${err instanceof Error ? err.message : String(err)}`));
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
process.exit(0);
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
const saved = await runLogin(options, (line) => console.log(line));
|
|
660
|
+
console.log(savedLine(options, saved));
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
console.error(`scenescout login: ${err instanceof Error ? err.message : String(err)}`);
|
|
664
|
+
process.exit(1);
|
|
665
|
+
}
|
|
666
|
+
process.exit(0);
|
|
667
|
+
}
|
|
601
668
|
const [, , command, ...args] = process.argv;
|
|
602
669
|
// A CLI's failure mode should be a sentence, not a stack trace. `scan` on a
|
|
603
670
|
// path that does not exist and `status` on a half-written status.json both
|
|
@@ -644,6 +711,10 @@ try {
|
|
|
644
711
|
await ci(args);
|
|
645
712
|
break;
|
|
646
713
|
}
|
|
714
|
+
case "login": {
|
|
715
|
+
await login(args);
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
647
718
|
case "status": {
|
|
648
719
|
status(path.resolve(args[0] ?? process.cwd()));
|
|
649
720
|
break;
|