Files
site-builder/craft/scripts/e2e-mobile-guard-entry.ts
T
shadowdaoandClaude Opus 5.5 bb4fb7548b Mobile guard: real-browser overflow suite, card-grid + HtmlBlock mobile hooks, wide custom HTML publish warning
- scripts/e2e-mobile-guard.mjs (npm run test:browser:mobile-guard): Chromium + Firefox at 360/390/768/1280 over
  exporter-built pages (navbar, columns, FeaturesGrid/Testimonials/NumberCounter/Gallery/Pricing/Reviews, big
  image, long word/URL, wide customer HtmlBlocks). Asserts body.scrollWidth and every visible box/text right edge
  (html,body{overflow-x:clip} hides overflow from documentElement.scrollWidth), stacking on phones, columns kept
  on tablet, navbar wrap, intentional overflow-x:auto still scrolls, and 1280 box + pixel identity with the hooks
  removed (optional cross-commit baseline).
- OLS home overflow was FeaturesGrid (inline repeat(3,1fr)), not an HtmlBlock. FeaturesGrid, Testimonials and
  NumberCounter grids carry data-wsb-grid; the <=767px head block makes them one column.
- HtmlBlock output is exported inside <div data-wsb-html style="display:contents"> (applied after attr
  injection, toHtml().html unchanged); on phones it becomes a max-width:100% sideways-scrolling box, media
  shrink, long words break. body{overflow-wrap:break-word} on phones. Navbar link wrap now up to 991px
  (8 links + long logo overflowed at 768).
- Head parity pins updated (mirror in WHP generateCompiledHTML); editor.css mirrors for the mobile frame.
- publish-checks: warn when an HtmlBlock's inline/<style> CSS sets widths, flex sizes or grids over 390px or a
  3+ column grid with no phone @media rule; says what is not checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:58:39 -08:00

111 lines
8.1 KiB
TypeScript

// Bundled (browser IIFE) by scripts/e2e-mobile-guard.mjs. Builds representative published documents
// with the REAL exporter (exportBodyHtml + exportComposedPreviewHtml, the Preview/publish path) so the
// real-browser suite measures exactly what customers get. HtmlBlock sanitising needs a DOM, which is
// why this runs inside the browser rather than in node.
// Uses only exports that exist before and after the mobile-guard change, so the SAME harness can record
// a desktop baseline on the base commit (MOBILE_GUARD_RECORD).
import { exportBodyHtml, exportComposedPreviewHtml } from '../src/utils/html-export';
import { componentResolver } from '../src/components/resolver';
type N = Record<string, any>;
const defaults = (type: string): Record<string, unknown> => {
const c = (componentResolver as any)[type];
return c?.craft?.props ? JSON.parse(JSON.stringify(c.craft.props)) : {};
};
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: N = {}) => ({
type: { resolvedName: type }, isCanvas: type === 'Container', props: { ...defaults(type), ...props }, displayName: type,
custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
});
const LONG = 'Honest prices, straight answers and work that is finished when we say it will be, every time.';
function columns(nodes: N, id: string, split: string, n: number) {
const linked: Record<string, string> = {};
for (let i = 0; i < n; i++) {
const c = `${id}-c${i}`; const t = `${id}-t${i}`;
nodes[c] = node('Container', { tag: 'div', style: { padding: '8px' } }, [t], { parent: id });
nodes[t] = node('TextBlock', { text: `${LONG} (${id} col ${i + 1})`, style: { fontSize: '16px' } }, [], { parent: c });
linked[`col-${i}`] = c;
}
nodes[id] = node('ColumnLayout', { columns: n, gap: '16px', split }, [], { parent: 'ROOT', linkedNodes: linked });
}
const NAV_LINKS = ['Home', 'About us', 'Services', 'Our work', 'Pricing', 'Testimonials', 'Blog', 'Contact us'].map((text, i, a) => ({
text, href: '#' + text.toLowerCase().replace(/\s/g, '-'), ...(i === a.length - 1 ? { isCta: true } : {}),
}));
/** showMobileMenu false = every Navbar saved before the hamburger existed (links must wrap);
* true = the default for a newly dropped Navbar (hamburger). */
function header(showMobileMenu: boolean): string {
const nodes: N = { nav: node('Navbar', { logoText: 'Acme Plumbing & Heating Ltd', links: NAV_LINKS, padding: '16px 24px', showMobileMenu }, [], { parent: 'ROOT' }) };
nodes.ROOT = node('Container', { tag: 'div', style: {} }, ['nav']);
return JSON.stringify(nodes);
}
/** First-party components only: every one of them must fit every width. */
export function componentsState(): string {
const nodes: N = {};
const kids: string[] = [];
const add = (id: string, type: string, props: Record<string, unknown> = {}) => { nodes[id] = node(type, props, [], { parent: 'ROOT' }); kids.push(id); };
add('hero', 'HeroSimple', { title: 'Plumbing and heating you can actually rely on', subtitle: LONG });
for (const [id, split, n] of [['c2', '50-50', 2], ['c3', '33-33-33', 3], ['c4', '25-25-25-25', 4], ['side', '30-70', 2]] as const) { columns(nodes, id, split, n); kids.push(id); }
add('features', 'FeaturesGrid');
add('testimonials', 'Testimonials');
add('counter', 'NumberCounter');
add('gallery', 'Gallery');
add('pricing', 'PricingTable');
add('reviews', 'ReviewsStrip');
add('img', 'ImageBlock', { src: 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="600"><rect width="1600" height="600" fill="#888"/></svg>') });
add('longword', 'TextBlock', { text: 'Supercalifragilisticexpialidociouslyunbreakablewordthatgoesonandonandonforeverandever and https://example.com/a/really/long/path/that/never/breaks/because/it/has/no/spaces/at/all?query=string&more=yes' });
nodes.ROOT = node('Container', { tag: 'div', style: {} }, kids);
return JSON.stringify(nodes);
}
/** Customer raw HTML that is wider than a phone. Keyed by node id so the runner can name offenders. */
export const HTML_BLOCKS: Record<string, string> = {
hbFixed: '<div style="width:1100px;background:#eee;padding:20px">A fixed 1100px banner</div>',
hbGridPx: '<div style="display:grid;grid-template-columns:repeat(3,300px);gap:20px"><div style="padding:16px;border:1px solid #ccc">One</div><div style="padding:16px;border:1px solid #ccc">Two</div><div style="padding:16px;border:1px solid #ccc">Three</div></div>',
hbGridFr: '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:32px"><div style="padding:24px"><h3>Multi-Brand Portfolio</h3></div><div style="padding:24px"><h3>Hosting</h3></div><div style="padding:24px"><h3>StreamerTools</h3></div><div style="padding:24px"><h3>Consultancy</h3></div></div>',
hbStyleFlex: '<style>.cards{display:flex;gap:16px}.card{flex:0 0 360px;padding:16px;border:1px solid #ccc}</style><div class="cards"><div class="card">A</div><div class="card">B</div><div class="card">C</div></div>',
hbMinWidth: '<div style="min-width:900px">min-width 900px content</div>',
hbIframe: '<iframe width="800" height="450" src="about:blank" title="video"></iframe>',
hbImg: '<img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" width="1200" height="400" alt="">',
hbTableBare: '<table><tr>' + Array.from({ length: 10 }, (_, i) => `<td>Column_heading_${i}</td>`).join('') + '</tr></table>',
hbPre: '<pre>const reallyLongLine = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";</pre>',
hbUrl: '<p>See https://example.com/a/really/long/path/that/never/breaks/because/it/has/no/spaces/at/all/ok</p>',
// Deliberate horizontal scroller: must STILL scroll (the net must not flatten or clip it).
hbScrollTable: '<div id="intentional-scroll" style="overflow-x:auto"><table style="width:1000px"><tr><td>a</td><td>b</td><td>c</td></tr></table></div>',
};
export function htmlBlocksState(): string {
const nodes: N = {};
const kids: string[] = [];
nodes.intro = node('TextBlock', { text: 'Before the custom HTML' }, [], { parent: 'ROOT' }); kids.push('intro');
for (const [id, code] of Object.entries(HTML_BLOCKS)) { nodes[id] = node('HtmlBlock', { code }, [], { parent: 'ROOT' }); kids.push(id); }
// Desktop-identity probes: a boxed wrapper would merge these roots into ONE flex item, stop the root
// margins collapsing with their neighbours', and break the inline run. display:contents must not.
nodes.flexRow = node('Container', { tag: 'div', style: { display: 'flex', flexDirection: 'row', gap: '12px' } }, ['hbRoots', 'rowText'], { parent: 'ROOT' });
nodes.hbRoots = node('HtmlBlock', { code: '<div style="flex:1;background:#eef">root one</div><div style="flex:2;background:#fee">root two</div>' }, [], { parent: 'flexRow' });
nodes.rowText = node('TextBlock', { text: 'flex sibling' }, [], { parent: 'flexRow' });
kids.push('flexRow');
nodes.marginText = node('TextBlock', { text: 'margin neighbour', style: { marginBottom: '30px' } }, [], { parent: 'ROOT' }); kids.push('marginText');
nodes.hbMargins = node('HtmlBlock', { code: '<h2 style="margin:40px 0">Collapsing margins</h2>' }, [], { parent: 'ROOT' }); kids.push('hbMargins');
nodes.inlineRun = node('Container', { tag: 'div', style: {} }, ['hbInline', 'inlineBtn'], { parent: 'ROOT' });
nodes.hbInline = node('HtmlBlock', { code: '<span>inline custom</span>' }, [], { parent: 'inlineRun' });
nodes.inlineBtn = node('ButtonLink', { text: 'inline button' }, [], { parent: 'inlineRun' });
kids.push('inlineRun');
nodes.outro = node('TextBlock', { text: 'After the custom HTML' }, [], { parent: 'ROOT' }); kids.push('outro');
nodes.ROOT = node('Container', { tag: 'div', style: { padding: '20px' } }, kids);
return JSON.stringify(nodes);
}
function compose(state: string, title: string, showMobileMenu: boolean): string {
const hdr = exportBodyHtml(header(showMobileMenu), 'header').html;
const body = exportBodyHtml(state).html;
return exportComposedPreviewHtml(state, hdr + body, { title, includeFonts: false });
}
(window as any).wsbMobileGuardDocs = (): Record<string, string> => ({
components: compose(componentsState(), 'Components', false),
htmlblocks: compose(htmlBlocksState(), 'Custom HTML', true),
});