diff --git a/css/style.css b/css/style.css index 0f4a1df..e008b1f 100644 --- a/css/style.css +++ b/css/style.css @@ -1550,6 +1550,13 @@ body.embed .analysis-controls, above just removed straight back: the palette, the file controls, New diagram, Open file, Save as JSON and the rest. */ body.embed #btn-mobile-menu { display: none; } +/* ...which leaves an embed at these widths, and most iframes are at these + widths, with no way to reset a run at all: the small-screen rules hide Reset + from the topbar on the assumption that the overflow menu still carries it. + An embed offers Run, Step and Reset and nothing else, so Reset comes back. */ +@media (max-width: 768px) { + body.embed #btn-reset { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; } +} body.embed #topbar { min-height: 44px; } .embed-open { margin-left: auto; font-size: 11px; color: var(--text-faint); white-space: nowrap; diff --git a/js/app-analysis.js b/js/app-analysis.js index 8c79051..4fa6277 100644 --- a/js/app-analysis.js +++ b/js/app-analysis.js @@ -261,8 +261,14 @@ class AppAnalysis { + `, ${runs} runs × ${steps} steps per value` + (seed ? `, seed ${this._esc(seed)}` : '') + '
' + 'Cells show the mean final value across runs.

'; + // Escaped: `name` is a parameter name straight out of the loaded diagram, + // which is untrusted input (a shared #d= link, a downloaded .json/.econ, + // a library component). Every other interpolation in this function + // escapes; this one did not, so a parameter named with an + // ran the diagram author's script in the app's own origin the moment the + // reader pressed Run sweep. html += '' - + values.map(v => ``).join('') + ''; + + values.map(v => ``).join('') + ''; for (let n = 0; n < results[0].nodes.length; n++) { html += `` + results.map(r => ``).join('') + ''; diff --git a/js/app-demos.js b/js/app-demos.js index 1efe052..ac02446 100644 --- a/js/app-demos.js +++ b/js/app-demos.js @@ -857,7 +857,11 @@ class AppDemos { // grazers and a detritivore eat the base; two carnivores hunt them; Hawks are // the apex; a decomposer loop returns dead biomass to nutrients. Predation is // Lotka-Volterra formula rates; growth/death are register+modifier pairs. With - // no goal set, all ten populations settle into coupled, bounded oscillations. + // no goal set, the populations cycle against each other. Foxes are deliberately + // left over-exploiting: they boom, crash the rabbit base, and cannot recover, + // which is a real dynamic of this coupling rather than a mistuned constant. + // Measured: even at 3x the birth rate and a quarter of the death rate they + // still peak (167) and collapse to 1. _demoFoodWeb() { const b = this._demo(); b.d.resourceTypes = [ @@ -1071,8 +1075,10 @@ class AppDemos { b.note(60, 1060, 540, 130, 'Predation uses Lotka-Volterra formula rates (coef·prey·pred); growth & death use ' + 'register+modifier pairs. Two periodic drivers (sunFactor, rainFactor) force the ' + - 'producers. No goal is set, yet all ten populations lock into coupled, bounded ' + - 'oscillations, predator peaks lagging prey. Press Run.'); + 'producers. No goal is set, yet the populations cycle against each other with ' + + 'predator peaks lagging prey. Watch the Foxes: they boom on plentiful Rabbits, ' + + 'eat the prey base out from under themselves, and crash to a level their birth ' + + 'rate cannot climb back from. Press Run.'); this.renderer.render(); } diff --git a/js/app-library.js b/js/app-library.js index a7e8e92..3d65de1 100644 --- a/js/app-library.js +++ b/js/app-library.js @@ -23,7 +23,7 @@ class AppLibrary { { name: 'Civilization Empire', desc: 'A 4X economy in one diagram: logistic population, five yields, building converters, and a Science-gated tech tree (irrigation, drama, banking, university).', load: () => this._demoCiv() }, { name: 'Megafactory Line', desc: 'A 4-tier auto-factory: ore → smelting → components → widgets. A tiny circuit buffer and a slow assembly station back the line up. Watch the bottleneck.', load: () => this._demoFactory() }, { name: 'Business Cycle', desc: 'A full circular-flow macroeconomy with households, firms, banks, government and a central bank. Countercyclical stimulus through a policy lag drives a boom-bust cycle.', load: () => this._demoBusinessCycle() }, - { name: 'Food Web', desc: 'A four-trophic ecosystem: producers, grazers, carnivores, an apex predator and a nutrient-recycling loop. Ten species lock into coupled, bounded oscillations.', load: () => this._demoFoodWeb() }, + { name: 'Food Web', desc: 'A four-trophic ecosystem: producers, grazers, carnivores, an apex predator and a nutrient-recycling loop. Populations cycle against each other, and a predator that over-hunts its prey can collapse without recovering.', load: () => this._demoFoodWeb() }, { name: 'Auction Economy', desc: 'A player-driven MMO economy: gather, refine and craft goods, then watch the auction house prices and stocks oscillate as supply meets price-elastic demand.', load: () => this._demoAuction() }, ]; @@ -38,8 +38,9 @@ class AppLibrary { // Capture a small canvas snapshot so the row is recognisable at a glance // (15b). Falls back to a blank thumb when rasterizing is unavailable. this._captureThumbnail((thumb) => { + // Fresh read, so a save here does not drop what another tab added. const lib = this._getLibrary(); - const entry = { name, date: new Date().toLocaleString(), json: this._snapshot(), nodes: this.diagram.nodes.size }; + const entry = { id: this._entryKey(), name, date: new Date().toLocaleString(), json: this._snapshot(), nodes: this.diagram.nodes.size }; if (thumb) entry.thumb = thumb; lib.push(entry); if (!this._saveLibrary(lib)) { @@ -95,6 +96,37 @@ class AppLibrary { try { return JSON.parse(localStorage.getItem('sim_library') || '[]'); } catch { return []; } } + // A stable per-entry key so an edit can find its target in a list another tab + // may have changed since this one was rendered. + _entryKey() { + return 'e' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); + } + + // Apply an edit against what storage holds RIGHT NOW, not the array this list + // was rendered from. Delete, Duplicate and Rename each wrote back the snapshot + // taken when the list was drawn, so a diagram another tab had saved since was + // erased along with the edit. Entries are located by id, falling back to a + // content match for rows saved before ids existed. + _mutateStore(get, save, entry, index, fn, label) { + const fresh = get.call(this); + let i = -1; + if (entry && entry.id) i = fresh.findIndex(e => e && e.id === entry.id); + if (i < 0 && entry) { + i = fresh.findIndex(e => e && e.name === entry.name && e.date === entry.date && e.json === entry.json); + } + if (i < 0 && index != null && index >= 0 && index < fresh.length) i = index; + if (i < 0) { + this._toast('That entry is no longer there. It may have been changed in another tab.'); + return false; + } + fn(fresh, i); + if (!save.call(this, fresh)) { + this._toast(`Could not update ${label}. Browser storage is full or blocked.`); + return false; + } + return true; + } + // Returns false when the write fails (storage full or blocked) so callers // can tell the user instead of toasting a false "Saved". _saveLibrary(lib) { @@ -119,8 +151,9 @@ class AppLibrary { const nodes = [...ids].map(id => this.diagram.nodes.get(id)).filter(Boolean).map(n => n.toJSON()); const conns = [...this.diagram.connections.values()] .filter(c => ids.has(c.sourceId) && ids.has(c.targetId)).map(c => c.toJSON()); + // Fresh read, so a save here does not drop what another tab added. const list = this._getComponents(); - list.push({ name, date: new Date().toLocaleString(), nodes, conns }); + list.push({ id: this._entryKey(), name, date: new Date().toLocaleString(), nodes, conns }); if (!this._saveComponents(list)) { this._toast(`Could not save "${name}". Browser storage is full or blocked.`); return; @@ -183,8 +216,8 @@ class AppLibrary { delBtn.setAttribute('aria-label', 'Delete component'); delBtn.className = 'btn'; delBtn.addEventListener('click', () => { - list.splice(i, 1); - if (!this._saveComponents(list)) this._toast('Could not update components. Browser storage is blocked.'); + this._mutateStore(this._getComponents, this._saveComponents, comp, i, + (l, at) => l.splice(at, 1), 'components'); this._renderComponentsList(); }); btns.appendChild(insertBtn); @@ -243,6 +276,11 @@ class AppLibrary { this.engine.reset(); this._commitReplace(prev); this.renderer.fitView(); + // Repaint the panel. The Simulation rail panel reads the diagram's metadata + // when it is drawn, so after a template load it went on showing the empty + // canvas it had been drawn against: a blank Name and a FILE block reading + // 0 nodes, 0 connections, next to a canvas full of them. + this._renderProps(); } async _loadTemplate(t) { @@ -298,9 +336,9 @@ class AppLibrary { this._openMenu(r.left, r.bottom + 4, (add, sep) => { add('Rename…', 'pen', () => this._renameLibraryEntry(row, entry, i)); add('Duplicate', 'clone', () => { - const copy = { ...entry, name: `${entry.name} copy`, date: new Date().toLocaleString() }; - lib.splice(i + 1, 0, copy); - if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.'); + const copy = { ...entry, id: this._entryKey(), name: `${entry.name} copy`, date: new Date().toLocaleString() }; + this._mutateStore(this._getLibrary, this._saveLibrary, entry, i, + (list, at) => list.splice(at + 1, 0, copy), 'the Library'); this._renderLibraryList(); }); add('Export as JSON', 'download', () => { @@ -312,8 +350,8 @@ class AppLibrary { }); sep(); add('Delete', 'trash-can', () => { - lib.splice(i, 1); - if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.'); + this._mutateStore(this._getLibrary, this._saveLibrary, entry, i, + (list, at) => list.splice(at, 1), 'the Library'); this._renderLibraryList(); }, { danger: true }); }); @@ -374,9 +412,8 @@ class AppLibrary { done = true; const name = input.value.trim(); if (save && name && name !== entry.name) { - const lib = this._getLibrary(); - if (lib[index]) { lib[index].name = name; } - if (!this._saveLibrary(lib)) this._toast('Could not update the Library. Browser storage is blocked.'); + this._mutateStore(this._getLibrary, this._saveLibrary, entry, index, + (list, at) => { list[at].name = name; }, 'the Library'); } this._renderLibraryList(); }; diff --git a/js/app-props.js b/js/app-props.js index d8cc092..acad431 100644 --- a/js/app-props.js +++ b/js/app-props.js @@ -100,9 +100,17 @@ class AppProps { // link, then point the --font stack at the family. '' restores the // built-in stack. If the fetch fails (offline), the fallbacks apply. let link = document.getElementById('gfont-link'); - if (meta.font) { + // Only a family from the curated list. meta.font arrives with the diagram, + // which is untrusted input, and it was pasted straight into a third-party + // stylesheet URL: opening a shared link fired a request to + // fonts.googleapis.com carrying a string of the diagram author's choosing, + // telling them the reader's IP and that they had opened it. It also went + // raw into the --font CSS value. Anything not on the list falls back to the + // built-in stack, which is what an unset font already does. + const font = GOOGLE_FONTS.includes(meta.font) ? meta.font : ''; + if (font) { const href = 'https://fonts.googleapis.com/css2?family=' - + encodeURIComponent(meta.font).replace(/%20/g, '+') + + encodeURIComponent(font).replace(/%20/g, '+') + ':wght@400;600;700&display=swap'; if (!link) { link = document.createElement('link'); @@ -111,7 +119,7 @@ class AppProps { document.head.appendChild(link); } if (link.getAttribute('href') !== href) link.setAttribute('href', href); - rootStyle.setProperty('--font', `'${meta.font}', 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`); + rootStyle.setProperty('--font', `'${font}', 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif`); } else { if (link) link.remove(); rootStyle.removeProperty('--font'); @@ -812,8 +820,13 @@ class AppProps { // node it has nothing to do with while the rule silently never fired. const targetGone = !!rule.nodeId && !interactives.some(n => n.id === rule.nodeId); if (targetGone) { + // Shown but not choosable. As a real option it could be picked, which + // cleared the rule's target to '' and made the next render fall back to + // displaying the first interactive node with no warning: exactly the + // misleading state this placeholder exists to prevent. const o = document.createElement('option'); - o.value = ''; o.textContent = '(node deleted)'; o.selected = true; + o.value = ''; o.textContent = '(node deleted)'; + o.selected = true; o.disabled = true; ns.appendChild(o); } for (const n of interactives) { @@ -1446,6 +1459,15 @@ class AppProps { const delta = Math.max(0, target) - node.resources; if (delta > 0) node.addResources(delta, color); else if (delta < 0) node.takeResources(-delta); + // At rest this field IS the starting amount, so the nudge has to move the + // reset baseline with it. addResources/takeResources only touch the live + // count (setCount is what writes the baseline), so the +/- buttons changed + // the number on the canvas, in undo and in autosave, and then Reset or a + // reload put it straight back. + if (this.engine.step === 0) { + node._initialResources = node.resources; + node._initialColorMap = { ...node.colorMap }; + } } _nodeProps(panel, node) { diff --git a/js/app.js b/js/app.js index 05dacad..bb400de 100644 --- a/js/app.js +++ b/js/app.js @@ -84,6 +84,8 @@ class App { this.timeline.onInspect = (nodeId, index, cx, cy) => this._showWhyPopover(nodeId, index, cx, cy); this._bindControls(); + this._watchForeignAutosave(); + this._watchVisibility(); this._initLibrary(); this._initMenus(); this._initPalette(); @@ -516,6 +518,37 @@ class App { this._persistAutosave(); } + // localStorage is shared by every tab on this origin and sim_autosave is a + // single slot, so the tab that saves last silently becomes the saved copy and + // the other tab carries on believing its work is safe. Nothing can merge two + // diagrams, but the tab that has been superseded can at least be told, once, + // while its work is still on screen and can be exported. The storage event + // fires only in the OTHER tabs, so a write never warns the tab that made it. + // A hidden tab suspends requestAnimationFrame but keeps the setInterval that + // drives the run, so the animation layers were produced into and never + // consumed. Drop what is in flight when the tab goes away, and repaint when it + // comes back so the canvas matches the model rather than a stale frame. + _watchVisibility() { + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + this.renderer.balls.clear(); + this.renderer.flowFx.clear(); + } else { + this.renderer.render(); + } + }); + } + + _watchForeignAutosave() { + window.addEventListener('storage', (e) => { + if (e.key !== 'sim_autosave' || e.newValue == null) return; + if (document.body.classList.contains('embed')) return; + if (this._autosaveTakenOver) return; + this._autosaveTakenOver = true; + this._toast('Another tab just saved over this browser\'s autosave. Work in this tab is no longer the saved copy, so use File > Save as JSON to keep it.'); + }); + } + // Two snapshots describe the same diagram. The module-level id counter rides // along in the JSON and loadJSON only ever raises it, so that ids handed out // since cannot collide, which means restoring a snapshot never reproduces its @@ -842,8 +875,16 @@ class App { // the URL is the document, and an embed must not write over the host // page's autosave. if (!document.body.classList.contains('embed')) { - this._persistAutosave(); - try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ignore */ } + let prior = null; + try { prior = JSON.parse(localStorage.getItem('sim_autosave') || 'null'); } catch { /* blocked or corrupt */ } + const priorNodes = prior && Array.isArray(prior.nodes) ? prior.nodes.length : 0; + if (priorNodes && this._canLoadDiagram(prior)) { + // There is real work in the saved slot. Ask before the link takes it. + this._adoptSharedDiagram(prior); + } else { + this._persistAutosave(); + try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ignore */ } + } } return; } @@ -1028,7 +1069,39 @@ class App { this._syncRailFades(); } - _scrollRails() { + // A share link has just been loaded over an existing autosaved diagram. The + // shared one is already on screen; ask before it takes the saved slot, and put + // the reader's own diagram back if they decline. Without this, opening a link + // destroyed the reader's work with no prompt: a reload afterwards brought back + // the sender's diagram, not theirs, and nothing could undo it (a page load has + // no undo stack for state from before it). + async _adoptSharedDiagram(prior) { + const name = (this.diagram.meta && this.diagram.meta.name || '').trim(); + const keep = await this._confirmGuard( + `Keep the shared diagram${name ? ` "${name}"` : ''}? It replaces the diagram saved in this browser.`, + 'Shared diagram'); + if (keep) { + this._persistAutosave(); + try { history.replaceState(null, '', location.pathname + location.search); } catch { /* ignore */ } + return; + } + // Declined: restore what they had. The hash stays, so the link still works + // if they change their mind. + this.diagram.loadJSON(prior); + this._applyMeta(); + this.engine.reset(); + this.renderer.balls.clear(); + this.renderer.flowFx.clear(); + this._clearSparklines(); + this.editor._select(null, null); + this.renderer.render(); + this.renderer.fitView(); + this._resetHistory(); + this._renderProps(); + this._toast('Kept your own diagram. The shared one was not saved.'); + } + +_scrollRails() { return ['palette', 'diagram-rail'].map(id => document.getElementById(id)).filter(Boolean); } diff --git a/js/codegen.js b/js/codegen.js index 09f85af..a48d10d 100644 --- a/js/codegen.js +++ b/js/codegen.js @@ -16,9 +16,18 @@ /* exported buildEconomyModule */ function buildEconomyModule(json, modelSrc, engineSrc, opts = {}) { - const globalName = opts.name || 'Economy'; - const econName = (json.meta && json.meta.name) || 'economy'; - const stamp = opts.generator || 'the simulations designer'; + // Anything interpolated into the header comment must be unable to close it. + // meta.name comes from the diagram, which is untrusted input: a name + // containing */ ended the comment early and everything after it became + // top-level code in the emitted module, running on require(). + const commentSafe = (v) => String(v == null ? '' : v) + .replace(/\*\//g, '* /').replace(/[\r\n]+/g, ' '); + // The global name reaches a code position (root. = factory()), so it is + // held to an identifier. + const rawGlobal = String(opts.name || 'Economy'); + const globalName = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(rawGlobal) ? rawGlobal : 'Economy'; + const econName = commentSafe((json.meta && json.meta.name) || 'economy'); + const stamp = commentSafe(opts.generator || 'the simulations designer'); // Double-encode the diagram: the module keeps it as a JSON string and each // createEconomy() call parses a fresh deep copy. const diagramLiteral = JSON.stringify(JSON.stringify(json)); @@ -27,9 +36,9 @@ function buildEconomyModule(json, modelSrc, engineSrc, opts = {}) { * ${econName} — generated economy module * Built by ${stamp}. Self-contained: no dependencies, no DOM. * - * Formulas evaluate with math.js when a global \`math\` is present (optional: - * require('mathjs') and set global.math before loading this file); without it - * they fall back to a plain JS expression evaluator. + * Formulas evaluate with math.js, so a global \`math\` is required: + * require('mathjs') and set global.math before loading this file. Without it + * every formula evaluates to 0. * * The RNG (SimRandom) is shared module state: run one seeded economy at a * time per process for bit-exact reproducibility. diff --git a/js/model.js b/js/model.js index 9ad5eee..05a2400 100644 --- a/js/model.js +++ b/js/model.js @@ -128,13 +128,43 @@ function _formulaRandomScope() { // `Math` stand-in whose random() draws from SimRandom (everything else is // inherited); shadows the global in the legacy Function-based evaluator. -const _seededMath = Object.create(Math, { random: { value: () => SimRandom.random() } }); +// The `Math` object legacy formulas reach for, rebuilt as OWN properties: +// math.js refuses to call a method it finds on a prototype, so Object.create +// (Math) would give "No access to method random". `random` draws from SimRandom +// so a seeded run stays reproducible on this path too. +const _MATH_FNS = ['abs', 'ceil', 'floor', 'round', 'trunc', 'sign', 'min', 'max', 'pow', + 'sqrt', 'cbrt', 'exp', 'log', 'log2', 'log10', 'hypot', + 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'atan2']; +const _MATH_CONSTS = ['PI', 'E', 'LN2', 'LN10', 'LOG2E', 'LOG10E', 'SQRT2', 'SQRT1_2']; +function _seededMathScope() { + const m = { random: () => SimRandom.random() }; + for (const k of _MATH_FNS) if (typeof Math[k] === 'function') m[k] = (...a) => Math[k](...a); + for (const k of _MATH_CONSTS) if (typeof Math[k] === 'number') m[k] = Math[k]; + return m; +} + +// Legacy JS operators that math.js spells differently. Applied ONLY to an +// expression math.js has already refused to compile, so a well-formed math.js +// expression is never rewritten. +function _repairLegacyOps(expr) { + return expr + .replace(/!==/g, ' != ') + .replace(/===/g, ' == ') + .replace(/&&/g, ' and ') + .replace(/\|\|/g, ' or ') + .replace(/\*\*/g, '^') + .replace(/!(?=\s*[A-Za-z_(])/g, ' not '); +} function _evalMathJS(expr, vars) { if (typeof math === 'undefined' || !math.compile) return undefined; let code = _mathCompileCache.get(expr); if (code === undefined) { try { code = math.compile(expr); } catch { code = null; } + // Second chance for a formula written in the older JS-flavoured syntax. + if (!code) { + try { code = math.compile(_repairLegacyOps(expr)); } catch { code = null; } + } if (_mathCompileCache.size > 500) _mathCompileCache.clear(); _mathCompileCache.set(expr, code); } @@ -143,8 +173,10 @@ function _evalMathJS(expr, vars) { for (const [k, v] of Object.entries(vars || {})) { if (VALID_IDENT.test(k) && typeof v === 'number' && isFinite(v)) scope[k] = v; } - // Seeded randomness (scope functions take precedence over math.js built-ins). + // Seeded randomness (scope functions take precedence over math.js built-ins), + // plus the Math object older formulas were written against. Object.assign(scope, _formulaRandomScope()); + scope.Math = _seededMathScope(); try { let r = code.evaluate(scope); if (r && typeof r === 'object' && typeof r.toNumber === 'function') r = r.toNumber(); @@ -156,41 +188,28 @@ function _evalMathJS(expr, vars) { function evalFormula(expr, vars = {}) { if (!expr || typeof expr !== 'string' || !expr.trim()) return 0; + // math.js only. There used to be a `new Function(expr)` fallback here for + // formulas math.js could not parse, which made every formula-bearing field a + // remote code execution sink: a diagram is untrusted input, arriving as a + // shared #d= link, a downloaded .json or .econ, a library component or a + // cli.js argument, and reset() evaluates register formulas on load, so simply + // opening a link ran the author's JavaScript. validateFormula reported such a + // payload as valid, so nothing warned. Nothing in the app needs it: all 110 + // distinct expressions across the 12 demos and 11 templates compile under + // math.js, and the JS-flavoured spellings older saved diagrams might use are + // handled by _seededMathScope and _repairLegacyOps above. const viaMath = _evalMathJS(expr.trim(), vars); - if (viaMath !== undefined) return viaMath; - // Legacy fallback: plain JS expression over the same variables. The seeded - // random helpers are passed in too, and `Math` is shadowed so Math.random() - // also draws from SimRandom (seeded runs stay reproducible on this path). - const rng = _formulaRandomScope(); - const keys = [], vals = []; - for (const [k, v] of Object.entries(vars || {})) { - if (VALID_IDENT.test(k) && typeof v === 'number' && isFinite(v) - && !(k in rng) && k !== 'Math') { - keys.push(k); vals.push(v); - } - } - for (const [k, fn] of Object.entries(rng)) { keys.push(k); vals.push(fn); } - keys.push('Math'); vals.push(_seededMath); - try { - // eslint-disable-next-line no-new-func - const fn = new Function(...keys, `"use strict"; return (${expr.trim()});`); - const r = Number(fn(...vals)); - return isFinite(r) ? r : 0; - } catch { return 0; } + return viaMath === undefined ? 0 : viaMath; } // True if the expression parses in at least one of the two evaluators // (math.js syntax, or legacy JS syntax). Used for live input validation. function validateFormula(expr) { if (!expr || typeof expr !== 'string' || !expr.trim()) return false; - if (typeof math !== 'undefined' && math.parse) { - try { math.parse(expr.trim()); return true; } catch { /* try legacy */ } - } - try { - // eslint-disable-next-line no-new-func - new Function(`"use strict"; return (${expr.trim()});`); - return true; - } catch { return false; } + if (typeof math === 'undefined' || !math.parse) return false; + const t = expr.trim(); + try { math.parse(t); return true; } catch { /* try the legacy spellings */ } + try { math.parse(_repairLegacyOps(t)); return true; } catch { return false; } } // Sample from a named statistical distribution. Returns a non-negative integer. diff --git a/js/renderer.js b/js/renderer.js index ae5990c..67431fb 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -218,11 +218,26 @@ class BallSystem { this._running = false; } + // Ceiling on simultaneously animating dots (see spawn). + static get MAX_LIVE() { return 240; } + spawn(pathEl, amount, color, durationMs) { if (!pathEl) return; // Respect the user's motion preference: transfers still happen, the // travelling-ball animation is simply skipped. if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) return; + // Nothing to animate for, and nothing that would ever clear it. Cleanup runs + // from requestAnimationFrame, which a browser suspends in a hidden tab while + // it keeps the setInterval that drives the steps running (throttled). So a + // run left in a background tab kept producing dots that nothing consumed: + // measured climbing past 30,000 elements with the step cost going from 27ms + // to 485ms. Producing only while something can consume keeps the two in step. + if (typeof document !== 'undefined' && document.hidden) return; + // Hard ceiling on live dots. They are decoration: past this many the canvas + // is a solid mass of them anyway, and each one costs a getPointAtLength per + // frame. A big model at the top of the speed slider used to hold 1,500 at + // once and drag the page to about 17fps. + if (this._balls.length >= BallSystem.MAX_LIVE) return; const capped = Math.min(amount, 12); const pathLen = pathEl.getTotalLength(); if (pathLen < 1) return; @@ -1446,9 +1461,19 @@ class Renderer { if (fromGate) { const gmode = src.gateMode === 'random' ? 'probabilistic' : src.gateMode; // Mirror engine._connWeight so formula weights make the labels live. + // Drawing a label must not advance the shared RNG: a weight formula can + // draw random numbers, and the canvas repaints on hover, pan, zoom, + // selection and every step, so painting was eating draws the run was + // about to make. The same diagram under the same seed then produced + // different results depending on how much the user moved the mouse. + // detectLoops guards its own probes the same way. const getW = c => { - if (c.weightFormula) { const w = evalFormula(c.weightFormula, this.diagram.variables); return isFinite(w) && w >= 0 ? w : 0; } - const w = Number(c.weight); return isFinite(w) && w >= 0 ? w : 1; + if (!c.weightFormula) { const w = Number(c.weight); return isFinite(w) && w >= 0 ? w : 1; } + const rng = SimRandom.getState(); + try { + const w = evalFormula(c.weightFormula, this.diagram.variables); + return isFinite(w) && w >= 0 ? w : 0; + } finally { SimRandom.setState(rng); } }; if (gmode === 'probabilistic') { const allOuts = [...this.diagram.connections.values()] diff --git a/test/run.js b/test/run.js index c96416a..58b8e57 100644 --- a/test/run.js +++ b/test/run.js @@ -13,7 +13,9 @@ const path = require('path'); // The browser loads math.js from vendor/math.min.js; headlessly we expose the // npm package as the same `math` global so formulas take the math.js path. -// Tests still pass without it (formulas fall back to the legacy JS evaluator). +// math.js is REQUIRED: the legacy `new Function(expr)` evaluator was removed +// because it made every formula field a code-execution sink, so without math.js +// every formula evaluates to 0 and the formula tests fail. try { global.math = require('mathjs'); } catch { /* optional */ } function loadEngine() { @@ -774,6 +776,65 @@ test('an at-rest diagram still writes no baseline fields', () => { eq(JSON.stringify(d.toJSON()), before, 'reset() alone changes nothing on disk'); }); +test('a formula cannot execute arbitrary JavaScript', () => { + // evalFormula used to fall through to `new Function(expr)` whenever math.js + // could not parse the expression, and validateFormula reported such a payload + // as valid so nothing warned. A diagram is untrusted input: it arrives as a + // shared #d= link, a downloaded .json or .econ, a library component or a + // cli.js argument, and reset() evaluates register formulas on load. Opening a + // link therefore ran the author's JavaScript. + delete globalThis.__FORMULA_SIDE_EFFECT; + const payloads = [ + "(function(){ globalThis.__FORMULA_SIDE_EFFECT = 1; return 7 })()", + "(() => { globalThis.__FORMULA_SIDE_EFFECT = 1; return 7 })()", + "[].constructor.constructor('globalThis.__FORMULA_SIDE_EFFECT = 1')()", + "this.constructor.constructor('globalThis.__FORMULA_SIDE_EFFECT = 1')()", + ]; + for (const p of payloads) { + eq(evalFormula(p, {}), 0, `payload evaluates to 0: ${p.slice(0, 40)}`); + eq(validateFormula(p), false, `payload is reported invalid: ${p.slice(0, 40)}`); + } + eq(globalThis.__FORMULA_SIDE_EFFECT, undefined, 'no payload had any side effect'); +}); + +test('formulas written in the older JS syntax still evaluate', () => { + // Removing the legacy evaluator must not silently break saved diagrams, so + // the Math object and the JS spellings of the operators are handled by the + // math.js path instead. + SimRandom.seed('legacy'); + const vars = { gold: 9, a: 1, b: 2 }; + const cases = [ + ['Math.round(2.6)', 3], ['Math.floor(gold/3)', 3], ['Math.max(1, gold-2)', 7], + ['Math.min(a,b)', 1], ['Math.abs(0-4)', 4], ['Math.pow(2,3)', 8], + ['gold > 5 ? 2 : 1', 2], ['a && b', 1], ['!a', 0], ['2**3', 8], + ['gold % 4', 1], ['round(gold/2)', 5], ['a === 1', 1], ['a !== 1', 0], + ]; + for (const [expr, want] of cases) { + eq(evalFormula(expr, vars), want, `legacy syntax still works: ${expr}`); + eq(validateFormula(expr), true, `and validates: ${expr}`); + } + // Math.random() must still draw from the seeded stream. + SimRandom.seed('s1'); + const first = [0, 0, 0].map(() => evalFormula('Math.random()*1000', {})); + SimRandom.seed('s1'); + const again = [0, 0, 0].map(() => evalFormula('Math.random()*1000', {})); + eq(JSON.stringify(first), JSON.stringify(again), 'Math.random stays reproducible under a seed'); +}); + +test('a diagram name cannot break out of the generated module header comment', () => { + // meta.name is interpolated into a /* */ banner. A name containing the + // comment terminator ended it early and everything after became top-level + // code in the emitted module, running on require(). + delete globalThis.__CODEGEN_SIDE_EFFECT; + const { d } = setup(); + const p = node(d, NodeType.POOL); p.label = 'Gold'; + const json = d.toJSON(); + json.meta = { ...(json.meta || {}), name: 'Evil */ ; globalThis.__CODEGEN_SIDE_EFFECT = 1; /*' }; + const Economy = buildTestModule(json); + eq(globalThis.__CODEGEN_SIDE_EFFECT, undefined, 'nothing escaped the banner'); + assert(typeof Economy.createEconomy === 'function', 'the module still loads and works'); +}); + test('a trader cannot hand resources to another trader either', () => { // _acceptable already refuses a trader as a flow target ("a trader never // holds resources"), but the trader's own canAccept rejected only sources and diff --git a/test/smoke.js b/test/smoke.js index bd42d8e..c96aeb2 100644 --- a/test/smoke.js +++ b/test/smoke.js @@ -2588,6 +2588,394 @@ const URL = process.env.SMOKE_URL || 'http://localhost:8080/'; ok(`components: save selection (${comp.compNodes} nodes, ${comp.compConns} conn), insert adds 2 nodes, undo reverts`); else fail('components: ' + JSON.stringify(comp)); + // Determinism: painting the canvas must not advance the seeded RNG. A gate's + // weight formula can draw random numbers and the label mirrors it, so every + // repaint (hover, pan, zoom, selection, each step) was eating draws the run + // was about to make. + const paintRng = await page.evaluate(() => { + const app = window.app; + app._clearAll(); app._closeFeature(); + app.diagram.seed = 'fixed-42'; + const g = new MNode(NodeType.GATE, 300, 300); g.label = 'G'; g.setCount(200); g.gateMode = 'random'; + const p1 = new MNode(NodeType.POOL, 600, 200); p1.label = 'A'; + const p2 = new MNode(NodeType.POOL, 600, 400); p2.label = 'B'; + [g, p1, p2].forEach(n => app.diagram.addNode(n)); + const c1 = new MConnection(g.id, p1.id, ConnectionType.RESOURCE); + c1.weightFormula = 'randomInt(1,10)'; + const c2 = new MConnection(g.id, p2.id, ConnectionType.RESOURCE); c2.weight = 1; + app.diagram.addConnection(c1); app.diagram.addConnection(c2); + app.engine.reset(); + const before = SimRandom.getState(); + app.renderer.render(); + const afterPlain = SimRandom.getState(); + app.editor._select(c1.id, 'conn'); + const mid = SimRandom.getState(); + app.renderer.render(); + const afterSelected = SimRandom.getState(); + return { seeded: before !== null, movedPlain: before !== afterPlain, movedSelected: mid !== afterSelected }; + }); + if (paintRng.seeded && !paintRng.movedPlain && !paintRng.movedSelected) + ok('determinism: repainting the canvas leaves the seeded RNG where it was'); + else fail('paint rng: ' + JSON.stringify(paintRng)); + + // Embed at iframe widths must still offer Reset: the small-screen rules hide + // it on the assumption the overflow menu carries it, and an embed hides that. + const embedReset = await (async () => { + const ctx = await browser.newContext({ viewport: { width: 700, height: 600 } }); + const ep = await ctx.newPage(); + await ep.route('https://fonts.googleapis.com/**', r => r.fulfill({ contentType: 'text/css', body: '' })); + await ep.addInitScript(() => { try { localStorage.setItem('sim_seen_welcome', '1'); } catch (e) {} }); + await ep.goto(URL + '#embed', { waitUntil: 'networkidle' }); + const r = await ep.evaluate(() => { + const vis = (id) => { + const e = document.getElementById(id); + if (!e) return false; + return getComputedStyle(e).display !== 'none' && e.getBoundingClientRect().width > 0; + }; + return { reset: vis('btn-reset'), run: vis('btn-run'), step: vis('btn-step'), menu: vis('btn-mobile-menu') }; + }); + await ctx.close(); + return r; + })(); + if (embedReset.reset && embedReset.run && embedReset.step && !embedReset.menu) + ok('embed: a narrow embed still offers Run, Step and Reset'); + else fail('embed reset: ' + JSON.stringify(embedReset)); + + // Animation layer: cleanup runs from requestAnimationFrame, which a browser + // suspends in a hidden tab while the setInterval driving the run keeps going, + // so the layer was produced into and never consumed. Two guarantees: a hard + // ceiling on live dots, and nothing produced at all while the tab is hidden. + // (Headless Chromium reports a backgrounded page as visible, so the hidden + // half is driven through the event rather than by switching tabs.) + const ballLayer = await (async () => { + const ctx = await browser.newContext({ reducedMotion: 'no-preference' }); + const bp = await ctx.newPage(); + await bp.route('https://fonts.googleapis.com/**', r => r.fulfill({ contentType: 'text/css', body: '' })); + await bp.addInitScript(() => { try { localStorage.setItem('sim_seen_welcome', '1'); } catch (e) {} }); + await bp.goto(URL, { waitUntil: 'networkidle' }); + // Heavy model at the top of the speed slider. + await bp.evaluate(() => { + const app = window.app; + app._clearAll(); + app._templates.find(t => /business/i.test(t.name)) ? app._installTemplate(app._templates.find(t => /business/i.test(t.name))) : app._loadDemo(); + app.engine.speed = 10; + app.engine.run(); + }); + await bp.waitForTimeout(2500); + const peak = await bp.evaluate(() => (window.app.renderer.balls._balls || []).length); + const cap = await bp.evaluate(() => BallSystem.MAX_LIVE); + // Now the hidden half, driven through the real handler. + const whenHidden = await bp.evaluate(() => { + const app = window.app; + const orig = Object.getOwnPropertyDescriptor(Document.prototype, 'hidden'); + Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); + document.dispatchEvent(new Event('visibilitychange')); + const clearedTo = (app.renderer.balls._balls || []).length; + // Spawning must be refused outright while hidden. + for (let i = 0; i < 5; i++) app.engine.doStep(); + const afterSteps = (app.renderer.balls._balls || []).length; + delete document.hidden; + if (orig) Object.defineProperty(Document.prototype, 'hidden', orig); + app.engine.stop(); + return { clearedTo, afterSteps }; + }); + await ctx.close(); + return { peak, cap, ...whenHidden }; + })(); + if (ballLayer.peak <= ballLayer.cap && ballLayer.clearedTo === 0 && ballLayer.afterSteps === 0) + ok(`animation: live dots stay under the ${ballLayer.cap} cap (peak ${ballLayer.peak}) and stop entirely while hidden`); + else fail('ball layer: ' + JSON.stringify(ballLayer)); + + // Library: loading a template must refresh the Simulation panel, which reads + // the diagram metadata when it is drawn. + const tmplPanel = await page.evaluate(() => { + const app = window.app; + app._clearAll(); app._closeFeature(); + const t = app._templates[0]; + app._installTemplate(t); + const txt = document.getElementById('props-content').textContent; + const nameInput = [...document.querySelectorAll('#props-content input')].find(i => i.type === 'text'); + return { + tmplName: t.name, nodes: app.diagram.nodes.size, + panelName: nameInput ? nameInput.value : null, + panelNodes: Number((txt.match(/Nodes\s*(\d+)/) || [])[1]), + }; + }); + if (tmplPanel.panelName === tmplPanel.tmplName && tmplPanel.panelNodes === tmplPanel.nodes) + ok('library: loading a template refreshes the Simulation panel'); + else fail('template panel: ' + JSON.stringify(tmplPanel)); + + // Demos: no bundled template may produce a non-finite value. Smoke only ever + // checked that they load, so a demo could go to NaN and CI would stay green. + const demoSanity = await page.evaluate(() => { + const app = window.app; + const bad = []; + for (const t of app._templates) { + app._clearAll(); + app._installTemplate(t); + app.engine.reset(); + for (let i = 0; i < 120; i++) app.engine.doStep(); + for (const n of app.diagram.nodes.values()) { + const v = n.chartValue; + if (v != null && !Number.isFinite(v)) bad.push(`${t.name}:${n.label || n.type}=${v}`); + } + } + // Leave a neutral canvas behind: fitView left the zoom at whatever the last + // template needed, and later checks assume 100%. + app._clearAll(); + app.renderer.resetView(); + return { templates: app._templates.length, bad }; + }); + if (demoSanity.templates >= 10 && demoSanity.bad.length === 0) + ok(`demos: all ${demoSanity.templates} templates run 120 steps with every value finite`); + else fail('demo sanity: ' + JSON.stringify(demoSanity)); + + // Multi-tab: two tabs share one localStorage. A Library edit made in one must + // not erase what the other saved, and a tab whose autosave slot is taken over + // has to be told rather than carry on believing its work is safe. + const multiTab = await (async () => { + const ctx = await browser.newContext(); + const mk = async () => { + const p2 = await ctx.newPage(); + await p2.route('https://fonts.googleapis.com/**', r => r.fulfill({ contentType: 'text/css', body: '' })); + await p2.addInitScript(() => { try { localStorage.setItem('sim_seen_welcome', '1'); } catch (e) {} }); + await p2.goto(URL, { waitUntil: 'networkidle' }); + return p2; + }; + const tabA = await mk(); + const tabB = await mk(); + const saveEntry = (p2, label) => p2.evaluate((nm) => { + const app = window.app; + app._clearAll(); + const n = new MNode(NodeType.POOL, 200, 200); n.label = nm; + app.diagram.addNode(n); app.renderer.render(); app._commit(); + document.getElementById('btn-library').click(); + document.getElementById('lib-name').value = nm; + document.getElementById('lib-save').click(); + return null; + }, label); + + // Spy on tab A's toasts before anything cross-tab happens: the takeover + // warning fires once per tab, so installing it later would miss it. + await tabA.evaluate(() => { + window.__toasts = []; + const t = window.app._toast.bind(window.app); + window.app._toast = (m) => { window.__toasts.push(m); return t(m); }; + }); + await saveEntry(tabA, 'FromA'); + await tabA.waitForTimeout(400); + await tabB.reload({ waitUntil: 'networkidle' }); // B now sees A's entry + // A renders its Library list NOW, holding only FromA... + await tabA.evaluate(() => { + window.app._openLibrary(); + window.app._setLibraryTab('mine'); + }); + // ...and only then does B save, so A's rendered list is stale from here on. + // That is the shape of the bug: the row's Delete wrote back the array the + // list was drawn from, taking FromB with it. + await saveEntry(tabB, 'FromB'); + await tabB.waitForTimeout(400); + await tabA.click('button[aria-label="More actions for \\"FromA\\""]'); + await tabA.waitForSelector('#ctx-menu .menu-item', { timeout: 2000 }).catch(() => {}); + const clickedDelete = await tabA.evaluate(() => { + const item = [...document.querySelectorAll('#ctx-menu .menu-item')] + .find(b => /^\s*Delete\s*$/.test(b.textContent)); + if (!item) return false; + item.click(); + return true; + }); + await tabA.waitForTimeout(200); + const deleted = await tabA.evaluate(() => { + try { return JSON.parse(localStorage.getItem('sim_library') || '[]').map(e => e.name); } + catch { return ['READ_FAILED']; } + }); + + // Autosave takeover warning: B's saves reach A's storage event. + await tabB.evaluate(() => { + const app = window.app; + const n = new MNode(NodeType.POOL, 500, 400); n.label = 'BEdit'; + app.diagram.addNode(n); app.renderer.render(); app._commit(); + }); + await tabA.waitForTimeout(600); + const warned = await tabA.evaluate(() => (window.__toasts || []).some(m => /no longer the saved copy/i.test(m))); + await ctx.close(); + return { clickedDelete, afterDelete: deleted, warned }; + })(); + if (multiTab.clickedDelete && multiTab.afterDelete.includes('FromB') + && !multiTab.afterDelete.includes('FromA') && multiTab.warned) + ok('multi-tab: a Library delete keeps the other tab\'s entry, and autosave takeover is announced'); + else fail('multi-tab: ' + JSON.stringify(multiTab)); + + // Properties: at step 0 the amount field is the Starting amount, so the +/- + // buttons have to move the reset baseline. They only moved the live count, so + // the canvas, undo and autosave all showed the new number and Reset or a + // reload silently put the old one back. + const stepperBaseline = await page.evaluate(() => { + const app = window.app; + app._clearAll(); app._closeFeature(); + const p = new MNode(NodeType.POOL, 300, 300); p.label = 'Gold'; + p.setCount(10, '#9e9e9e'); + app.diagram.addNode(p); + app.renderer.render(); app._commit(); + app.editor._select(p.id, 'node'); + const plus = [...document.querySelectorAll('#props-content button')] + .find(b => b.getAttribute('aria-label') === 'Add one resource'); + if (!plus) return { error: 'no stepper' }; + for (let i = 0; i < 3; i++) plus.click(); + const afterClicks = p.resources; + let saved = null; + try { + const j = JSON.parse(localStorage.getItem('sim_autosave') || '{}'); + const n = (j.nodes || []).find(x => x.label === 'Gold') || {}; + saved = { resources: n.resources, initial: n.initialResources }; + } catch { /* blocked */ } + app.engine.reset(); + return { afterClicks, afterReset: p.resources, saved }; + }); + if (!stepperBaseline.error && stepperBaseline.afterClicks === 13 + && stepperBaseline.afterReset === 13 + && stepperBaseline.saved.resources === 13 && stepperBaseline.saved.initial === undefined) + ok('properties: the +/- steppers move the starting amount, and Reset keeps it'); + else fail('stepper baseline: ' + JSON.stringify(stepperBaseline)); + + // Player: the "(node deleted)" placeholder must not be selectable. + const placeholder = await page.evaluate(() => { + const app = window.app; + app._clearAll(); app._closeFeature(); + const a = new MNode(NodeType.POOL, 200, 200); a.label = 'Chest'; + a.activation = ActivationMode.INTERACTIVE; + const b = new MNode(NodeType.POOL, 400, 200); b.label = 'Shop'; + b.activation = ActivationMode.INTERACTIVE; + app.diagram.addNode(a); app.diagram.addNode(b); + app.renderer.render(); app._commit(); + document.querySelector('#diagram-rail .rail-btn[data-feature="player"]').click(); + const ai = app.diagram.aiPlayer; + ai.rules.push({ nodeId: b.id, mode: 'interval', every: 2 }); + ai.enabled = true; + app.diagram.removeNode(b.id); + app.renderer.render(); + app._renderProps(); + const opt = [...document.querySelectorAll('#props-content .ai-rule option')] + .find(o => /deleted/i.test(o.textContent)); + return { + present: !!opt, + disabled: !!(opt && opt.disabled), + selected: !!(opt && opt.selected), + stillWarns: /never fires/i.test(document.getElementById('props-content').textContent), + }; + }); + if (placeholder.present && placeholder.disabled && placeholder.selected && placeholder.stillWarns) + ok('player: the "(node deleted)" placeholder is shown but cannot be chosen'); + else fail('deleted placeholder: ' + JSON.stringify(placeholder)); + + // Security: opening a share link used to replace the reader's autosaved + // diagram silently, with nothing to undo it and a reload bringing back the + // sender's diagram rather than theirs. + const shareGuard = await (async () => { + const ctx = await browser.newContext(); + const sp = await ctx.newPage(); + await sp.route('https://fonts.googleapis.com/**', r => r.fulfill({ contentType: 'text/css', body: '' })); + await sp.addInitScript(() => { try { localStorage.setItem('sim_seen_welcome', '1'); } catch (e) {} }); + await sp.goto(URL, { waitUntil: 'networkidle' }); + const hash = await sp.evaluate(() => { + const app = window.app; + app._clearAll(); + for (let i = 0; i < 3; i++) { + const n = new MNode(NodeType.POOL, 150 + i * 120, 200); n.label = 'Mine' + i; + app.diagram.addNode(n); + } + app.diagram.meta.name = 'MyWork'; + app._commit(); + const d = new Diagram(); + const n = new MNode(NodeType.POOL, 100, 100); n.label = 'Theirs'; + d.addNode(n); d.meta.name = 'Colleague'; + return '#d=' + btoa(unescape(encodeURIComponent(JSON.stringify(d.toJSON())))); + }); + const savedName = () => sp.evaluate(() => { + try { return (JSON.parse(localStorage.getItem('sim_autosave') || '{}').meta || {}).name; } + catch { return null; } + }); + // A hash-only goto is a same-document navigation, so reload to re-init. + await sp.goto(URL + hash, { waitUntil: 'networkidle' }); + await sp.reload({ waitUntil: 'networkidle' }); + await sp.waitForSelector('#guard-overlay:not(.hidden)', { timeout: 3000 }).catch(() => {}); + const asked = await sp.evaluate(() => !document.getElementById('guard-overlay').classList.contains('hidden')); + if (asked) await sp.click('#guard-cancel'); + await sp.waitForTimeout(150); + const declined = { saved: await savedName(), canvas: await sp.evaluate(() => window.app.diagram.meta.name) }; + await sp.goto(URL + hash, { waitUntil: 'networkidle' }); + await sp.reload({ waitUntil: 'networkidle' }); + await sp.waitForSelector('#guard-overlay:not(.hidden)', { timeout: 3000 }).catch(() => {}); + const asked2 = await sp.evaluate(() => !document.getElementById('guard-overlay').classList.contains('hidden')); + if (asked2) await sp.click('#guard-confirm'); + await sp.waitForTimeout(150); + const accepted = { saved: await savedName(), hash: await sp.evaluate(() => location.hash) }; + await ctx.close(); + return { asked, declined, accepted }; + })(); + if (shareGuard.asked && shareGuard.declined.saved === 'MyWork' && shareGuard.declined.canvas === 'MyWork' + && shareGuard.accepted.saved === 'Colleague' && shareGuard.accepted.hash === '') + ok('security: a share link asks before it replaces the reader\'s saved diagram'); + else fail('share guard: ' + JSON.stringify(shareGuard)); + + // Security: a diagram is untrusted input. Its parameter names reach the sweep + // results table, which built its header row with innerHTML and no escaping. + const sweepXss = await page.evaluate(async () => { + const app = window.app; + app._clearAll(); app._closeFeature(); + const p = new MNode(NodeType.POOL, 200, 200); p.label = 'Gold'; p.setCount(5); + app.diagram.addNode(p); + app.diagram.params = { 'rate"> window.app._hideModal('mc-overlay')); + if (!sweepXss.fired && sweepXss.imgs === 0 && sweepXss.headerText.includes(' { + const ctx = await browser.newContext(); + const fp = await ctx.newPage(); + const hits = []; + await fp.route('**://fonts.googleapis.com/**', r => { hits.push(r.request().url()); r.fulfill({ contentType: 'text/css', body: '' }); }); + await fp.addInitScript(() => { try { localStorage.setItem('sim_seen_welcome', '1'); } catch (e) {} }); + await fp.goto(URL, { waitUntil: 'networkidle' }); + const check = async (font) => { + hits.length = 0; + await fp.evaluate((f) => { + const app = window.app; + app._clearAll(); + app.diagram.addNode(new MNode(NodeType.POOL, 200, 200)); + app.diagram.meta.font = f; + app._applyMeta(); + }, font); + await fp.waitForTimeout(250); + return { requests: hits.length, link: await fp.evaluate(() => !!document.getElementById('gfont-link')) }; + }; + const hostile = await check('Attacker-Beacon-12345'); + const curated = await check('Lexend'); + await ctx.close(); + return { hostile, curated }; + })(); + if (fontGuard.hostile.requests === 0 && !fontGuard.hostile.link + && fontGuard.curated.requests === 1 && fontGuard.curated.link) + ok('security: an off-list display font fires no third-party request, a curated one still works'); + else fail('font guard: ' + JSON.stringify(fontGuard)); + // Accessibility: three controls whose accessible name has to track state. const a11yNames = await (async () => { await page.evaluate(() => {
Node${name}=${v}
${this._esc(name)}=${v}
${this._esc(results[0].nodes[n].label || results[0].nodes[n].type)}${r.nodes[n].mean}