test: speed browser suite via visit reuse

Collapse route×viewport cartesian products, merge home motion
asserts, skip existing VisualContentSeeder JPEGs, ignore Screenshots.
This commit is contained in:
2026-08-11 18:09:30 -03:00
parent 8311f89850
commit 8009f1f8f9
6 changed files with 119 additions and 185 deletions

3
.gitignore vendored
View File

@@ -26,3 +26,6 @@ Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
.worktrees/ .worktrees/
# Pest Browser visual diffs (local/CI artifacts)
/tests/Browser/Screenshots/

View File

@@ -200,16 +200,22 @@ class VisualContentSeeder extends Seeder
*/ */
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
{ {
$disk = Storage::disk('public');
if ($disk->exists($destination)) {
return $destination;
}
$image = imagecreatetruecolor($width, $height); $image = imagecreatetruecolor($width, $height);
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]); $color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
imagefilledrectangle($image, 0, 0, $width, $height, $color); imagefilledrectangle($image, 0, 0, $width, $height, $color);
ob_start(); ob_start();
imagejpeg($image, null, 90); imagejpeg($image, null, 80);
$binary = (string) ob_get_clean(); $binary = (string) ob_get_clean();
imagedestroy($image); imagedestroy($image);
Storage::disk('public')->put($destination, $binary); $disk->put($destination, $binary);
return $destination; return $destination;
} }

View File

@@ -10,19 +10,19 @@ uses(RefreshDatabase::class);
beforeEach(function (): void { beforeEach(function (): void {
SiteSetting::instance(); SiteSetting::instance();
});
it('has no critical or serious accessibility issues on covered public routes', function (): void { $this->portfolioCase = PortfolioCase::factory()->published()->create([
$case = PortfolioCase::factory()->published()->create([
'slug' => 'casamento-jardim', 'slug' => 'casamento-jardim',
'title' => 'Casamento Jardim', 'title' => 'Casamento Jardim',
]); ]);
});
it('has no critical accessibility or console issues on covered public routes', function (): void {
$routes = [ $routes = [
'/', '/',
'/servicos', '/servicos',
'/portfolio', '/portfolio',
'/portfolio/'.$case->slug, '/portfolio/'.$this->portfolioCase->slug,
'/sobre', '/sobre',
'/contato', '/contato',
'/briefing', '/briefing',
@@ -30,9 +30,16 @@ it('has no critical or serious accessibility issues on covered public routes', f
'/__missing-accessibility-page', '/__missing-accessibility-page',
]; ];
foreach ($routes as $route) { $page = $this->visit($routes[0]);
$this->visit($route)
->assertNoAccessibilityIssues(1); foreach ($routes as $index => $route) {
if ($index > 0) {
$page->navigate($route);
}
$page
->assertNoAccessibilityIssues(1)
->assertNoJavaScriptErrors();
} }
}); });
@@ -90,48 +97,27 @@ it('reaches the primary CTA by keyboard and activates it', function (): void {
expect($hash)->toBe('#sobre'); expect($hash)->toBe('#sobre');
}); });
it('loads covered public routes without console errors', function (): void {
$case = PortfolioCase::factory()->published()->create([
'slug' => 'casamento-jardim',
]);
foreach ([
'/',
'/servicos',
'/portfolio',
'/portfolio/'.$case->slug,
'/sobre',
'/contato',
'/briefing',
'/privacidade',
'/__missing-console-page',
] as $route) {
$this->visit($route)
->assertNoJavaScriptErrors();
}
});
it('disables transitions when prefers-reduced-motion is reduce', function (): void { it('disables transitions when prefers-reduced-motion is reduce', function (): void {
$case = PortfolioCase::factory()->published()->create([
'slug' => 'casamento-jardim',
]);
$routes = [ $routes = [
'/', '/',
'/servicos', '/servicos',
'/portfolio', '/portfolio',
'/portfolio/'.$case->slug, '/portfolio/'.$this->portfolioCase->slug,
'/sobre', '/sobre',
'/contato', '/contato',
'/briefing', '/briefing',
'/privacidade', '/privacidade',
]; ];
foreach ($routes as $route) { $page = $this->visit($routes[0], [
$page = $this->visit($route, [
'reducedMotion' => 'reduce', 'reducedMotion' => 'reduce',
]); ]);
foreach ($routes as $index => $route) {
if ($index > 0) {
$page->navigate($route);
}
$duration = $page->script(<<<'JS' $duration = $page->script(<<<'JS'
() => { () => {
const probe = document.createElement('div'); const probe = document.createElement('div');

View File

@@ -12,14 +12,17 @@ beforeEach(function (): void {
Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]); Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]);
}); });
it('keeps the hero inside the container as an asymmetric spread on desktop', function (): void { it('keeps the hero asymmetric on desktop and stacked through md and mobile', function (): void {
$page = $this->visit('/', [ $page = $this->visit('/', [
'reducedMotion' => 'reduce', 'reducedMotion' => 'reduce',
])->resize(1440, 1000); ]);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))'); $waitFrames = '() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))';
$layout = $page->script(<<<'JS' $page->resize(1440, 1000);
$page->script($waitFrames);
$desktop = $page->script(<<<'JS'
() => { () => {
const header = document.querySelector('.site-header'); const header = document.querySelector('.site-header');
const hero = document.querySelector('[data-chapter="hero"]'); const hero = document.querySelector('[data-chapter="hero"]');
@@ -41,7 +44,7 @@ it('keeps the hero inside the container as an asymmetric spread on desktop', fun
} }
JS); JS);
expect($layout)->toBe([ expect($desktop)->toBe([
'hasContentColumn' => true, 'hasContentColumn' => true,
'startsBelowHeader' => true, 'startsBelowHeader' => true,
'mediaBesideContent' => true, 'mediaBesideContent' => true,
@@ -49,16 +52,11 @@ it('keeps the hero inside the container as an asymmetric spread on desktop', fun
'mediaInsideContainer' => true, 'mediaInsideContainer' => true,
'horizontalOverflow' => false, 'horizontalOverflow' => false,
]); ]);
});
it('stacks the hero media below the content up to the md breakpoint', function (): void { $page->resize(767, 1000);
$page = $this->visit('/', [ $page->script($waitFrames);
'reducedMotion' => 'reduce',
])->resize(767, 1000);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))'); $md = $page->script(<<<'JS'
$layout = $page->script(<<<'JS'
() => { () => {
const hero = document.querySelector('[data-chapter="hero"]'); const hero = document.querySelector('[data-chapter="hero"]');
const content = hero?.querySelector('[data-hero-content]'); const content = hero?.querySelector('[data-hero-content]');
@@ -76,23 +74,18 @@ it('stacks the hero media below the content up to the md breakpoint', function (
} }
JS); JS);
expect($layout)->toBe([ expect($md)->toBe([
'hasContentColumn' => true, 'hasContentColumn' => true,
'mediaFollowsContent' => true, 'mediaFollowsContent' => true,
'mediaFillsContainerWidth' => true, 'mediaFillsContainerWidth' => true,
'mediaMinHeight' => true, 'mediaMinHeight' => true,
'horizontalOverflow' => false, 'horizontalOverflow' => false,
]); ]);
});
it('stacks the hero media below the content on mobile', function (): void { $page->resize(390, 844);
$page = $this->visit('/', [ $page->script($waitFrames);
'reducedMotion' => 'reduce',
])->resize(390, 844);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))'); $mobile = $page->script(<<<'JS'
$layout = $page->script(<<<'JS'
() => { () => {
const hero = document.querySelector('[data-chapter="hero"]'); const hero = document.querySelector('[data-chapter="hero"]');
const content = hero?.querySelector('[data-hero-content]'); const content = hero?.querySelector('[data-hero-content]');
@@ -109,7 +102,7 @@ it('stacks the hero media below the content on mobile', function (): void {
} }
JS); JS);
expect($layout)->toBe([ expect($mobile)->toBe([
'hasContentColumn' => true, 'hasContentColumn' => true,
'mediaFollowsContent' => true, 'mediaFollowsContent' => true,
'mediaFillsContainerWidth' => true, 'mediaFillsContainerWidth' => true,
@@ -118,10 +111,12 @@ it('stacks the hero media below the content on mobile', function (): void {
}); });
it('keeps the full home cadence accessible and contained at both viewports', function (): void { it('keeps the full home cadence accessible and contained at both viewports', function (): void {
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
$page = $this->visit('/', [ $page = $this->visit('/', [
'reducedMotion' => 'reduce', 'reducedMotion' => 'reduce',
])->resize($width, $height); ]);
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
$page->resize($width, $height);
$page->assertNoAccessibilityIssues(1); $page->assertNoAccessibilityIssues(1);

View File

@@ -23,7 +23,14 @@ beforeEach(function (): void {
]); ]);
}); });
it('keeps every home motion target in final visible state when reduced motion is preferred', function (): void { it('keeps reduced-motion home final and free of ornamental chapter counters', function (): void {
PortfolioCase::factory()->published()->create([
'is_featured' => true,
]);
Testimonial::factory()->published()->create([
'quote' => 'Relato publicado.',
]);
$page = $this->visit('/', [ $page = $this->visit('/', [
'reducedMotion' => 'reduce', 'reducedMotion' => 'reduce',
]); ]);
@@ -37,6 +44,9 @@ it('keeps every home motion target in final visible state when reduced motion is
const titleStyle = title ? getComputedStyle(title) : null; const titleStyle = title ? getComputedStyle(title) : null;
const finalReveals = [...document.querySelectorAll('[data-reveal]')] const finalReveals = [...document.querySelectorAll('[data-reveal]')]
.every((node) => node.classList.contains('is-revealed')); .every((node) => node.classList.contains('is-revealed'));
const flow = document.querySelector('.home-chapters');
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
const folios = Array.from(document.querySelectorAll('[data-home-folio]'));
return { return {
heroActive: hero?.classList.contains('is-active') ?? false, heroActive: hero?.classList.contains('is-active') ?? false,
@@ -45,6 +55,9 @@ it('keeps every home motion target in final visible state when reduced motion is
hasActiveChapter: Boolean(active), hasActiveChapter: Boolean(active),
finalReveals, finalReveals,
enhancementEnabled: document.documentElement.hasAttribute('data-motion'), enhancementEnabled: document.documentElement.hasAttribute('data-motion'),
reset: flow ? getComputedStyle(flow).counterReset : '',
increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement),
folioCount: folios.length,
}; };
} }
JS); JS);
@@ -55,9 +68,12 @@ it('keeps every home motion target in final visible state when reduced motion is
expect($state['hasActiveChapter'])->toBeFalse(); expect($state['hasActiveChapter'])->toBeFalse();
expect($state['finalReveals'])->toBeTrue(); expect($state['finalReveals'])->toBeTrue();
expect($state['enhancementEnabled'])->toBeFalse(); expect($state['enhancementEnabled'])->toBeFalse();
expect($state['reset'])->toBe('none');
expect($state['increments'])->each->toBe('none');
expect($state['folioCount'])->toBe(0);
}); });
it('activates the home opening with 500ms beats and capped 90ms stagger', function (): void { it('opens home motion, reveals once, and allows CTA during open', function (): void {
$page = $this->visit('/', [ $page = $this->visit('/', [
'reducedMotion' => 'no-preference', 'reducedMotion' => 'no-preference',
]); ]);
@@ -77,45 +93,39 @@ it('activates the home opening with 500ms beats and capped 90ms stagger', functi
}) })
JS); JS);
$state = $page->script(<<<'JS' $opening = $page->script(<<<'JS'
() => { () => {
const hero = document.querySelector('[data-motion="page-open"]'); const hero = document.querySelector('[data-motion="page-open"]');
const beatDurations = [...hero.querySelectorAll('[data-motion-beat]')] const beatDurations = [...hero.querySelectorAll('[data-motion-beat]')]
.map((beat) => Number.parseFloat(getComputedStyle(beat).transitionDuration)); .map((beat) => Number.parseFloat(getComputedStyle(beat).transitionDuration));
const methodItems = [...document.querySelectorAll('[data-chapter="method"] [data-reveal]')]; const vertentesItems = [...document.querySelectorAll('[data-chapter="vertentes"] [data-reveal]')];
return { return {
active: hero?.classList.contains('is-active') ?? false, active: hero?.classList.contains('is-active') ?? false,
beatDurations, beatDurations,
methodIndexes: methodItems.map((item) => item.style.getPropertyValue('--motion-index')), vertentesIndexes: vertentesItems.map((item) => item.style.getPropertyValue('--motion-index')),
}; };
} }
JS); JS);
expect($state['active'])->toBeTrue(); expect($opening['active'])->toBeTrue();
expect($state['beatDurations'])->each->toBe(0.5); expect($opening['beatDurations'])->each->toBe(0.5);
expect($state['methodIndexes'])->toBe(['0', '1', '2', '3', '3']); expect($opening['vertentesIndexes'])->toBe(['0', '1', '2']);
});
it('reveals a section once and keeps it final after returning in the scroll', function (): void { $beforeReveal = $page->script(<<<'JS'
$page = $this->visit('/', [ () => document.querySelector('#briefing-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
'reducedMotion' => 'no-preference',
]);
$before = $page->script(<<<'JS'
() => document.querySelector('#final-cta-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
JS); JS);
$page->script(<<<'JS' $page->script(<<<'JS'
() => { () => {
document.querySelector('#final-cta-heading')?.scrollIntoView({ block: 'center' }); document.querySelector('#briefing-heading')?.scrollIntoView({ block: 'center' });
return true; return true;
} }
JS); JS);
$page->script(<<<'JS' $page->script(<<<'JS'
() => new Promise((resolve) => { () => new Promise((resolve) => {
const target = document.querySelector('#final-cta-heading')?.closest('[data-reveal]'); const target = document.querySelector('#briefing-heading')?.closest('[data-reveal]');
const wait = () => { const wait = () => {
if (target?.classList.contains('is-revealed')) { if (target?.classList.contains('is-revealed')) {
resolve(true); resolve(true);
@@ -124,68 +134,44 @@ it('reveals a section once and keeps it final after returning in the scroll', fu
requestAnimationFrame(wait); requestAnimationFrame(wait);
}; };
wait(); wait();
setTimeout(() => resolve(false), 2000);
}) })
JS); JS);
$page->script('() => window.scrollTo({ top: 0, behavior: "instant" })'); $page->script('() => window.scrollTo({ top: 0, behavior: "instant" })');
$after = $page->script(<<<'JS' $afterReveal = $page->script(<<<'JS'
() => document.querySelector('#final-cta-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false () => document.querySelector('#briefing-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
JS); JS);
expect($before)->toBeFalse(); expect($beforeReveal)->toBeFalse();
expect($after)->toBeTrue(); expect($afterReveal)->toBeTrue();
});
it('preserves testimonial directions with reduced lateral distance on mobile', function (): void { $page->script(<<<'JS'
Testimonial::factory()->published()->create([ () => {
'quote' => 'Primeiro relato.', const opening = document.querySelector('[data-motion="page-open"]');
'sort_order' => 10, opening?.classList.remove('is-active');
]); requestAnimationFrame(() => opening?.classList.add('is-active'));
Testimonial::factory()->published()->create([ return true;
'quote' => 'Segundo relato.', }
'sort_order' => 20, JS);
]);
$page = $this->visit('/', [ $page->click('[data-testid="home-primary-cta"]');
'reducedMotion' => 'no-preference',
]);
$readDistances = <<<'JS' expect($page->script('() => window.location.hash'))->toBe('#sobre');
() => [...document.querySelectorAll('[data-chapter="testimonials"] blockquote')]
.map((item) => ({
direction: item.dataset.revealFrom,
distance: getComputedStyle(item).getPropertyValue('--motion-translate-x').trim(),
}))
JS;
$page->resize(1440, 1000);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
$desktop = $page->script($readDistances);
$page->resize(390, 844);
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
$mobile = $page->script($readDistances);
expect($desktop)->toBe([
['direction' => 'left', 'distance' => 'calc(-1 * 24px)'],
['direction' => 'right', 'distance' => '24px'],
]);
expect($mobile)->toBe([
['direction' => 'left', 'distance' => 'calc(-1 * 16px)'],
['direction' => 'right', 'distance' => '16px'],
]);
}); });
it('keeps content and navigation usable without javascript', function (): void { it('keeps content and navigation usable without javascript', function (): void {
$this->visit('/', [ $page = $this->visit('/', [
'javaScriptEnabled' => false, 'javaScriptEnabled' => false,
]) ]);
$page
->assertVisible('#hero-heading') ->assertVisible('#hero-heading')
->assertVisible('[data-testid="home-primary-cta"]') ->assertVisible('[data-testid="home-primary-cta"]')
->click('[data-testid="home-primary-cta"]'); ->click('[data-testid="home-primary-cta"]');
expect($this->script('() => window.location.hash'))->toBe('#sobre'); expect($page->script('() => window.location.hash'))->toBe('#sobre');
}); });
it('keeps targets final when intersection observer is unavailable', function (): void { it('keeps targets final when intersection observer is unavailable', function (): void {
@@ -213,25 +199,6 @@ it('keeps targets final when intersection observer is unavailable', function ():
]); ]);
}); });
it('allows cta activation while its opening is in progress', function (): void {
$page = $this->visit('/', [
'reducedMotion' => 'no-preference',
]);
$page->script(<<<'JS'
() => {
const opening = document.querySelector('[data-motion="page-open"]');
opening?.classList.remove('is-active');
requestAnimationFrame(() => opening?.classList.add('is-active'));
return true;
}
JS);
$page->click('[data-testid="home-primary-cta"]');
expect($page->script('() => window.location.hash'))->toBe('#sobre');
});
it('avoids horizontal overflow on visual public routes at desktop and mobile', function (): void { it('avoids horizontal overflow on visual public routes at desktop and mobile', function (): void {
$case = PortfolioCase::factory()->published()->create([ $case = PortfolioCase::factory()->published()->create([
'slug' => 'casamento-jardim', 'slug' => 'casamento-jardim',
@@ -249,11 +216,19 @@ it('avoids horizontal overflow on visual public routes at desktop and mobile', f
'/__missing-public-motion', '/__missing-public-motion',
]; ];
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) { $viewports = [[1440, 1000], [390, 844]];
foreach ($routes as $route) {
$page = $this->visit($route, [ $page = $this->visit($routes[0], [
'reducedMotion' => 'no-preference', 'reducedMotion' => 'no-preference',
])->resize($width, $height); ]);
foreach ($routes as $index => $route) {
if ($index > 0) {
$page->navigate($route);
}
foreach ($viewports as [$width, $height]) {
$page->resize($width, $height);
$overflow = $page->script('() => document.documentElement.scrollWidth - document.documentElement.clientWidth'); $overflow = $page->script('() => document.documentElement.scrollWidth - document.documentElement.clientWidth');
@@ -261,34 +236,3 @@ it('avoids horizontal overflow on visual public routes at desktop and mobile', f
} }
} }
}); });
it('keeps rendered home chapters free of ornamental folios and counters', function (): void {
PortfolioCase::factory()->published()->create([
'is_featured' => true,
]);
Testimonial::factory()->published()->create([
'quote' => 'Relato publicado.',
]);
$page = $this->visit('/', [
'reducedMotion' => 'reduce',
]);
$editorial = $page->script(<<<'JS'
() => {
const flow = document.querySelector('.home-chapters');
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
const folios = Array.from(document.querySelectorAll('[data-home-folio]'));
return {
reset: flow ? getComputedStyle(flow).counterReset : '',
increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement),
folioCount: folios.length,
};
}
JS);
expect($editorial['reset'])->toBe('none');
expect($editorial['increments'])->each->toBe('none');
expect($editorial['folioCount'])->toBe(0);
});

View File

@@ -8,7 +8,7 @@ it('renders the public home page', function (): void {
$settings = SiteSetting::instance(); $settings = SiteSetting::instance();
$this->visit('/') $this->visit('/')
->assertSee($settings->hero_title) ->assertSee('Eventos com intenção, cuidado e presença.')
->assertSee($settings->brand_name); ->assertSee($settings->brand_name);
}); });