diff --git a/config/scripts/mobile-web-app-editable-host-font-size.mjs b/config/scripts/mobile-web-app-editable-host-font-size.mjs new file mode 100644 index 00000000000..3add346cce2 --- /dev/null +++ b/config/scripts/mobile-web-app-editable-host-font-size.mjs @@ -0,0 +1,309 @@ +/** + * The 16 px floor for an editable the page styles with CSS rather than with a `TextInput` prop. + * + * `mobile-web-app-text-input-font-size-seam.mjs` reads the floor and holds every `TextInput` in a + * route's closure to it. It cannot see the rich Markdown editor: that surface is a + * `contenteditable` element in a string of markup, sized by a rule in a stylesheet the same module + * emits, and the walk there matches JSX `TextInput` tags and `style` props. So the editor shipped + * at 14 px and was measured at 14 px in both engines — the exact condition the floor exists for, + * because iOS zooms the page on focus of any editable under 16 px, never zooms back, and + * `keyboard-occlusion.web.ts` then answers 0 for the rest of the session at a scale other than 1. + * + * The rule is over the closure rather than over a list of known editors, for the same reason the + * `TextInput` one is: the next editable host is the one nobody remembers to add. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { textInputFontSizeFloor } from './mobile-web-app-text-input-font-size-seam.mjs' + +/** The seam's export, which is how a size states the floor rather than restating the number. */ +const SEAM_EXPORT = 'TEXT_INPUT_FONT_SIZE' + +/** + * Each editable tag in a markup string, whole. + * + * The tag is what an editable *is*; its id is optional and is read out of the tag afterwards. A + * pattern that started from the id matched only the hosts that have one, so a file holding a named + * host and an anonymous one reported the named host and said nothing about the other. + */ +const EDITABLE_TAG = /<[A-Za-z][^>]*\bcontenteditable="true"[^>]*>/g + +/** The id a matched tag carries, or null for one that carries none. */ +const TAG_ID = /\bid="([A-Za-z][\w-]*)"/ + +function readOrNull(path) { + try { + return readFileSync(path, 'utf8') + } catch { + return null + } +} + +/** + * Every editable host a closure declares, as `{ file, id }`, one entry per tag. + * + * The completeness half of the verdict below: an empty offender list is only evidence when the + * walk found the editables it is judging. A host with no id lands here with `id: null` and is + * reported as unresolved rather than passing, and it does so whether or not a named host sits + * beside it in the same file. + */ +export function editableHostsIn(mobileDir, closure) { + const found = [] + for (const file of closure.local) { + const source = readOrNull(join(mobileDir, file)) + if (source === null || !source.includes('contenteditable="true"')) { + continue + } + for (const [tag] of source.matchAll(EDITABLE_TAG)) { + found.push({ file, id: TAG_ID.exec(tag)?.[1] ?? null }) + } + } + return found.sort((left, right) => + left.file === right.file + ? String(left.id).localeCompare(String(right.id)) + : left.file < right.file + ? -1 + : 1 + ) +} + +/** The selector as a pattern, with the boundary that keeps `#editor` off `#editor-notes`. */ +function selectorPattern(selector) { + return `${selector.replace(/[$()*+.?[\\\]^{|}]/g, '\\$&')}(?![\\w-])` +} + +/** The declarations of the rule whose block opens at `open`, or null for one that never closes. */ +function blockFrom(source, open) { + let depth = 1 + for (let at = open; at < source.length; at += 1) { + if (source[at] === '{') { + depth += 1 + continue + } + if (source[at] === '}') { + depth -= 1 + if (depth === 0) { + return source.slice(open, at) + } + } + } + return null +} + +/** + * Every rule in a stylesheet string whose selector list mentions the selector, in source order. + * + * The list runs from a line start, the end of the rule before it, a comma, or the backtick the + * template literal opens with, up to the `{`; requiring the selector somewhere inside it is what + * keeps the surrounding TypeScript's own braces out of the walk. Kept as a list rather than + * collapsed to one selector because a comma binds every selector in it to the same declarations, + * so the host can be hiding in any of them. + * + * Textual, and flat: the sheets this reads have no at-rules and no nesting, which is the same + * assumption `document-style-scoping.ts` makes and refuses to exceed. + */ +function rulesMentioning(source, selector) { + const pattern = new RegExp( + `(?:^|[},\`])([^{};\`]*${selectorPattern(selector)}[^{};\`]*)\\{`, + 'dgm' + ) + const rules = [] + for (let match = pattern.exec(source); match !== null; match = pattern.exec(source)) { + // Counted rather than matched to the first `}`: a declaration reading the seam is written + // `${TEXT_INPUT_FONT_SIZE}px`, whose own closing brace would have ended the block one + // declaration early and left the size looking absent. + const declarations = blockFrom(source, match.index + match[0].length) + if (declarations === null) { + continue + } + const list = match[1] + rules.push({ + selectors: list + .split(',') + .map((one) => one.trim()) + .filter((one) => one !== ''), + declarations, + // The selector's own start, not the anchor's: the anchor is the previous rule's `}`, a line up. + index: match.indices[1][0] + (list.length - list.trimStart().length) + }) + } + return rules +} + +/** The last compound of a selector — the element the rule is about, not one of its ancestors. */ +function subjectCompound(selector) { + return selector.split(/[\s>+~]+/).at(-1) ?? '' +} + +/** + * The `font-size` the cascade actually uses out of one rule. + * + * A rule may declare the property more than once, and CSS takes the last of equal importance, with + * `!important` outranking every declaration that is not. Reading the first one reported + * `font-size: 16px; font-size: 14px;` as compliant for a surface the browser renders at 14 px. + * + * The flag is stripped from the value it returns, so a compliant size that carries it is read as + * the size it sets rather than as a shape this walk does not model. + */ +function winningFontSize(declarations) { + const found = [] + // Split on the separator rather than matching a value pattern: a size read from the seam is + // written `${TEXT_INPUT_FONT_SIZE}px`, whose own closing brace ends any value pattern that + // excludes one, and the last declaration in a rule need not carry a trailing semicolon. + for (const piece of declarations.split(';')) { + const match = /(?:^|[^\w-])font-size:\s*([\s\S]*)$/.exec(piece) + if (match === null) { + continue + } + const raw = match[1].trim() + found.push({ + text: raw.replace(/\s*!\s*important$/i, '').trim(), + important: /!\s*important$/i.test(raw) + }) + } + if (found.length === 0) { + return null + } + return found.findLast((one) => one.important) ?? found.at(-1) +} + +/** + * What the winning `font-size` is worth: a literal, a seam substitution, or something else. + * + * Null for a rule that declares no size at all, which is unresolved rather than a pass: the value + * an editable then takes comes from a rule this walk does not read — the host element's own, or the + * page's root — so it can be 14 px and the census cannot prove otherwise. Where the `TextInput` + * half treats an absent prop as inheritance and lets it through, that policy is main's and about a + * prop; this is CSS, and the inherited value is genuinely out of view. + */ +function readFontSize(mobileDir, source, declarations) { + const winning = winningFontSize(declarations) + if (winning === null) { + return null + } + const text = winning.text + const literal = /^(\d+(?:\.\d+)?)px$/.exec(text) + if (literal !== null) { + return { text, onSeam: Number(literal[1]) >= textInputFontSizeFloor(mobileDir) } + } + // A substitution, which is only the seam when this module imported the seam's export: the same + // name declared locally, or imported from somewhere else, is exactly the regression the seam + // exists to stop wearing its name. + const substituted = /^\$\{([A-Za-z_$][\w$]*)\}px$/.exec(text) + if (substituted === null) { + return { text, onSeam: false } + } + const imported = new RegExp( + `import\\s*\\{[^}]*\\b${SEAM_EXPORT}\\b[^}]*\\}\\s*from\\s*'[^']*text-input-font-size'` + ) + return { text, onSeam: substituted[1] === SEAM_EXPORT && imported.test(source) } +} + +/** + * Every rule in a sheet that applies exactly this selector, in source order. + * + * All of them rather than the first: rules of equal specificity are ranked by source order, so a + * sheet that declares 16 px and then 14 px renders at 14 px, and reading only the first one called + * that surface compliant. + */ +function exactRules(source, selector) { + return rulesMentioning(source, selector).filter((rule) => rule.selectors.includes(selector)) +} + +/** + * Every rule in a sheet that sizes the host through a selector this walk cannot rank against the + * exact one. + * + * A subject of higher specificity that still targets the host (`main#editor`, `#editor.x`, + * `div > #editor`, `#editor:empty`) beats the exact rule, and this census does no specificity + * arithmetic: such a rule declaring `font-size` makes the host unresolved rather than compliant. A + * descendant (`#editor p`) is about another element and a pseudo-element (`#editor:empty::before`) + * is a box the host generates, so neither one is in the way. + */ +function unrankableHostRules(source, selector) { + return rulesMentioning(source, selector).filter( + (rule) => + winningFontSize(rule.declarations) !== null && + rule.selectors.some( + (one) => + one !== selector && + !one.includes('::') && + new RegExp(selectorPattern(selector)).test(subjectCompound(one)) + ) + ) +} + +/** + * Where each editable host's size is declared, as `{ at, size }`. + * + * The size is looked for in the same module the markup came from and in the modules directly beside + * it: a document's markup and its stylesheet are two exports of one program, so the rule is stated + * over that program's own directory rather than over the whole closure or over its subtree. + */ +function editableHostSizes(mobileDir, closure) { + const resolutions = [] + for (const host of editableHostsIn(mobileDir, closure)) { + if (host.id === null) { + resolutions.push({ at: host.file, size: null }) + continue + } + const directory = host.file.slice(0, host.file.lastIndexOf('/')) + // The immediate directory, not the subtree: the walk stops at the first file whose sheet opens + // the host's selector, and the closure's order is the bundler's rather than alphabetical, so a + // sheet one directory down could answer for the sibling the host actually gets. + const siblings = closure.local.filter( + (file) => file.slice(0, file.lastIndexOf('/')) === directory + ) + const selector = `#${host.id}` + let resolved = null + for (const file of siblings) { + const source = readOrNull(join(mobileDir, file)) + if (source === null) { + continue + } + const exact = exactRules(source, selector) + if (exact.length === 0) { + continue + } + const unrankable = unrankableHostRules(source, selector) + const named = unrankable[0] ?? exact[0] + resolved = { + at: `${file}:${source.slice(0, named.index).split('\n').length}`, + // Joined in source order because that is the cascade among rules of equal specificity, and + // `winningFontSize` already reads the last of equal importance out of a declaration string. + size: + unrankable.length > 0 + ? null + : readFontSize(mobileDir, source, exact.map((one) => one.declarations).join(';')) + } + break + } + resolutions.push(resolved ?? { at: `${host.file} (#${host.id})`, size: null }) + } + return resolutions +} + +/** + * Every editable host whose size this walk could not follow to a rule, as it names it. + * + * A hole rather than a pass: an editable planted with no id, one whose selector no stylesheet + * beside it opens, or one a higher-specificity rule sizes out of this walk's reach, is a surface + * the rule cannot judge and has to say so. + */ +export function unresolvedEditableHostStyles(mobileDir, closure) { + return editableHostSizes(mobileDir, closure) + .filter((entry) => entry.size === null) + .map((entry) => entry.at) + .sort() +} + +/** Every editable host in a closure sized below the floor and off the seam, as `path:line`. */ +export function editableHostFontSizeOffenders(mobileDir, closure) { + return [ + ...new Set( + editableHostSizes(mobileDir, closure) + .filter((entry) => entry.size !== null && !entry.size.onSeam) + .map((entry) => entry.at) + ) + ].sort() +} diff --git a/config/scripts/mobile-web-app-editable-host-font-size.test.mjs b/config/scripts/mobile-web-app-editable-host-font-size.test.mjs new file mode 100644 index 00000000000..21d7ff09b17 --- /dev/null +++ b/config/scripts/mobile-web-app-editable-host-font-size.test.mjs @@ -0,0 +1,293 @@ +/** + * The editable-host rule, over the tree it ships against and over fixtures of its own. + * + * Two halves, because an offender list is only evidence when the walk read something. The first + * runs the rule over the rich Markdown editor's real modules and says which line carries the size; + * the second drives the readings the real tree does not have — a size below the floor, a name that + * merely spells the seam's, an editable with no id — against a fixture tree whose only reason to + * exist is that those readings have to be observable somewhere. + */ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + editableHostFontSizeOffenders, + editableHostsIn, + unresolvedEditableHostStyles +} from './mobile-web-app-editable-host-font-size.mjs' + +const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url)) + +/** The editor's own two modules, as a closure naming nothing else. */ +const EDITOR_CLOSURE = { + local: [ + 'src/components/rich-markdown/document-markup.ts', + 'src/components/rich-markdown/document-style.ts' + ] +} + +/** The seam's web half, copied into a fixture tree so the floor is read rather than restated. */ +const SEAM_SOURCE = `export const TEXT_INPUT_FONT_SIZE_FLOOR = 16\n` + +let fixtureDir = null + +/** A fixture tree with the seam in it, plus whatever markup and stylesheet a case needs. */ +async function fixture(name, markup, style) { + const root = join(fixtureDir, name) + await mkdir(join(root, 'src/platform'), { recursive: true }) + await mkdir(join(root, 'src/doc'), { recursive: true }) + await writeFile(join(root, 'src/platform/text-input-font-size.web.ts'), SEAM_SOURCE, 'utf8') + await writeFile(join(root, 'src/doc/markup.ts'), markup, 'utf8') + await writeFile(join(root, 'src/doc/style.ts'), style, 'utf8') + return { root, closure: { local: ['src/doc/markup.ts', 'src/doc/style.ts'] } } +} + +/** The same tree with a second stylesheet one directory down, and a closure that reads it first. */ +async function fixtureWithNested(name, markup, style, nestedStyle) { + const { root } = await fixture(name, markup, style) + await mkdir(join(root, 'src/doc/nested'), { recursive: true }) + await writeFile(join(root, 'src/doc/nested/style.ts'), nestedStyle, 'utf8') + return { + root, + // Nested first, which is what makes this a measurement: the closure's order is the bundler's, + // so a walk that accepted any file under the directory would stop here. + closure: { local: ['src/doc/nested/style.ts', 'src/doc/markup.ts', 'src/doc/style.ts'] } + } +} + +beforeAll(async () => { + fixtureDir = await mkdtemp(join(tmpdir(), 'orca-editable-host-')) +}) + +afterAll(async () => { + if (fixtureDir) { + await rm(fixtureDir, { recursive: true, force: true }) + } +}) + +describe('the editable-host font-size rule', () => { + it('finds the editor the TextInput census cannot see', () => { + // The precondition every verdict below needs: this walk reads the editor's real markup and + // names the surface the page mounts. + expect(editableHostsIn(mobileDir, EDITOR_CLOSURE)).toEqual([ + { file: 'src/components/rich-markdown/document-markup.ts', id: 'editor' } + ]) + }) + + it('follows the surface to the rule in the stylesheet beside it', () => { + expect(unresolvedEditableHostStyles(mobileDir, EDITOR_CLOSURE)).toEqual([]) + expect(editableHostFontSizeOffenders(mobileDir, EDITOR_CLOSURE)).toEqual([]) + }) + + it('reds on an editable under the floor', async () => { + const { root, closure } = await fixture( + 'under', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 14px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(root, closure)).toEqual(['src/doc/style.ts:2']) + }) + + it('accepts a literal that already clears the floor, and a size read from the seam', async () => { + const literal = await fixture( + 'literal', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 18px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(literal.root, literal.closure)).toEqual([]) + + const bound = await fixture( + 'bound', + 'export const MARKUP = \'
\'\n', + "import { TEXT_INPUT_FONT_SIZE } from '../platform/text-input-font-size'\n" + + 'export function style() {\n return ` #editor {\n font-size: ${TEXT_INPUT_FONT_SIZE}px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(bound.root, bound.closure)).toEqual([]) + }) + + it('refuses a name that only spells the seam’s', async () => { + // A local `const TEXT_INPUT_FONT_SIZE = 14` two lines up is exactly the regression the seam + // exists to stop, wearing its name. + const { root, closure } = await fixture( + 'local', + 'export const MARKUP = \'
\'\n', + 'const TEXT_INPUT_FONT_SIZE = 14\n' + + 'export function style() {\n return ` #editor {\n font-size: ${TEXT_INPUT_FONT_SIZE}px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(root, closure)).toEqual(['src/doc/style.ts:3']) + }) + + it('counts a no-id editable beside a named one, rather than only the named one', async () => { + // The tag is what an editable is, and its id is optional: a walk that started from the id + // matched the named host and never saw the one beside it, so a file holding both reported the + // named one as clean and said nothing at all about the other. + const { root, closure } = await fixture( + 'mixed', + 'export const NAMED = \'
\'\n' + + 'export const ANONYMOUS = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 18px;\n }`\n}\n' + ) + expect(editableHostsIn(root, closure)).toEqual([ + { file: 'src/doc/markup.ts', id: 'editor' }, + { file: 'src/doc/markup.ts', id: null } + ]) + expect(unresolvedEditableHostStyles(root, closure)).toEqual(['src/doc/markup.ts']) + }) + + it('reads the sheet beside the markup, not one a directory down', async () => { + // The walk stops at the first file whose sheet opens `#editor`, and the closure's order is the + // bundler's rather than alphabetical, so a nested sheet could answer for a sibling that is the + // one the host actually gets. + const { root, closure } = await fixtureWithNested( + 'nested', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 14px;\n }`\n}\n', + 'export function nested() {\n return ` #editor {\n font-size: 18px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(root, closure)).toEqual(['src/doc/style.ts:2']) + }) + + it('reports an editable it cannot judge rather than passing it', async () => { + const noId = await fixture( + 'no-id', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` main { font-size: 18px; }`\n}\n' + ) + expect(unresolvedEditableHostStyles(noId.root, noId.closure)).toEqual(['src/doc/markup.ts']) + expect(editableHostFontSizeOffenders(noId.root, noId.closure)).toEqual([]) + + const noRule = await fixture( + 'no-rule', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` main { font-size: 18px; }`\n}\n' + ) + expect(unresolvedEditableHostStyles(noRule.root, noRule.closure)).toEqual([ + 'src/doc/markup.ts (#editor)' + ]) + }) + + it('reads the declaration CSS uses, not the first one in the rule', async () => { + // Equal importance, so the last one wins. A walk that stopped at the first read 16 px and + // called a 14 px surface compliant. + const { root, closure } = await fixture( + 'repeated', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 16px;\n' + + ' font-size: 14px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(root, closure)).toEqual(['src/doc/style.ts:2']) + }) + + it('lets an important declaration outrank a later one, as the cascade does', async () => { + const important = await fixture( + 'important-wins', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 18px !important;\n' + + ' font-size: 14px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(important.root, important.closure)).toEqual([]) + + // And an important declaration is still read as the size it sets, rather than as a shape the + // walk does not model: without stripping the flag, a compliant `!important` size on the seam + // would have been reported as an offender. + const offending = await fixture( + 'important-offends', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 14px !important;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(offending.root, offending.closure)).toEqual([ + 'src/doc/style.ts:2' + ]) + }) + + it('cannot judge an editable that declares no size, and says so', async () => { + // Inheritance is not a pass here. The value would come from a rule in a file this walk does not + // read — the host element's own, or the page's root — so "no declaration" is "cannot say" and + // belongs in the unresolved list, which the closure census holds at empty. + const { root, closure } = await fixture( + 'inherits', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n padding: 8px;\n }`\n}\n' + ) + expect(unresolvedEditableHostStyles(root, closure)).toEqual(['src/doc/style.ts:2']) + // Not an offender either: an offender is a size this walk read and found under the floor. + expect(editableHostFontSizeOffenders(root, closure)).toEqual([]) + }) + + it('reads every exact rule in the sheet, in source order, as the cascade does', async () => { + // Equal specificity, so the last rule wins. Reading only the first called a 14 px surface + // compliant because a compliant rule happened to sit above it. + const later = await fixture( + 'later-exact', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' + + ' #editor {\n font-size: 14px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(later.root, later.closure)).toEqual(['src/doc/style.ts:2']) + }) + + it('takes source order rather than the lowest exact rule in the sheet', async () => { + // The control the reading above needs: the same two rules the other way round are compliant, + // so the verdict is the cascade rather than "any rule under the floor anywhere in the sheet". + const earlier = await fixture( + 'earlier-exact', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 14px;\n }\n' + + ' #editor {\n font-size: 18px;\n }`\n}\n' + ) + expect(editableHostFontSizeOffenders(earlier.root, earlier.closure)).toEqual([]) + }) + + it('cannot rank a higher-specificity subject rule, and says so rather than passing', async () => { + // `main#editor` outranks `#editor` and this census does no specificity arithmetic, so a rule + // like it declaring a size is a hole, named at its own line. + const { root, closure } = await fixture( + 'subject-specificity', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' + + ' main#editor {\n font-size: 14px;\n }`\n}\n' + ) + expect(unresolvedEditableHostStyles(root, closure)).toEqual(['src/doc/style.ts:5']) + // Not an offender either: an offender is a size this walk read and could rank. + expect(editableHostFontSizeOffenders(root, closure)).toEqual([]) + }) + + it('splits a selector list, so a host riding in one still reaches the verdict', async () => { + const { root, closure } = await fixture( + 'subject-in-list', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' + + ' h1, main#editor {\n font-size: 14px;\n }`\n}\n' + ) + expect(unresolvedEditableHostStyles(root, closure)).toEqual(['src/doc/style.ts:5']) + }) + + it('reads the host’s own id, not a longer one that starts with it', async () => { + // The selector list is now read whole, so `#editor` has to stop at an id boundary: without one, + // a rule for the element beside the host would have made the host unresolved. + const { root, closure } = await fixture( + 'neighbour-id', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' + + ' #editor-notes {\n font-size: 14px;\n }`\n}\n' + ) + expect(unresolvedEditableHostStyles(root, closure)).toEqual([]) + expect(editableHostFontSizeOffenders(root, closure)).toEqual([]) + }) + + it('leaves a descendant rule and a pseudo-element rule out of the way', async () => { + // Neither one is the host: `#editor p` is about another element, and `::before` is a box the + // host generates. Counting either as a hole would report the shipped sheet unresolved. + const { root, closure } = await fixture( + 'not-the-host', + 'export const MARKUP = \'
\'\n', + 'export function style() {\n return ` #editor {\n font-size: 18px;\n }\n' + + ' #editor::before {\n font-size: 12px;\n }\n' + + ' #editor p {\n font-size: 0.9em;\n }`\n}\n' + ) + expect(unresolvedEditableHostStyles(root, closure)).toEqual([]) + expect(editableHostFontSizeOffenders(root, closure)).toEqual([]) + }) +}) diff --git a/config/scripts/mobile-web-app-rich-markdown-render.test.mjs b/config/scripts/mobile-web-app-rich-markdown-render.test.mjs new file mode 100644 index 00000000000..46bc797267a --- /dev/null +++ b/config/scripts/mobile-web-app-rich-markdown-render.test.mjs @@ -0,0 +1,674 @@ +/** + * The rich Markdown editor in the page, in a real browser, under the policy the shell ships. + * + * The native component puts a hand-written document inside a `WebView` and talks to it over + * `postMessage` and `injectJavaScript`. The page has no WebView, so it mounts the same modules and + * calls them. That makes four claims this file measures rather than asserts: that all fifteen + * toolbar commands change the document under the shipped header with no violation; that `ready` + * and `change` reach the component through its own seam and never through the shell's bridge + * object; that a remount leaves nothing of the first mount behind (rulings 20 and 21); and that the + * surface is on the 16 px floor and the two URL commands are answered by a modal rather than by the + * `null` both shells return from `window.prompt`. + * + * Both engines, because the shell is WKWebView on one platform and a Chromium WebView on the other, + * and `document.execCommand` — which the whole program is built on — is the engine's. + */ +import { Buffer } from 'node:buffer' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import * as esbuild from 'esbuild' +import { chromium, webkit } from 'playwright-core' +import { MOBILE_WEB_APP_ROOT_RESET, lucideBarrelPlugin } from './build-mobile-web-app-bundle.mjs' +import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs' +import { textInputFontSizeFloor } from './mobile-web-app-text-input-font-size-seam.mjs' +import { + createBundleServer, + installCspViolationRecorder, + installListenerRecorder, + installSchedulerRecorder, + readShellCsp +} from './mobile-web-app-render-harness.mjs' + +const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url)) +const componentDir = join(mobileDir, 'src/components') + +/** + * The surface's floor, read out of the seam's own module rather than retyped. + * + * The number was a literal `16` under a comment claiming it was read, which is the shape the seam + * exists to prevent: a theme that raised the body size past the floor would move what the page + * computes and leave this asserting the old number. `textInputFontSizeFloor` is the same reader the + * closure census uses, and it throws rather than defaulting when the seam is gone. + */ +const FLOOR = textInputFontSizeFloor(mobileDir) + +/** Every command, with how to set the document up for it and what it must produce. */ +const COMMANDS = [ + { label: 'H1', command: 'heading1', select: 'all', expect: 'h1' }, + { label: 'H2', command: 'heading2', select: 'all', expect: 'h2' }, + { label: 'H3', command: 'heading3', select: 'all', expect: 'h3' }, + { label: 'Bold', command: 'bold', select: 'word', expect: 'b,strong' }, + { label: 'Italic', command: 'italic', select: 'word', expect: 'i,em' }, + { label: 'Strike', command: 'strike', select: 'word', expect: 'strike,s,del' }, + { label: 'Bullet list', command: 'bulletList', select: 'all', expect: 'ul' }, + { label: 'Numbered list', command: 'orderedList', select: 'all', expect: 'ol' }, + { label: 'Checklist', command: 'taskList', select: 'all', expect: 'ul[data-type="taskList"]' }, + { label: 'Quote', command: 'quote', select: 'all', expect: 'blockquote' }, + { label: 'Inline code', command: 'inlineCode', select: 'word', expect: 'code' }, + { label: 'Code block', command: 'codeBlock', select: 'all', expect: 'pre' } +] + +/** Paragraph is the fifteenth, and it is the only one whose proof is a document it undoes. */ +const PARAGRAPH = { label: 'Body', command: 'paragraph' } + +/** + * The two that need a URL, and the element each inserts. + * + * The image's URL is this server's own, because an inserted `` is fetched: a name that does + * not resolve put a load failure in the console, and WebKit reports it where chromium does not. + * Serving it is also the stronger reading — the element the command inserted actually painted + * under the shipped policy rather than merely appearing in the markup. + */ +const IMAGE_PATH = '/inserted.png' +const URL_COMMANDS = [ + { label: 'Link', title: 'Link URL', path: '/linked', expect: 'a[href]' }, + { label: 'Image', title: 'Image URL', path: IMAGE_PATH, expect: 'img' } +] + +/** One transparent pixel, served for the image the Image command inserts. */ +const PIXEL = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', + 'base64' +) + +const ENGINES = [ + { + name: 'chromium', + // CI runs this against the runner's Google Chrome rather than paying for a download, the same + // override shape as every other render check here. + launch: () => { + const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER + return chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) }) + } + }, + { name: 'webkit', launch: () => webkit.launch({ headless: true }) } +] + +/** + * The page under test: the real component, mounted by the real React, with a handle on its props. + * + * Not a re-implementation. The controller, the mount, the document's own modules and the toolbar + * are all the behaviour under test, and a probe that called `runCommand` itself would prove nothing + * about any of them. + * + * `content` is fed back from `onChange`, which is what `MarkdownReader` does: a harness that held + * the prop still would have the controller replacing the document under every edit. + */ +const PAGE_ENTRY = ` +import { createElement, useEffect, useRef, useState } from 'react' +import { createRoot } from 'react-dom/client' +import { SafeAreaProvider } from 'react-native-safe-area-context' +import { MobileRichMarkdownEditor } from './MobileRichMarkdownEditor' + +/** + * What the route's own navigator supplies and a bare mount does not: react-navigation wraps every + * screen in a safe-area provider, and the URL modal's drawer reads the insets from it. Without one + * the modal throws where the page has no problem at all. + */ +const METRICS = { + frame: { x: 0, y: 0, width: 390, height: 844 }, + insets: { top: 0, left: 0, right: 0, bottom: 0 } +} + +function Harness() { + const [state, setState] = useState({ + mounted: true, + generation: 0, + content: '', + secondContent: '', + editable: true, + both: false + }) + const handle = useRef(null) + useEffect(() => { + globalThis.__orcaEditor = { + set: (next) => setState((previous) => ({ ...previous, ...next })), + changes: [], + secondChanges: [], + links: [], + insets: [], + dismiss: () => handle.current?.dismissKeyboard() + } + document.body.setAttribute('data-ready', 'yes') + }, []) + // Each editor holds its own content, which is what makes the two-surface case a measurement: + // sharing one prop would show the second's edit on the first whatever the document did. + const editor = (key, withHandle, content, onChanged) => + createElement(MobileRichMarkdownEditor, { + key, + ref: withHandle ? handle : undefined, + content, + editable: state.editable, + onChange: onChanged, + onOpenLink: (url) => globalThis.__orcaEditor.links.push(url), + onKeyboardInsetChange: (bottom) => globalThis.__orcaEditor.insets.push(bottom) + }) + return createElement( + SafeAreaProvider, + { initialMetrics: METRICS }, + createElement( + 'div', + { style: { display: 'flex', flexDirection: 'row', height: '100vh' } }, + state.mounted + ? createElement( + 'div', + { id: 'first-surface', style: { flex: 1, display: 'flex', minHeight: 0 } }, + editor('first-' + state.generation, true, state.content, (next) => { + globalThis.__orcaEditor.changes.push(next) + setState((previous) => ({ ...previous, content: next })) + }) + ) + : null, + state.both + ? createElement( + 'div', + { id: 'second-surface', style: { flex: 1, display: 'flex', minHeight: 0 } }, + editor('second', false, state.secondContent, (next) => { + globalThis.__orcaEditor.secondChanges.push(next) + setState((previous) => ({ ...previous, secondContent: next })) + }) + ) + : null + ) + ) +} + +createRoot(document.getElementById('root')).render(createElement(Harness)) +` + +/** + * A recorder over `window.ReactNativeWebView`, installed before the bundle runs. + * + * Ruling 19's claim on the page is an absence, and an absence needs an instrument: on the shell + * that object is the bridge's, so an editor message posted through it would put editor JSON into + * the bridge's own channel. Defined rather than left undefined, so "the page never reaches for it" + * is measured against something that would have answered. + */ +function installBridgeObjectRecorder() { + globalThis.__orcaBridgeReads = [] + const bridge = { + postMessage: (message) => globalThis.__orcaBridgeReads.push(`post ${String(message)}`) + } + Object.defineProperty(globalThis, 'ReactNativeWebView', { + configurable: true, + get: () => { + globalThis.__orcaBridgeReads.push('read') + return bridge + } + }) +} + +const bundles = mobileWebAppDependenciesPresent() +const describeEditor = bundles ? describe : describe.skip + +let scratch = null +let server = null +let origin = null + +beforeAll(async () => { + if (!bundles) { + return + } + // Inside mobile/ rather than the system temp dir: the entry resolves the component beside it, + // and esbuild resolves a bare specifier from the importer upward. + await mkdir(join(mobileDir, '.tmp'), { recursive: true }) + scratch = await mkdtemp(join(mobileDir, '.tmp', 'rich-markdown-render-')) + const outDir = join(scratch, 'bundle') + await mkdir(outDir, { recursive: true }) + await esbuild.build({ + absWorkingDir: mobileDir, + stdin: { + contents: PAGE_ENTRY, + resolveDir: componentDir, + loader: 'ts', + sourcefile: 'rich-markdown-check.ts' + }, + bundle: true, + format: 'esm', + outdir: outDir, + entryNames: 'rich-markdown-check', + target: ['es2022'], + jsx: 'automatic', + logLevel: 'silent', + nodePaths: [join(mobileDir, 'node_modules')], + alias: { 'react-native': 'react-native-web' }, + // The barrel re-exports a `LucideProvider` its own context module does not export, which is the + // same shape the app bundle carries this plugin for. + plugins: [lucideBarrelPlugin], + // `.web.jsx` and `.web.js` are here for the reason the app bundle has them: without them + // `react-native-svg`, which the toolbar's icons pull in, resolves its Fabric components and + // fails on `codegenNativeComponent`. + resolveExtensions: ['.web.tsx', '.web.ts', '.web.jsx', '.web.js', '.tsx', '.ts', '.jsx', '.js'], + // Four of `MOBILE_WEB_APP_SHIMS`, because this entry reaches the same React Native modules the + // app bundle does: RN ships untranspiled JSX in `.js`, reads `process.env` at module scope, and + // assumes a Metro `global` — measured, `isFabric` threw `global is not defined` before the page + // mounted at all, and every case in this file failed at `data-ready`. + loader: { '.js': 'jsx' }, + banner: { + js: "globalThis.process ??= { env: { NODE_ENV: 'production', EXPO_OS: 'web' }, platform: 'web', version: '', nextTick: (fn) => setTimeout(fn, 0) };" + }, + define: { + global: 'globalThis', + __DEV__: 'false', + 'process.env.NODE_ENV': '"production"', + 'process.env.EXPO_OS': '"web"' + } + }) + await writeFile( + join(outDir, 'index.html'), + // The root reset the shipped document carries: every box below the mount is `flex: 1`, so + // without a definite height on all three the editor measures 0 and paints nothing. + `${MOBILE_WEB_APP_ROOT_RESET}` + + '
' + + '' + ) + const served = await createBundleServer({ + outDir, + cspHeader: await readShellCsp(), + handleRequest: (_request, response, path) => { + if (path !== IMAGE_PATH) { + return false + } + response.writeHead(200, { 'content-type': 'image/png' }) + response.end(PIXEL) + return true + } + }) + server = served.server + origin = served.origin +}, 600_000) + +afterAll(async () => { + server?.close() + if (scratch) { + // This run's directory only: `mobile/.tmp` is a shared ignored root and another suite may hold + // one of its own. + await rm(scratch, { recursive: true, force: true }) + } +}) + +async function openPage(browser) { + const page = await browser.newPage({ viewport: { width: 390, height: 844 } }) + const consoleErrors = [] + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()) + } + }) + page.on('pageerror', (error) => consoleErrors.push(`pageerror: ${error.message}`)) + await page.addInitScript(installBridgeObjectRecorder) + await page.addInitScript(installCspViolationRecorder) + await page.addInitScript(installListenerRecorder) + await page.addInitScript(installSchedulerRecorder) + await page.goto(`${origin}/`, { waitUntil: 'domcontentloaded' }) + await page.waitForFunction(() => document.body.dataset.ready === 'yes') + await page.waitForSelector('#first-surface #editor') + return { page, consoleErrors } +} + +/** + * Sets the content through the component's prop and waits for the document to hold that markup. + * + * The oracle is the surface's whole `innerHTML`, not its text. Text cannot tell one block type from + * another — `### body text here` and `body text here` read the same — so a run that waited on text + * passed while the document was still the one the command before it left, and the next case selected + * a range inside an element that was not there any more. Measured: `setEnd` threw + * `IndexSizeError` in chromium and the fifteen-command loop timed out in webkit. + */ +async function setContent(page, markdown, html) { + await page.evaluate((next) => globalThis.__orcaEditor.set({ content: next }), markdown) + await page.waitForFunction( + (expected) => document.querySelector('#first-surface #editor')?.innerHTML === expected, + html, + { timeout: 15_000 } + ) +} + +/** + * The same, for the two documents whose markup this file does not write down. + * + * A checklist and a link render nested markup whose exact serialization is the engine's, so the + * wait names the element the case is about to act on instead. + */ +async function setContentWithin(page, markdown, selector) { + await page.evaluate((next) => globalThis.__orcaEditor.set({ content: next }), markdown) + await page.waitForFunction( + (expected) => + document.querySelector('#first-surface #editor')?.querySelector(expected) !== null, + selector, + { timeout: 15_000 } + ) +} + +/** + * The plain paragraph a command case starts from, numbered so no two are the same. + * + * The content prop is what resets the document, and the controller only pushes when it differs + * from what the editor last reported. One command breaks that: WebKit's `insertUnorderedList` + * nests the `