import type { APIRequestContext } from '@playwright/test' import { test as base, expect, request as playwrightRequest } from '@playwright/test' import { campaignFixture, type CampaignE2EFixture } from './campaignE2EFixtures.js' /** * Browserless full-stack e2e base (OPS35): same job, same server, same * database, real HTTP — no browser ever launches. * * Deliberately extends the RAW `@playwright/test` test, not `e2eTest.ts`: * `e2eFailureGuard` is `auto: true` and would pull the `page`/`context` * fixtures, launching a browser in every spec. The client-failure guard stays * exclusive to browser specs — the HTTP mode does not pretend to observe what * it cannot see (intention OPS35). * * The `campaign` fixture is the shared one from the browser suite (ownership, * in-process Payload, cleanup) — fixtures are created the same way, no * parallel seeding path. */ // Same stable contract `setCampaignAuthCookie` writes // (src/utilities/campaignAuth.ts:12) — the module is `server-only` and cannot // be imported from the test process (precedent: tests/unit/campaignLoginAction.unit.spec.ts:27). const CAMPAIGN_TOKEN_COOKIE = 'campaign-token' const CAMPAIGN_COOKIE_PATH = '/campanha' type CampaignRequestLogin = ( user: { email?: string | null; username?: string | null }, password: string, ) => Promise type CampaignHttpTestFixtures = { campaign: CampaignE2EFixture campaignRequest: CampaignRequestLogin } /** * Authenticates through the app's real login endpoint (Payload REST * `loginOperation` — the same operation `loginCampaign` calls) and returns a * request context carrying the canonical `campaign-token` cookie scoped to * `/campanha`, mirroring `setCampaignAuthCookie`'s options. No browser, no * duplicated auth logic, no re-signed TTL: the REST login returns the * configured 14-day token, which is more than enough for a test session. */ const loginCampaignRequest = async ( baseURL: string, user: { email?: string | null; username?: string | null }, password: string, ): Promise<{ token: string; user: { id: number | string } }> => { const loginContext = await playwrightRequest.newContext({ baseURL }) try { const body = user.email !== undefined && user.email !== null ? { email: user.email, password } : { username: user.username ?? '', password } const login = await loginContext.post('/api/campaignUser/login', { data: body }) if (!login.ok()) { throw new Error( `Campaign REST login failed: ${login.status()} ${(await login.text()).slice(0, 300)}`, ) } return (await login.json()) as { token: string; user: { id: number | string } } } finally { await loginContext.dispose() } } const campaignTokenStorageState = (baseURL: string, token: string) => { const url = new URL(baseURL) return { cookies: [ { name: CAMPAIGN_TOKEN_COOKIE, value: token, domain: url.hostname, path: CAMPAIGN_COOKIE_PATH, expires: -1, httpOnly: true, secure: url.protocol === 'https:', sameSite: 'Lax' as const, }, ], origins: [], } } export const test = base.extend({ campaign: campaignFixture, // Array form (same as `e2eTest.ts`'s guard): the shorthand property names // the function `campaignRequest`, which trips the react-hooks rule on the // fixture's `use(...)` callback. Factory-created contexts are not // auto-disposed by Playwright; the per-test closure tracks them and // disposes them at teardown. campaignRequest: [ async ({ campaign }, use) => { const contexts: APIRequestContext[] = [] await use(async (user, password) => { const { token } = await loginCampaignRequest(campaign.baseURL, user, password) const context = await playwrightRequest.newContext({ baseURL: campaign.baseURL, storageState: campaignTokenStorageState(campaign.baseURL, token), }) contexts.push(context) return context }) await Promise.all(contexts.map((context) => context.dispose())) }, {}, ], }) export { expect } /** * OPS35 → OPS87 — React's streaming inserts `` text-node separators * between adjacent server-rendered nodes: transport, not content. Every text * assertion over the rendered HTML runs through this strip. Was local to * `campaignTerritoriesHttp` until the second HTTP family needed it (OPS87) — * the base fixture is the natural owner of the HTTP-mode parsing utility. */ export const rendered = (html: string) => html.replaceAll('', '') /** * The route-gate redirect contract (OPS35 → OPS87, 4 call sites): `noLeader`/ * `staff` gates throw `redirect()` deep in the page, after the layout has * started streaming — so the document usually answers 200 carrying Next's * route-redirect meta tag instead of a 3xx; when the redirect beats the * stream it is a real 3xx (dev 307 / prod 308). Both are the server contract * the browser follows — assert the redirect TARGET, never pin the transport. */ export const assertLeaderRedirect = async (request: APIRequestContext, path: string) => { const direct = await request.get(path, { maxRedirects: 0 }) expect([200, 307, 308]).toContain(direct.status()) if (direct.status() === 200) { expect(await direct.text()).toContain( 'http-equiv="refresh" content="1;url=/campanha/meus-contatos"', ) } else { expect(direct.headers()['location']).toMatch(/\/campanha\/meus-contatos$/) } }