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 += '| Node | '
- + values.map(v => `${name}=${v} | `).join('') + '
';
+ + values.map(v => `${this._esc(name)}=${v} | `).join('') + '';
for (let n = 0; n < results[0].nodes.length; n++) {
html += `| ${this._esc(results[0].nodes[n].label || results[0].nodes[n].type)} | `
+ results.map(r => `${r.nodes[n].mean} | `).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(() => {