12 new Playwright tests closing the UI coverage gap in the collective
lifecycle (onboarding → invitation → admin manage). Writing them surfaced
three product-code bugs that have silently been present since Fase 1;
fixed as part of this phase.
Tests:
- tests/e2e/onboarding.test.ts O-01..O-03 (Eva auto-redirect, happy
path create, empty-name submit-disabled)
- tests/e2e/invitation.test.ts I-01..I-05 (token generation, accept
logged-out + logged-in, expired, used)
- tests/e2e/manage-collective.test.ts MC-02..MC-05 (promote, demote, remove
member, generate pending invite)
- tests/fixtures/db.ts resetEva, seedExpiredInvitation,
seedUsedInvitation, restoreSeedMembership,
countMembership — all via raw SQL so they
don't depend on SUPABASE_SERVICE_ROLE_KEY
Bugs found & fixed:
- supabase/migrations/012_create_collective_rpc.sql: atomic
`create_collective(name, emoji)` SECURITY DEFINER RPC. Fase 1 onboarding
used `.insert(...).select().single()` which triggers the SELECT policy on
the fresh row — creator isn't a member yet, so the INSERT was rejected with
"row-level security" even though the INSERT policy passed. onboarding now
calls the RPC which inserts both the collective AND the creator-as-admin
membership in one transaction, bypassing RLS. F-08 in rls-isolation.test.ts
has been silently masking this bug (only the spoof branch was asserted).
- routes/+layout.svelte: post-login redirect now reads sessionStorage
`pendingInvitationToken` and routes back to /invitation/<token> instead of
defaulting to /onboarding or /lists. The invitation page already stashed
the token before kicking off Keycloak, but nothing read it back.
- routes/(app)/collective/manage/+page.svelte: loadMembers now subscribes
to currentCollective so it re-runs when the store resolves after cold
navigation. onMount alone races with the auth listener's first emit and
the member list stayed empty on direct URL hits.
- routes/invitation/[token]/+page.svelte: awaits authLoading before deciding
whether to redirect to login. Previously raced with the auth listener and
triggered a redundant Keycloak round-trip for freshly-authenticated users.
Stable selectors added (Playwright):
- Onboarding: onboarding-create-tab, onboarding-join-tab,
collective-name-input, collective-submit
- Manage: member-row-<uid>, role-select-<uid>, remove-member-<uid>,
generate-invite, invite-link
- Invitation: invitation-accept, invitation-error (+ data-error-key attr)
Scope dropped from the plan — UI does not exist, would be feature work:
- O-04: "+ new collective" affordance in the sidebar.
- MC-01: rename-collective input in /collective/manage.
Both flagged as follow-ups in plan/fase-7-collective-flow-tests.md.
Test totals: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 58
Playwright + 1 gated rate-limit. 3 skipped (2 Realtime presence, 1 gated).
72 lines
2.7 KiB
TypeScript
72 lines
2.7 KiB
TypeScript
/**
|
||
* Live login helper — runs the real Keycloak OAuth flow in the current context.
|
||
*
|
||
* We use this instead of `storageState` because Supabase JS v2's async
|
||
* `_recoverAndRefresh` doesn't reliably re-fire `onAuthStateChange`/INITIAL_SESSION
|
||
* when the browser context is built from a saved localStorage snapshot. Doing the
|
||
* full login for each test adds ~2–3 seconds but produces deterministic state.
|
||
*/
|
||
import type { Page } from '@playwright/test';
|
||
import type { USERS } from './users.js';
|
||
|
||
export async function loginAs(
|
||
page: Page,
|
||
user: (typeof USERS)[keyof typeof USERS],
|
||
opts: { waitForCollective?: boolean } = {}
|
||
): Promise<void> {
|
||
const { waitForCollective = true } = opts;
|
||
|
||
await page.goto('/');
|
||
await page.waitForURL(/\/realms\/colectivo\/protocol\/openid-connect\/auth/, {
|
||
timeout: 15_000
|
||
});
|
||
await page.fill('#username', user.email);
|
||
await page.fill('#password', user.password);
|
||
await page.click('#kc-login');
|
||
|
||
// We pin Playwright's baseURL to http://localhost:5173 (see playwright.config.ts)
|
||
// regardless of whether PUBLIC_APP_URL is the LAN IP for device previews, so
|
||
// the post-login origin is always localhost.
|
||
await page.waitForURL(
|
||
(url) => url.origin === 'http://localhost:5173' && !url.pathname.startsWith('/auth/callback'),
|
||
{ timeout: 20_000 }
|
||
);
|
||
await page.waitForFunction(
|
||
() =>
|
||
Object.keys(window.localStorage).some(
|
||
(k) => k.startsWith('sb-') && k.endsWith('-auth-token')
|
||
),
|
||
null,
|
||
{ timeout: 15_000 }
|
||
);
|
||
|
||
// Opt-out for no-collective users (Eva): the sidebar never appears because
|
||
// the root layout sends them straight to /onboarding, so the emoji-button
|
||
// heuristic below would time out with no useful signal. Callers that exercise
|
||
// the onboarding flow pass `waitForCollective: false`.
|
||
if (!waitForCollective) return;
|
||
|
||
// Wait for `$currentCollective` to populate — any test that creates rows
|
||
// needs this, because `handleCreate` silently returns when collective is
|
||
// null. On localhost the round-trip was fast enough that races were rare,
|
||
// but over LAN IP the extra latency exposes the race. We detect it by the
|
||
// sidebar's collective button rendering the actual name (otherwise it
|
||
// shows the app_name fallback "Colectivo").
|
||
await page.waitForFunction(
|
||
(appName) => {
|
||
const buttons = document.querySelectorAll('button');
|
||
for (const b of Array.from(buttons)) {
|
||
const txt = b.textContent?.trim();
|
||
if (txt && txt !== appName && !txt.startsWith(appName)) {
|
||
// Heuristic: any non-default button with emoji-prefixed name
|
||
// (seed collective is "Casa García-López" with emoji "🏠").
|
||
if (/\p{Emoji}/u.test(txt)) return true;
|
||
}
|
||
}
|
||
return false;
|
||
},
|
||
'Colectivo',
|
||
{ timeout: 10_000 }
|
||
);
|
||
}
|