Files
site-builder/craft/scripts/e2e-mobile-guard-entry.ts
T
shadowdaoandClaude Opus 5.5 542f5efc78 Mobile guard: drop the HtmlBlock table rule; colour e2e baseline = first 2026.11.4-head commit
- Tables inside HtmlBlocks are left alone on phones: display:block (the only way to make one scroll)
  shrank a fitting width:100% table's rows from 349px to 77px at 390. Wide tables stay clipped as before.
  The <pre> rule (max-width:100%;overflow-x:auto, no display change) and the media rules stay.
- Browser suite: width:100% table still spans the screen (fails on cfeeef3's rule: first row 78px); the
  10-column bare table moved to htmlwide (clipped, page never scrolls sideways).
- e2e-color-system: default COLOR_BASE_COMMIT ab7a100 -> 69fd34b, the first commit with the 2026.11.4
  published head (ColumnLayout stacking + Navbar wrap) and none of the colour feature. ab7a100 predates
  that head, so check 5 failed on main itself. This branch's own head change (mobile guard) is folded back
  by exact string, asserted present once, before comparing, so the check still proves the colour feature
  changed nothing else. Passes on 69fd34b; fails (as it should) against ab7a100.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:16:53 -08:00

127 lines
9.8 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 fits a phone with the net: media shrink, <pre> scrolls on its own, long words wrap,
* a full-width table keeps spanning the screen. */
export const HTML_FITS: Record<string, string> = {
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="">',
hbTableFull: '<table id="full-table" style="width:100%;border-collapse:collapse" border="1"><tr><th>Plan</th><th>Price</th></tr><tr><td>Basic</td><td>$5</td></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>',
// Phone probes: none of these may be clipped, unstuck or merged by the net.
hbSticky: '<div id="sticky-box" style="height:1200px;background:#f5f5f5"><div id="sticky-probe" style="position:sticky;top:0;background:#ffeb3b;padding:8px">Sticky bar</div></div>',
hbDropdown: '<div style="position:relative"><button type="button">Menu</button><ul id="dropdown-probe" style="position:absolute;top:100%;left:0;z-index:10;list-style:none;margin:0;padding:8px;background:#fff;box-shadow:0 4px 12px #0004"><li>One</li><li>Two</li><li>Three</li><li>Four</li><li id="dropdown-last">Last item</li></ul></div>',
};
/** Customer raw HTML wider than a phone by design (fixed widths / px grids): clipped as before, never a sideways page. */
export const HTML_WIDE: 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>',
// Tables are deliberately left alone by the net (scrolling one would shrink full-width tables): clipped as before.
hbTableBare: '<table id="bare-table"><tr>' + Array.from({ length: 10 }, (_, i) => `<td>Column_heading_${i}</td>`).join('') + '</tr></table>',
};
function htmlState(blocks: Record<string, string>, probes: boolean): 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(blocks)) { nodes[id] = node('HtmlBlock', { code }, [], { parent: 'ROOT' }); kids.push(id); }
if (probes) {
// A multi-root HtmlBlock that is a direct flex / grid item: its roots must stay separate items/cells, at every
// width (and a boxed wrapper would also move the margin and inline probes at desktop).
nodes.flexRow = node('Container', { tag: 'div', style: { display: 'flex', flexDirection: 'row', gap: '12px' } }, ['hbRoots', 'rowText'], { parent: 'ROOT' });
nodes.hbRoots = node('HtmlBlock', { code: '<div class="root-probe" style="flex:1;background:#eef">root one</div><div class="root-probe" style="flex:2;background:#fee">root two</div>' }, [], { parent: 'flexRow' });
nodes.rowText = node('TextBlock', { text: 'flex sibling' }, [], { parent: 'flexRow' });
kids.push('flexRow');
nodes.gridBox = node('Container', { tag: 'div', style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' } }, ['hbCells'], { parent: 'ROOT' });
nodes.hbCells = node('HtmlBlock', { code: '<div class="cell-probe" style="background:#efe">cell one</div><div class="cell-probe" style="background:#fef">cell two</div>' }, [], { parent: 'gridBox' });
kids.push('gridBox');
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(htmlState(HTML_FITS, true), 'Custom HTML', true),
htmlwide: compose(htmlState(HTML_WIDE, false), 'Wide custom HTML', true),
});