teqo-backup/tests/e2e/fixtures/e2eTest.ts
Francisco Solla c723d6accf
merge(OPS75): convergência Forgejo→GitHub — GitHub como fonte única
* deploy: limpeza pós-deploy do build cache e tags locais antigas (INF3/F2)

O deploy compila no homeserver e acumulava build cache + tags locais
antigas no disco raiz (incidente 19/08: 45G de cache). Após o smoke:
docker builder prune -f + remoção das tags locais teqo-1313 exceto o
SHA em uso (best-effort, registry preserva o rollback).

* feat(S9): captura de novidades da campanha na home pública (form + CTA do hero)

Seção 'Receba as novidades da campanha' acima do rodapé: nome + WhatsApp
obrigatórios, e-mail/estado/cidade/comentário opcionais, toggle pré-selecionado
'Quero fazer parte do time' gravado como subscription.campaignLevel
(time|esporadico). CTA secundário do hero vira o atalho (#novidades, scroll
suave). Contact + Subscription em withPayloadTransaction com consentimento
novo fail-closed por chave estável 'campanha-novidades' — nada é gravado sem o
texto aprovado no admin. Migration 20260819_192605 (state de contact nullable
+ coluna campaign_level). Impl plan, changelog, int + e2e.

* plans(C141,C143,C142): permissões granulares por assessor + responsáveis de demanda + UI por perfil

* plans(C141,C143,C142): permissões granulares por assessor + responsáveis de demanda + UI por perfil

* feat(S8): seção 'Nossa história' na home — vídeo da trajetória embutido do YouTube (Closes #91)

* docs(S8): resolução do conflito com S9 (história → newsletter) + nota do guard e2e dev-only

* feat(S10): pixel do Meta na página pública de campanha (campo admin em SiteSettings + PageView na home + Lead no sucesso da captura)

Campo tracking.facebookPixelId no global SiteSettings (mesma validação/normalização do precedente Petition, regra centralizada em facebookPixel.ts); home (grupo (home)) injeta o base code + PageView quando o ID está configurado (fail-closed) e o CampaignNewsletterForm dispara exatamente um Lead no sucesso do S9. Migration 20260819_213947_add_site_settings_facebook_pixel_id. Closes #94

* refactor(S10): simplify pass — Lead wiring unit-tested e raças de estado compartilhado do e2e resolvidas

e2e: stub hermético de connect.facebook.net no guard compartilhado (specs paralelos renderizam a home com o pixel); advisory lock no Postgres serializando o login do usuário de teste (Payload perde sessão em login concorrente read-modify-write — classe documentada no webauthn); seedTestUser vira create-if-missing (sem janela de usuário ausente nem reset de senha); Lead do form protegido do catch de erro (tracking nunca falha uma captura commitada); descrição do campo compartilhada; unidade nova campaignNewsletterLead cobre exatamente um Lead no sucesso.

* feat(C141): perfis de permissão por assessor — Visão × Edição configuráveis, enforcement fail-closed (Closes #105)

* chore(C141): regenera changelog agregado após rebase com S10

* plans(C144): options write-scoped para formulários de escrita pós-C141

---------

Co-authored-by: Francisco Solla <admin@solla.dev>
2026-08-21 01:41:18 -03:00

136 lines
4.7 KiB
TypeScript

import {
test as base,
expect,
type ConsoleMessage,
type Response,
type WebError,
} from '@playwright/test'
type FailureKind = 'console.error' | 'pageerror' | 'same-origin 5xx' | 'weberror'
type BrowserFailure = {
kind: FailureKind
detail: string
}
type E2EFailureGuardFixtures = {
e2eFailureGuard: void
/**
* Same-origin paths whose failed responses this spec provokes on purpose.
*
* A `fetch` the app handles in code still makes the browser log "Failed to
* load resource", so a spec asserting a refusal (B40: signing in with a
* revoked passkey) would otherwise fail the guard for doing its job. Declaring
* the path — rather than allowing 4xx broadly — keeps an unhandled request
* failure in any other spec a failure. It is an option so a spec declares it
* once with `test.use`, instead of pushing at exactly the right moment
* mid-test.
*/
expectedRequestFailurePaths: string[]
}
const loadFailurePrefix = 'Failed to load resource'
const missingFaviconConsoleError =
'Failed to load resource: the server responded with a status of 404 (Not Found)'
const errorDetail = (error: Error): string => error.stack ?? error.message
const responseDetail = (response: Response): string =>
`${response.status()} ${response.request().method()} ${response.url()}`
const consoleDetail = (message: ConsoleMessage): string => {
const { url, lineNumber, columnNumber } = message.location()
const location = url ? ` (${url}:${lineNumber}:${columnNumber})` : ''
return `${message.text()}${location}`
}
const isAllowedConsoleError = (
message: ConsoleMessage,
origin: string | null,
expectedRequestFailurePaths: string[],
): boolean => {
const { url } = message.location()
if (!url || origin === null) return false
const location = new URL(url)
if (location.origin !== origin) return false
// The app intentionally has no /favicon.ico asset yet.
if (message.text() === missingFaviconConsoleError) return location.pathname === '/favicon.ico'
return (
message.text().startsWith(loadFailurePrefix) &&
expectedRequestFailurePaths.includes(location.pathname)
)
}
export const test = base.extend<E2EFailureGuardFixtures>({
expectedRequestFailurePaths: [[], { option: true }],
e2eFailureGuard: [
async ({ baseURL, context, expectedRequestFailurePaths, page }, use) => {
const failures: BrowserFailure[] = []
const origin = baseURL ? new URL(baseURL).origin : null
const onConsole = (message: ConsoleMessage) => {
if (
message.type() !== 'error' ||
isAllowedConsoleError(message, origin, expectedRequestFailurePaths)
) {
return
}
failures.push({ kind: 'console.error', detail: consoleDetail(message) })
}
const onPageError = (error: Error) => {
failures.push({ kind: 'pageerror', detail: errorDetail(error) })
}
const onResponse = (response: Response) => {
if (
response.status() >= 500 &&
origin !== null &&
new URL(response.url()).origin === origin
) {
failures.push({ kind: 'same-origin 5xx', detail: responseDetail(response) })
}
}
const onWebError = (webError: WebError) => {
failures.push({ kind: 'weberror', detail: errorDetail(webError.error()) })
}
page.on('console', onConsole)
page.on('pageerror', onPageError)
page.on('response', onResponse)
context.on('weberror', onWebError)
// S8 — the home story-video embed is a YouTube iframe; e2e must stay
// hermetic (CI network must never gate a home-page test on the real
// player loading — same spirit as the S2/S3 feed stubs).
await context.route('https://www.youtube-nocookie.com/**', (route) => route.abort())
// S10 — the campaign-home pixel spec publishes a Meta Pixel ID into the
// shared test DB, so concurrent specs may render the home with the base
// code. Fulfill the external script locally (never a real request to
// Meta): a blocked network would trip the console-error guard below.
await context.route('https://connect.facebook.net/**', (route) =>
route.fulfill({ body: '', contentType: 'application/javascript' }),
)
await use()
page.off('console', onConsole)
page.off('pageerror', onPageError)
page.off('response', onResponse)
context.off('weberror', onWebError)
if (failures.length) {
const details = failures
.map(({ kind, detail }, index) => `${index + 1}. [${kind}] ${detail}`)
.join('\n\n')
throw new Error(`Unexpected browser/runtime failures:\n\n${details}`)
}
},
{ auto: true },
],
})
export { expect }