| 38 | const mv = document.querySelector('meta[name="viewport"]'); |
38 | const mv = document.querySelector('meta[name="viewport"]'); |
| 39 | res.viewport = mv ? mv.getAttribute("content") : null; |
39 | res.viewport = mv ? mv.getAttribute("content") : null; |
| 40 | |
40 | |
| 41 | − // débordement horizontal + fautifs |
41 | + // débordement horizontal RÉEL (scrollWidth peut mentir quand overflow-x:clip |
| |
42 | + // est posé — on vérifie que le viewport défile vraiment) |
| 42 | const iw = window.innerWidth; |
43 | const iw = window.innerWidth; |
| 43 | − res.overflowPx = Math.max( |
44 | + const rawOverflow = Math.max( |
| 44 | document.documentElement.scrollWidth - iw, |
45 | document.documentElement.scrollWidth - iw, |
| 45 | document.body ? document.body.scrollWidth - iw : 0); |
46 | document.body ? document.body.scrollWidth - iw : 0); |
| |
47 | + res.overflowPx = 0; |
| |
48 | + if (rawOverflow > 1) { |
| |
49 | + const x0 = window.scrollX; |
| |
50 | + window.scrollBy(rawOverflow, 0); |
| |
51 | + if (window.scrollX - x0 > 1) res.overflowPx = rawOverflow; |
| |
52 | + window.scrollTo(x0, 0); |
| |
53 | + } |
| 46 | res.overflowEls = []; |
54 | res.overflowEls = []; |
| 47 | if (res.overflowPx > 1) { |
55 | if (res.overflowPx > 1) { |
| 48 | for (const el of document.querySelectorAll("body *")) { |
56 | for (const el of document.querySelectorAll("body *")) { |
| 105 | const bodyPad = parseFloat(getComputedStyle(document.body).paddingBottom) || 0; |
113 | const bodyPad = parseFloat(getComputedStyle(document.body).paddingBottom) || 0; |
| 106 | const main = document.querySelector("main"); |
114 | const main = document.querySelector("main"); |
| 107 | const mainPad = main ? parseFloat(getComputedStyle(main).paddingBottom) || 0 : 0; |
115 | const mainPad = main ? parseFloat(getComputedStyle(main).paddingBottom) || 0 : 0; |
| 108 | − res.bottomBar = { sel: bar.sel, h: bar.rect.h, bodyPadBottom: bodyPad, mainPadBottom: mainPad }; |
116 | + let wrapPad = 0; |
| |
117 | + for (const w of document.querySelectorAll("#root, #__next, .app, #app")) { |
| |
118 | + wrapPad = Math.max(wrapPad, parseFloat(getComputedStyle(w).paddingBottom) || 0); |
| |
119 | + } |
| |
120 | + res.bottomBar = { sel: bar.sel, h: bar.rect.h, bodyPadBottom: bodyPad, mainPadBottom: mainPad, wrapPadBottom: wrapPad }; |
| 109 | } |
121 | } |
| 110 | |
122 | |
| 111 | // champs zoom iOS |
123 | // champs zoom iOS |
| 120 | if (res.smallInputs.length >= 10) break; |
132 | if (res.smallInputs.length >= 10) break; |
| 121 | } |
133 | } |
| 122 | |
134 | |
| 123 | − // cibles tactiles dans header/nav |
135 | + // cibles tactiles dans header/nav — en tenant compte des extensions de zone |
| |
136 | + // par pseudo-élément (::after/::before absolu avec inset négatif) |
| |
137 | + const hitExt = (el) => { |
| |
138 | + let ext = 0; |
| |
139 | + for (const ps of ["::after", "::before"]) { |
| |
140 | + const cs = getComputedStyle(el, ps); |
| |
141 | + if (cs.content !== "none" && cs.position === "absolute") { |
| |
142 | + const t = parseFloat(cs.top), b = parseFloat(cs.bottom); |
| |
143 | + if (!isNaN(t) && t < 0) ext = Math.max(ext, -t - (isNaN(b) ? 0 : Math.min(b, 0))); |
| |
144 | + else if (!isNaN(t) && !isNaN(b)) ext = Math.max(ext, Math.max(0, -t) + Math.max(0, -b)); |
| |
145 | + } |
| |
146 | + } |
| |
147 | + return ext; |
| |
148 | + }; |
| 124 | res.smallTargets = []; |
149 | res.smallTargets = []; |
| 125 | for (const el of document.querySelectorAll("header a, header button, nav a, nav button, [class*=menu] a, [class*=menu] button")) { |
150 | for (const el of document.querySelectorAll("header a, header button, nav a, nav button, [class*=menu] a, [class*=menu] button")) { |
| 126 | const r = el.getBoundingClientRect(); |
151 | const r = el.getBoundingClientRect(); |
| 127 | if (r.width === 0 || r.height === 0) continue; |
152 | if (r.width === 0 || r.height === 0) continue; |
| 128 | − if ((r.height < 34 || r.width < 34) && r.top >= 0 && r.top < window.innerHeight) { |
153 | + const ext = hitExt(el); |
| 129 | − res.smallTargets.push({ sel: sel(el), w: Math.round(r.width), h: Math.round(r.height), text: (el.textContent || "").trim().slice(0, 20) }); |
154 | + const effH = r.height + 2 * ext, effW = r.width + 2 * ext; |
| |
155 | + if ((effH < 34 || effW < 34) && r.top >= 0 && r.top < window.innerHeight) { |
| |
156 | + res.smallTargets.push({ sel: sel(el), w: Math.round(effW), h: Math.round(effH), text: (el.textContent || "").trim().slice(0, 20) }); |
| 130 | if (res.smallTargets.length >= 10) break; |
157 | if (res.smallTargets.length >= 10) break; |
| 131 | } |
158 | } |
| 132 | } |
159 | } |
| 230 | if (d.overflowPx > 1) r.issues.push(`débordement horizontal +${d.overflowPx}px : ${(d.overflowEls || []).map((e) => e.sel).slice(0, 4).join(", ")}`); |
257 | if (d.overflowPx > 1) r.issues.push(`débordement horizontal +${d.overflowPx}px : ${(d.overflowEls || []).map((e) => e.sel).slice(0, 4).join(", ")}`); |
| 231 | for (const f of d.fixed || []) if (f.brokenBy) r.issues.push(`fixed cassé par ancêtre ${f.brokenBy} → [${f.sel}]`); |
258 | for (const f of d.fixed || []) if (f.brokenBy) r.issues.push(`fixed cassé par ancêtre ${f.brokenBy} → [${f.sel}]`); |
| 232 | const bb = d.bottomBar; |
259 | const bb = d.bottomBar; |
| 233 | − if (bb && bb.bodyPadBottom < bb.h - 8 && bb.mainPadBottom < bb.h - 8) |
260 | + if (bb && !/cookie|consent|banner/i.test(bb.sel) && bb.bodyPadBottom < bb.h - 8 && bb.mainPadBottom < bb.h - 8 && (bb.wrapPadBottom ?? 0) < bb.h - 8) |
| 234 | − r.issues.push(`pas de compensation padding sous la barre basse [${bb.sel}] h=${bb.h}px (body ${bb.bodyPadBottom}px / main ${bb.mainPadBottom}px)`); |
261 | + r.issues.push(`pas de compensation padding sous la barre basse [${bb.sel}] h=${bb.h}px (body ${bb.bodyPadBottom}px / main ${bb.mainPadBottom}px / wrapper ${bb.wrapPadBottom ?? 0}px)`); |
| 235 | if (bar && !/env\(|calc\(/.test(bar.cssBottom || "") && r.css.safeArea === 0) |
262 | if (bar && !/env\(|calc\(/.test(bar.cssBottom || "") && r.css.safeArea === 0) |
| 236 | r.issues.push(`aucune trace de safe-area-inset-bottom (barre basse [${bar.sel}])`); |
263 | r.issues.push(`aucune trace de safe-area-inset-bottom (barre basse [${bar.sel}])`); |
| 237 | if (r.css.vh100 > 0 && r.css.dvh === 0 && r.css.svh === 0) r.issues.push(`${r.css.vh100}× 100vh sans dvh/svh`); |
264 | if (r.css.vh100 > 0 && r.css.dvh === 0 && r.css.svh === 0) r.issues.push(`${r.css.vh100}× 100vh sans dvh/svh`); |
| 239 | if (d.smallInputs.length) r.issues.push(`${d.smallInputs.length} champ(s) <16px (zoom iOS) : ${d.smallInputs.slice(0, 3).map((i) => i.sel).join(", ")}`); |
266 | if (d.smallInputs.length) r.issues.push(`${d.smallInputs.length} champ(s) <16px (zoom iOS) : ${d.smallInputs.slice(0, 3).map((i) => i.sel).join(", ")}`); |
| 240 | if (d.smallTargets.length) r.issues.push(`${d.smallTargets.length} cible(s) tactile(s) <34px : ${d.smallTargets.slice(0, 3).map((t) => t.sel + "(" + t.w + "×" + t.h + ")").join(", ")}`); |
267 | if (d.smallTargets.length) r.issues.push(`${d.smallTargets.length} cible(s) tactile(s) <34px : ${d.smallTargets.slice(0, 3).map((t) => t.sel + "(" + t.w + "×" + t.h + ")").join(", ")}`); |
| 241 | |
268 | |
| 242 | − // test tap : ouvrir le premier bouton menu/hamburger trouvé |
269 | + // test tap : ouvrir le VRAI bouton de menu (aria-label/menu-btn/burger en |
| |
270 | + // priorité — pas n'importe quel bouton du header), vérifier qu'un panneau |
| |
271 | + // s'ouvre réellement (aria-expanded / classe open / dialog) puis le verrou. |
| 243 | try { |
272 | try { |
| 244 | − const btn = page.locator('header button, [class*="burger"], [class*="hamburger"], button[aria-label*="menu" i], button[aria-label*="Menu"]').first(); |
273 | + const btn = page.locator('button[aria-label*="menu" i], .menu-btn, [class*="burger"], [class*="hamburger"], label[for*="menu"]').first(); |
| 245 | if (await btn.count()) { |
274 | if (await btn.count()) { |
| 246 | − const before = await page.evaluate(() => document.body.innerHTML.length); |
| |
| 247 | await btn.tap({ timeout: 3000 }).catch(() => btn.click({ timeout: 3000 })); |
275 | await btn.tap({ timeout: 3000 }).catch(() => btn.click({ timeout: 3000 })); |
| 248 | await page.waitForTimeout(600); |
276 | await page.waitForTimeout(600); |
| 249 | const after = await page.evaluate(() => ({ |
277 | const after = await page.evaluate(() => ({ |
| 250 | − len: document.body.innerHTML.length, |
278 | + opened: !!document.querySelector('.mobile-menu.open, [role="dialog"], .gk-mobile-overlay, [aria-expanded="true"], .mm-backdrop, input[id*="menu"]:checked'), |
| 251 | − bodyOverflow: getComputedStyle(document.body).overflow, |
279 | + bodyOverflow: (document.body.style.overflow || getComputedStyle(document.body).overflow), |
| 252 | htmlOverflow: getComputedStyle(document.documentElement).overflow, |
280 | htmlOverflow: getComputedStyle(document.documentElement).overflow, |
| |
281 | + htmlLock: document.documentElement.classList.contains("ka-scroll-lock") || document.documentElement.classList.contains("kaa-lock"), |
| 253 | })); |
282 | })); |
| 254 | − const opened = Math.abs(after.len - before) > 50; |
283 | + if (after.opened && !after.htmlLock && |
| 255 | − if (opened && after.bodyOverflow !== "hidden" && after.htmlOverflow !== "hidden") |
284 | + !/hidden/.test(after.bodyOverflow) && !/hidden/.test(after.htmlOverflow)) |
| 256 | r.issues.push("menu ouvert SANS blocage du scroll d'arrière-plan"); |
285 | r.issues.push("menu ouvert SANS blocage du scroll d'arrière-plan"); |
| 257 | − if (shot) await page.screenshot({ path: shot.replace(".png", "-menu.png") }); |
286 | + if (shot && after.opened) await page.screenshot({ path: shot.replace(".png", "-menu.png") }); |
| 258 | await page.keyboard.press("Escape").catch(() => {}); |
287 | await page.keyboard.press("Escape").catch(() => {}); |
| 259 | await page.mouse.click(5, Math.round((page.viewportSize()?.height || 800) * 0.75)).catch(() => {}); |
288 | await page.mouse.click(5, Math.round((page.viewportSize()?.height || 800) * 0.75)).catch(() => {}); |
| 260 | } |
289 | } |