- 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>
111 lines
8.1 KiB
TypeScript
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),
|
|
});
|