merge: origin/main into feat/visual-placeholders

Resolve conflicts keeping DESIGN.md type scale, main blade fallbacks,
and StableScreenshot visual helper. Regenerate browser baselines.
This commit is contained in:
2026-08-06 09:42:37 -03:00
24 changed files with 574 additions and 67 deletions

View File

@@ -31,7 +31,8 @@ jobs:
name: static name: static
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -39,7 +40,7 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v4 - uses: actions/cache@v5
with: with:
path: ~/.composer/cache/files path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
@@ -55,7 +56,8 @@ jobs:
name: unit name: unit
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -63,7 +65,7 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v4 - uses: actions/cache@v5
with: with:
path: ~/.composer/cache/files path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
@@ -92,7 +94,8 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -100,13 +103,13 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v4 - uses: actions/cache@v5
with: with:
path: ~/.composer/cache/files path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}- restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v4 - uses: actions/cache@v5
with: with:
path: ~/.npm path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
@@ -136,7 +139,8 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -144,13 +148,13 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v4 - uses: actions/cache@v5
with: with:
path: ~/.composer/cache/files path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}- restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v4 - uses: actions/cache@v5
with: with:
path: ~/.npm path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
@@ -224,7 +228,7 @@ jobs:
name: container name: container
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Build production image - name: Build production image
run: docker build -t amare-app:ci . run: docker build -t amare-app:ci .

1
.gitignore vendored
View File

@@ -18,6 +18,7 @@
/public/fonts-manifest.dev.json /public/fonts-manifest.dev.json
/public/hot /public/hot
/public/storage /public/storage
/tests/Browser/Screenshots
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/vendor /vendor

View File

@@ -85,7 +85,7 @@ final readonly class PageMeta
} }
/** /**
* Build the metadata for branded error pages (404/500). * Build the metadata for branded error pages.
* *
* Error pages carry no canonical, are excluded from search indexes and use * Error pages carry no canonical, are excluded from search indexes and use
* the page name suffixed with the brand name as their title. * the page name suffixed with the brand name as their title.
@@ -94,9 +94,13 @@ final readonly class PageMeta
SiteSetting $settings, SiteSetting $settings,
int $status = 404, int $status = 404,
): self { ): self {
[$title, $description] = $status === 500 [$title, $description] = match ($status) {
? ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'] 500 => ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'],
: ['Página não encontrada', 'A página que você procura não existe ou foi movida.']; 419 => ['Sessão expirada', 'Sua sessão expirou. Volte e tente enviar novamente.'],
429 => ['Muitas solicitações', 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.'],
503 => ['Em manutenção', 'Estamos realizando uma breve manutenção. Tente novamente em instantes.'],
default => ['Página não encontrada', 'A página que você procura não existe ou foi movida.'],
};
return new self( return new self(
title: trim($title).' - '.$settings->brand_name, title: trim($title).' - '.$settings->brand_name,

View File

@@ -66,6 +66,7 @@
background-color: var(--amare-color-bg); background-color: var(--amare-color-bg);
color: var(--amare-color-text); color: var(--amare-color-text);
font-family: var(--amare-font-serif); font-family: var(--amare-font-serif);
overflow-wrap: break-word;
} }
[id$='-heading'] { [id$='-heading'] {
@@ -175,6 +176,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--amare-color-muted); color: var(--amare-color-muted);
text-decoration: none; text-decoration: none;
overflow-wrap: break-word;
transition: color var(--amare-duration-fast) var(--amare-ease-standard); transition: color var(--amare-duration-fast) var(--amare-ease-standard);
} }

View File

@@ -6,6 +6,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (menuButton && navigation) { if (menuButton && navigation) {
const isDesktop = () => window.matchMedia('(min-width: 768px)').matches; const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
const isMenuOpen = () => navigation.classList.contains('is-open');
const setOpen = (isOpen, { returnFocus = false } = {}) => { const setOpen = (isOpen, { returnFocus = false } = {}) => {
navigation.classList.toggle('is-open', isOpen); navigation.classList.toggle('is-open', isOpen);
@@ -39,6 +40,27 @@ document.addEventListener('DOMContentLoaded', () => {
navigation.addEventListener('keydown', (event) => { navigation.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setOpen(false, { returnFocus: true }); setOpen(false, { returnFocus: true });
return;
}
if (event.key === 'Tab' && !isDesktop() && isMenuOpen()) {
const focusables = navigation.querySelectorAll('a[href], button:not([disabled])');
if (focusables.length === 0) {
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
} }
}); });
@@ -53,12 +75,33 @@ document.addEventListener('DOMContentLoaded', () => {
} }
document.querySelectorAll('form[data-contact-form]').forEach((form) => { document.querySelectorAll('form[data-contact-form]').forEach((form) => {
form.addEventListener('submit', () => { const submitButton = form.querySelector('[data-submit-button]');
const button = form.querySelector('[data-submit-button]');
if (button) { const resetSubmitState = () => {
button.disabled = true; form.removeAttribute('aria-busy');
button.setAttribute('aria-busy', 'true'); form.removeAttribute('aria-disabled');
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = submitButton.dataset.defaultLabel ?? 'Enviar briefing';
submitButton.removeAttribute('aria-busy');
}
};
form.addEventListener('submit', () => {
if (submitButton) {
submitButton.dataset.defaultLabel ??= submitButton.textContent.trim();
submitButton.textContent = 'Enviando…';
submitButton.disabled = true;
submitButton.setAttribute('aria-busy', 'true');
}
form.setAttribute('aria-busy', 'true');
});
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
resetSubmitState();
} }
}); });
}); });

View File

@@ -14,7 +14,7 @@
href="{{ route('contact') }}" href="{{ route('contact') }}"
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep"
> >
{{ $settings->hero_cta_label }} {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -18,7 +18,7 @@
</div> </div>
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text"> <h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
{{ $settings->hero_title }} {{ $settings->hero_title ?: 'Celebrações com propósito' }}
</h1> </h1>
@if (filled($settings->hero_subtitle)) @if (filled($settings->hero_subtitle))
@@ -31,7 +31,7 @@
data-testid="home-primary-cta" data-testid="home-primary-cta"
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
> >
{{ $settings->hero_cta_label }} {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a> </a>
@if (filled($settings->hero_secondary_cta_label)) @if (filled($settings->hero_secondary_cta_label))

View File

@@ -12,6 +12,7 @@
<div class="grid gap-6 md:grid-cols-2"> <div class="grid gap-6 md:grid-cols-2">
@foreach ($testimonials as $testimonial) @foreach ($testimonials as $testimonial)
@continue(blank(trim((string) $testimonial->quote)))
@php @php
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: []; $paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== '')); $paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));

View File

@@ -0,0 +1,28 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 419);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Sessão expirada - Amare Assessoria',
description: 'Sua sessão expirou. Volte e tente enviar novamente.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public')
@section('content')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 419</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Sessão expirada</h1>
<p class="text-amare-muted">Sua sessão expirou. Volte e tente enviar novamente.</p>
<p>
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home
</a>
</p>
</div>
@endsection

View File

@@ -0,0 +1,28 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 429);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Muitas solicitações - Amare Assessoria',
description: 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public')
@section('content')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 429</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Muitas solicitações</h1>
<p class="text-amare-muted">Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.</p>
<p>
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home
</a>
</p>
</div>
@endsection

View File

@@ -0,0 +1,28 @@
@php
try {
$errorSettings = \App\Models\SiteSetting::instance();
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 503);
} catch (\Throwable $e) {
$pageMeta = new \App\Application\Data\PageMeta(
title: 'Em manutenção - Amare Assessoria',
description: 'Estamos realizando uma breve manutenção. Tente novamente em instantes.',
canonical: '',
robots: 'noindex, nofollow',
);
}
@endphp
@extends('layouts.public')
@section('content')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 503</p>
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Em manutenção</h1>
<p class="text-amare-muted">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
<p>
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
Voltar para a home
</a>
</p>
</div>
@endsection

View File

@@ -106,18 +106,18 @@
@endif @endif
@if ($siteSettings->email) @if ($siteSettings->email)
<p> <p>
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->email }}</a> <a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
</p> </p>
@endif @endif
@if ($siteSettings->phone) @if ($siteSettings->phone)
<p> <p>
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->phone }}</a> <a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
</p> </p>
@endif @endif
@foreach ($siteSettings->social_links ?? [] as $network => $url) @foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url)) @if (filled($url))
<p> <p>
<a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a> <a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
</p> </p>
@endif @endif
@endforeach @endforeach

View File

@@ -15,18 +15,18 @@
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p> <p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
@if ($siteSettings->email) @if ($siteSettings->email)
<p> <p>
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a> <a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
</p> </p>
@endif @endif
@if ($siteSettings->phone) @if ($siteSettings->phone)
<p> <p>
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->phone }}</a> <a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
</p> </p>
@endif @endif
@foreach ($siteSettings->social_links ?? [] as $network => $url) @foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url)) @if (filled($url))
<p> <p>
<a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a> <a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
</p> </p>
@endif @endif
@endforeach @endforeach

View File

@@ -5,10 +5,12 @@
<div class="border-b border-amare-border bg-amare-bg"> <div class="border-b border-amare-border bg-amare-bg">
<div class="container-amare space-y-8 py-16 md:py-24"> <div class="container-amare space-y-8 py-16 md:py-24">
<header class="max-w-3xl space-y-4"> <header class="max-w-3xl space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p> @if (filled($case->event_type))
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
@endif
<h1 class="text-headline font-medium tracking-tight text-amare-text">{{ $case->title }}</h1> <h1 class="text-headline font-medium tracking-tight text-amare-text">{{ $case->title }}</h1>
<p class="text-lg text-amare-muted">{{ $case->summary }}</p> <p class="text-lg text-amare-muted">{{ $case->summary }}</p>
<p class="text-sm text-amare-muted"> <p class="text-sm break-words text-amare-muted">
@if ($case->city){{ $case->city }}@endif @if ($case->city){{ $case->city }}@endif
@if ($case->venue) · {{ $case->venue }}@endif @if ($case->venue) · {{ $case->venue }}@endif
@if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif @if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif

View File

@@ -45,3 +45,35 @@
- [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada - [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada
- [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco - [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
- [x] Relatório de entrega - [x] Relatório de entrega
## T7 — Hardening Public Blades
- [x] Chunk 1: CSS overflow resilience (`overflow-wrap` body + `[data-chapter-index]` wrap safety, browser test)
- [x] Chunk 2: Hero + final-CTA fallbacks + `leading-tight` (TDD)
- [x] Chunk 3a: Skip blank-quote testimonials (TDD)
- [x] Chunk 3b: Guard empty `event_type` + `break-words` meta line (TDD)
- [x] Chunk 4: Extend `PageMeta::forErrorPage` + branded 419/429/503 error views (TDD)
- [x] Chunk 5: `app.js` defensive hardening — bfcache submit reset, "Enviando…" label swap, aria-busy, mobile-menu focus trap + Escape
- [x] Chunk 6: Quality gates — Pint passed, PHPStan clean, 104 feature tests passed (587 assertions), Vite build succeeded
### Deviations from plan
- `PageMeta::forErrorPage` landed on `main` during implementation; rebased and extended it for 419/429/503.
- Contact form landed on `main`; submit-state browser coverage was restored.
- `event_type: null` violates NOT NULL column — test uses `''` instead (guard covers both).
# Browser CI Gate — estabilidade e diagnosticabilidade
## BG — Causa raiz
- [x] Diagnosticar 3 runs: mismatch visual (8 testes), acessibilidade (1), timeout networkidle 5s + overflow real
- [x] Reproduzir local: `waitForLoadState('networkidle')` fragil (conexões longas), default 5s
- [x] Reproduzir warning `file_get_contents(.env)` em TODO teste (CI sem .env)
## BG — Fixes
- [x] Helper `StableScreenshot` (tests/Support/): drop networkidle → readyState + fonts + settle
- [x] Exportar PNGs standalone (diff/expected/actual) no mismatch — visíveis nos artifacts
- [x] CI: `cp .env.example .env` nos jobs de teste; checkout/cache v5
- [x] Gitignore tests/Browser/Screenshots
- [x] Unit test MismatchScreenshotExporter; pint/phpstan/unit/feature verdes
- [x] Push + PR + CI browser verde (PR #15 merge f09ea83, run 31098941038)
- [x] Refresh baselines de CI se necessário (não necessário — browser passou com baselines atuais)

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Models\SiteSetting;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
SiteSetting::instance();
});
it('does not overflow horizontally when contact info contains long unbroken strings', function (): void {
SiteSetting::instance()->update([
'email' => str_repeat('a', 50).'@email.'.str_repeat('b', 40),
'phone' => '(11) 99999-9999',
]);
$page = $this->visit('/contato');
$overflow = $page->script(
'() => document.documentElement.scrollWidth - document.documentElement.clientWidth',
);
expect((int) $overflow)->toBeLessThanOrEqual(0);
});
it('shows sending state on submit and resets on bfcache restore', function (): void {
$page = $this->visit('/contato');
$state = $page->script(<<<'JS'
() => {
const form = document.querySelector("form[data-contact-form]");
const button = document.querySelector("[data-submit-button]");
form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
const afterSubmit = {
label: button?.textContent?.trim(),
disabled: button?.disabled ?? false,
formBusy: form?.getAttribute("aria-busy"),
};
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
return {
afterSubmit,
afterRestore: {
label: button?.textContent?.trim(),
disabled: button?.disabled ?? false,
formBusy: form?.getAttribute("aria-busy"),
},
};
}
JS);
expect($state['afterSubmit']['label'])->toBe('Enviando…');
expect($state['afterSubmit']['disabled'])->toBeTrue();
expect($state['afterSubmit']['formBusy'])->toBe('true');
expect($state['afterRestore']['label'])->toBe('Enviar briefing');
expect($state['afterRestore']['disabled'])->toBeFalse();
expect($state['afterRestore']['formBusy'])->toBeNull();
});
it('traps focus within the open mobile menu', function (): void {
$page = $this->visit('/');
$page->resize(390, 844);
$page->click('[data-menu-button]');
for ($i = 0; $i < 20; $i++) {
$inside = $page->script(
'() => document.querySelector("[data-main-nav]")?.contains(document.activeElement) ?? false',
);
expect($inside)->toBeTrue();
$page->keys(':focus', 'Tab');
}
$page->script('() => document.querySelector("[data-main-nav] a")?.focus()');
$page->keys(':focus', 'Shift+Tab');
$wrappedToLast = $page->script('() => {
const nav = document.querySelector("[data-main-nav]");
const links = nav?.querySelectorAll("a[href], button:not([disabled])") ?? [];
return links.length > 0 && document.activeElement === links[links.length - 1];
}');
expect($wrappedToLast)->toBeTrue();
});

View File

@@ -41,41 +41,14 @@ $viewports = [
foreach ($screens as $screen => $path) { foreach ($screens as $screen => $path) {
foreach ($viewports as $viewport => [$width, $height]) { foreach ($viewports as $viewport => [$width, $height]) {
it("matches {$screen} {$viewport} visual baseline", function () use ($path, $width, $height): void { it("matches {$screen} {$viewport} visual baseline", function () use ($path, $width, $height): void {
$webpage = $this->visit($path, [ $this->assertStableScreenshotMatches(
'reducedMotion' => 'reduce', $this->visit($path, [
]) 'reducedMotion' => 'reduce',
->withLocale('pt-BR') ])
->withTimezone('America/Fortaleza') ->withLocale('pt-BR')
->resize($width, $height); ->withTimezone('America/Fortaleza')
->resize($width, $height)
// Force full paint: scroll through page, decode every image, wait fonts. );
$webpage->script(<<<'JS'
(async () => {
const step = Math.max(window.innerHeight, 400);
const max = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
for (let y = 0; y <= max; y += step) {
window.scrollTo(0, y);
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
}
window.scrollTo(0, 0);
await Promise.all([...document.images].map((i) => {
if (i.complete) {
return i.decode ? i.decode().catch(() => {}) : Promise.resolve();
}
return new Promise((resolve) => {
i.addEventListener('load', () => {
(i.decode ? i.decode() : Promise.resolve()).then(resolve).catch(resolve);
}, { once: true });
i.addEventListener('error', resolve, { once: true });
});
}));
await document.fonts.ready;
await new Promise((r) => setTimeout(r, 150));
})()
JS);
$webpage->wait(0.2);
$webpage->assertScreenshotMatches();
}); });
} }
} }

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
class ErrorPagesTest extends TestCase
{
use RefreshDatabase;
public function test_419_renders_branded_session_expired_page(): void
{
Route::get('/__test-419', fn (): never => abort(419));
$this->get('/__test-419')
->assertStatus(419)
->assertSee('Erro 419')
->assertSee('Sessão expirada')
->assertSee('Voltar para a home');
}
public function test_429_renders_branded_rate_limited_page(): void
{
Route::get('/__test-429', fn (): never => abort(429));
$this->get('/__test-429')
->assertStatus(429)
->assertSee('Erro 429')
->assertSee('Muitas solicitações')
->assertSee('Voltar para a home');
}
public function test_503_renders_branded_maintenance_page(): void
{
Route::get('/__test-503', fn (): never => abort(503));
$this->get('/__test-503')
->assertStatus(503)
->assertSee('Erro 503')
->assertSee('Em manutenção')
->assertSee('Voltar para a home');
}
public function test_error_pages_never_leak_debug_stack_traces(): void
{
config(['app.debug' => false]);
Route::get('/__test-503', fn (): never => abort(503));
$this->get('/__test-503')
->assertStatus(503)
->assertDontSee('segredo-interno-amare')
->assertDontSee('Exception');
}
}

View File

@@ -110,4 +110,42 @@ class HomePageContentTest extends TestCase
->assertSee('id="positioning-heading"', false) ->assertSee('id="positioning-heading"', false)
->assertSee('id="final-cta-heading"', false); ->assertSee('id="final-cta-heading"', false);
} }
public function test_hero_falls_back_to_default_copy_when_fields_are_empty(): void
{
SiteSetting::instance()->update([
'hero_title' => '',
'hero_cta_label' => '',
'hero_secondary_cta_label' => '',
'hero_subtitle' => '',
'hero_note' => '',
]);
$response = $this->get(route('home'));
$response
->assertOk()
->assertSeeInOrder(['id="hero-heading"', 'Celebrações com propósito'])
->assertSeeInOrder(['data-testid="home-primary-cta"', 'Solicitar proposta']);
}
public function test_blank_quote_testimonials_are_skipped(): void
{
Testimonial::factory()->published()->create([
'author_name' => 'Autor Mantido',
'quote' => 'Experiência impecável do início ao fim.',
]);
Testimonial::factory()->published()->create([
'author_name' => 'Autor Oculto',
'quote' => ' ',
]);
$response = $this->get(route('home'));
$response
->assertOk()
->assertSee('Autor Mantido')
->assertSee('Experiência impecável do início ao fim.')
->assertDontSee('Autor Oculto');
}
} }

View File

@@ -196,4 +196,26 @@ class PublicPagesTest extends TestCase
$this->assertLessThan(15, $queryCount); $this->assertLessThan(15, $queryCount);
} }
public function test_case_without_event_type_does_not_render_empty_eyebrow(): void
{
SiteSetting::instance();
$case = PortfolioCase::factory()->published()->create([
'slug' => 'caso-sem-tipo',
'title' => 'Caso Sem Tipo',
'event_type' => '',
]);
$response = $this->get(route('portfolio.show', $case->slug));
$response->assertOk()->assertSee('Caso Sem Tipo');
$emptyAccentParagraphs = preg_match_all(
'/class="[^"]*text-amare-accent[^"]*"><\/p>/',
(string) $response->getContent(),
);
expect($emptyAccentParagraphs)->toBe(0);
}
} }

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
final class MismatchScreenshotExporter
{
/**
* Extracts the diff, expected, and actual images embedded in an ImageDiffView
* HTML file and writes them as standalone PNG files so they can be inspected
* directly in CI artifacts.
*
* @return list<string> paths of the written PNG files
*/
public static function export(string $htmlPath, string $targetDir): array
{
$html = (string) file_get_contents($htmlPath);
if (preg_match_all('/src="data:image\/png;base64,([^"]+)"/', $html, $matches) === false || ! isset($matches[1][2])) {
return [];
}
$base = pathinfo($htmlPath, PATHINFO_FILENAME);
$labels = ['diff', 'expected', 'actual'];
$written = [];
foreach (array_slice($matches[1], 0, 3) as $index => $base64) {
$path = $targetDir.'/'.$base.'-'.($labels[$index] ?? 'image-'.$index).'.png';
if (file_put_contents($path, (string) base64_decode($base64, true)) === false) {
continue;
}
$written[] = $path;
}
return $written;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace Tests\Support;
use Pest\Browser\Api\AwaitableWebpage;
use Pest\Browser\Exceptions\BrowserExpectationFailedException;
use Pest\Browser\Support\Screenshot;
use Pest\TestSuite;
use PHPUnit\Framework\ExpectationFailedException;
trait StableScreenshot
{
/**
* Asserts the page matches its visual baseline without relying on the
* networkidle load state, which is inherently flaky (long-lived connections
* like fonts, polling, or keep-alive can keep it from ever firing and cause
* spurious timeouts at the 5s client default).
*
* On mismatch the vendor only writes an HTML diff view with base64-embedded
* images, so this helper additionally exports standalone diff/expected/actual
* PNG files that are directly viewable in CI artifacts.
*/
public function assertStableScreenshotMatches(AwaitableWebpage $awaitable): void
{
$page = $awaitable->page();
$page->addStyleTag('* {
transition: none !important;
animation: none !important;
font-family: Arial, sans-serif !important;
body {
-webkit-font-smoothing: antialiased !important;
-moz-osx-font-smoothing: grayscale !important;
}
}');
$page->waitForFunction(
'document.readyState === "complete"'
.' && document.fonts.status === "loaded"'
);
usleep(300_000);
try {
$page->expectScreenshot(true, false);
} catch (ExpectationFailedException $exception) {
$this->exportMismatchScreenshots();
throw BrowserExpectationFailedException::from($page, $exception);
}
}
private function exportMismatchScreenshots(): void
{
[$snapshotName] = TestSuite::getInstance()->snapshots->get();
$base = pathinfo($snapshotName, PATHINFO_FILENAME);
$htmlPath = Screenshot::dir().'/ImageDiffView/'.$base.'.html';
if (! is_file($htmlPath)) {
return;
}
MismatchScreenshotExporter::export($htmlPath, Screenshot::dir());
}
}

View File

@@ -3,8 +3,9 @@
namespace Tests; namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Tests\Support\StableScreenshot;
abstract class TestCase extends BaseTestCase abstract class TestCase extends BaseTestCase
{ {
// use StableScreenshot;
} }

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
use Tests\Support\MismatchScreenshotExporter;
it('exports diff, expected, and actual PNGs from an ImageDiffView html', function (): void {
$targetDir = sys_get_temp_dir().'/mismatch-exporter-'.uniqid();
mkdir($targetDir, 0755, true);
$expected = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
$actual = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
$diff = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgAAACAAFWoS02AAAAAElFTkSuQmCC';
$html = <<<HTML
<!DOCTYPE html>
<html><body>
<img alt="Diff" src="data:image/png;base64,{$diff}"/>
<img slot="image-1" alt="Expected" src="data:image/png;base64,{$expected}"/>
<img slot="image-2" alt="Actual" src="data:image/png;base64,{$actual}"/>
</body></html>
HTML;
$htmlPath = $targetDir.'/it_matches_home_desktop.html';
file_put_contents($htmlPath, $html);
$written = MismatchScreenshotExporter::export($htmlPath, $targetDir);
expect($written)->toHaveCount(3);
expect($written)->toContain($targetDir.'/it_matches_home_desktop-diff.png');
expect($written)->toContain($targetDir.'/it_matches_home_desktop-expected.png');
expect($written)->toContain($targetDir.'/it_matches_home_desktop-actual.png');
expect(base64_decode($diff, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-diff.png'));
expect(base64_decode($expected, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-expected.png'));
expect(base64_decode($actual, true))->toBe((string) file_get_contents($targetDir.'/it_matches_home_desktop-actual.png'));
});
it('returns an empty list when the html has no embedded images', function (): void {
$targetDir = sys_get_temp_dir().'/mismatch-exporter-'.uniqid();
mkdir($targetDir, 0755, true);
$htmlPath = $targetDir.'/plain.html';
file_put_contents($htmlPath, '<html><body>no images</body></html>');
expect(MismatchScreenshotExporter::export($htmlPath, $targetDir))->toBe([]);
});