SPB Git

spb/ka-ui

Public
15.1 KB · 324 lines javascript
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// ka-ui/mobile-audit.mjs — audit mobile approfondi des 13 sites Groupe KA.3// Usage : cd ~/Desktop/ka-ui && node mobile-audit.mjs [site…]4// Pour chaque site (chromium mobile 390×844 + webkit iPhone) :5//  - meta viewport, débordement horizontal (+ éléments fautifs)6//  - inventaire des éléments fixed/sticky : ancrage bas, ancêtre transform/filter7//    (casse le fixed), safe-area, compensation padding, stabilité au scroll8//  - scan CSS : 100vh sans dvh, env(safe-area-inset-bottom), z-index extrêmes9//  - menus déroulants : règles :hover-reveal, cibles tactiles <44px, test tap10//  - champs avec font-size <16px (zoom iOS)11// Sortie : audit/report.json + captures audit/*.png + résumé console.12import { chromium, webkit, devices } from "playwright";13import { mkdirSync, readFileSync, writeFileSync } from "fs";14import { dirname, join } from "path";15import { fileURLToPath } from "url";1617const here = dirname(fileURLToPath(import.meta.url));18const eco = JSON.parse(readFileSync(join(here, "ecosystem.json"), "utf8"));19const outDir = join(here, "audit");20mkdirSync(outDir, { recursive: true });2122const only = process.argv.slice(2);23const sites = eco.sites.filter((s) => !only.length || only.includes(s.id));2425// ---------- code injecté dans la page ----------26const PAGE_AUDIT = `(() => {27  const res = {};28  const sel = (el) => {29    if (!el || el === document.documentElement) return "html";30    let s = el.tagName.toLowerCase();31    if (el.id) return s + "#" + el.id;32    const c = (el.className && typeof el.className === "string")33      ? el.className.trim().split(/\\s+/).slice(0, 3).join(".") : "";34    return c ? s + "." + c : s;35  };3637  // meta viewport38  const mv = document.querySelector('meta[name="viewport"]');39  res.viewport = mv ? mv.getAttribute("content") : null;4041  // débordement horizontal + fautifs42  const iw = window.innerWidth;43  res.overflowPx = Math.max(44    document.documentElement.scrollWidth - iw,45    document.body ? document.body.scrollWidth - iw : 0);46  res.overflowEls = [];47  if (res.overflowPx > 1) {48    for (const el of document.querySelectorAll("body *")) {49      const r = el.getBoundingClientRect();50      if (r.width > 1 && (r.right > iw + 1 || r.left < -1) && r.height > 4) {51        const cs = getComputedStyle(el);52        if (cs.position === "fixed" && r.left >= -1 && r.right <= iw + 1) continue;53        res.overflowEls.push({ sel: sel(el), left: Math.round(r.left), right: Math.round(r.right), w: Math.round(r.width) });54        if (res.overflowEls.length >= 12) break;55      }56    }57  }5859  // fixed / sticky60  const breaksFixed = (el) => {61    let p = el.parentElement;62    while (p && p !== document.documentElement) {63      const cs = getComputedStyle(p);64      if (cs.transform !== "none" || cs.filter !== "none" ||65          cs.backdropFilter && cs.backdropFilter !== "none" ||66          cs.perspective !== "none" ||67          (cs.willChange && /transform|filter|perspective/.test(cs.willChange)) ||68          (cs.contain && /layout|paint|strict|content/.test(cs.contain)))69        return sel(p) + " (" + [70          cs.transform !== "none" ? "transform" : "",71          cs.filter !== "none" ? "filter" : "",72          cs.backdropFilter && cs.backdropFilter !== "none" ? "backdrop-filter" : "",73          cs.perspective !== "none" ? "perspective" : "",74          cs.willChange && /transform|filter/.test(cs.willChange) ? "will-change" : "",75        ].filter(Boolean).join(",") + ")";76      p = p.parentElement;77    }78    return null;79  };80  res.fixed = [];81  for (const el of document.querySelectorAll("body *")) {82    const cs = getComputedStyle(el);83    if (cs.position !== "fixed" && cs.position !== "sticky") continue;84    const r = el.getBoundingClientRect();85    if (r.width < 8 || r.height < 8 || cs.display === "none") continue;86    const vh = window.innerHeight;87    /* une « barre » basse = pleine largeur (≥60 % du viewport) ; un bouton88       flottant (FAB, bulle KA Agent) n'exige pas de compensation de contenu */89    const bottomAnchored = cs.position === "fixed" && r.bottom > vh - 120 && r.top > vh * 0.490      && r.width >= window.innerWidth * 0.6;91    res.fixed.push({92      sel: sel(el), pos: cs.position,93      rect: { t: Math.round(r.top), b: Math.round(r.bottom), h: Math.round(r.height) },94      bottomAnchored,95      z: cs.zIndex,96      brokenBy: cs.position === "fixed" ? breaksFixed(el) : null,97      padBottom: cs.paddingBottom, cssBottom: cs.bottom,98    });99    if (res.fixed.length >= 25) break;100  }101102  // compensation : padding-bottom du body/main vs barre basse103  const bar = res.fixed.find((f) => f.bottomAnchored);104  if (bar) {105    const bodyPad = parseFloat(getComputedStyle(document.body).paddingBottom) || 0;106    const main = document.querySelector("main");107    const mainPad = main ? parseFloat(getComputedStyle(main).paddingBottom) || 0 : 0;108    res.bottomBar = { sel: bar.sel, h: bar.rect.h, bodyPadBottom: bodyPad, mainPadBottom: mainPad };109  }110111  // champs zoom iOS112  res.smallInputs = [];113  for (const el of document.querySelectorAll("input, select, textarea")) {114    const cs = getComputedStyle(el);115    if (el.type === "hidden" || el.type === "checkbox" || el.type === "radio" || cs.display === "none") continue;116    const rr = el.getBoundingClientRect();117    if (rr.width < 4 || rr.height < 4) continue; /* sr-only / clip */118    const fs = parseFloat(cs.fontSize);119    if (fs && fs < 16) res.smallInputs.push({ sel: sel(el), fs });120    if (res.smallInputs.length >= 10) break;121  }122123  // cibles tactiles dans header/nav124  res.smallTargets = [];125  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();127    if (r.width === 0 || r.height === 0) continue;128    if ((r.height < 34 || r.width < 34) && r.top >= 0 && r.top < window.innerHeight) {129      res.smallTargets.push({ sel: sel(el), w: Math.round(r.width), h: Math.round(r.height), text: (el.textContent || "").trim().slice(0, 20) });130      if (res.smallTargets.length >= 10) break;131    }132  }133134  // candidats menus déroulants135  res.dropdownCandidates = [];136  for (const el of document.querySelectorAll('[aria-haspopup], [class*="dropdown"], [class*="Dropdown"], details, [data-menu], [class*="select"]:not(select)')) {137    const r = el.getBoundingClientRect();138    if (r.width === 0) continue;139    res.dropdownCandidates.push({ sel: sel(el), tag: el.tagName.toLowerCase() });140    if (res.dropdownCandidates.length >= 15) break;141  }142143  // feuilles de style (mêmes origines) pour scan texte144  res.styleSheets = [...document.styleSheets].map((ss) => ss.href).filter(Boolean);145  res.inlineCss = [...document.querySelectorAll("style")].map((s) => s.textContent || "").join("\\n");146  return res;147})()`;148149// détection des règles :hover qui révèlent un sous-menu150function scanCss(text) {151  const out = { vh100: 0, dvh: 0, svh: 0, safeArea: 0, hoverReveal: [], zMax: 0 };152  if (!text) return out;153  out.vh100 = (text.match(/100vh/g) || []).length;154  out.dvh = (text.match(/dvh/g) || []).length;155  out.svh = (text.match(/svh/g) || []).length;156  out.safeArea = (text.match(/safe-area-inset-bottom/g) || []).length;157  const rules = text.match(/[^{}]+\{[^{}]*\}/g) || [];158  for (const rule of rules) {159    const [selPart, body] = rule.split("{");160    if (!selPart || !body) continue;161    if (/:hover/.test(selPart) &&162        /(display\s*:\s*(block|flex|grid)|visibility\s*:\s*visible|opacity\s*:\s*1|pointer-events\s*:\s*auto)/.test(body) &&163        /(menu|dropdown|nav|sub)/i.test(selPart)) {164      out.hoverReveal.push(selPart.trim().replace(/\s+/g, " ").slice(0, 90));165      if (out.hoverReveal.length >= 8) break;166    }167    const zm = body.match(/z-index\s*:\s*(\d+)/g) || [];168    for (const z of zm) out.zMax = Math.max(out.zMax, parseInt(z.replace(/\D+/g, ""), 10) || 0);169  }170  return out;171}172173async function auditPage(page, url, tag, shot) {174  const r = { url, tag, issues: [], data: null };175  const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45000 });176  if (!resp || resp.status() >= 400) { r.issues.push(`HTTP ${resp ? resp.status() : "??"}`); return r; }177  await page.waitForLoadState("networkidle", { timeout: 20000 }).catch(() => {});178  await page.waitForTimeout(1500);179  const d = await page.evaluate(PAGE_AUDIT);180  r.data = d;181182  // scan CSS (inline + feuilles même origine)183  let cssText = d.inlineCss || "";184  const origin = new URL(url).origin;185  for (const href of d.styleSheets.slice(0, 8)) {186    try {187      if (!href.startsWith(origin) && !href.startsWith("/")) continue;188      const t = await page.evaluate(async (h) => { try { const x = await fetch(h); return await x.text(); } catch { return ""; } }, href);189      cssText += "\n" + t;190    } catch {}191  }192  r.css = scanCss(cssText);193  delete d.inlineCss;194195  // stabilité au scroll de la barre basse196  const bar = (d.fixed || []).find((f) => f.bottomAnchored);197  if (bar) {198    const track = await page.evaluate(async (barSel) => {199      const find = () => {200        for (const el of document.querySelectorAll("body *")) {201          const cs = getComputedStyle(el);202          if (cs.position !== "fixed") continue;203          const r = el.getBoundingClientRect();204          if (r.bottom > innerHeight - 120 && r.top > innerHeight * 0.4 && r.height >= 8) return el;205        }206        return null;207      };208      const el = find();209      if (!el) return null;210      const pos = [];211      for (let i = 0; i < 4; i++) {212        window.scrollBy(0, 600);213        await new Promise((r2) => setTimeout(r2, 350));214        const r = el.getBoundingClientRect();215        pos.push({ b: Math.round(innerHeight - r.bottom), visible: r.height > 0 && getComputedStyle(el).display !== "none" });216      }217      window.scrollTo(0, 0);218      return pos;219    }, bar.sel).catch(() => null);220    if (track) {221      const bottoms = track.map((p) => p.b);222      const drift = Math.max(...bottoms) - Math.min(...bottoms);223      if (drift > 2) r.issues.push(`barre basse instable au scroll (dérive ${drift}px) [${bar.sel}]`);224      if (track.some((p) => !p.visible)) r.issues.push(`barre basse disparaît au scroll [${bar.sel}]`);225    }226  }227228  // signalements229  if (!d.viewport || !/width=device-width/.test(d.viewport)) r.issues.push(`meta viewport: ${d.viewport || "ABSENTE"}`);230  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}]`);232  const bb = d.bottomBar;233  if (bb && bb.bodyPadBottom < bb.h - 8 && bb.mainPadBottom < 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)`);235  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}])`);237  if (r.css.vh100 > 0 && r.css.dvh === 0 && r.css.svh === 0) r.issues.push(`${r.css.vh100}× 100vh sans dvh/svh`);238  if (r.css.hoverReveal.length) r.issues.push(`menus :hover-reveal CSS : ${r.css.hoverReveal.slice(0, 3).join(" | ")}`);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(", ")}`);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(", ")}`);241242  // test tap : ouvrir le premier bouton menu/hamburger trouvé243  try {244    const btn = page.locator('header button, [class*="burger"], [class*="hamburger"], button[aria-label*="menu" i], button[aria-label*="Menu"]').first();245    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 }));248      await page.waitForTimeout(600);249      const after = await page.evaluate(() => ({250        len: document.body.innerHTML.length,251        bodyOverflow: getComputedStyle(document.body).overflow,252        htmlOverflow: getComputedStyle(document.documentElement).overflow,253      }));254      const opened = Math.abs(after.len - before) > 50;255      if (opened && after.bodyOverflow !== "hidden" && after.htmlOverflow !== "hidden")256        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") });258      await page.keyboard.press("Escape").catch(() => {});259      await page.mouse.click(5, Math.round((page.viewportSize()?.height || 800) * 0.75)).catch(() => {});260    }261  } catch {}262263  if (shot) {264    await page.evaluate(() => window.scrollTo(0, 0));265    await page.waitForTimeout(300);266    await page.screenshot({ path: shot });267    await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));268    await page.waitForTimeout(500);269    await page.screenshot({ path: shot.replace(".png", "-bas.png") });270  }271  return r;272}273274const engines = [275  { name: "android", launcher: chromium, ctx: { ...devices["Pixel 7"] } },276  { name: "ios", launcher: webkit, ctx: { ...devices["iPhone 13"] } },277];278279const report = [];280for (const engine of engines) {281  const browser = await engine.launcher.launch();282  for (const site of sites) {283    const ctx = await browser.newContext(engine.ctx);284    const page = await ctx.newPage();285    const siteRep = { site: site.id, engine: engine.name, pages: [] };286    try {287      const home = `https://${site.domain}/`;288      const shot = join(outDir, `${site.id}-${engine.name}.png`);289      siteRep.pages.push(await auditPage(page, home, "accueil", shot));290      // pages internes : 2 liens de nav distincts (chromium seulement pour limiter le temps)291      if (engine.name === "android") {292        const links = await page.evaluate(() => {293          const seen = new Set();294          const out = [];295          for (const a of document.querySelectorAll("a[href]")) {296            const h = a.getAttribute("href");297            if (!h || !h.startsWith("/") || h === "/" || h.startsWith("//") || h.startsWith("/#")) continue;298            const p = h.split("?")[0].split("#")[0];299            if (seen.has(p) || /\.(png|jpg|pdf|xml|js)$/.test(p)) continue;300            seen.add(p);301            out.push(p);302            if (out.length >= 6) break;303          }304          return out;305        }).catch(() => []);306        for (const l of links.slice(0, 2)) {307          siteRep.pages.push(await auditPage(page, `https://${site.domain}${l}`, l, null)308            .catch((e) => ({ url: l, issues: ["ERREUR " + String(e).slice(0, 80)] })));309        }310      }311    } catch (e) {312      siteRep.pages.push({ url: site.domain, issues: ["ERREUR " + String(e).split("\n")[0].slice(0, 120)] });313    }314    report.push(siteRep);315    const n = siteRep.pages.reduce((a, p) => a + (p.issues || []).length, 0);316    console.log(`${n ? "⚠️ " : "✅"} ${engine.name.padEnd(7)} ${site.id.padEnd(10)} ${n} problème(s)`);317    for (const p of siteRep.pages) for (const i of p.issues || []) console.log(`     [${p.tag || p.url}] ${i}`);318    await ctx.close();319  }320  await browser.close();321}322writeFileSync(join(outDir, "report.json"), JSON.stringify(report, null, 2));323console.log(`\nRapport : ${join(outDir, "report.json")}`);324
Files · 56447b82d77ec66415fa3458ec72294bcaf7ac5e
Menu

Theme