mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 18:53:06 +00:00
Extract sollinhaChatStreamBody + mockSollinhaChat into campaignE2EFixtures.ts (canonical v7 wire format), reuse across all 6 chat e2e specs and migrate the two legacy specs (transcribe, openingChips) from the format that fails the v7 client schema. Closes #556
851 lines
31 KiB
TypeScript
851 lines
31 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
||
|
||
import { sql } from '@payloadcms/db-postgres'
|
||
import type { BrowserContext, Fixtures, Frame, Page } from '@playwright/test'
|
||
import { getPayload, type CollectionSlug, type Payload, type PayloadRequest } from 'payload'
|
||
|
||
import { municipalityCatalog } from '../../../src/lib/municipalityCatalog.js'
|
||
import type { CampaignUser } from '../../../src/payload-types.js'
|
||
import config from '../../../src/payload.config.js'
|
||
import { withPayloadTransaction } from '../../../src/utilities/payloadTransaction.js'
|
||
import { assertTestDatabase } from '../../helpers/assertTestDatabase.js'
|
||
import {
|
||
claimMunicipalityIndex,
|
||
releaseMunicipalityClaims,
|
||
} from '../../helpers/campaignMunicipalityAllocator.js'
|
||
import { purgeMunicipalityResidue } from '../../helpers/campaignResidue.js'
|
||
import { withInviteConsent } from '../../helpers/testDatabaseLease.js'
|
||
import { test as base, expect } from './e2eTest.js'
|
||
|
||
const defaultBaseURL = 'http://localhost:3000'
|
||
|
||
/**
|
||
* The campaign session cookie name is owned by `src/utilities/campaignAuth.ts`
|
||
* (CAMPAIGN_TOKEN_COOKIE) — the seeded cookie below must match it.
|
||
*/
|
||
const CAMPAIGN_TOKEN_COOKIE = 'campaign-token'
|
||
type TransactionRequest = Pick<PayloadRequest, 'transactionID'>
|
||
type OwnedCollection =
|
||
| 'users'
|
||
| 'campaignUser'
|
||
| 'organization'
|
||
| 'contact'
|
||
| 'stateDeputy'
|
||
| 'leadership'
|
||
| 'votePledge'
|
||
| 'campaignDemand'
|
||
| 'allocationDecision'
|
||
| 'municipalityUpdate'
|
||
| 'activity'
|
||
| 'campaignInvite'
|
||
| 'consent'
|
||
| 'supporter'
|
||
| 'calendarFeed'
|
||
| 'googleCalendarSync'
|
||
|
||
const deletionOrder: OwnedCollection[] = [
|
||
'campaignInvite',
|
||
'votePledge',
|
||
'campaignDemand',
|
||
'allocationDecision',
|
||
'municipalityUpdate',
|
||
'activity',
|
||
'calendarFeed',
|
||
'googleCalendarSync',
|
||
'leadership',
|
||
'supporter',
|
||
'organization',
|
||
'stateDeputy',
|
||
'contact',
|
||
'campaignUser',
|
||
'consent',
|
||
'users',
|
||
]
|
||
|
||
/**
|
||
* Municipalities are SEEDED reference rows (435 from the static catalog): E2E tests
|
||
* never create/delete them. `claimMunicipality` hands out a globally unique seeded
|
||
* municipality per call (Postgres sequence — safe across parallel workers) and
|
||
* cleanup resets the operational fields the test may have touched.
|
||
*
|
||
* OWNERSHIP CONTRACT: rows created through `fixtures.payload` (i.e. `campaign.payload`)
|
||
* are auto-owned by the proxy below and deleted at cleanup — never call `own()` for
|
||
* them (that is why the class exposes no public `own`). Rows created through the raw
|
||
* `rootPayload` are NOT tracked. Shared stable-key consent rows are NEVER created
|
||
* through the proxy: use `ensureLeasedConsent` (testDatabaseLease.ts) so cleanup
|
||
* cannot rob a parallel spec of the shared row.
|
||
*/
|
||
export class CampaignE2EOwnership {
|
||
readonly payload: Payload
|
||
readonly runID = randomUUID()
|
||
private counter = 0
|
||
private claimedAnyMunicipality = false
|
||
private readonly touchedMunicipalities = new Set<number>()
|
||
private readonly owned = new Map<OwnedCollection, Set<number>>(
|
||
deletionOrder.map((collection) => [collection, new Set<number>()]),
|
||
)
|
||
|
||
constructor(private readonly rootPayload: Payload) {
|
||
this.payload = new Proxy(Object.create(rootPayload) as Payload, {
|
||
get: (_target, property) => {
|
||
if (property === 'create') {
|
||
return async (args: Parameters<Payload['create']>[0]) => {
|
||
const document = await rootPayload.create(args)
|
||
if (this.isOwnedCollection(args.collection) && typeof document.id === 'number') {
|
||
this.own(args.collection, document.id)
|
||
}
|
||
return document
|
||
}
|
||
}
|
||
const value = Reflect.get(rootPayload, property)
|
||
return typeof value === 'function' ? value.bind(rootPayload) : value
|
||
},
|
||
}) as Payload
|
||
}
|
||
|
||
value(prefix: string): string {
|
||
this.counter += 1
|
||
return `${prefix}-${this.runID}-${this.counter}`
|
||
}
|
||
|
||
phone(): string {
|
||
this.counter += 1
|
||
const seed = BigInt(`0x${this.runID.replaceAll('-', '').slice(0, 12)}`)
|
||
const suffix = ((seed + BigInt(this.counter)) % 90_000_000n) + 10_000_000n
|
||
return `719${suffix}`
|
||
}
|
||
|
||
/**
|
||
* One campaign account with a deterministic generated password (returned for
|
||
* the UI login) — replaces the ~28 hand-written `payload.create` blocks the
|
||
* specs used to spell out per role. The proxy above auto-owns the row.
|
||
*
|
||
* C142 — `visibility` and `editing` fields are passed through so e2e tests
|
||
* can exercise advisor permission profiles (`somente_leitura`, `tudo`,
|
||
* `carteira`).
|
||
*/
|
||
async createCampaignUser(
|
||
role: CampaignUser['role'],
|
||
input: {
|
||
name?: string
|
||
email?: string
|
||
username?: string
|
||
visibility?: CampaignUser['visibility']
|
||
editing?: CampaignUser['editing']
|
||
} = {},
|
||
): Promise<CampaignUser & { password: string }> {
|
||
const password = this.value('password')
|
||
const user = await this.payload.create({
|
||
collection: 'campaignUser',
|
||
data: {
|
||
name: this.value(`Usuário ${role}`),
|
||
email: `${this.value(role)}@example.com`,
|
||
password,
|
||
role,
|
||
...input,
|
||
},
|
||
depth: 0,
|
||
})
|
||
return { ...user, password }
|
||
}
|
||
|
||
async claimMunicipality(): Promise<{ id: number; name: string; slug: string }> {
|
||
const index = await claimMunicipalityIndex(
|
||
this.rootPayload,
|
||
municipalityCatalog.length,
|
||
this.runID,
|
||
)
|
||
this.claimedAnyMunicipality = true
|
||
const slug = municipalityCatalog[index]!.slug
|
||
const found = await this.rootPayload.find({
|
||
collection: 'municipality',
|
||
where: { slug: { equals: slug } },
|
||
depth: 0,
|
||
limit: 1,
|
||
pagination: false,
|
||
})
|
||
const municipality = found.docs[0]
|
||
if (!municipality)
|
||
throw new Error(`Seeded municipality "${slug}" not found — run migrations first.`)
|
||
// OPS45 — a crashed e2e run (worker killed before the cleanup `finally`)
|
||
// leaves rows behind; purge them on the fresh claim, mirroring the int
|
||
// `getMunicipality` contract. Safe by the allocator: no live run owns
|
||
// this municipality.
|
||
await purgeMunicipalityResidue(this.rootPayload, municipality.id)
|
||
this.touchedMunicipalities.add(municipality.id)
|
||
// The catalog name, not the row's: since B34+ the chips label from
|
||
// `municipalityCatalog`, so a row an int spec renamed and left behind would
|
||
// make a specifier here miss the element the UI actually renders.
|
||
return {
|
||
id: municipality.id,
|
||
name: municipalityCatalog[index]!.name,
|
||
slug: municipality.slug,
|
||
}
|
||
}
|
||
|
||
touchMunicipality(id: number): void {
|
||
this.touchedMunicipalities.add(id)
|
||
}
|
||
|
||
/**
|
||
* A leadership plus the `contact` it is required to point at — the pair every
|
||
* spec that needs a row on `/campanha/liderancas` was writing out by hand.
|
||
* Returns both, since assertions look the leadership up by id and find the row
|
||
* by the contact's name.
|
||
*/
|
||
async createStaffLeadership({
|
||
namePrefix,
|
||
municipalities,
|
||
supportStatus = 'a_abordar',
|
||
user,
|
||
}: {
|
||
namePrefix: string
|
||
municipalities: { id: number; name: string }[]
|
||
supportStatus?: 'a_abordar' | 'em_disputa' | 'lembranca' | 'engajado' | 'negativo'
|
||
user?: number
|
||
}): Promise<{ contactName: string; contactId: number; leadershipId: number }> {
|
||
const contactName = this.value(namePrefix)
|
||
const contact = await this.payload.create({
|
||
collection: 'contact',
|
||
data: {
|
||
name: contactName,
|
||
phones: [{ value: this.phone() }],
|
||
state: 'BA',
|
||
city: municipalities[0]?.name ?? 'Salvador',
|
||
},
|
||
depth: 0,
|
||
})
|
||
|
||
const leadership = await this.payload.create({
|
||
collection: 'leadership',
|
||
data: {
|
||
contact: contact.id,
|
||
municipalities: municipalities.map((municipality) => municipality.id),
|
||
supportStatus,
|
||
...(user === undefined ? {} : { user }),
|
||
},
|
||
depth: 0,
|
||
})
|
||
|
||
return { contactName, contactId: contact.id, leadershipId: leadership.id }
|
||
}
|
||
|
||
/**
|
||
* C143 — demand factory with explicit ownership: the row is registered for
|
||
* cleanup and the municipality touched (mirror of the int fixture helper).
|
||
*/
|
||
async createCampaignDemand(input: {
|
||
municipality: number
|
||
title: string
|
||
createdBy?: number
|
||
responsibles?: number[]
|
||
}): Promise<{ id: number; slug: string; title: string }> {
|
||
const demand = await this.payload.create({
|
||
collection: 'campaignDemand',
|
||
data: {
|
||
kind: 'material',
|
||
slug: this.value('demanda'),
|
||
status: 'aberta',
|
||
...input,
|
||
},
|
||
depth: 0,
|
||
draft: false,
|
||
})
|
||
this.own('campaignDemand', demand.id)
|
||
this.touchMunicipality(input.municipality)
|
||
return demand
|
||
}
|
||
|
||
private isOwnedCollection(collection: CollectionSlug): collection is OwnedCollection {
|
||
return this.owned.has(collection as OwnedCollection)
|
||
}
|
||
|
||
private own(collection: OwnedCollection, id: number): void {
|
||
this.owned.get(collection)!.add(id)
|
||
}
|
||
|
||
private ids(collection: OwnedCollection): number[] {
|
||
return [...this.owned.get(collection)!]
|
||
}
|
||
|
||
private async discoverOwnedRows(): Promise<void> {
|
||
const [users, campaignUsers, contacts, organizations, activities, consents] = await Promise.all(
|
||
[
|
||
this.rootPayload.find({
|
||
collection: 'users',
|
||
where: { email: { contains: this.runID } },
|
||
depth: 0,
|
||
pagination: false,
|
||
}),
|
||
this.rootPayload.find({
|
||
collection: 'campaignUser',
|
||
where: {
|
||
or: [
|
||
{ name: { contains: this.runID } },
|
||
{ email: { contains: this.runID } },
|
||
{ username: { contains: this.runID } },
|
||
],
|
||
},
|
||
depth: 0,
|
||
pagination: false,
|
||
}),
|
||
this.rootPayload.find({
|
||
collection: 'contact',
|
||
where: {
|
||
or: [{ name: { contains: this.runID } }, { email: { contains: this.runID } }],
|
||
},
|
||
depth: 0,
|
||
pagination: false,
|
||
}),
|
||
this.rootPayload.find({
|
||
collection: 'organization',
|
||
where: {
|
||
or: [{ name: { contains: this.runID } }, { slug: { contains: this.runID } }],
|
||
},
|
||
depth: 0,
|
||
pagination: false,
|
||
}),
|
||
this.rootPayload.find({
|
||
collection: 'activity',
|
||
where: {
|
||
or: [{ title: { contains: this.runID } }, { slug: { contains: this.runID } }],
|
||
},
|
||
depth: 0,
|
||
pagination: false,
|
||
}),
|
||
this.rootPayload.find({
|
||
collection: 'consent',
|
||
where: { key: { contains: this.runID } },
|
||
depth: 0,
|
||
pagination: false,
|
||
}),
|
||
],
|
||
)
|
||
for (const user of users.docs) this.own('users', user.id)
|
||
for (const user of campaignUsers.docs) this.own('campaignUser', user.id)
|
||
for (const contact of contacts.docs) this.own('contact', contact.id)
|
||
for (const organization of organizations.docs) this.own('organization', organization.id)
|
||
for (const activity of activities.docs) this.own('activity', activity.id)
|
||
for (const consent of consents.docs) this.own('consent', consent.id)
|
||
|
||
const userIDs = this.ids('campaignUser')
|
||
const contactIDs = this.ids('contact')
|
||
const stateDeputies = contactIDs.length
|
||
? await this.rootPayload.find({
|
||
collection: 'stateDeputy',
|
||
where: { contact: { in: contactIDs } },
|
||
depth: 0,
|
||
pagination: false,
|
||
})
|
||
: { docs: [] }
|
||
for (const stateDeputy of stateDeputies.docs) this.own('stateDeputy', stateDeputy.id)
|
||
const leaderships = await this.rootPayload.find({
|
||
collection: 'leadership',
|
||
where: {
|
||
or: [
|
||
...(userIDs.length ? [{ createdBy: { in: userIDs } }] : []),
|
||
...(userIDs.length ? [{ user: { in: userIDs } }] : []),
|
||
...(contactIDs.length ? [{ contact: { in: contactIDs } }] : []),
|
||
],
|
||
},
|
||
depth: 0,
|
||
pagination: false,
|
||
})
|
||
for (const leadership of leaderships.docs) this.own('leadership', leadership.id)
|
||
|
||
const leadershipIDs = this.ids('leadership')
|
||
if (leadershipIDs.length) {
|
||
const [invites, pledges, demands] = await Promise.all([
|
||
this.rootPayload.find({
|
||
collection: 'campaignInvite',
|
||
where: { leadership: { in: leadershipIDs } },
|
||
depth: 0,
|
||
pagination: false,
|
||
}),
|
||
this.rootPayload.find({
|
||
collection: 'votePledge',
|
||
where: { leadership: { in: leadershipIDs } },
|
||
depth: 0,
|
||
pagination: false,
|
||
}),
|
||
this.rootPayload.find({
|
||
collection: 'campaignDemand',
|
||
where: { leadership: { in: leadershipIDs } },
|
||
depth: 0,
|
||
pagination: false,
|
||
}),
|
||
])
|
||
for (const invite of invites.docs) this.own('campaignInvite', invite.id)
|
||
for (const pledge of pledges.docs) this.own('votePledge', pledge.id)
|
||
for (const demand of demands.docs) this.own('campaignDemand', demand.id)
|
||
}
|
||
if (userIDs.length) {
|
||
const [updates, demands] = await Promise.all([
|
||
this.rootPayload.find({
|
||
collection: 'municipalityUpdate',
|
||
where: { author: { in: userIDs } },
|
||
depth: 0,
|
||
pagination: false,
|
||
}),
|
||
this.rootPayload.find({
|
||
collection: 'campaignDemand',
|
||
where: { createdBy: { in: userIDs } },
|
||
depth: 0,
|
||
pagination: false,
|
||
}),
|
||
])
|
||
for (const update of updates.docs) this.own('municipalityUpdate', update.id)
|
||
for (const demand of demands.docs) this.own('campaignDemand', demand.id)
|
||
}
|
||
if (contactIDs.length || userIDs.length) {
|
||
const supporters = await this.rootPayload.find({
|
||
collection: 'supporter',
|
||
where: {
|
||
or: [
|
||
...(contactIDs.length ? [{ contact: { in: contactIDs } }] : []),
|
||
...(userIDs.length ? [{ createdBy: { in: userIDs } }] : []),
|
||
],
|
||
},
|
||
depth: 0,
|
||
pagination: false,
|
||
})
|
||
for (const supporter of supporters.docs) this.own('supporter', supporter.id)
|
||
}
|
||
if (userIDs.length) {
|
||
const feeds = await this.rootPayload.find({
|
||
collection: 'calendarFeed',
|
||
where: { createdBy: { in: userIDs } },
|
||
depth: 0,
|
||
pagination: false,
|
||
})
|
||
for (const feed of feeds.docs) this.own('calendarFeed', feed.id)
|
||
}
|
||
}
|
||
|
||
async cleanup(): Promise<void> {
|
||
await this.discoverOwnedRows()
|
||
await withPayloadTransaction(this.rootPayload, async ({ req, transactionID }) => {
|
||
const transaction = this.rootPayload.db.sessions?.[String(transactionID)]?.db as
|
||
| { execute: (query: ReturnType<typeof sql>) => Promise<unknown> }
|
||
| undefined
|
||
if (!transaction) throw new Error('Campaign E2E cleanup transaction is unavailable.')
|
||
const userIDs = this.ids('campaignUser')
|
||
if (userIDs.length) {
|
||
await transaction.execute(sql`
|
||
DELETE FROM "payload_preferences"
|
||
WHERE "id" IN (
|
||
SELECT "parent_id"
|
||
FROM "payload_preferences_rels"
|
||
WHERE "campaign_user_id" IN (${sql.join(
|
||
userIDs.map((id) => sql`${id}`),
|
||
sql`, `,
|
||
)})
|
||
)
|
||
`)
|
||
}
|
||
for (const collection of deletionOrder) {
|
||
const ids = this.ids(collection)
|
||
if (!ids.length) continue
|
||
const result = await this.rootPayload.delete({
|
||
collection,
|
||
where: { id: { in: ids } },
|
||
depth: 0,
|
||
req,
|
||
})
|
||
if (result.errors.length) {
|
||
throw new AggregateError(result.errors, `Failed to clean owned ${collection} rows.`)
|
||
}
|
||
}
|
||
const touched = await this.rootPayload.find({
|
||
collection: 'municipality',
|
||
where: { id: { in: [...this.touchedMunicipalities] } },
|
||
depth: 0,
|
||
pagination: false,
|
||
select: { slug: true },
|
||
})
|
||
const catalogNameById = new Map(
|
||
touched.docs.map((municipality) => [
|
||
municipality.id,
|
||
municipalityCatalog.find((entry) => entry.slug === municipality.slug)?.name,
|
||
]),
|
||
)
|
||
for (const municipalityID of this.touchedMunicipalities) {
|
||
const catalogName = catalogNameById.get(municipalityID)
|
||
await this.rootPayload.update({
|
||
collection: 'municipality',
|
||
id: municipalityID,
|
||
// Same reset set as the int cleanup (tests/helpers/campaignFixtures.ts):
|
||
// a field added there belongs here too — keep them in lockstep.
|
||
data: {
|
||
...(catalogName ? { name: catalogName } : {}),
|
||
advisors: [],
|
||
priority: 'normal',
|
||
expectedVotes: { pessimistic: null, central: null, optimistic: null },
|
||
politicalTrend: { status: null, note: null, recordedBy: null, recordedAt: null },
|
||
engagementLevel: null,
|
||
levelNote: null,
|
||
strengths: [],
|
||
risks: [],
|
||
stateDeputies: [],
|
||
dobradinhaNotes: null,
|
||
nextSteps: null,
|
||
lastUpdateAt: null,
|
||
},
|
||
depth: 0,
|
||
req,
|
||
})
|
||
}
|
||
})
|
||
// Release the allocator claim only AFTER the run's rows are purged: a
|
||
// concurrent run that claims this slot in the meantime would purge-on-claim
|
||
// the rows still live here (the release→purge window is the corruption the
|
||
// registry exists to prevent).
|
||
if (this.claimedAnyMunicipality) {
|
||
await releaseMunicipalityClaims(this.rootPayload, this.runID)
|
||
}
|
||
}
|
||
|
||
async expectNoOwnedRows(): Promise<void> {
|
||
for (const collection of deletionOrder) {
|
||
const ids = this.ids(collection)
|
||
if (!ids.length) continue
|
||
const remaining = await this.rootPayload.find({
|
||
collection,
|
||
where: { id: { in: ids } },
|
||
depth: 0,
|
||
limit: 1,
|
||
})
|
||
expect(remaining.totalDocs, `Owned ${collection} rows remain`).toBe(0)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Waits for an auto-saving cell's write to actually land.
|
||
*
|
||
* Every cell in the B32+ family paints optimistically, so asserting on the text
|
||
* proves nothing about the database — a `page.reload()` right after would race
|
||
* the POST. Pair this with the interaction inside one `Promise.all`.
|
||
*/
|
||
export const expectPostResponse = (page: Page, urlFragment: string) =>
|
||
page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes(urlFragment) &&
|
||
response.request().method() === 'POST' &&
|
||
response.ok(),
|
||
)
|
||
|
||
/**
|
||
* Checks a Radix Checkbox only once the page has hydrated. A Radix checkbox is
|
||
* a button whose hidden input is synced by React, so a click that lands BEFORE
|
||
* hydration is a silent no-op (the SSR button has no handler) — the B13/B17
|
||
* flake class. `toPass` retries the probe until the state sticks: pre-hydration
|
||
* clicks change nothing, so the loop survives them; once hydration attaches,
|
||
* the next click toggles and the loop ends. Safe because an already-checked
|
||
* state short-circuits before any click.
|
||
*/
|
||
export const checkRadixWhenHydrated = async (page: Page, label: string) => {
|
||
const checkbox = page.getByLabel(label)
|
||
await expect(async () => {
|
||
if ((await checkbox.getAttribute('data-state')) !== 'checked') {
|
||
await checkbox.click({ timeout: 1_000 })
|
||
}
|
||
await expect(checkbox).toHaveAttribute('data-state', 'checked', { timeout: 1_000 })
|
||
}).toPass({ timeout: 15_000 })
|
||
}
|
||
|
||
/**
|
||
* The SSE body the Sollinha chat mocks reply with. Chunks follow the SDK v7
|
||
* UI-message-stream wire format (start / text-start / text-delta / text-end /
|
||
* finish) — a bare legacy `{"type":"text"}` chunk fails the client schema and
|
||
* the chat would never settle. Shared by every chat e2e mock so the v7 shape
|
||
* lives in exactly one place.
|
||
*/
|
||
export const sollinhaChatStreamBody = (delta: string): string =>
|
||
[
|
||
'data: {"type":"start"}\n\n',
|
||
'data: {"type":"text-start","id":"t1"}\n\n',
|
||
`data: {"type":"text-delta","id":"t1","delta":${JSON.stringify(delta)}}\n\n`,
|
||
'data: {"type":"text-end","id":"t1"}\n\n',
|
||
'data: {"type":"finish","finishReason":"stop"}\n\n',
|
||
].join('')
|
||
|
||
/**
|
||
* Mock `/campanha/api/ai-chat` with a minimal v7 SSE stream so sending a
|
||
* message neither needs a DeepSeek API key nor hits the real rate limiter. The
|
||
* reply carries a markdown link so the "navigate without reload" path has a
|
||
* real anchor. Mirrors the precedent helpers (`checkRadixWhenHydrated`,
|
||
* `expectPostResponse`) that already live in this fixture module.
|
||
*/
|
||
export const mockSollinhaChat = async (page: Page, delta: string): Promise<void> => {
|
||
await page.route('**/campanha/api/ai-chat', async (route) => {
|
||
await route.fulfill({
|
||
status: 200,
|
||
headers: { 'Content-Type': 'text/event-stream' },
|
||
body: sollinhaChatStreamBody(delta),
|
||
})
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Waits for dynamic campaign pages to commit their RSC stream by polling
|
||
* until the browser has no transient hidden `#S:*` shell copies left.
|
||
*
|
||
* Every campaign page that loads via `await searchParams` can hold a
|
||
* transient hidden `div[id^="S:*"]` in the DOM while the RSC chunks land;
|
||
* class-based locators and click actions that race the stream see
|
||
* the stale copy (OPS83 verify #15 — observed flake at 4 workers).
|
||
*
|
||
* **The generic `S:` gate alone is not enough for some flows** (OPS83
|
||
* verified the dossiê tab needs a specific DOM poll after the settle).
|
||
* Pair with a focused poll when the assertion is on a specific element
|
||
* (e.g. `expect.poll(() => link.count()).toBeGreaterThan(0)`) rather
|
||
* than relying on the settle gate to prove the element exists.
|
||
*
|
||
* Default timeout: 15 000 ms (matching the OPS83-validated settle
|
||
* budget for campaign pages). Override via `options` for heavier pages.
|
||
*/
|
||
export const waitForStreamSettled = (page: Page, options?: { timeout?: number }) =>
|
||
page.waitForFunction(
|
||
() => document.querySelectorAll('div[id^="S:"]').length === 0,
|
||
undefined,
|
||
options,
|
||
)
|
||
|
||
/**
|
||
* OPS42 — waits out a pending dev-mode router navigation before the test
|
||
* interacts. Next dev's hydration/router churn can commit a layout remount
|
||
* (new chat provider, new textarea with `defaultValue=''`) ~150–500 ms AFTER
|
||
* the page load — measured 2026-08-11 under load — so a fill/Enter landing in
|
||
* that window is silently lost. The commit is preceded by a `framenavigated`
|
||
* (a bare `history.replaceState`, no request), so the helper returns only
|
||
* after a quiet window with no framenav. Dev-only: production builds have no
|
||
* dev compile churn, and CI runs the prod build — the wait is a no-op there.
|
||
* Fail-soft on timeout: a router that never quiesces (HMR churn under extreme
|
||
* load) must not mask a real app failure — the next assertion carries it.
|
||
*/
|
||
export const waitForRouterSettled = async (page: Page): Promise<void> => {
|
||
const quietWindowMs = 500
|
||
const timeoutMs = 15_000
|
||
if (process.env.CI || process.env.E2E_PROD === '1') return
|
||
let lastNavigationAt = Date.now()
|
||
const onNavigation = (frame: Frame) => {
|
||
if (frame !== page.mainFrame()) return
|
||
lastNavigationAt = Date.now()
|
||
}
|
||
page.on('framenavigated', onNavigation)
|
||
try {
|
||
await expect(async () => {
|
||
expect(Date.now() - lastNavigationAt).toBeGreaterThan(quietWindowMs)
|
||
}).toPass({ timeout: timeoutMs })
|
||
} catch {
|
||
console.warn('waitForRouterSettled: router did not quiet down in time — proceeding.')
|
||
} finally {
|
||
page.off('framenavigated', onNavigation)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Miss #53: the app's biometrics probe is ONE-SHOT per island mount
|
||
* (`useCampaignBiometricsAvailable`) — if
|
||
* `isUserVerifyingPlatformAuthenticatorAvailable()` resolves before the CDP
|
||
* virtual authenticator is ready, the enrollment form / biometric button
|
||
* never mount and no amount of waiting flips them. Gate the ceremony on the
|
||
* platform answer BEFORE navigating into the surface whose island probes it,
|
||
* so the race is decided before the app even asks. Requires a loaded page
|
||
* (secure context) — call after `campaign.login` / `page.goto`.
|
||
*/
|
||
export const expectCampaignBiometricsReady = async (page: Page): Promise<void> => {
|
||
await expect(async () => {
|
||
const available = await page.evaluate(
|
||
() => window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable?.() ?? false,
|
||
)
|
||
expect(available).toBe(true)
|
||
}).toPass({ timeout: 15_000 })
|
||
}
|
||
|
||
export type CampaignSessionUser = CampaignUser & { password: string }
|
||
|
||
/**
|
||
* OPS36 — session seeding. The shared-session journeys skip the browser login
|
||
* round trip: `mintCampaignSession` goes through `payload.login` (the same
|
||
* Local API path the real login action uses, so the session row and JWT are
|
||
* exactly what the app validates) and `seedCampaignSession` injects the
|
||
* resulting `campaign-token` cookie into a test context. The cookie mirrors
|
||
* `campaignCookieOptions` in `src/utilities/campaignAuth.ts` (path `/campanha`,
|
||
* httpOnly, sameSite lax; no `secure` on the http test origin).
|
||
*/
|
||
export const mintCampaignSession = async (
|
||
payload: Payload,
|
||
user: CampaignSessionUser,
|
||
): Promise<string> => {
|
||
if (!user.email) {
|
||
throw new Error('Campaign session seeding requires a user with an email.')
|
||
}
|
||
const { token } = await payload.login({
|
||
collection: 'campaignUser',
|
||
data: { email: user.email, password: user.password },
|
||
depth: 0,
|
||
})
|
||
if (!token) {
|
||
throw new Error('Campaign session seeding failed to obtain a session token.')
|
||
}
|
||
return token
|
||
}
|
||
|
||
export const seedCampaignSession = async (
|
||
context: BrowserContext,
|
||
baseURL: string,
|
||
token: string,
|
||
): Promise<void> => {
|
||
// `addCookies` accepts either `url` or `domain`+`path`, never both — and the
|
||
// campaign cookie must live on `/campanha`, so the domain/path pair is used.
|
||
await context.addCookies([
|
||
{
|
||
name: CAMPAIGN_TOKEN_COOKIE,
|
||
value: token,
|
||
domain: new URL(baseURL).hostname,
|
||
path: '/campanha',
|
||
httpOnly: true,
|
||
sameSite: 'Lax',
|
||
},
|
||
])
|
||
}
|
||
|
||
/**
|
||
* One ownership instance (own `runID`, own cleanup) wrapping the memoized
|
||
* in-process Payload — the describe-level counterpart of the per-test
|
||
* `campaign` fixture, for users shared across a journey group.
|
||
*/
|
||
export const createCampaignOwnership = async (): Promise<CampaignE2EOwnership> => {
|
||
assertTestDatabase(process.env.DATABASE_URL)
|
||
const payload = await getPayload({ config })
|
||
return new CampaignE2EOwnership(payload)
|
||
}
|
||
|
||
export type CampaignE2EFixture = {
|
||
baseURL: string
|
||
fixtures: CampaignE2EOwnership
|
||
login: (page: Page, identifier: string, password: string) => Promise<void>
|
||
payload: Payload
|
||
sessionFor: (context: BrowserContext, user: CampaignSessionUser) => Promise<void>
|
||
transaction: <Result>(
|
||
operation: (payload: Payload, req: TransactionRequest) => Promise<Result>,
|
||
) => Promise<Result>
|
||
withInviteConsent: typeof withInviteConsent
|
||
}
|
||
|
||
type CampaignE2ETestFixtures = {
|
||
campaign: CampaignE2EFixture
|
||
}
|
||
|
||
/**
|
||
* The shared campaign ownership fixture (in-process Payload, auto-owned rows,
|
||
* municipality claims, transactional cleanup). Exported so the browser suite
|
||
* (`campaignE2EFixtures.ts`) and the browserless HTTP suite
|
||
* (`campaignHttpTest.ts`) both extend from the same definition — never twin it.
|
||
*/
|
||
type CampaignE2EFixtureValue = Fixtures<CampaignE2ETestFixtures>['campaign']
|
||
|
||
export const campaignFixture: CampaignE2EFixtureValue = async ({}, runFixture) => {
|
||
assertTestDatabase(process.env.DATABASE_URL)
|
||
const payload = await getPayload({ config })
|
||
const fixtures = new CampaignE2EOwnership(payload)
|
||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? defaultBaseURL
|
||
|
||
let testFailure: unknown
|
||
try {
|
||
await runFixture({
|
||
baseURL,
|
||
fixtures,
|
||
login: async (page, identifier, password) => {
|
||
await page.context().clearCookies()
|
||
await page.goto(`${baseURL}/campanha/login`)
|
||
// Dev-mode resilience (OPS30): Next dev compiles a route on its first
|
||
// hit, and that compile can full-page-reload a connected client —
|
||
// aborting the login POST before it lands (the mechanism documented
|
||
// in setup.e2e.spec.ts). A budget-only wait cannot recover an aborted
|
||
// submit, so on a navigation timeout we re-submit ONCE: the action is
|
||
// warm by then. The grace wait absorbs a navigation that landed just
|
||
// past the first wait, and the URL branch refuses a blind re-submit
|
||
// against a page that left the login screen. Sub-budgets sum below
|
||
// the 60 s test timeout, so a real credential failure surfaces as a
|
||
// crisp wait error, not a generic test timeout. A second failure
|
||
// surfaces — wrong credentials fail both attempts.
|
||
const dashboardURL = `${baseURL}/campanha`
|
||
const onDashboard = (url: string) => url === dashboardURL || url === `${dashboardURL}/`
|
||
const submitLogin = async () => {
|
||
await page.getByLabel('E-mail ou celular').fill(identifier)
|
||
await page.getByLabel('Senha').fill(password)
|
||
// `exact`: the login screen also offers "Entrar com digital ou Face
|
||
// ID" (B40) whenever the device has a platform authenticator — which
|
||
// any spec adding a virtual one does.
|
||
await page.getByRole('button', { name: 'Entrar', exact: true }).click()
|
||
}
|
||
await submitLogin()
|
||
try {
|
||
await page.waitForURL(dashboardURL, { timeout: 15_000 })
|
||
} catch {
|
||
try {
|
||
await page.waitForURL(dashboardURL, { timeout: 5_000 })
|
||
} catch {
|
||
const current = page.url()
|
||
if (onDashboard(current)) return
|
||
if (!current.startsWith(`${baseURL}/campanha/login`)) {
|
||
throw new Error(`Login retry aborted: page left the login screen (${current}).`)
|
||
}
|
||
await submitLogin()
|
||
await page.waitForURL(dashboardURL, { timeout: 30_000 })
|
||
}
|
||
}
|
||
// OPS42 — dev-only quiescence: when the URL change lands before the
|
||
// redirect's RSC payload commits (observed under load), the layout can
|
||
// remount right after login — wait for the router to quiet down
|
||
// before the test interacts (no-op in prod; see `waitForRouterSettled`).
|
||
await waitForRouterSettled(page)
|
||
},
|
||
payload: fixtures.payload,
|
||
sessionFor: async (context, user) => {
|
||
const token = await mintCampaignSession(payload, user)
|
||
await seedCampaignSession(context, baseURL, token)
|
||
},
|
||
transaction: (operation) =>
|
||
withPayloadTransaction(payload, ({ req }) => operation(fixtures.payload, req)),
|
||
withInviteConsent,
|
||
})
|
||
} catch (error) {
|
||
testFailure = error
|
||
throw error
|
||
} finally {
|
||
let cleanupFailure: unknown
|
||
try {
|
||
assertTestDatabase(process.env.DATABASE_URL)
|
||
await fixtures.cleanup()
|
||
await fixtures.expectNoOwnedRows()
|
||
} catch (error) {
|
||
cleanupFailure = error
|
||
}
|
||
if (cleanupFailure !== undefined) {
|
||
if (testFailure !== undefined) {
|
||
throw new AggregateError(
|
||
[testFailure, cleanupFailure],
|
||
'Campaign E2E test and ownership cleanup both failed.',
|
||
)
|
||
}
|
||
throw cleanupFailure
|
||
}
|
||
}
|
||
}
|
||
|
||
export const test = base.extend<CampaignE2ETestFixtures>({
|
||
campaign: campaignFixture,
|
||
})
|
||
|
||
export const campaignPageChrome = (page: Page, title: string | RegExp) =>
|
||
page
|
||
.locator('[data-slot="campaign-page-chrome"]')
|
||
.filter({ visible: true })
|
||
.locator('[data-slot="campaign-page-chrome-title"]')
|
||
.getByText(title, typeof title === 'string' ? { exact: true } : undefined)
|
||
|
||
export { expect }
|