Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/Pages/CommonPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,9 @@ private function applyVisualMasks(array $masks): void
$this->getPage()->evaluate(
"(function(){var s=document.createElement('style');s.id='pf-visual-masks';s.textContent={$css};document.head.appendChild(s);})()"
)->getReturnValue();
// En plus du <style> (qui couvre les éléments insérés ensuite) : inline
// !important, seul à battre une règle !important en @layer.
$this->applyVisualInline('masks', $masks, 'visibility', 'hidden', true);
}

private function removeVisualMasks(array $masks): void
Expand All @@ -517,6 +520,45 @@ private function removeVisualMasks(array $masks): void
} catch (\Throwable $e) {
// best-effort : ne doit jamais masquer l'exception d'origine de la capture
}
$this->restoreVisualInline('masks');
}

/**
* Style inline `$prop: $value !important` sur les éléments de $selectors
* (+ descendants si $deep), valeurs d'origine mémorisées côté page. Le
* <style> injecté ne suffit pas : une règle !important en @layer
* (utilitaires Bootstrap de Hummingbird) bat une règle !important hors
* couche ; l'inline !important gagne toujours. Best-effort.
*/
private function applyVisualInline(string $kind, array $selectors, string $prop, string $value, bool $deep): void
{
try {
$this->getPage()->evaluate(sprintf(
'(%s)(%s, %s, %s, %s, %s)',
\PrestaFlow\Library\Visual\PageScripts::APPLY_INLINE,
json_encode($kind, JSON_THROW_ON_ERROR),
json_encode(array_values($selectors), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
json_encode($prop, JSON_THROW_ON_ERROR),
json_encode($value, JSON_THROW_ON_ERROR),
json_encode($deep, JSON_THROW_ON_ERROR)
))->getReturnValue();
} catch (\Throwable $e) {
// best-effort : le <style> injecté reste en place
}
}

/** Restaure les styles inline posés par applyVisualInline() pour $kind (best-effort). */
private function restoreVisualInline(string $kind): void
{
try {
$this->getPage()->evaluate(sprintf(
'(%s)(%s)',
\PrestaFlow\Library\Visual\PageScripts::RESTORE_INLINE,
json_encode($kind, JSON_THROW_ON_ERROR)
))->getReturnValue();
} catch (\Throwable $e) {
// best-effort : ne doit jamais masquer l'exception d'origine de la capture
}
}

/** Remet le scroll en haut de page (best-effort). */
Expand Down Expand Up @@ -643,6 +685,10 @@ private function getScrollOffset(): array
*
* $hide : sélecteurs passés en `display: none` le temps de la capture
* (popups, fonds de modale : libère la place au lieu de la griser).
* $hide et $masks sont posés via un <style> ET en style inline !important
* sur les éléments trouvés (une règle !important en @layer, ex. utilitaires
* Bootstrap de Hummingbird, bat le <style> hors couche) ; tout est restauré
* après la capture, y compris en cas d'échec.
* $freezeTransitions : coupe les transitions/animations CSS avant le
* réglage des animations (menu animé du BO). Styles retirés après la capture.
*/
Expand Down Expand Up @@ -688,6 +734,11 @@ public function visualCheckpoint(string $name, ?string $selector = null, ?float
try {
// Une règle par sélecteur, comme les masques ; `display: none` libère la place.
$this->injectVisualStyle('pf-visual-hide', implode("\n", array_map(fn ($h) => ":is({$h}) { display: none !important; }", $hide)));
if ($hide !== []) {
// Inline !important en plus : le <style> perd face à un utilitaire
// !important en @layer (`.d-md-block` de Hummingbird).
$this->applyVisualInline('hide', $hide, 'display', 'none', false);
}
if ($selector !== null) {
$node = $page->dom()->querySelector($selector);
if ($node === null) {
Expand Down Expand Up @@ -729,6 +780,7 @@ public function visualCheckpoint(string $name, ?string $selector = null, ?float
} finally {
$this->removeVisualMasks($masks);
if ($hide !== []) {
$this->restoreVisualInline('hide');
$this->removeVisualStyle('pf-visual-hide');
}
if ($freezeTransitions) {
Expand Down
51 changes: 51 additions & 0 deletions src/Visual/PageScripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -223,5 +223,56 @@ classes: Array.prototype.slice.call(k.el.classList, 0, 8), box: k.box,
};
}));
})
JS;

/**
* Applique `PROP: VALUE !important` en style inline sur les éléments des
* sélecteurs (et leurs descendants si DEEP). Nécessaire en plus du <style>
* injecté : pour les déclarations !important, une règle en @layer
* (utilitaires Bootstrap de Hummingbird, ex. `.d-md-block`) l'emporte sur
* une règle hors couche ; le style inline !important, lui, gagne toujours.
* Valeur et priorité d'origine mémorisées dans window.__pfVisualInline[KIND]
* pour RESTORE_INLINE. Un sélecteur invalide n'empêche pas les autres.
* Appel : sprintf('(%s)(%s, %s, %s, %s, %s)', APPLY_INLINE, kind, sélecteurs, prop, valeur, deep) en JSON.
*/
public const APPLY_INLINE = <<<'JS'
function (KIND, SELECTORS, PROP, VALUE, DEEP) {
var reg = window.__pfVisualInline = window.__pfVisualInline || {};
var saved = reg[KIND] = reg[KIND] || [];
var apply = function (el) {
saved.push([el, PROP, el.style.getPropertyValue(PROP), el.style.getPropertyPriority(PROP)]);
el.style.setProperty(PROP, VALUE, 'important');
};
SELECTORS.forEach(function (sel) {
try {
document.querySelectorAll(sel).forEach(function (el) {
apply(el);
if (DEEP) { el.querySelectorAll('*').forEach(apply); }
});
} catch (e) {}
});
return saved.length;
}
JS;

/**
* Restaure exactement (valeur + priorité, ou retrait) les styles inline
* posés par APPLY_INLINE pour KIND, dans l'ordre inverse d'application.
* Appel : sprintf('(%s)(%s)', RESTORE_INLINE, kind) en JSON.
*/
public const RESTORE_INLINE = <<<'JS'
function (KIND) {
var reg = window.__pfVisualInline;
if (!reg || !reg[KIND]) { return 0; }
var saved = reg[KIND];
delete reg[KIND];
for (var i = saved.length - 1; i >= 0; i--) {
var el = saved[i][0], prop = saved[i][1], old = saved[i][2], prio = saved[i][3];
try {
if (old === '') { el.style.removeProperty(prop); } else { el.style.setProperty(prop, old, prio); }
} catch (e) {}
}
return saved.length;
}
JS;
}
94 changes: 94 additions & 0 deletions tests/Unit/Visual/VisualCheckpointMasksTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,98 @@ public function test_hide_and_freeze_styles_are_removed_when_the_capture_fails()
$this->assertStringContainsString("getElementById('pf-visual-hide')", $js);
$this->assertStringContainsString("getElementById('pf-visual-freeze')", $js);
}
private static function firstIndex(array $log, callable $match): ?int
{
return array_key_first(array_filter($log, $match));
}

private static function isScreenshotMarker(string $js): bool
{
// le stub ne journalise pas screenshot() : en viewport, l'offset de scroll
// est lu juste avant la capture
return str_contains($js, 'window.scrollY');
}

public function test_hide_is_also_applied_inline_after_the_style_and_before_the_capture(): void
{
$page = $this->makePage();
$page->visualCheckpoint('hdr', null, null, false, 'auto', [], null, ['.header-top', '.a, .b']);
$log = $page->evaluatedLog;

$style = self::firstIndex($log, fn ($s) => str_contains($s, ':is(.header-top) { display: none !important; }'));
$inline = self::firstIndex($log, fn ($s) => str_contains($s, '__pfVisualInline') && str_contains($s, '"hide"') && !str_contains($s, 'removeProperty'));
$shot = self::firstIndex($log, fn ($s) => self::isScreenshotMarker($s));
$this->assertNotNull($style);
$this->assertNotNull($inline);
$this->assertNotNull($shot);
$this->assertLessThan($inline, $style);
$this->assertLessThan($shot, $inline);

$js = $log[$inline];
$this->assertStringContainsString(json_encode(['.header-top', '.a, .b']), $js);
$this->assertStringContainsString('"display"', $js);
$this->assertStringContainsString('"none"', $js);
$this->assertStringContainsString("'important'", $js);
$this->assertStringContainsString('getPropertyPriority', $js);
}

public function test_hide_inline_styles_are_restored_after_the_capture(): void
{
$page = $this->makePage();
$page->visualCheckpoint('hdr', null, null, false, 'auto', [], null, ['.popup']);
$log = $page->evaluatedLog;

$shot = self::firstIndex($log, fn ($s) => self::isScreenshotMarker($s));
$restore = self::firstIndex($log, fn ($s) => str_contains($s, '__pfVisualInline') && str_contains($s, '"hide"') && str_contains($s, 'removeProperty'));
$this->assertNotNull($restore);
$this->assertLessThan($restore, $shot);
}

public function test_masks_are_also_applied_inline_on_elements_and_descendants(): void
{
$page = $this->makePage();
$page->visualCheckpoint('hdr', null, null, false, 'auto', ['#logos', '.a, .b']);
$log = $page->evaluatedLog;

$style = self::firstIndex($log, fn ($s) => str_contains($s, 'pf-visual-masks') && str_contains($s, 'createElement'));
$inline = self::firstIndex($log, fn ($s) => str_contains($s, '__pfVisualInline') && str_contains($s, '"masks"') && !str_contains($s, 'removeProperty'));
$shot = self::firstIndex($log, fn ($s) => self::isScreenshotMarker($s));
$restore = self::firstIndex($log, fn ($s) => str_contains($s, '__pfVisualInline') && str_contains($s, '"masks"') && str_contains($s, 'removeProperty'));
$this->assertNotNull($style);
$this->assertNotNull($inline);
$this->assertNotNull($restore);
$this->assertLessThan($inline, $style);
$this->assertLessThan($shot, $inline);
$this->assertLessThan($restore, $shot);

$js = $log[$inline];
$this->assertStringContainsString(json_encode(['#logos', '.a, .b']), $js);
$this->assertStringContainsString('"visibility"', $js);
$this->assertStringContainsString('"hidden"', $js);
$this->assertStringContainsString('true', $js); // descendants inclus
$this->assertStringContainsString("querySelectorAll('*')", $js);
}

public function test_inline_styles_are_restored_even_when_the_capture_throws(): void
{
$page = $this->makePage(screenshotThrows: true);
try {
$page->visualCheckpoint('hdr', null, null, false, 'auto', ['.carousel'], null, ['.popup']);
$this->fail('la capture devait lever');
} catch (\RuntimeException) {
}
$restores = array_filter($page->evaluatedLog, fn ($s) => str_contains($s, '__pfVisualInline') && str_contains($s, 'removeProperty'));
$joined = implode("\n", $restores);

$this->assertStringContainsString('"masks"', $joined);
$this->assertStringContainsString('"hide"', $joined);
}

public function test_no_inline_js_without_hide_nor_masks(): void
{
$page = $this->makePage();
$page->visualCheckpoint('hdr', null, null, false);

$this->assertStringNotContainsString('__pfVisualInline', implode("\n", $page->evaluatedLog));
}
}
Loading