mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* test(mobile): measure mermaid rendered in the page Red-first for C7.10 item B. The check mounts the real web sibling in chromium and webkit under the shipped shell CSP and asks four things of it: that a diagram renders with zero policy violations and zero eval / new Function calls, that the SVG is the native buildHtml's own output once the diagram id and xmlns:xlink are normalised away, that a hostile diagram lands inert, and that a source change, an unmount and a remount leave exactly one SVG and no listener of the first mount. The equality oracle is buildHtml itself, bundled for Node behind a Proxy stub for its native imports and served as its own document in the same browser, so neither side of the comparison is retyped. All eight cases fail on this commit: the sibling is still the labelled source box. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): fence the session download rather than its module list Ruling 28. mobileWebAppRouteClosure reads metafile.inputs, which holds dynamically imported modules under splitting: true exactly as it does under splitting: false, so it cannot say "on demand" about anything: an on-demand mermaid moves the session route's module list 4320 -> 6362 while its download does not move at all. So the fence moves to entryStaticClosure. The new helper walks the emitted chunks from the output the route's own module landed in and follows import-statement edges only, and hands back both halves, because mermaid's absence from the download is only a measurement while its 66 files are present in the deferred half. The module list's new total is recorded in the docstring with its reason and asserted beside the engine's own file count, which moves only when the pinned mermaid version does. Red on this commit: no mermaid in the closure yet. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): render mermaid in the page The web sibling stops being a source box. mermaid is a browser library, so the page imports it inside the render effect and draws the diagram in this document: no WebView, no 3.7 MB engine string, and nothing of the engine downloaded by a session with no diagram on it. What replaces the sandbox is mermaid's own securityLevel: 'strict', which runs its serialized SVG through DOMPurify. The native path's </script> escaping has no analogue here and needs none, because the source is a JS string argument rather than text spliced into an inline script. Measured in both engines: a script in a label, a </script>, an onerror and a javascript: click all land inert. The configuration is now one object both hosts read, so the theme cannot drift between the page and the phone; buildHtml serializes it instead of holding a second copy. It gains suppressErrorRendering, because mermaid otherwise draws its own error diagram into a temporary element and leaves that element behind when it rethrows -- an orphan SVG on the page, and on native a diagram the component is about to replace with the source box anyway. The dispose clears the host on unmount and on a source change; the id is a useId, because mermaid writes it into the stylesheet inside the SVG and it has to be a CSS identifier. Also re-records the closure total the previous commit pinned: with the real component the session route's module list is 6376, not the design probe's 6362, and the reason is in that file's docstring. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): budget the deferred engine's chunks apart from the routes Putting mermaid on the page took the app bundle from 69 emitted scripts to 172, and the asset budget failed: 215 assets against a ceiling of 115. The cause is not a page split running away, which is what that ceiling is for -- it is that mermaid lazily imports each of its own diagram types, so one import() lands 103 scripts no route count predicts. So the ceiling gains a second term, named and measured (172 scripts with mermaid against 69 with it aliased to a stub, at 11.17.2), rather than the route term being raised to cover it. A page split running away still fails on the route term, and the failure still says which of the two grew. The consequence is worth reading twice: the derived ceiling has to stay inside the 256 assets the shell will load, and with 42 images it now crosses that at 24 routes instead of 50. The bundle is at 215 today with 14 routes, so there is room for about ten more routes before a green build produces a manifest no phone will open. Measured by the config/scripts suite failing on this head, not predicted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): pre-bundle the page's mermaid into one artifact import('mermaid') from inside the app bundle emitted 103 scripts, not one: mermaid lazily imports each of its own diagram types and esbuild splits along those boundaries. Every one of those scripts sits inside the OTA generation the phone has already downloaded, so the split moved no bytes over the wire and spent 103 of the 256 manifest assets the shell will load -- which is the scarce resource here, and the reason the previous commit had to invent a second ceiling term. So a sibling generator bundles the package into one ESM module beside the WebView engine it already builds, emitted by the same postinstall run, gitignored and lint-ignored with the others. The page imports that artifact on demand instead, through a loader whose return type names the two calls the component makes -- checked against the artifact's own inferred export rather than cast to it. Measured, at 14 routes: emitted scripts 172 -> 69 (68 with no deferred engine at all) manifest assets 215 -> 112 (111 with none) session modules 6376 -> 4323 (+3 over main: config, loader, artifact) chunks fetched for one graph TD 27 -> 1 bytes fetched 837,530 -> 3,482,965 The static-closure fence is unchanged in meaning and now reads on the artifact: absent from every chunk the route reaches by an import statement, present in the deferred half. The rendered SVG is byte-for- byte what it was, so the equality against the native document still holds on both engines. Also adds the diagram to the webview-consumers list, which is what that list means: its native component imports the package and its sibling is what the builder resolves instead. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * revert(mobile): drop the deferred-engine ceiling term, keep the control With the engine pre-bundled into one artifact the bundle emits 69 scripts at 14 routes against the route term's 72, so the second term this series added has nothing left to do and the route count is the only term again. mobileWebAppBundleMaxChunks and the asset ceiling derived from it are back to what main has; the shell's 256 assets are crossed at 50 routes again rather than at 24. What stays is why. A ceiling raised to admit 172 scripts would have admitted any split at all, so the budget test gains the control that holds the line: the single-artifact count passes the ceiling and the lazily-chunked count fails it, both measured at 14 routes, with mermaid named as what produced the second. Red before the term came out: the control failed asserting 172 > 175. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): keep build output out of the raw-request-port census The census walks mobile/src for AST reaches into the unvalidated request port, and the pre-bundled mermaid artifact is the first generated file under src that is executable code rather than a string literal. Two of its own vendored dependencies contain the token `sendRequest`, so the walk read minified third-party code as a new call site and asked for an inventory line nobody can ever migrate. So `*.generated.ts` joins node_modules and test files in that file's stated list of what it does not scan, with the reason. The scripts that emit those artifacts are ordinary source and are still scanned, which is where a real reach would be. Two halves to the new control, because a filter that skipped everything would satisfy either alone: nothing generated is left in the scan, and the matcher still finds the port when handed one line of code. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): escape the shared config into the native inline script buildHtml spliced JSON.stringify(MERMAID_DIAGRAM_CONFIG) straight into the inline <script>, twenty lines below the function that exists because JSON.stringify leaves `<`, `>`, `&` and the U+2028/9 separators raw. Inert at today's five hex colours, and not inert for a themeCSS or a font stack, which is free text going into the same script element. So the escaping splits from the stringify and both callers use it: the source keeps its own wrapper, the config gets one. Those characters only ever appear inside JSON string literals, so escaping them is valid for an object serialization exactly as it is for a string. Red first: a config carrying `</script><script>` put four raw closers in the document where a benign build has two. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): pin the page mermaid type against the package's own The loader returned the artifact's default as PageMermaid, which checked that two names exist and nothing about their shapes: the artifact is minified vendor output and both members infer as `any` there -- a probe assigning engine.render to a number compiles -- and `any` satisfies every signature there is. So the shapes are asserted against the package's `Mermaid`, which is precise. A PageMermaid member whose signature the engine does not really have now fails at this line rather than at a call the page makes. In the product module, not a test: mobile/tsconfig.json excludes test files, so a type-only assertion in one is never compiled. Underscored because it is a compile-time statement with no runtime reader, which is the form the linter asks for. Control, verified both ways: changing render to (id: number) => Promise<{ svg: number }> reds tsc naming both parameter and return, and the real signatures compile. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): re-measure the chunk series and say what it does not show The four-point series was stale and read as a slope it is not. Measured again on this head, by copying the route tree and dropping routes from the end of the sorted key list -- both siblings of each, because deleting a .web.tsx alone leaves the native file for the builder to resolve and measures an entirely different closure, which is how the first attempt at this produced 77 scripts for 14 routes: 8 routes -> 32 scripts 10 routes -> 43 12 routes -> 61 14 routes -> 69 (the real tree) Between four and nine more per route depending on which route, so 4r + 16 is a bound and not a fit, and the justification now says that instead of claiming three per route. It also says the part that matters more: at 14 routes the tree measures 69 against 72, and the last two routes cost the 8 the ceiling grants for two. The fence is at break-even, and the new assertion states that slope from the function rather than from a comment. Also records what the generation weighs, since every chunk ships in it whether or not a phone fetches one: 8,016,714 bytes across 112 assets against the 9 MiB ceiling, 84.9%, 1,420,470 left. It was 4,539,090 before item B, and the engine is the difference. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the native fallback under suppressErrorRendering The shared config reaches the phone too, and it gained a key the native path did not have. So the native document is now loaded for a diagram that throws, in both engines, with window.ReactNativeWebView standing in for the host: mermaid's run still rethrows, the document's own catch still posts `error`, and that is the message the component turns into the source box. Measured both ways, so the case says which half the key owns. Whether the fallback fires does not depend on it -- `error` is posted with the key and without it. What depends on it is that nothing is drawn behind the fallback: removing the key leaves mermaid's own error diagram in the document and reds this case at 1 SVG against 0, on chromium and webkit alike. The control is the same document for a diagram that parses: a height, not `error`, and one SVG. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): one walk for every source census, without build output Nine censuses under mobile/src each held a copy of the same recursive walk, and each decided for itself what a source file is: seven had no opinion about generated files, one excluded them in its own regex, and one had the exclusion I added last round. So all nine read 7.9 MB of emitted vendor code -- 3.7 MB of mermaid for the WebView, 3.5 MB of it for the page -- and the two largest censuses TypeScript-parsed all of it, looking for call sites nobody wrote and nobody can move. That is what took rpc-params-contract-type-only-boundary over its 5 s timeout in CI once the fifth artifact arrived. Measured here, median of 3, import plus tests: main, 4 artifacts, no exclusion 1004 ms (slowest case 831 ms) with the 5th, no exclusion 1513 ms (slowest case 1358 ms) with the 5th, this commit 947 ms (slowest case 788 ms) So it lands below where main has it, not merely below where I left it. Across the nine, four more halve: rpc-operation-cast-fence 769 -> 441, rpc-subscription-boundary 946 -> 468, unchecked-rpc-reader-boundary 1042 -> 538, lifecycle-owner 747 -> 433, reanimated-web-mapper-deps 1028 -> 516. The two that already excluded generated files do not move. What each census counts as interesting -- extensions, whether test files are in -- stays its own, because they genuinely disagree. What counts as a source file at all is now said once. The control is the file that started it: a *.generated.ts whose text holds exactly the import a census is hunting, planted beside an ordinary file carrying the same text. The generated one is not returned and the ordinary one is, so the absence is a measurement. A second control reads mobile/.gitignore and holds the predicate to every artifact the tree generates, and a third fences the walk itself to one spelling, so a tenth census cannot paste the cost back in. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): correct why the type pin sits in the product module The comment said a type-only pin in a test file "is never compiled". That is false: mobile/tsconfig.json excludes *.test.ts, but tsconfig.test.json is a second program that does check them, run by check:tests-typecheck and held by the tests-typecheck ratchet. The conclusion is unchanged and the reason is now the true one. The app's own typecheck is the unconditional gate and would not cover a pin written in a test; the test program is real but carries a grandfathered baseline and a few files held outside it on purpose. And the assertion is about this module's own type either way, so it belongs beside it. Comment only; tsc, the ratchet and both lints re-run on the file. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
565 lines
25 KiB
JavaScript
565 lines
25 KiB
JavaScript
/**
|
|
* Mermaid rendered in the page, in a real browser, under the policy the shell ships.
|
|
*
|
|
* The native component seals an untrusted diagram inside a `WebView` whose document embeds the
|
|
* whole engine as a string. The page has no second content process, so what replaces it is
|
|
* `import('mermaid')` on demand and mermaid's own `securityLevel: 'strict'` output. That makes
|
|
* three claims this file measures rather than asserts: that rendering violates no directive and
|
|
* asks for no JIT, that what the page paints is the diagram the phone already paints, and that a
|
|
* hostile diagram reaches the document inert.
|
|
*
|
|
* The equality oracle is the native `buildHtml` itself, bundled and served as its own document in
|
|
* the same browser. Two differences survive and are normalised away: the diagram id (mermaid's own
|
|
* `mermaid-<epoch>` on the native path, the component's `useId` on the page) and the `xmlns:xlink`
|
|
* declaration the native document's `innerHTML` serialization adds. Everything else — the viewBox,
|
|
* the `max-width`, the injected `<style>`'s rules — is compared byte for byte.
|
|
*
|
|
* Both engines, because the shell is WKWebView on one platform and a Chromium WebView on the
|
|
* other, and "does mermaid need eval" is answered by the engine rather than by mermaid.
|
|
*
|
|
* Recorded from this file's own run, for whoever needs the trade. Rendering one `graph TD` fetches
|
|
* one chunk of 3,482,965 minified bytes on top of a 283,956-byte entry — the pre-bundled engine,
|
|
* not in the entry, and not fetched at all by a page with no diagram on it (ruling 28's fence,
|
|
* held in `mobile-web-app-session-terminal-closure.test.mjs`). One chunk rather than the 103 that
|
|
* `import('mermaid')` emitted: mermaid splits along its own lazy diagram-type boundaries, all of
|
|
* which sit inside the generation the phone has already downloaded, so that split moved no bytes
|
|
* and spent 103 of the 256 manifest assets the shell will load. The native document pays 3,705,846
|
|
* bytes of engine string instead, in the closure, on every mount.
|
|
*
|
|
* The SVG itself: 17,143 bytes on the page against 17,504 in the native document (chromium; webkit
|
|
* is 8 longer on each side), equal at 15,447 once normalised. The gap is the id string repeated
|
|
* across 57 selectors, and dropping `xmlns:xlink` is load-bearing rather than cosmetic — the
|
|
* comparison fails without it.
|
|
*/
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import * as esbuild from 'esbuild'
|
|
import { chromium, webkit } from 'playwright-core'
|
|
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
|
|
import {
|
|
createBundleServer,
|
|
installCspViolationRecorder,
|
|
installListenerRecorder,
|
|
readShellCsp
|
|
} from './mobile-web-app-render-harness.mjs'
|
|
|
|
const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
|
|
const diagramDir = join(mobileDir, 'src/components/pr-sidebar')
|
|
|
|
/** The design's own fixture, so the byte counts in the docstring above name this diagram. */
|
|
const FIXTURE =
|
|
'graph TD\n A[Start] --> B{Choice}\n B -->|yes| C[Ship it]\n B -->|no| D[Fix it]\n D --> A'
|
|
|
|
/** A second valid diagram for the source change: a different shape, so a stale SVG is visible. */
|
|
const SECOND = 'graph LR\n One --> Two\n Two --> Three'
|
|
|
|
/**
|
|
* A script in a label, a `</script>` in a label, an `onerror` attribute and a `javascript:` click.
|
|
*
|
|
* The native path escapes `<`, `>` and the line separators because the source is spliced into an
|
|
* inline `<script>`; on the page it is a JS string argument and that escaping has no analogue, so
|
|
* the only fence left is mermaid's own strict-mode sanitiser. This is what measures it.
|
|
*/
|
|
const HOSTILE =
|
|
'graph TD\n A["<img src=x onerror=window.__pwned=1><script>window.__pwned=2<\\/script>"] --> B\n' +
|
|
' B --> C\n click A "javascript:window.__pwned=3"\n' +
|
|
' C --> D["</script><script>window.__pwned=4</script>"]'
|
|
|
|
/** Not a diagram in any grammar mermaid has, so `render` rejects and the component falls back. */
|
|
const BROKEN = 'graph TD\n A[[[unclosed'
|
|
|
|
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 of what the component does — the dispose, the fallback and the remount
|
|
* are the behaviour under test, and a probe that called `mermaid.render` itself would prove
|
|
* nothing about any of them.
|
|
*/
|
|
const PAGE_ENTRY = `
|
|
import { createElement, useEffect, useState } from 'react'
|
|
import { createRoot } from 'react-dom/client'
|
|
import { MermaidDiagram } from './MermaidDiagram'
|
|
|
|
function Harness() {
|
|
const [state, setState] = useState({ mounted: false, source: '' })
|
|
useEffect(() => {
|
|
globalThis.__orcaMermaidSet = setState
|
|
document.body.setAttribute('data-ready', 'yes')
|
|
}, [])
|
|
return state.mounted ? createElement(MermaidDiagram, { source: state.source, base: 15 }) : null
|
|
}
|
|
|
|
createRoot(document.getElementById('root')).render(createElement(Harness))
|
|
`
|
|
|
|
/**
|
|
* A stand-in for React, React Native and `react-native-webview`, so the native module can be
|
|
* bundled for Node to get its HTML.
|
|
*
|
|
* `buildHtml` is a pure function of the source and the theme, but it lives beside a component
|
|
* whose other imports are all native. CommonJS with a Proxy rather than a list of named exports:
|
|
* what that component reaches for is its own business, and none of it is called here.
|
|
*/
|
|
const IMPORT_STUB = `
|
|
const identity = (value) => value
|
|
module.exports = new Proxy(
|
|
{
|
|
StyleSheet: { create: (styles) => styles, hairlineWidth: 1 },
|
|
memo: identity,
|
|
default: identity
|
|
},
|
|
{ get: (target, key) => (key in target ? target[key] : identity) }
|
|
)
|
|
`
|
|
|
|
const bundles = mobileWebAppDependenciesPresent()
|
|
const describeMermaid = bundles ? describe : describe.skip
|
|
|
|
let scratch = null
|
|
let server = null
|
|
let origin = null
|
|
|
|
/** The native document's own render of `FIXTURE`, built from `buildHtml` and served as a page. */
|
|
async function buildNativeDocument(outDir) {
|
|
const stubPath = join(scratch, 'import-stub.cjs')
|
|
await writeFile(stubPath, IMPORT_STUB, 'utf8')
|
|
const nativeHtmlModule = join(scratch, 'native-html.mjs')
|
|
await esbuild.build({
|
|
absWorkingDir: mobileDir,
|
|
stdin: {
|
|
contents: "export { buildHtml } from './MermaidDiagram'\n",
|
|
resolveDir: diagramDir,
|
|
loader: 'ts',
|
|
sourcefile: 'native-html-entry.ts'
|
|
},
|
|
bundle: true,
|
|
format: 'esm',
|
|
platform: 'node',
|
|
outfile: nativeHtmlModule,
|
|
target: ['node20'],
|
|
jsx: 'automatic',
|
|
logLevel: 'silent',
|
|
nodePaths: [join(mobileDir, 'node_modules')],
|
|
// `resolveExtensions` is left at its default here, with no `.web.*`, so `./MermaidDiagram`
|
|
// resolves to the file the phone builds rather than to the sibling under test.
|
|
alias: {
|
|
react: stubPath,
|
|
'react/jsx-runtime': stubPath,
|
|
'react-native': stubPath,
|
|
'react-native-webview': stubPath
|
|
},
|
|
define: { __DEV__: 'false', 'process.env.NODE_ENV': '"production"' }
|
|
})
|
|
const { buildHtml } = await import(pathToFileURL(nativeHtmlModule).href)
|
|
await writeFile(join(outDir, 'native.html'), buildHtml(FIXTURE), 'utf8')
|
|
// The same document for a diagram that throws, because the shared config the page introduced
|
|
// reaches the phone too and one of its keys changes what mermaid does on that path.
|
|
await writeFile(join(outDir, 'native-broken.html'), buildHtml(BROKEN), 'utf8')
|
|
}
|
|
|
|
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', 'mermaid-render-'))
|
|
const outDir = join(scratch, 'bundle')
|
|
await mkdir(outDir, { recursive: true })
|
|
await esbuild.build({
|
|
absWorkingDir: mobileDir,
|
|
stdin: {
|
|
contents: PAGE_ENTRY,
|
|
resolveDir: diagramDir,
|
|
loader: 'ts',
|
|
sourcefile: 'mermaid-check.ts'
|
|
},
|
|
bundle: true,
|
|
// esm with splitting, because `import('mermaid')` has to be a chunk the browser fetches when
|
|
// the diagram renders. An iife would inline the engine into the entry, which is the one shape
|
|
// this item exists to avoid.
|
|
format: 'esm',
|
|
splitting: true,
|
|
// Minified, like the bundle the shell serves: the chunk count and the bytes one render fetches
|
|
// are numbers this file records, and an unminified bundle records neither.
|
|
minify: true,
|
|
outdir: outDir,
|
|
entryNames: 'mermaid-check',
|
|
chunkNames: 'chunk-[hash]',
|
|
target: ['es2022'],
|
|
jsx: 'automatic',
|
|
logLevel: 'silent',
|
|
nodePaths: [join(mobileDir, 'node_modules')],
|
|
alias: { 'react-native': 'react-native-web' },
|
|
// The web sibling is what the page runs; the native file reaches a WebView that a browser
|
|
// renders as a line of text.
|
|
resolveExtensions: ['.web.tsx', '.web.ts', '.web.js', '.tsx', '.ts', '.js'],
|
|
define: { __DEV__: 'false', 'process.env.NODE_ENV': '"production"' }
|
|
})
|
|
await writeFile(
|
|
join(outDir, 'index.html'),
|
|
'<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div>' +
|
|
'<script type="module" src="/mermaid-check.js"></script></body></html>'
|
|
)
|
|
await buildNativeDocument(outDir)
|
|
const served = await createBundleServer({ outDir, cspHeader: await readShellCsp() })
|
|
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 })
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Every `eval` and `new Function` attempted on the page, with the stack that asked for it.
|
|
*
|
|
* `script-src 'self'` carries no `'unsafe-eval'`, so a JIT call raises a violation too — but a
|
|
* library that catches its own `EvalError` and takes a slower path would leave that violation
|
|
* looking like noise from elsewhere. The stack is what names the caller, and it has to, because
|
|
* Playwright evaluates every one of this file's own page functions through `eval`: the calls on
|
|
* this list are mostly the harness's, and only the ones from the bundle's scripts are the page's.
|
|
*/
|
|
function installJitRecorder() {
|
|
globalThis.__orcaJit = []
|
|
const record = (kind, source) => {
|
|
// Line 0 is the error's own header and line 1 is this recorder; the rest is whoever asked.
|
|
const stack = (new Error('jit').stack ?? '').split('\n').slice(2).join(' | ')
|
|
globalThis.__orcaJit.push({ kind, source: String(source).slice(0, 60), stack })
|
|
}
|
|
// oxlint-disable-next-line eslint/no-eval -- SAFETY: the recorder holds the real eval so it can count and forward calls; naming it is this function's whole purpose.
|
|
const realEval = globalThis.eval
|
|
// oxlint-disable-next-line eslint/no-eval -- SAFETY: replacing eval with a counting wrapper is the measurement, not a call.
|
|
globalThis.eval = function (source) {
|
|
record('eval', source)
|
|
return realEval.call(globalThis, source)
|
|
}
|
|
const RealFunction = globalThis.Function
|
|
function PatchedFunction(...args) {
|
|
record('Function', args.map((one) => String(one).slice(0, 40)).join('|'))
|
|
return RealFunction.apply(this, args)
|
|
}
|
|
PatchedFunction.prototype = RealFunction.prototype
|
|
globalThis.Function = PatchedFunction
|
|
}
|
|
|
|
/**
|
|
* The JIT calls that came from the bundle rather than from the harness driving it.
|
|
*
|
|
* `__orcaJit` being non-empty is the precondition: an attribution filter over a list nothing ever
|
|
* wrote to answers "none from the page" for a recorder that was never installed.
|
|
*/
|
|
async function pageJitCalls(page) {
|
|
const all = await page.evaluate(() => globalThis.__orcaJit)
|
|
expect(all.length).toBeGreaterThan(0)
|
|
return all.filter((one) => /mermaid-check\.js|\/chunk-/.test(one.stack))
|
|
}
|
|
|
|
/** What the component has on the page: its frame, the SVG under it, and every SVG anywhere. */
|
|
function readDiagram() {
|
|
const frame = document.querySelector('[data-testid="mermaid-diagram"]')
|
|
const svg = frame?.querySelector('svg') ?? null
|
|
return {
|
|
framed: frame !== null,
|
|
inFrame: frame ? frame.querySelectorAll('svg').length : -1,
|
|
// Every SVG in the document, not only the framed one: mermaid renders into a temporary
|
|
// element of its own, and an orphan left in the body is invisible to a count under the host.
|
|
inDocument: document.querySelectorAll('svg').length,
|
|
sourceBox: document.querySelector('[data-testid="mermaid-diagram-source"]') !== null,
|
|
id: svg?.id ?? null,
|
|
html: svg?.outerHTML ?? null,
|
|
text: frame?.textContent ?? null
|
|
}
|
|
}
|
|
|
|
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(installJitRecorder)
|
|
await page.addInitScript(installCspViolationRecorder)
|
|
await page.addInitScript(installListenerRecorder)
|
|
await page.goto(`${origin}/`, { waitUntil: 'domcontentloaded' })
|
|
await page.waitForFunction(() => document.body.dataset.ready === 'yes')
|
|
return { page, consoleErrors }
|
|
}
|
|
|
|
/**
|
|
* Drives the harness and waits for the component to settle into a diagram or a fallback.
|
|
*
|
|
* `contains` is for a source change, where "an SVG is present" is already true of the diagram being
|
|
* replaced: naming a label only the new diagram carries is what makes the wait about the new one.
|
|
*/
|
|
async function show(page, source, contains = null) {
|
|
await page.evaluate(
|
|
(next) => globalThis.__orcaMermaidSet({ mounted: true, source: next }),
|
|
source
|
|
)
|
|
await page.waitForFunction((needle) => {
|
|
const frame = document.querySelector('[data-testid="mermaid-diagram"]')
|
|
if (frame === null) {
|
|
return false
|
|
}
|
|
const svg = frame.querySelector('svg')
|
|
if (needle !== null) {
|
|
return (svg?.textContent ?? '').includes(needle)
|
|
}
|
|
return svg !== null || document.querySelector('[data-testid="mermaid-diagram-source"]') !== null
|
|
}, contains)
|
|
}
|
|
|
|
async function unmount(page) {
|
|
await page.evaluate(() => globalThis.__orcaMermaidSet({ mounted: false, source: '' }))
|
|
await page.waitForFunction(
|
|
() => document.querySelector('[data-testid="mermaid-diagram"]') === null
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The two strings, with the only two differences the design measured taken out: the diagram id,
|
|
* which each host generates its own way, and the `xmlns:xlink` the native serialization adds.
|
|
*
|
|
* The id is read off the element rather than matched by a pattern, so a host that changes its id
|
|
* scheme normalises correctly instead of comparing an unreplaced string.
|
|
*/
|
|
function normaliseSvg(html, id) {
|
|
return html
|
|
.split(id)
|
|
.join('ID')
|
|
.replace(/ xmlns:xlink="[^"]*"/g, '')
|
|
}
|
|
|
|
/**
|
|
* The native document, loaded in the same browser, with the host it posts to standing in.
|
|
*
|
|
* `window.ReactNativeWebView` is what the WebView injects; the document's `post` is a no-op
|
|
* without it, so the message that drives the component's fallback would be unobservable. Recorded
|
|
* as a list because the two outcomes are told apart by what it posts: a height, or `error`.
|
|
*/
|
|
async function readNativeDocument(browser, file) {
|
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
|
|
try {
|
|
await page.addInitScript(() => {
|
|
globalThis.__orcaNativePosts = []
|
|
globalThis.ReactNativeWebView = {
|
|
postMessage: (message) => globalThis.__orcaNativePosts.push(String(message))
|
|
}
|
|
})
|
|
await page.goto(`${origin}/${file}`, { waitUntil: 'load' })
|
|
await page.waitForFunction(() => globalThis.__orcaNativePosts.length > 0, null, {
|
|
timeout: 120_000
|
|
})
|
|
return await page.evaluate(() => {
|
|
const svg = document.querySelector('#c svg')
|
|
return {
|
|
posts: globalThis.__orcaNativePosts,
|
|
svgs: document.querySelectorAll('svg').length,
|
|
html: svg?.outerHTML ?? null,
|
|
id: svg?.id ?? null
|
|
}
|
|
})
|
|
} finally {
|
|
await page.close()
|
|
}
|
|
}
|
|
|
|
describeMermaid(
|
|
'mermaid on the page',
|
|
() => {
|
|
for (const engine of ENGINES) {
|
|
describe(engine.name, () => {
|
|
let browser = null
|
|
|
|
beforeAll(async () => {
|
|
browser = await engine.launch()
|
|
}, 180_000)
|
|
|
|
afterAll(async () => {
|
|
await browser?.close()
|
|
})
|
|
|
|
it('paints the diagram the native document paints, with no violation and no JIT', async () => {
|
|
const { page, consoleErrors } = await openPage(browser)
|
|
const fetched = []
|
|
page.on('response', (response) => fetched.push(response.url()))
|
|
try {
|
|
await show(page, FIXTURE)
|
|
const shown = await page.evaluate(readDiagram)
|
|
expect(shown.inFrame).toBe(1)
|
|
// The precondition the absences below need: a diagram rendered, and it is mermaid's.
|
|
expect(shown.html).toContain('aria-roledescription="flowchart-v2"')
|
|
expect(await pageJitCalls(page)).toEqual([])
|
|
expect(await page.evaluate(() => globalThis.__orcaCspViolations)).toEqual([])
|
|
expect(consoleErrors).toEqual([])
|
|
// A CSS identifier, because mermaid writes `#<id>` into the stylesheet it puts inside
|
|
// the SVG; an id spelled `«r0»` would leave every one of those rules inert.
|
|
expect(shown.id).toMatch(/^[A-Za-z_][\w-]*$/)
|
|
// On demand, from here, and in one piece: the engine arrived as exactly one chunk the
|
|
// render asked for, and nothing was fetched off this origin. The count is the claim —
|
|
// importing the package rather than the artifact fetched 27 here and emitted 103 in
|
|
// the app bundle, which is what spends the shell's asset budget.
|
|
expect(fetched.filter((url) => url.includes('/chunk-'))).toHaveLength(1)
|
|
expect(fetched.filter((url) => !url.startsWith(origin))).toEqual([])
|
|
|
|
const native = await readNativeDocument(browser, 'native.html')
|
|
expect(normaliseSvg(shown.html, shown.id)).toBe(normaliseSvg(native.html, native.id))
|
|
} finally {
|
|
await page.close()
|
|
}
|
|
}, 600_000)
|
|
|
|
it('leaves the native document reporting a diagram that throws, with nothing drawn', async () => {
|
|
// The page's shared config reaches the phone as well, and `suppressErrorRendering` is a
|
|
// key the native path did not have before it. What must not change is that the component
|
|
// above the WebView still hears about a diagram that throws: `run` rethrows, the
|
|
// document's own catch posts `error`, and the component swaps in the source box.
|
|
const broken = await readNativeDocument(browser, 'native-broken.html')
|
|
expect(broken.posts).toEqual(['error'])
|
|
// And what the key does change: mermaid draws no error diagram of its own, so the
|
|
// document is empty behind the fallback rather than showing a diagram for a moment.
|
|
expect(broken.svgs).toBe(0)
|
|
|
|
// The control, the same document for a diagram that parses: a height, not `error`.
|
|
const rendered = await readNativeDocument(browser, 'native.html')
|
|
expect(rendered.posts).not.toContain('error')
|
|
expect(Number(rendered.posts[0])).toBeGreaterThan(0)
|
|
expect(rendered.svgs).toBe(1)
|
|
}, 600_000)
|
|
|
|
it('leaves one SVG across a source change, an unmount and a remount', async () => {
|
|
const { page, consoleErrors } = await openPage(browser)
|
|
const listeners = () => page.evaluate(() => globalThis.__orcaListeners.snapshot())
|
|
try {
|
|
const beforeAnyMount = await listeners()
|
|
await show(page, FIXTURE)
|
|
const first = await page.evaluate(readDiagram)
|
|
await unmount(page)
|
|
// A first mount installs listeners no dispose can take off, and they are not a leak:
|
|
// mermaid's own `window` `load` (inert under `startOnLoad: false`, and the module's
|
|
// rather than the mount's) and react-native-web's responder system, which arms itself
|
|
// on the first `View` the page renders. So the baseline a per-mount leak would move is
|
|
// the snapshot after one whole cycle, not the one before it — with mermaid's named,
|
|
// because a cycle that installed nothing would make the comparison below vacuous.
|
|
const afterEngineLoaded = await listeners()
|
|
expect(
|
|
Object.keys(afterEngineLoaded).filter((key) => !(key in beforeAnyMount))
|
|
).toContain('window load')
|
|
|
|
await show(page, FIXTURE)
|
|
await show(page, SECOND, 'Three')
|
|
const changed = await page.evaluate(readDiagram)
|
|
// One in the frame and one in the document: a diagram the first source left behind
|
|
// would be the second, wherever it hung.
|
|
expect(changed.inFrame).toBe(1)
|
|
expect(changed.inDocument).toBe(1)
|
|
expect(changed.html).not.toBe(first.html)
|
|
|
|
await unmount(page)
|
|
const gone = await page.evaluate(readDiagram)
|
|
expect(gone.framed).toBe(false)
|
|
expect(gone.inDocument).toBe(0)
|
|
// Two mounts and a source change later, the page is listening to exactly what it was
|
|
// after the first of them. A mount that registered anything of its own would show up
|
|
// here as the third.
|
|
expect(await listeners()).toEqual(afterEngineLoaded)
|
|
|
|
await show(page, FIXTURE)
|
|
const again = await page.evaluate(readDiagram)
|
|
expect(again.inFrame).toBe(1)
|
|
expect(again.inDocument).toBe(1)
|
|
expect(normaliseSvg(again.html, again.id)).toBe(normaliseSvg(first.html, first.id))
|
|
expect(await page.evaluate(() => globalThis.__orcaCspViolations)).toEqual([])
|
|
expect(consoleErrors).toEqual([])
|
|
} finally {
|
|
await page.close()
|
|
}
|
|
}, 600_000)
|
|
|
|
it('renders a hostile diagram inert', async () => {
|
|
const { page } = await openPage(browser)
|
|
try {
|
|
await show(page, HOSTILE)
|
|
const inert = await page.evaluate(() => {
|
|
const frame = document.querySelector('[data-testid="mermaid-diagram"]')
|
|
return {
|
|
rendered: frame?.querySelector('svg') !== null,
|
|
scripts: frame.querySelectorAll('script').length,
|
|
inlineHandlers: [...frame.querySelectorAll('*')].filter((element) =>
|
|
[...element.attributes].some((attribute) => attribute.name.startsWith('on'))
|
|
).length,
|
|
javascriptHrefs: [...frame.querySelectorAll('[*|href]')]
|
|
.map(
|
|
(element) =>
|
|
element.getAttribute('href') ?? element.getAttribute('xlink:href') ?? ''
|
|
)
|
|
.filter((href) => href.toLowerCase().startsWith('javascript:')).length,
|
|
pwned: globalThis.__pwned ?? null
|
|
}
|
|
})
|
|
// Rendered rather than refused, which is the whole point: the payload is carried into
|
|
// the document as data and does nothing there.
|
|
expect(inert.rendered).toBe(true)
|
|
expect(inert.scripts).toBe(0)
|
|
expect(inert.inlineHandlers).toBe(0)
|
|
expect(inert.javascriptHrefs).toBe(0)
|
|
expect(inert.pwned).toBeNull()
|
|
expect(await pageJitCalls(page)).toEqual([])
|
|
} finally {
|
|
await page.close()
|
|
}
|
|
}, 600_000)
|
|
|
|
it('falls back to the source when the diagram throws, and recovers from it', async () => {
|
|
const { page } = await openPage(browser)
|
|
try {
|
|
await show(page, BROKEN)
|
|
const failed = await page.evaluate(readDiagram)
|
|
expect(failed.sourceBox).toBe(true)
|
|
expect(failed.inFrame).toBe(0)
|
|
expect(failed.text).toContain('unclosed')
|
|
// The fallback is a state of this component, not a page with a diagram left on it:
|
|
// mermaid draws its own error diagram unless it is told not to.
|
|
expect(failed.inDocument).toBe(0)
|
|
|
|
// The control: the same component, the same mount, a diagram that parses. Named,
|
|
// because the fallback it is replacing already satisfies a bare settle.
|
|
await show(page, FIXTURE, 'Ship it')
|
|
const recovered = await page.evaluate(readDiagram)
|
|
expect(recovered.sourceBox).toBe(false)
|
|
expect(recovered.inFrame).toBe(1)
|
|
} finally {
|
|
await page.close()
|
|
}
|
|
}, 600_000)
|
|
})
|
|
}
|
|
},
|
|
3_600_000
|
|
)
|