diff --git a/.npmignore b/.npmignore index eb79dd5f..69584dda 100644 --- a/.npmignore +++ b/.npmignore @@ -1,2 +1,8 @@ node_modules .idea + +# Playwright repro harness (test/playwright-repro/README.md) - local dev/QA +# tooling only, not part of the published package. Also guards against a +# real .env with stg credentials ever being included in a publish. +test/playwright-repro +.env diff --git a/package.json b/package.json index 3cd43a30..a3bf7480 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "middleware", "stores", "test", + "!test/playwright-repro", "index.js", "uuid.js" ], diff --git a/test/playwright-repro/.env.example b/test/playwright-repro/.env.example new file mode 100644 index 00000000..de903a07 --- /dev/null +++ b/test/playwright-repro/.env.example @@ -0,0 +1,71 @@ +# Copy this file to .env and fill in real values. .env is gitignored - never commit it. +# +# These are STAGING credentials only. This harness is scoped to stg for now +# (see the plan: prod is out of scope until the harness proves useful there). + +# --- Keycloak hostnames (stg) --- +# These are TWO DIFFERENT hostnames, confirmed by direct testing - do not +# collapse them into one: +# KC_BASE_URL - public, serves OIDC login endpoints only +# (/realms/*/protocol/*). This is what real users and +# the fixture app's browser flow talk to. +# KC_ADMIN_BASE_URL - internal, the ONLY place the Admin REST API +# (/admin/realms/*) is reachable. The public hostname +# returns a network-level 403 for every /admin/* path +# regardless of credentials. Only provision.js uses this. +KC_BASE_URL=https://sso.stg.smartling.net/auth +KC_ADMIN_BASE_URL=https://admin-keycloak-ha-v26.inception.dev.smartling.net/auth +KC_REALM=Smartling + +# Ask a current Inception team member for admin credentials with rights to +# create/update clients and users in the Smartling realm (not a master-realm +# account - that realm isn't reachable from the public hostname, and master +# users don't exist in the Smartling realm's own user store). +KC_ADMIN_USERNAME= +KC_ADMIN_PASSWORD= +KC_ADMIN_CLIENT_ID=admin-cli +# Realm the admin account itself lives in. Usually the same as KC_REALM if +# it's a realm-scoped admin (the common case here) - only set this to +# "master" if your account is specifically a master-realm superadmin. +KC_ADMIN_REALM=Smartling + +# --- Throwaway client/user this harness manages --- +# provision.js creates/updates these. Change KC_CLIENT_ID if you want your own +# isolated copy alongside a teammate's (e.g. playwright-repro-), so +# you're not fighting over the same client's session overrides mid-run. +KC_CLIENT_ID=playwright-repro-stg +# Leave blank - provision.js creates this client as confidential (not +# public; see provision.js for why) and fetches its auto-generated secret, +# writing it here for you. repro.js refuses to start without it. +KC_CLIENT_SECRET= +# Must be email-shaped: Smartling's stg login theme's first step is an HTML +# type="email" field with native browser validation, so a plain non-email +# username can never even be submitted. ".invalid" is an IANA-reserved TLD +# guaranteed to never resolve or match a real SSO domain-routing rule, so it +# won't accidentally get redirected to Google/Okta federated login. +KC_TEST_USERNAME=playwright-repro-test-user@stg-repro.invalid +# Leave blank to have provision.js generate a random password and print it once. +KC_TEST_PASSWORD= + +# --- Fixture app (runs under Node 16, see README) --- +FIXTURE_APP_PORT=3987 +# Optional: absolute path to a Node 16 binary, e.g. output of `nvm which 16`. +# If unset, the harness tries `nvm exec 16 node ...` itself and fails loudly +# with setup instructions if that doesn't resolve on your machine. +FIXTURE_NODE_BINARY= + +# --- Session/token overrides applied ONLY to KC_CLIENT_ID --- +# These are per-client "Advanced > Fine Grain OpenID Connect Configuration" +# overrides in the Keycloak admin console - they do NOT touch the realm-wide +# SSO Session Max, so nobody else's stg session is affected. Verify the exact +# attribute keys provision.js sets (client.session.max.lifespan, +# client.session.idle.timeout, access.token.lifespan) still match what the +# stg KC26 admin console's Advanced tab shows before relying on results - +# this hasn't been confirmed against a live server yet. +# Keep these ordered: SESSION_MAX > ACCESS_TOKEN_LIFESPAN > TOKEN_MIN_TTL, +# so the session-cap window actually has room to trigger before the session +# itself ends. +KC_CLIENT_SESSION_MAX_SECONDS=90 +KC_CLIENT_SESSION_IDLE_SECONDS=120 +KC_ACCESS_TOKEN_LIFESPAN_SECONDS=30 +KC_TOKEN_MIN_TTL_SECONDS=20 diff --git a/test/playwright-repro/.gitignore b/test/playwright-repro/.gitignore new file mode 100644 index 00000000..dcfce76e --- /dev/null +++ b/test/playwright-repro/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +results/*.json +results/traces/ +results/har/ diff --git a/test/playwright-repro/README.md b/test/playwright-repro/README.md new file mode 100644 index 00000000..a672af32 --- /dev/null +++ b/test/playwright-repro/README.md @@ -0,0 +1,216 @@ +# AUT-1462 redirect-loop repro harness + +Playwright harness that tries to trigger the intermittent "Too Many Redirects" +loop on stg, many times in a row, without needing a human to manually shrink +Keycloak session timeouts and babysit browser tabs each time. + +Background: see `AUT-1461` / `AUT-1462` in Jira and the `#keycloak-dev` Slack +channel. Even with the fixes in `middleware/grant-attacher.js`, +`middleware/logout.js` and `middleware/post-auth.js`, the team can still +intermittently reproduce the loop on stg - this harness exists to make that +reproducible on demand instead of by luck. + +## Scope: what this DOES and DOES NOT test + +**Does:** exercises this repo's own `keycloak-connect` adapter code +(`index.js`, `middleware/*`) directly, end to end - real login against real +stg Keycloak, real session/token handling, real grant storage, through the +actual middleware chain (`Setup → PostAuth → Admin → GrantAttacher → +Logout → Protect`). The session-cap/redirect-loop bug we're chasing lives +entirely in this code, so testing it in isolation is sufficient to reproduce +and diagnose it. + +**Does NOT (yet):** touch `tms-dashboard-app`, `ti-next`, `wa`, or any other +real Smartling product surface. `fixture-app/` is a bare-bones Express app +with one protected JSON route - it never calls TMS's backend, never checks +account/permission data, and has no dependency on whatever additional +provisioning (beyond the Keycloak identity itself) a real product login +would need. The throwaway test user this harness creates exists **only** +in Keycloak; it is not, and does not need to be, provisioned in TMS. This +is a deliberate choice to keep the reproduction focused on the adapter code +under test, not a limitation to work around - but it does mean this harness +cannot tell you whether the bug (or its fix) behaves the same way inside the +real dashboard app. If that ever becomes necessary, see +[`ROADMAP-dashboard.md`](./ROADMAP-dashboard.md) for a brief note on what +would need to change. + +## How it works, briefly + +1. `provision.js` creates a **throwaway OIDC client** in the stg Smartling + realm (not the shared `tms-dashboard-app`/`ti-next`/`wa` clients) and + overrides *only that client's* session/token lifetimes to ~60-120 seconds + via the Advanced settings Keycloak exposes per-client. This forces the + KC26 "session-cap" condition (see `grant-manager.js`'s `ensureFreshness`) + to happen quickly, without touching the realm-wide SSO Session Max that + every other stg user/client relies on. +2. `repro.js` spins up a small fixture Express app (`fixture-app/`) built on + **this repo's own `index.js`/`middleware/*`** - the actual code being + tested - logs in as a dedicated throwaway test user, waits past the + session-cap window, and reloads a deep-linked protected URL repeatedly + (optionally from two concurrent tabs, to target the multi-tab race shape + both AUT-1461 and the suspected residual AUT-1462 issue share). It records + a result row per iteration and keeps a Playwright trace + HAR only for + iterations that actually reproduce the loop. + +## One-time setup + +Prerequisite: run `npm install` at the **repo root** first, under Node 16 +(`nvm use`), if you haven't already for other work in this repo. The fixture +app requires `../../../index.js` directly and reuses `express`/ +`express-session` from the root `node_modules` (both already root +devDependencies) rather than duplicating them here. + +```bash +cd test/playwright-repro +npm install +cp .env.example .env +``` + +`repro.js` drives real installed **Google Chrome** (via Playwright's +`channel: 'chrome'`), not Playwright's bundled Chromium - the original bug +report is specifically about Chrome, so that's what this reproduces against. +This requires Chrome to already be installed on your machine (normal for a +dev laptop); if it isn't, either install it or run +`npx playwright install chromium` and switch `repro.js`'s `chromium.launch()` +call back to no `channel` option. + +Fill in `.env`: +- `KC_ADMIN_USERNAME` / `KC_ADMIN_PASSWORD`: must be an admin account that + exists **inside the `Smartling` realm itself** (i.e. shows up under + Users when you have the Smartling realm selected in the admin console), + with realm-management rights to create/update clients and users there - + **not** a `master`-realm superadmin account. A master-realm account will + fail with a generic "Invalid user credentials" error here even when the + password is correct, because it doesn't exist in the Smartling realm's own + user store (confirmed by direct testing - see `KC_ADMIN_REALM` below). + Ask the team for the shared 1Password entry - don't use a personal stg + login. **This harness never asks for or stores prod credentials or access.** +- `KC_TEST_PASSWORD`: leave blank and `provision.js` will generate one and + print it once (it's also written to `.env` for you on first run). +- Everything else has a reasonable default; only change `KC_CLIENT_ID` if you + want your own isolated throwaway client alongside a teammate's. + +The fixture app needs to run under **Node 16** (this repo's pinned version, +see the root `CLAUDE.md`/`.nvmrc`), independent of whatever Node version you +use to run Playwright itself. If you have `nvm` with Node 16 installed +(`nvm install 16`), you don't need to do anything else - the harness resolves +it automatically. If that fails on your machine, set `FIXTURE_NODE_BINARY` in +`.env` to the absolute path of a Node 16 binary (e.g. `nvm which 16`). + +## Safety model - read this before running anything + +`provision.js` is **dry-run by default**. Without `--apply`, it authenticates +(read-only) and prints exactly what it *would* create/update/delete without +making any change. Nothing in the stg realm is touched until you explicitly +add `--apply`. + +Every client/user this tool creates is tagged with a +`smartling.playwright-repro` attribute. If `KC_CLIENT_ID` or +`KC_TEST_USERNAME` happens to collide with something that already exists and +doesn't carry that tag, `provision.js` refuses to touch it (create, update, +*or* delete) and errors out instead - so a naming collision can never +silently modify or delete something real. + +Even in dry-run mode, `provision.js` still makes one real (but read-only) +authenticated call to stg Keycloak with the admin credentials in `.env`, to +look up whether the client/user already exist. That's the only network +activity dry-run performs. + +Both `provision.js` and `repro.js` also hard-fail immediately if +`KC_BASE_URL` doesn't look like a staging URL (i.e. doesn't contain "stg") - +this harness is scoped to stg only, and this check exists so a `.env` typo +can't point it at prod. + +## Running it + +```bash +# 1. See exactly what would be created, without touching stg +npm run provision:plan + +# 2. Review that output, then actually create it +npm run provision:apply + +# 3. Open the stg admin console (Clients -> playwright-repro-stg -> Advanced) +# and cross-check the attributes provision.js printed against what's +# actually there. Do this before running repro.js at scale. + +# 4. Start small: one iteration, one tab, headed so you can watch it +node repro.js --iterations=1 --tabs=1 --headed + +# 5. Once that looks right, run the real loop +node repro.js --iterations=50 --tabs=2 +``` + +Results land in `results/results.json` (one JSON object per line - iteration +number, tab count, reproduced true/false, redirect-chain length, elapsed +time, final URL/error). Traces and HARs for iterations that **did** reproduce +the bug are saved under `results/traces/` and `results/har/` respectively; +non-reproducing iterations don't leave artifacts behind. + +When you're done: + +```bash +npm run teardown:plan # see what would be deleted +npm run teardown:apply # actually delete the throwaway client + user +``` + +## Sanity-checking the harness itself + +Before trusting a run of "0/50 reproduced" against current `master`, first +confirm the harness can actually catch the bug: check out the commit just +before the AUT-1462 fix (`git log --oneline` to find the parent of `cbef5ad`) +in a separate worktree, point the fixture app at that checkout, and confirm +`repro.js` reliably reports `reproduced: true`. If it doesn't reproduce the +*known* old bug, don't trust it reporting "no repro" on current code either. + +## Known defect found while building this harness + +While getting a single iteration working, this harness surfaced a real, +confirmed bug in this repo's own code - independent of whether the redirect +loop itself reproduces: + +**`ensureFreshness()` in `middleware/auth-utils/grant-manager.js` (around +line 173-176) omits `client_id` from the `refresh_token` grant request +body.** `postOptions()` only adds an `Authorization: Basic` header for +confidential clients (`if (!manager.public)`), so a **public** client's +refresh request carries neither that header nor `client_id` anywhere in the +payload - Keycloak has no way to identify the calling client and correctly +rejects it with `invalid_client`. Confirmed live: our throwaway harness +client failed every refresh with exactly this error while public; switching +it to confidential (see `provision.js`) made the identical flow work +immediately. `obtainDirectly()` elsewhere in the same file already includes +`client_id` in its params, so the fix is a one-line addition of +`client_id: this.clientId` to the `refresh_token` grant's `params` object, +matching that existing pattern. + +This most likely hasn't surfaced in production because `tms-dashboard-app`/ +`ti-next`/`wa` are presumably confidential clients, but it's a real +correctness bug in the library for anyone using this adapter with a public +client. Tracked as [AUT-1468](https://smartling.atlassian.net/browse/AUT-1468). + +## Known unknowns (flagging rather than guessing) + +Confirmed against the live stg KC26 admin console: +- The client attribute keys (`client.session.max.lifespan`, + `client.session.idle.timeout`, `access.token.lifespan`) are correct - + `provision.js`'s read-back after apply matched what the admin console + showed under Clients → `` → Advanced. +- The admin account must exist inside the `Smartling` realm itself, not + `master` (see the setup section above) - confirmed by testing both. +- The Admin REST API (`/admin/realms/*`) is only reachable via the internal + `KC_ADMIN_BASE_URL` hostname; the public `KC_BASE_URL` hostname returns a + network-level 403 for every `/admin/*` path regardless of credentials. +- The login form is a two-step identifier-first flow, confirmed by live + inspection (screenshot + DOM dump): the email field (`#username`, HTML + `type="email"` with native browser validation) is submitted first via + `#kc-login`, which reveals the password field - also submitted via the + same `#kc-login` selector. `login()` handles this now. This is also why + `KC_TEST_USERNAME` must be email-shaped (see `.env.example`) - a plain + username can never even be submitted past step one, regardless of what + Keycloak itself would accept. + +Still open: +- Whether a per-client session override actually reaches the same + "remaining session time" calculation `ensureFreshness()` reads, versus only + a realm-wide SSO Session Max doing so. The sanity-check section above is + what confirms or refutes this once a full iteration completes end-to-end. diff --git a/test/playwright-repro/ROADMAP-dashboard.md b/test/playwright-repro/ROADMAP-dashboard.md new file mode 100644 index 00000000..4136363d --- /dev/null +++ b/test/playwright-repro/ROADMAP-dashboard.md @@ -0,0 +1,46 @@ +# Roadmap: extending this harness to `tms-dashboard-app` / `ti-next` + +Brief notes on what would change if this harness needed to test the real +product surface instead of the isolated `fixture-app/` (see README.md +"Scope" section for why it doesn't today). Not a commitment to build this - +just what's known so it doesn't need to be re-derived later. + +1. **Target app.** Point the Playwright browser flow at a running + `tms-dashboard-app` (or `ti-next`) instance instead of `fixture-app/`. + Either a local dev instance of that app pointed at stg Keycloak, or the + real stg deployment - these have different tradeoffs (see point 3). + +2. **TMS-side account provisioning.** A real dashboard login needs more than + a Keycloak identity - per the checkpoint discussion in this session, the + product likely needs a corresponding account record in TMS's own backend + (accountUid, permissions, etc.) that a Keycloak-only user created by + `provision.js` won't have. Cheapest path: reuse an existing, already- + working stg test account (Keycloak + TMS both provisioned) rather than + trying to script that provisioning here. Whoever owns that provisioning + flow (ask in `#keycloak-dev`) would know if there's a supported way to + create one from scratch. + +3. **Session/token overrides can't be isolated the same way.** + `provision.js`'s whole approach depends on a throwaway client whose + session/token lifetimes we can shrink without affecting anyone else. The + real `tms-dashboard-app`/`ti-next`/`wa` clients are shared production + configuration - shrinking their overrides would affect every real stg + user, the exact coordination cost this harness was built to avoid (see + the 6/29-6/30 Slack history in `#keycloak-dev` about restoring shared + session settings afterward). Two options, not mutually exclusive: + - Run a local dev instance of the target app registered as its own + throwaway client (same isolation model as `fixture-app/`), or + - Coordinate a short, announced window to shrink the real shared client's + overrides, as the team has done manually before. + +4. **Login flow should mostly transfer.** The two-step identifier-first + login theme and the Tab-blur requirement discovered while building this + harness (see `repro.js`'s `login()`) are realm-level (Smartling realm), + not client-specific - they should apply the same way against + `tms-dashboard-app`/`ti-next`'s login pages. + +5. **Deep-linked protected routes.** `fixture-app/`'s `/app/:accountId` + route already loosely mirrors the real shape + (`/app/?locale=fr&start=0&...`) reported in the original bug. + Swapping in the actual dashboard route(s) that showed the loop should be + straightforward once points 1-3 are settled. diff --git a/test/playwright-repro/fixture-app/app.js b/test/playwright-repro/fixture-app/app.js new file mode 100644 index 00000000..8ec6e54c --- /dev/null +++ b/test/playwright-repro/fixture-app/app.js @@ -0,0 +1,73 @@ +'use strict'; + +// Minimal Express app built on THIS repo's own keycloak-connect code (not +// tms-dashboard-app), so the harness exercises the exact middleware under +// test rather than adding unrelated frontend flakiness. Deliberately spawned +// as its own process under Node 16 - see lib/spawn-fixture-app.js for why. + +const path = require('path'); +const express = require('express'); +const session = require('express-session'); +const Keycloak = require(path.join(__dirname, '..', '..', '..', 'index')); + +const PORT = Number(process.env.FIXTURE_APP_PORT || 3987); + +const keycloakConfig = { + realm: process.env.KC_REALM, + 'client-id': process.env.KC_CLIENT_ID, + // Confidential, not public: ensureFreshness()'s refresh_token request + // (grant-manager.js) relies on the Authorization: Basic header + // postOptions() only adds for confidential clients - a public client's + // refresh fails with "invalid_client" (confirmed live). Also more + // faithful to production, where real clients are presumably confidential. + 'public-client': false, + credentials: { secret: process.env.KC_CLIENT_SECRET }, + 'auth-server-url': process.env.KC_BASE_URL, + 'ssl-required': 'external', + // Mirrors production's tokenMinTtl so the session-cap detection in + // grant-manager.js's ensureFreshness() fires the same way it does there, + // just against a much shorter session window (see provision.js). + 'token-minimum-time-to-live': Number(process.env.KC_TOKEN_MIN_TTL_SECONDS || 20) +}; + +const app = express(); +const memoryStore = new session.MemoryStore(); + +app.use(session({ + secret: 'playwright-repro-not-for-production', + resave: false, + saveUninitialized: true, + store: memoryStore +})); + +const keycloak = new Keycloak({ store: memoryStore }, keycloakConfig); + +app.use(keycloak.middleware({ + logout: '/logout', + admin: '/' +})); + +app.get('/', (request, response) => { + response.send('fixture app ok (not authenticated route)'); +}); + +// Deep-linked protected route, mirroring the query-param-bearing URLs from +// the real bug reports (e.g. /app/?locale=fr&start=0&...) - the +// AUT-1462 14fb4ee fix is specifically about preserving this shape through +// the logout/redirect cycle, so the repro harness needs to hit it. +app.get('/app/:accountId', keycloak.protect(), (request, response) => { + const accessToken = request.kauth.grant.access_token.content; + response.json({ + ok: true, + accountId: request.params.accountId, + query: request.query, + tokenExp: accessToken.exp, + tokenIat: accessToken.iat, + issuedLifetime: accessToken.exp - accessToken.iat + }); +}); + +app.listen(PORT, () => { + // lib/spawn-fixture-app.js waits for this exact marker on stdout. + console.log(`FIXTURE_APP_READY port=${PORT}`); +}); diff --git a/test/playwright-repro/lib/admin-client.js b/test/playwright-repro/lib/admin-client.js new file mode 100644 index 00000000..713f1fa7 --- /dev/null +++ b/test/playwright-repro/lib/admin-client.js @@ -0,0 +1,46 @@ +'use strict'; + +// Thin wrapper around @keycloak/keycloak-admin-client, following the same +// pattern as test/utils/realm.js in the main test suite (that one is hardcoded +// to a local 127.0.0.1:8080 dev Keycloak; this one targets stg via env config +// so it's portable across machines - see lib/env.js). +// +// The package is ESM-only, so this stays a plain async factory rather than a +// module-level promise, to keep call sites explicit about when auth happens. +async function createAdminClient (env, { totp } = {}) { + const mod = await import('@keycloak/keycloak-admin-client'); + const KcAdminClient = mod.default; + + // Uses kcAdminBaseUrl (the internal hostname), NOT kcBaseUrl (the public + // one) - confirmed by direct testing that the public hostname blocks every + // /admin/* path at the network layer regardless of credentials or realm, + // while the internal hostname serves the real Admin REST API. + // + // realmName belongs on the constructor's ConnectionConfig, not on the + // Credentials object passed to .auth() (that type has no realmName field + // at all - passing it there is silently ignored, and the token request + // would otherwise default to realm "master"). + const client = new KcAdminClient({ baseUrl: env.kcAdminBaseUrl, realmName: env.kcAdminRealm }); + try { + await client.auth({ + username: env.kcAdminUsername, + password: env.kcAdminPassword, + grantType: 'password', + clientId: env.kcAdminClientId, + ...(totp ? { totp } : {}) + }); + } catch (err) { + // Surface Keycloak's actual token-endpoint error body (e.g. + // {"error":"...","error_description":"..."}) instead of just the bare + // HTTP status axios reports, since that's what actually explains a + // 401/403 here. + const detail = err.response && err.response.data + ? `\nKeycloak response: ${JSON.stringify(err.response.data)}` + : ''; + throw new Error(`Admin auth failed against ${env.kcAdminBaseUrl} (realm "${env.kcAdminRealm}"): ${err.message}${detail}`); + } + + return client; +} + +module.exports = { createAdminClient }; diff --git a/test/playwright-repro/lib/env.js b/test/playwright-repro/lib/env.js new file mode 100644 index 00000000..81105deb --- /dev/null +++ b/test/playwright-repro/lib/env.js @@ -0,0 +1,97 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); + +const REQUIRED = [ + 'KC_BASE_URL', + 'KC_ADMIN_BASE_URL', + 'KC_REALM', + 'KC_ADMIN_USERNAME', + 'KC_ADMIN_PASSWORD', + 'KC_ADMIN_CLIENT_ID', + 'KC_CLIENT_ID', + 'KC_TEST_USERNAME' +]; + +function requireEnv () { + const envPath = path.join(__dirname, '..', '.env'); + if (!fs.existsSync(envPath)) { + throw new Error( + `Missing ${envPath}.\n` + + 'Copy .env.example to .env and fill in stg Keycloak admin credentials before running this.' + ); + } + + const missing = REQUIRED.filter((key) => !process.env[key]); + if (missing.length > 0) { + throw new Error( + `Missing required .env values: ${missing.join(', ')}.\n` + + 'See .env.example for what each one means.' + ); + } + + return { + // Public hostname - serves OIDC login endpoints only (/realms/*/protocol/*) + // and is what the fixture app / Playwright's browser actually talks to, + // matching what real users see. + kcBaseUrl: process.env.KC_BASE_URL, + // Internal hostname - the ONLY place the Admin REST API (/admin/realms/*) + // is reachable. The public hostname returns a network-level 403 (HTML, + // not a Keycloak JSON error) for every /admin/* path regardless of + // credentials - confirmed by testing directly, not assumed. Only + // provision.js uses this. + kcAdminBaseUrl: process.env.KC_ADMIN_BASE_URL, + kcRealm: process.env.KC_REALM, + kcAdminUsername: process.env.KC_ADMIN_USERNAME, + kcAdminPassword: process.env.KC_ADMIN_PASSWORD, + kcAdminClientId: process.env.KC_ADMIN_CLIENT_ID, + // Keycloak admin accounts commonly live in the "master" realm even when + // managing a different realm's clients/users - that's the SDK default, + // made explicit and overridable here since it's a common source of + // 401/403s if this admin account instead lives in KC_REALM itself. + kcAdminRealm: process.env.KC_ADMIN_REALM || 'master', + + kcClientId: process.env.KC_CLIENT_ID, + // Not in REQUIRED - provision.js's first run creates the client and + // fetches this itself, so it can't already exist yet at that point. + // repro.js checks for it separately before starting (see main()). + kcClientSecret: process.env.KC_CLIENT_SECRET || '', + kcTestUsername: process.env.KC_TEST_USERNAME, + kcTestPassword: process.env.KC_TEST_PASSWORD || '', + + fixtureAppPort: Number(process.env.FIXTURE_APP_PORT || 3987), + fixtureNodeBinary: process.env.FIXTURE_NODE_BINARY || '', + + clientSessionMaxSeconds: Number(process.env.KC_CLIENT_SESSION_MAX_SECONDS || 90), + clientSessionIdleSeconds: Number(process.env.KC_CLIENT_SESSION_IDLE_SECONDS || 120), + accessTokenLifespanSeconds: Number(process.env.KC_ACCESS_TOKEN_LIFESPAN_SECONDS || 30), + tokenMinTtlSeconds: Number(process.env.KC_TOKEN_MIN_TTL_SECONDS || 20) + }; +} + +// This harness is deliberately scoped to stg only (see README.md). Both +// provision.js and repro.js call this so a .env misconfiguration can't +// silently point real logins/session overrides at prod. +function assertStgTarget (env) { + if (!/stg/i.test(env.kcBaseUrl)) { + throw new Error( + `KC_BASE_URL "${env.kcBaseUrl}" doesn't look like a staging URL (expected it to contain "stg").\n` + + 'This harness is scoped to stg only for now - refusing to continue.' + ); + } + // The internal admin hostname uses Smartling's "dev" naming convention for + // what the team refers to as stg (confirmed against Slack history - e.g. + // admin-keycloak-ha-v26.inception.dev.smartling.net was linked as "Stg KC + // sessions settings"), so accept either "stg" or "dev" here rather than + // just "stg" - but still hard-fail on anything that matches neither. + if (env.kcAdminBaseUrl && !/(stg|dev)/i.test(env.kcAdminBaseUrl)) { + throw new Error( + `KC_ADMIN_BASE_URL "${env.kcAdminBaseUrl}" doesn't look like a stg/dev admin URL.\n` + + 'This harness is scoped to stg only for now - refusing to continue.' + ); + } +} + +module.exports = { requireEnv, assertStgTarget }; diff --git a/test/playwright-repro/lib/login-selectors.js b/test/playwright-repro/lib/login-selectors.js new file mode 100644 index 00000000..1ccebc40 --- /dev/null +++ b/test/playwright-repro/lib/login-selectors.js @@ -0,0 +1,11 @@ +'use strict'; + +// Keycloak's default login theme field IDs. If Smartling's stg login theme +// (based on the KC1 template, see CLAUDE.md context) uses different IDs, +// repro.js's login step will fail with a Playwright timeout naming whichever +// selector below it couldn't find - update the values here to match. +module.exports = { + username: '#username', + password: '#password', + submit: '#kc-login' +}; diff --git a/test/playwright-repro/lib/prompt.js b/test/playwright-repro/lib/prompt.js new file mode 100644 index 00000000..8290b709 --- /dev/null +++ b/test/playwright-repro/lib/prompt.js @@ -0,0 +1,24 @@ +'use strict'; + +const readline = require('readline'); + +// Used to collect a live TOTP code at the moment of execution - these expire +// in ~30s, so they can never be stored in .env like the other credentials. +function promptLine (question) { + return new Promise((resolve, reject) => { + if (!process.stdin.isTTY) { + reject(new Error( + `Need to prompt for input ("${question}") but stdin isn't a TTY. ` + + 'Pass --totp= on the command line instead when running non-interactively.' + )); + return; + } + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +module.exports = { promptLine }; diff --git a/test/playwright-repro/lib/spawn-fixture-app.js b/test/playwright-repro/lib/spawn-fixture-app.js new file mode 100644 index 00000000..1a2e9043 --- /dev/null +++ b/test/playwright-repro/lib/spawn-fixture-app.js @@ -0,0 +1,103 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const { spawn, execSync } = require('child_process'); + +const FIXTURE_APP_PATH = path.join(__dirname, '..', 'fixture-app', 'app.js'); +const READY_MARKER = 'FIXTURE_APP_READY'; +const READY_TIMEOUT_MS = 20000; + +// The fixture app requires this repo's own index.js/middleware, which need +// the root repo's node_modules to have been installed under Node 16 (see +// CLAUDE.md / .nvmrc - some root devDependencies only compile there). The +// Playwright driver itself runs under a newer Node (see package.json +// engines), so the two are spawned as separate processes on purpose - don't +// try to unify them. +function resolveNodeBinary (env) { + if (env.fixtureNodeBinary) { + if (!fs.existsSync(env.fixtureNodeBinary)) { + throw new Error( + `FIXTURE_NODE_BINARY is set to "${env.fixtureNodeBinary}" but that path does not exist.` + ); + } + return env.fixtureNodeBinary; + } + + try { + const resolved = execSync( + 'bash -lc \'source "$HOME/.nvm/nvm.sh" 2>/dev/null; nvm which 16\'', + { encoding: 'utf8' } + ).trim().split('\n').pop().trim(); + + if (resolved && fs.existsSync(resolved)) { + return resolved; + } + } catch (err) { + // fall through to the actionable error below + } + + throw new Error( + 'Could not find a Node 16 binary to run the fixture app.\n' + + 'Either run `nvm install 16` so `nvm which 16` resolves, or set ' + + 'FIXTURE_NODE_BINARY in .env to an absolute path to a Node 16 binary.' + ); +} + +// Spawns the fixture app and resolves once it has printed its ready marker, +// or rejects (with the app's stderr attached) if it exits or times out first. +function spawnFixtureApp (env) { + const nodeBinary = resolveNodeBinary(env); + + const child = spawn(nodeBinary, [FIXTURE_APP_PATH], { + env: { + ...process.env, + KC_BASE_URL: env.kcBaseUrl, + KC_REALM: env.kcRealm, + KC_CLIENT_ID: env.kcClientId, + KC_CLIENT_SECRET: env.kcClientSecret, + FIXTURE_APP_PORT: String(env.fixtureAppPort), + KC_TOKEN_MIN_TTL_SECONDS: String(env.tokenMinTtlSeconds) + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stderrBuffer = ''; + child.stderr.on('data', (chunk) => { + stderrBuffer += chunk.toString(); + process.stderr.write(`[fixture-app] ${chunk}`); + }); + + const ready = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGTERM'); + reject(new Error( + `Fixture app did not report ready within ${READY_TIMEOUT_MS}ms.\n` + + `stderr so far:\n${stderrBuffer}` + )); + }, READY_TIMEOUT_MS); + + child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + process.stdout.write(`[fixture-app] ${text}`); + if (text.includes(READY_MARKER)) { + clearTimeout(timer); + resolve(); + } + }); + + child.once('exit', (code) => { + clearTimeout(timer); + reject(new Error(`Fixture app exited early (code ${code}) before becoming ready.\nstderr:\n${stderrBuffer}`)); + }); + }); + + return ready.then(() => ({ + child, + stop () { + child.kill('SIGTERM'); + } + })); +} + +module.exports = { spawnFixtureApp, resolveNodeBinary }; diff --git a/test/playwright-repro/package-lock.json b/test/playwright-repro/package-lock.json new file mode 100644 index 00000000..3a6d3d3f --- /dev/null +++ b/test/playwright-repro/package-lock.json @@ -0,0 +1,395 @@ +{ + "name": "keycloak-connect-playwright-repro", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "keycloak-connect-playwright-repro", + "dependencies": { + "@keycloak/keycloak-admin-client": "20.0.5", + "dotenv": "^16.4.5", + "playwright": "^1.47.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@keycloak/keycloak-admin-client": { + "version": "20.0.5", + "resolved": "https://registry.npmjs.org/@keycloak/keycloak-admin-client/-/keycloak-admin-client-20.0.5.tgz", + "integrity": "sha512-3Zcm9StUMB3rJKJdOoOx/97hVON4dtLJHWyS0F8LdpfgJ/svLAfBxsC3XfuZSGC5sfDNs39V9RDIep5YXl5BBw==", + "license": "Apache-2.0", + "dependencies": { + "axios": "^0.27.2", + "camelize-ts": "^2.1.1", + "lodash-es": "^4.17.21", + "url-join": "^5.0.0", + "url-template": "^3.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelize-ts": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/camelize-ts/-/camelize-ts-2.5.0.tgz", + "integrity": "sha512-ERaOJadw+ID9MuKGeTOF1kQOb/zZIv6Vkt44kFYZraiAFiZU6E3TwnJebp8jofsW/hDxME/U63lFdNKIkSqijw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/url-template": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-3.1.1.tgz", + "integrity": "sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==", + "license": "BSD-3-Clause", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + } + } +} diff --git a/test/playwright-repro/package.json b/test/playwright-repro/package.json new file mode 100644 index 00000000..e31fbc45 --- /dev/null +++ b/test/playwright-repro/package.json @@ -0,0 +1,20 @@ +{ + "name": "keycloak-connect-playwright-repro", + "private": true, + "description": "Playwright harness to reproduce the intermittent KC26 'Too Many Redirects' loop (AUT-1462) against stg. Not part of the published @smartling/keycloak-connect package.", + "engines": { + "node": ">=20" + }, + "scripts": { + "provision:plan": "node provision.js", + "provision:apply": "node provision.js --apply", + "teardown:plan": "node provision.js --teardown", + "teardown:apply": "node provision.js --teardown --apply", + "repro": "node repro.js" + }, + "dependencies": { + "@keycloak/keycloak-admin-client": "20.0.5", + "dotenv": "^16.4.5", + "playwright": "^1.47.0" + } +} diff --git a/test/playwright-repro/provision.js b/test/playwright-repro/provision.js new file mode 100644 index 00000000..fa03eb14 --- /dev/null +++ b/test/playwright-repro/provision.js @@ -0,0 +1,345 @@ +#!/usr/bin/env node +'use strict'; + +// Creates (or tears down) a throwaway OIDC client + test user in the stg +// Smartling realm, scoped ONLY to this harness. Session/token overrides are +// applied to this one client's Advanced settings, not the realm-wide SSO +// Session Max, so nobody else's stg session is affected. See README.md +// "Known unknowns" - the exact attribute keys used here haven't been +// confirmed against a live KC26 admin console yet. +// +// SAFETY MODEL: +// - Nothing is created/updated/deleted unless --apply is passed. Without +// it, this only reads and prints what it WOULD do. +// - Refuses to run against anything that doesn't look like a stg URL. +// - Every client/user this tool creates carries a MARKER_KEY attribute. +// Updating or deleting an EXISTING client/user is refused unless that +// marker is already present, so a clientId/username collision with +// something real can never be silently modified or deleted. + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { requireEnv, assertStgTarget } = require('./lib/env'); +const { createAdminClient } = require('./lib/admin-client'); +const { promptLine } = require('./lib/prompt'); + +const ENV_PATH = path.join(__dirname, '.env'); +const TEARDOWN = process.argv.includes('--teardown'); +const APPLY = process.argv.includes('--apply'); +const MARKER_KEY = 'smartling.playwright-repro'; + +function getTotpArg () { + const arg = process.argv.find((a) => a.startsWith('--totp=')); + return arg ? arg.split('=')[1] : null; +} + +// TOTP codes expire in ~30s, so they can never live in .env like the other +// credentials - they're supplied fresh per invocation, either via --totp on +// the command line (for non-interactive use), --no-totp to explicitly skip +// (for accounts with no MFA configured), or an interactive prompt. +async function resolveTotp () { + if (process.argv.includes('--no-totp')) { + return undefined; + } + const fromArg = getTotpArg(); + if (fromArg) { + return fromArg; + } + const entered = await promptLine('TOTP code for KC_ADMIN_USERNAME (leave blank if this account has no MFA): '); + return entered || undefined; +} + +// Guarantees at least 2 of each character class (plain random base64url can +// easily miss uppercase entirely by chance, which is exactly what happened +// against the Smartling realm's password policy on the first attempt here). +function generatePassword () { + const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; + const lower = 'abcdefghijkmnpqrstuvwxyz'; + const digits = '23456789'; + const special = '!@#%^*-_=+'; + const all = upper + lower + digits + special; + const pick = (charset) => charset[crypto.randomInt(charset.length)]; + + const required = [ + pick(upper), pick(upper), + pick(lower), pick(lower), + pick(digits), pick(digits), + pick(special), pick(special) + ]; + const targetLength = 24; + const rest = Array.from({ length: targetLength - required.length }, () => pick(all)); + const chars = [...required, ...rest]; + + for (let i = chars.length - 1; i > 0; i--) { + const j = crypto.randomInt(i + 1); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + return chars.join(''); +} + +// Writes a generated/fetched secret into the local .env so re-running +// provision.js (or running repro.js) picks it up without the operator having +// to copy/paste it by hand. +function persistEnvValue (key, value) { + const contents = fs.readFileSync(ENV_PATH, 'utf8'); + const line = `${key}=${value}`; + const pattern = new RegExp(`^${key}=.*$`, 'm'); + const updated = pattern.test(contents) + ? contents.replace(pattern, line) + : `${contents.trimEnd()}\n${line}\n`; + fs.writeFileSync(ENV_PATH, updated); +} + +function isOwnedByUs (representation) { + const attrs = representation.attributes || {}; + const value = Array.isArray(attrs[MARKER_KEY]) ? attrs[MARKER_KEY][0] : attrs[MARKER_KEY]; + return value === 'true'; +} + +async function findClient (admin, env) { + const matches = await admin.clients.find({ realm: env.kcRealm, clientId: env.kcClientId }); + return matches[0] || null; +} + +async function findUser (admin, env) { + const matches = await admin.users.find({ realm: env.kcRealm, username: env.kcTestUsername, exact: true }); + return matches[0] || null; +} + +async function planOrTeardownClient (admin, env) { + const existing = await findClient(admin, env); + if (!existing) { + console.log(`[client] "${env.kcClientId}" not found - nothing to delete.`); + return; + } + + if (!isOwnedByUs(existing)) { + throw new Error( + `[client] "${env.kcClientId}" (${existing.id}) exists but is missing the ${MARKER_KEY} marker ` + + 'this tool sets on everything it creates. Refusing to delete a client this tool did not create. ' + + 'Verify by hand in the admin console, or pick a different KC_CLIENT_ID.' + ); + } + + if (!APPLY) { + console.log(`[PLAN] Would delete client "${env.kcClientId}" (${existing.id}). Re-run with --apply to actually delete it.`); + return; + } + + await admin.clients.del({ id: existing.id, realm: env.kcRealm }); + console.log(`[APPLIED] Deleted client "${env.kcClientId}" (${existing.id}).`); +} + +async function planOrTeardownUser (admin, env) { + const existing = await findUser(admin, env); + if (!existing) { + console.log(`[user] "${env.kcTestUsername}" not found - nothing to delete.`); + return; + } + + if (!isOwnedByUs(existing)) { + throw new Error( + `[user] "${env.kcTestUsername}" (${existing.id}) exists but is missing the ${MARKER_KEY} marker ` + + 'this tool sets on everything it creates. Refusing to delete a user this tool did not create. ' + + 'Verify by hand in the admin console, or pick a different KC_TEST_USERNAME.' + ); + } + + if (!APPLY) { + console.log(`[PLAN] Would delete user "${env.kcTestUsername}" (${existing.id}). Re-run with --apply to actually delete it.`); + return; + } + + await admin.users.del({ id: existing.id, realm: env.kcRealm }); + console.log(`[APPLIED] Deleted user "${env.kcTestUsername}" (${existing.id}).`); +} + +function buildClientRep (env) { + const redirectUri = `http://localhost:${env.fixtureAppPort}/*`; + const rootUrl = `http://localhost:${env.fixtureAppPort}`; + + // These are Keycloak's standard "Fine Grain OpenID Connect Configuration" + // per-client attribute keys, shown in the admin console under + // Clients -> -> Advanced. Confirm they land there as expected - + // see README.md "Known unknowns". + const attributes = { + 'client.session.idle.timeout': String(env.clientSessionIdleSeconds), + 'client.session.max.lifespan': String(env.clientSessionMaxSeconds), + 'access.token.lifespan': String(env.accessTokenLifespanSeconds), + [MARKER_KEY]: 'true' + }; + + return { + clientId: env.kcClientId, + enabled: true, + // Confidential, not public: ensureFreshness()'s refresh_token request + // (grant-manager.js) omits client_id from the POST body, relying on the + // Authorization: Basic header postOptions() only adds for confidential + // clients. A public client's refresh therefore fails with + // "invalid_client" - confirmed live. Real production clients + // (tms-dashboard-app etc.) are presumably confidential, which is why + // this hasn't surfaced there. Matching that here is also more faithful + // to production, not just a workaround. + publicClient: false, + protocol: 'openid-connect', + rootUrl, + redirectUris: [redirectUri], + webOrigins: [rootUrl], + attributes + }; +} + +async function provisionClient (admin, env) { + const clientRep = buildClientRep(env); + const existing = await findClient(admin, env); + + if (existing && !isOwnedByUs(existing)) { + throw new Error( + `[client] "${env.kcClientId}" (${existing.id}) already exists but is missing the ${MARKER_KEY} marker ` + + 'this tool sets on everything it creates. Refusing to modify a client this tool did not create. ' + + 'Pick a different KC_CLIENT_ID, or verify by hand in the admin console first.' + ); + } + + if (!APPLY) { + console.log(`[PLAN] Would ${existing ? 'update' : 'create'} client "${env.kcClientId}" with:`); + console.log(JSON.stringify(clientRep, null, 2)); + console.log('Re-run with --apply to actually make this change.'); + return; + } + + let clientId; + if (existing) { + await admin.clients.update({ id: existing.id, realm: env.kcRealm }, clientRep); + clientId = existing.id; + console.log(`[APPLIED] Updated existing client "${env.kcClientId}" (${clientId}).`); + } else { + const created = await admin.clients.create({ ...clientRep, realm: env.kcRealm }); + clientId = created.id; + console.log(`[APPLIED] Created client "${env.kcClientId}" (${clientId}).`); + } + + // Read back what KC actually stored, rather than trusting the payload we + // sent, so this can be eyeballed against the admin console. + const stored = await admin.clients.findOne({ id: clientId, realm: env.kcRealm }); + console.log('Live attributes now stored on this client (cross-check against Clients -> Advanced in the admin console):'); + console.log(` client.session.idle.timeout = ${stored.attributes['client.session.idle.timeout']}`); + console.log(` client.session.max.lifespan = ${stored.attributes['client.session.max.lifespan']}`); + console.log(` access.token.lifespan = ${stored.attributes['access.token.lifespan']}`); + + const { value: secret } = await admin.clients.getClientSecret({ id: clientId, realm: env.kcRealm }); + persistEnvValue('KC_CLIENT_SECRET', secret); + console.log('Client secret fetched and saved to .env as KC_CLIENT_SECRET (not printed here).'); +} + +async function provisionUser (admin, env) { + const existing = await findUser(admin, env); + + if (existing && !isOwnedByUs(existing)) { + throw new Error( + `[user] "${env.kcTestUsername}" (${existing.id}) already exists but is missing the ${MARKER_KEY} marker ` + + 'this tool sets on everything it creates. Refusing to touch a user this tool did not create - ' + + 'resetting a real user\'s password would be exactly the kind of mistake these guardrails exist to prevent. ' + + 'Pick a different KC_TEST_USERNAME.' + ); + } + + // Always (re)apply the password below, even if the user already exists - + // an earlier run can create the user but fail on reset-password (e.g. a + // generated password rejected by the realm's password policy, as happened + // here), leaving an owned user with no usable credential. Re-running must + // fix that, not silently treat "user exists" as "fully provisioned". + if (!APPLY) { + console.log(`[PLAN] Would ${existing ? 'ensure a password is set on' : 'create'} user "${env.kcTestUsername}" (enabled, password-only auth, marked with ${MARKER_KEY}).`); + console.log('Re-run with --apply to actually do this.'); + return; + } + + const password = env.kcTestPassword || generatePassword(); + let userId = existing && existing.id; + + if (existing) { + console.log(`[user] "${env.kcTestUsername}" already exists (${userId}, owned by this tool) - ensuring password is set.`); + } else { + // Smartling's stg login theme's first step is an HTML type="email" field + // with native browser validation - a non-email-shaped username can + // never even be submitted, regardless of what Keycloak itself would + // accept. Set email = username so what's typed in the browser matches + // what Keycloak looks the user up by. + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(env.kcTestUsername)) { + console.log( + `WARNING: KC_TEST_USERNAME "${env.kcTestUsername}" isn't email-shaped. ` + + 'The stg login form requires an email-shaped value in its first field - login will fail. ' + + 'Use something like playwright-repro-test-user@stg-repro.invalid instead.' + ); + } + const created = await admin.users.create({ + realm: env.kcRealm, + username: env.kcTestUsername, + email: env.kcTestUsername, + enabled: true, + emailVerified: true, + attributes: { [MARKER_KEY]: ['true'] } + }); + userId = created.id; + console.log(`[APPLIED] Created user "${env.kcTestUsername}" (${userId}).`); + } + + try { + await admin.users.resetPassword({ + id: userId, + realm: env.kcRealm, + credential: { type: 'password', value: password, temporary: false } + }); + console.log(`[APPLIED] Password set for user "${env.kcTestUsername}" (${userId}).`); + } catch (err) { + // This specific rejection means the password we're "resetting" to is + // already the current one (KC's password-history policy treats + // resetting to the unchanged value as reuse) - not a real failure. + const kcError = err.response && err.response.data && err.response.data.error; + if (kcError === 'invalidPasswordHistoryMessage' && env.kcTestPassword) { + console.log(`[user] Password for "${env.kcTestUsername}" is already set to the value in KC_TEST_PASSWORD - no change needed.`); + } else { + throw err; + } + } + + if (!env.kcTestPassword) { + persistEnvValue('KC_TEST_PASSWORD', password); + console.log(`Generated password and saved it to .env as KC_TEST_PASSWORD (only printed this once): ${password}`); + } +} + +async function main () { + const env = requireEnv(); + assertStgTarget(env); + + console.log(`Target: ${env.kcBaseUrl} (OIDC) / ${env.kcAdminBaseUrl} (Admin API) realm=${env.kcRealm} client=${env.kcClientId}`); + console.log(`Mode: ${APPLY ? 'APPLY (will make real changes)' : 'DRY RUN (no changes will be made - pass --apply to execute)'}`); + console.log(''); + + const totp = await resolveTotp(); + const admin = await createAdminClient(env, { totp }); + console.log('Admin auth succeeded. Looking up existing client/user (read-only)...'); + + if (TEARDOWN) { + await planOrTeardownClient(admin, env); + await planOrTeardownUser(admin, env); + return; + } + + await provisionClient(admin, env); + await provisionUser(admin, env); +} + +main().catch((err) => { + const detail = err.response && err.response.data + ? `\nKeycloak response: ${JSON.stringify(err.response.data)}` + : ''; + const requestInfo = err.config + ? `\nRequest: ${err.config.method?.toUpperCase()} ${err.config.url}` + : ''; + console.error(`provision.js failed: ${err.message || err}${requestInfo}${detail}`); + process.exit(1); +}); diff --git a/test/playwright-repro/repro.js b/test/playwright-repro/repro.js new file mode 100644 index 00000000..d2b63368 --- /dev/null +++ b/test/playwright-repro/repro.js @@ -0,0 +1,243 @@ +#!/usr/bin/env node +'use strict'; + +// Drives real Chromium against the stg fixture app, looping login -> wait +// past the session-cap window -> reload, trying to trigger the intermittent +// "Too Many Redirects" loop (AUT-1462). Plain script, not @playwright/test - +// this is a repeated-fuzz-style probe where "the bug happened" is the +// interesting outcome, not a pass/fail assertion. See README.md. + +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('playwright'); +const { requireEnv, assertStgTarget } = require('./lib/env'); +const { spawnFixtureApp } = require('./lib/spawn-fixture-app'); +const selectors = require('./lib/login-selectors'); + +const RESULTS_DIR = path.join(__dirname, 'results'); +const RESULTS_FILE = path.join(RESULTS_DIR, 'results.json'); +const TRACES_DIR = path.join(RESULTS_DIR, 'traces'); +const HAR_DIR = path.join(RESULTS_DIR, 'har'); + +// Mirrors the deep-linked URL shape from the real bug reports +// (/app/?locale=fr&start=0&...) that the 14fb4ee fix targets. +const DEEP_LINK_PATH = '/app/playwright-repro-account?locale=fr&start=0&contentAuthorization=ALL'; + +function parseArgs (argv) { + const args = { iterations: 20, tabs: 1, headed: false }; + for (const arg of argv) { + if (arg === '--headed') { + args.headed = true; + } else if (arg.startsWith('--iterations=')) { + args.iterations = Number(arg.split('=')[1]); + } else if (arg.startsWith('--tabs=')) { + args.tabs = Number(arg.split('=')[1]); + } else { + throw new Error(`Unrecognized argument: ${arg}`); + } + } + if (![1, 2].includes(args.tabs)) { + throw new Error('--tabs must be 1 or 2'); + } + if (!Number.isInteger(args.iterations) || args.iterations < 1) { + throw new Error('--iterations must be a positive integer'); + } + return args; +} + +function ensureDirs () { + for (const dir of [RESULTS_DIR, TRACES_DIR, HAR_DIR]) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function appendResult (row) { + fs.appendFileSync(RESULTS_FILE, JSON.stringify(row) + '\n'); +} + +function sleep (ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function escapeRegExp (value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Tracks the navigation-only request chain for a page, reset before each +// reload attempt so counts reflect just that attempt, not the whole session. +function trackChain (page) { + const state = { requests: [] }; + page.on('request', (request) => { + if (request.isNavigationRequest()) { + state.requests.push({ url: request.url(), method: request.method() }); + } + }); + return state; +} + +function resetChain (state) { + state.requests.length = 0; +} + +// Smartling's stg login theme is a two-step identifier-first flow (confirmed +// by live inspection, not assumed): the email field is submitted first via +// #kc-login, which reveals the password field (also submitted via the same +// #kc-login selector) rather than a single page with both fields at once. +// The submit button stays disabled (class stays "btn disabled") after a +// plain .fill() until the field is blurred - a Tab press is required after +// each fill before the button becomes clickable, also confirmed live. +async function login (page, baseUrl, env) { + await page.goto(`${baseUrl}${DEEP_LINK_PATH}`, { waitUntil: 'domcontentloaded', timeout: 30000 }); + + await page.waitForSelector(selectors.username, { timeout: 15000 }); + await page.fill(selectors.username, env.kcTestUsername); + await page.press(selectors.username, 'Tab'); + await page.click(selectors.submit); + + await page.waitForSelector(selectors.password, { state: 'visible', timeout: 15000 }); + await page.fill(selectors.password, env.kcTestPassword); + await page.press(selectors.password, 'Tab'); + await Promise.all([ + page.waitForURL(new RegExp(`^${escapeRegExp(baseUrl)}`), { timeout: 30000 }), + page.click(selectors.submit) + ]); +} + +// Attempts the navigation that's expected to cross the session-cap boundary. +// Resolves with reproduced=true specifically when Chromium's own redirect +// cap fires (net::ERR_TOO_MANY_REDIRECTS) - the definitive "this is the bug" +// signal - rather than any navigation failure. +async function attemptReload (page, baseUrl) { + try { + await page.goto(`${baseUrl}${DEEP_LINK_PATH}`, { waitUntil: 'domcontentloaded', timeout: 30000 }); + return { reproduced: false, error: null }; + } catch (err) { + const message = err.message || String(err); + return { reproduced: message.includes('ERR_TOO_MANY_REDIRECTS'), error: message }; + } +} + +async function runIteration (browser, baseUrl, env, args, iteration) { + const harPath = path.join(HAR_DIR, `iteration-${iteration}.har`); + const context = await browser.newContext({ recordHar: { path: harPath } }); + await context.tracing.start({ screenshots: true, snapshots: true }); + + const startedAt = Date.now(); + let reproduced = false; + let errorMessage = null; + const chains = []; + + try { + const page1 = await context.newPage(); + chains.push(trackChain(page1)); + await login(page1, baseUrl, env); + + let page2 = null; + if (args.tabs === 2) { + page2 = await context.newPage(); + chains.push(trackChain(page2)); + await page2.goto(`${baseUrl}${DEEP_LINK_PATH}`, { waitUntil: 'domcontentloaded', timeout: 30000 }); + } + + // The session-cap window (ensureFreshness() in grant-manager.js) only + // exists for the last tokenMinTtlSeconds of the session - before that, + // refreshes are normal; after the session fully ends, the refresh token + // itself expires and a DIFFERENT, uninteresting error fires instead + // ("Unable to refresh with expired refresh token", confirmed live). + // A single fixed-delay reload easily overshoots that narrow window, so + // instead: wait until just before it opens, then poll with reloads + // through it and a bit past session end. + const pollStartMs = Math.max(0, env.clientSessionMaxSeconds - env.tokenMinTtlSeconds - 3) * 1000; + await sleep(pollStartMs); + + const pollEndAt = Date.now() + (env.tokenMinTtlSeconds + 15) * 1000; + while (Date.now() < pollEndAt) { + chains.forEach(resetChain); + + const reloadPromises = [attemptReload(page1, baseUrl)]; + if (page2) { + reloadPromises.push(attemptReload(page2, baseUrl)); + } + const results = await Promise.all(reloadPromises); + + reproduced = results.some((result) => result.reproduced); + errorMessage = results.map((result) => result.error).filter(Boolean).join(' | ') || null; + if (reproduced) { + break; + } + await sleep(2000); + } + } catch (err) { + errorMessage = err.message || String(err); + } + + const elapsedMs = Date.now() - startedAt; + + await context.tracing.stop({ path: reproduced ? path.join(TRACES_DIR, `iteration-${iteration}.zip`) : undefined }); + await context.close(); // finalizes the HAR file + + if (!reproduced) { + fs.rmSync(harPath, { force: true }); + } + + return { + timestamp: new Date().toISOString(), + iteration, + tabs: args.tabs, + clientSessionMaxSeconds: env.clientSessionMaxSeconds, + tokenMinTtlSeconds: env.tokenMinTtlSeconds, + reproduced, + chainLengths: chains.map((chain) => chain.requests.length), + elapsedMs, + error: errorMessage + }; +} + +async function main () { + const args = parseArgs(process.argv.slice(2)); + const env = requireEnv(); + assertStgTarget(env); + if (!env.kcClientSecret) { + throw new Error('KC_CLIENT_SECRET is not set in .env - run `npm run provision:apply` first to create the client and fetch its secret.'); + } + ensureDirs(); + + console.log(`Spawning fixture app (Node 16) on port ${env.fixtureAppPort}...`); + const fixtureApp = await spawnFixtureApp(env); + const baseUrl = `http://localhost:${env.fixtureAppPort}`; + + // Real Chrome, not Playwright's bundled Chromium - matches what users + // actually hit the bug in (per the original report: "intermittent website + // issue... in Chrome"), and avoids a separate browser binary download when + // Chrome is already installed. + const browser = await chromium.launch({ channel: 'chrome', headless: !args.headed }); + + let reproducedCount = 0; + try { + for (let iteration = 1; iteration <= args.iterations; iteration++) { + const row = await runIteration(browser, baseUrl, env, args, iteration); + appendResult(row); + if (row.reproduced) { + reproducedCount++; + } + console.log( + `[iteration ${iteration}/${args.iterations}] reproduced=${row.reproduced} ` + + `elapsedMs=${row.elapsedMs} chainLengths=${JSON.stringify(row.chainLengths)}` + ); + } + } finally { + await browser.close(); + fixtureApp.stop(); + } + + console.log(`\nDone: ${reproducedCount}/${args.iterations} iterations reproduced the loop.`); + console.log(`Results: ${RESULTS_FILE}`); + if (reproducedCount > 0) { + console.log(`Traces/HARs for reproducing iterations: ${TRACES_DIR}, ${HAR_DIR}`); + } +} + +main().catch((err) => { + console.error('repro.js failed:', err.message || err); + process.exit(1); +});