Skip to main content

Test Stack Runbook

How to drive sign-in / sign-up flows against the deployed stack (portal branch preview + optolink-test Heroku backend) without burning a session on avoidable traps. Learned during the FLOW-001 live re-audit (2026-09-17); all five techniques below were earned the hard way.

Session state: check first, then sign out by label​

The browser automation profile is persistent — the previous session is usually still alive. Before assuming a signed-out state, check it. Sign out through the "Open user menu" button (getByRole by accessible name), never a positional pick like header button — a positional match can land on the wrong control and leave the tab at about:blank.

The login page captures its query string once at mount, so ?redirect=%2Flinks survives Clerk's internal step navigations (identifier → password → verify). You do not need to re-append the param at each step: start a deep-link probe from the identifier step and it will still be honored after sign-in.

The Continue button needs exact: true​

getByRole('button', { name: 'Continue' }) matches two buttons — the Google button's accessible name ends in "Continue" too ("Sign in with Google Continue"). Always use { name: 'Continue', exact: true }.

Proving client-side chains: document-load log, not timeOrigin​

To prove a post-auth chain is fully client-side (one document load for the whole sign-in), inject a counter at document start and read it at the end:

await addInitScript(() => {
sessionStorage.setItem('doc-loads', String(Number(sessionStorage.getItem('doc-loads') ?? 0) + 1));
});
// …run the sign-in… then read sessionStorage 'doc-loads' — 1 means fully client-side.

Prefer this over performance.timeOrigin diffs: a lone timeOrigin comparison produced one false negative in the FLOW-001 session.

Console capture: register the hook late enough​

Collect console errors with a console.error hook plus window.addEventListener('error'), both installed via addInitScript. Register the hook after document.documentElement exists — earlier registration makes the MutationObserver variant throw. That throw is instrumentation noise, not an app error; don't file it.

Known ceilings​

  • Turnstile blocks automated sign-ups on the deployed instance — registration, half-onboarded, and orgless branches are human-manual-script material by construction.
  • The deployed Clerk instance is separate from the local one: instance-level config (display name, OAuth providers, bot protection) can drift between environments — see the "Sign in to Optolink" casing finding in docs/FLOW-001.md.

Verified against optolink-backend @ 583da0cc85046ee9fa4bcd4ab37f0e1b6ac3ba22, 2026-09-18.

Short-code availability probe needs a Clerk token​

The probe is a portal route (GET /portal/links/short-codes/:code/available; route contract in link-creation) — it wants a Clerk session bearer, per the real-token HTTP recipe:

curl -s "https://<backend>/portal/links/short-codes/promo-launch/available" \
-H "Authorization: Bearer $CLERK_TOKEN"
# → {"available":true}

curl -s "https://<backend>/portal/links/short-codes/ab/available" \
-H "Authorization: Bearer $CLERK_TOKEN"
# → 400 ("ab" is under the 3-char minimum)

The probe is advisory on the portal side: a race between probe and submit still 409s, so don't treat one "available" answer as a reservation.

428 before 402: the ordering trap​

The zero-AppConfig gate runs before the quota check on POST /portal/links. An org that hasn't registered an app gets 428 (APP_CONFIG_REQUIRED) even when it's also over quota. Quota tests written against an un-onboarded org will fail with the wrong status; register an app (or verify one exists) before asserting 402s.

Quota fillers: create → test → clean up​

Hitting the STARTER quota (10 links / 3 templates) needs filler rows. The audit scripts live in misc/:

  • misc/quota-fillers.mjs: fills links/templates up to a plan's limit
  • misc/quota-cleanup.mjs: removes the fillers afterwards
  • misc/matrix-verify-b.mjs, matrix-verify-params.mjs, matrix-clear-a.mjs, matrix-clear-b.mjs, matrix-final-cleanup.mjs: the creation-correctness matrix scripts from the FLOW-006 audit

Always run the cleanup script after a quota session; leftover fillers push the test org over quota and poison the next run. Two quota facts that save time:

  • System templates never consume the templates quota: free-plan QA can attach starters directly, no cloning, no quota cost.
  • Save-as-template does consume it: on STARTER that's the 3-template cap, and a save-as-template 402 during link creation offers the "Create link without template" escape rather than failing the link.

Portal shell: personas, impersonation, focus (FLOW-003)​

Verified against optolink-portal @ 7d31d457ad4e02e6d9b6bd3cb84a47439f2903ea and optolink-backend @ 583da0cc85046ee9fa4bcd4ab37f0e1b6ac3ba22, 2026-09-21.

Persona matrix for shell work​

PersonaAccountOrg stateExercise
Org memberdemo@org, has linksheader identity, member count, active highlight
Empty orgqa-shell+clerk_test@org, no dataempty-state header and sidebar
Ops adminadmin@ops orgadmin sidebar, impersonation banner and Exit

Sign-in mechanics (OTP 424242 for +clerk_test addresses, the exact-match Continue button) are the FLOW-001 techniques above; the full account list is in docs/testing/test-accounts.md.

Impersonation Exit: navigate first, clear second​

The exit handshake only works in this order (passthrough-banner.tsx → admin-sidebar.tsx): navigate to /admin/organizations with replace: true plus location state { passthroughExit: true } while the passthrough store is still set, then AdminLayout's mount effect clears the store and calls queryClient.clear(). Clear the store before navigating and AppLayout's admin redirect wins the race: you land on /admin instead of the orgs list. Writing an E2E for Exit: assert /admin/organizations. Refactoring the handshake: keep navigate-then-clear.

Everything is cold right after Exit​

queryClient.clear() wipes the whole TanStack cache. Screenshots or assertions taken immediately after Exit see loading states; wait for the target page's data to settle first.

Focus jumps to #main-content on every navigation​

useRouteFocus moves focus to the content region on each SPA navigation — that move is the screen-reader announcement; there is no live region. Assertions that assume focus stays on the clicked nav item will fail.

Sandbox nav item: dev builds only​

ADMIN_NAV_ITEMS filters Sandbox by import.meta.env.MODE !== "production". Production builds show 7 admin items; unit tests run in dev mode and must cover both branches (the admin-sidebar tests do).

Don't compare against docs/screenshots/FLOW-003/*​

Those screenshots show the pre-fix shell: placeholder brand, no active highlight, silent 404. Diff against the live app instead.

Verified against optolink-backend @ 583da0cc85046ee9fa4bcd4ab37f0e1b6ac3ba22, 2026-09-22.

POST /portal/links hits the 428 APP_CONFIG_REQUIRED gate before the quota check, so an org over quota but without a registered app answers 428, never 402. Seed an AppConfig row in beforeAll or every create assertion fails with the wrong status — the gate is documented in link-creation.

Free-plan links quota is 10; the seed leaves Demo Org at 5. After UI test sessions, delete any extra links until the count is exactly 5 again — leftover fillers walk the org toward the cap and poison the next quota test (seed accounts: test-accounts). The seed's shoesale link is template-attached: use it for provenance-badge checks on the detail page.

LinkService.create → resolveDefaultDomain upserts a Domain row, and that row counts against the domains quota. Orgs whose tests create links need plan: 'starter' or they hit the wrong 402.

Deactivating frees nothing; deleting frees the slot​

Links usage is a live prisma.link.count regardless of isActive (entitlements.service.ts:276). To reset quota state between tests you must delete — deactivating is not a cleanup step.

DELETE on a template that has links attached 409s with a linksUsing payload (link-templates). Detach from the link side first (PATCH /portal/links/:id with templateId: null), then delete the template.

Availability probe: regex floor, portal debounce​

Codes outside ^[a-zA-Z0-9_-]{3,100}$ answer 400; the portal hook debounces 400 ms and won't fire below 3 characters. Curl recipe in the FLOW-006 section above.

Resolution probe shapes​

GET /:orgKey/:shortCode serves the redirect HTML page with 200 for a live link and 410 for deactivated/expired; the JSON twin is /:orgKey/:shortCode/data. The orgKey segment is mandatory:

curl -s -o /dev/null -w "%{http_code}\n" "https://<backend>/<orgKey>/<code>" # 200 (live link)
curl -s -o /dev/null -w "%{http_code}\n" "https://<backend>/<code>" # 404 — bare code, no orgKey

That second shape is the FLOW-007 FB-3 regression: the list Copy-URL payload once shipped without the orgKey. If Copy URL ever 404s in a browser test, compare the copied string against <orgKey>/<code> before debugging the backend.

For quota fillers and cleanup scripts, reuse the FLOW-006 misc/ tooling above — same scripts, same cleanup discipline.