diff --git a/public/cb-presets.js b/public/cb-presets.js index 9142d3d0..921ffff5 100644 --- a/public/cb-presets.js +++ b/public/cb-presets.js @@ -279,11 +279,44 @@ if (v && _byId(v)) return v; } } catch (e) {} - return 'default'; + // #1446 — return null when no preset is stored. Previously this returned + // 'default' unconditionally, which forced body[data-cb-preset="default"] + // on every cold boot and trapped --mc-role-* in the Wong palette via the + // matching style.css rule. The CB preset is now an end-user opt-in: + // absent attribute = "no preset", role colors flow from server config. + return null; + } + + function clearPreset() { + try { if (typeof localStorage !== 'undefined') localStorage.removeItem(STORAGE_KEY); } catch (e) {} + if (typeof document !== 'undefined' && document.body && document.body.removeAttribute) { + document.body.removeAttribute(DATA_ATTR); + } + // Strip preset-written CSS vars from documentElement so the cascade + // re-falls through :root defaults (or server config, which the + // customizer pipeline re-applies via the cb-preset-changed listener). + if (typeof document !== 'undefined' && document.documentElement && document.documentElement.style) { + var style = document.documentElement.style; + ['repeater', 'companion', 'room', 'sensor', 'observer'].forEach(function (role) { + style.removeProperty('--mc-role-' + role); + style.removeProperty('--mc-role-' + role + '-text'); + }); + ['confirmed', 'suspected', 'unknown'].forEach(function (k) { + style.removeProperty('--mc-mb-' + k); + }); + for (var ri = 0; ri < 5; ri++) style.removeProperty('--mc-rt-ramp-' + ri); + } + if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' && typeof window.CustomEvent === 'function') { + try { window.dispatchEvent(new window.CustomEvent('cb-preset-changed', { detail: { id: null } })); } catch (e) {} + } + return true; } function initFromStorage() { - applyPreset(currentPreset(), { skipPersist: true }); + var id = currentPreset(); + // #1446 — only apply when a preset is actually stored. No stored preset + // means "no preset active" (the new default), not "fall back to Wong". + if (id) applyPreset(id, { skipPersist: true }); } // Cross-tab sync via storage event. @@ -305,6 +338,7 @@ var api = { list: PRESETS, applyPreset: applyPreset, + clearPreset: clearPreset, currentPreset: currentPreset, initFromStorage: initFromStorage, validatePreset: validatePreset, diff --git a/public/customize-v2.js b/public/customize-v2.js index 2c2c7752..515819bf 100644 --- a/public/customize-v2.js +++ b/public/customize-v2.js @@ -554,12 +554,18 @@ // overrides still flow through setRoleColorOverride() in customize.js. var nc = effectiveConfig.nodeColors; if (nc) { - // #1438 final: scope --mc-role-{role} writes to USER overrides only. - // Server-config nodeColors must stay out of --mc-role-* because - // ROLE_COLORS live getter (roles.js) reads from that var; writing - // server defaults there would re-introduce the #1412 bug (server - // legacy palette trapping cb-preset propagation). + // #1438 final: scope --mc-role-{role} writes to USER overrides only, + // UNLESS no CB preset is active (#1446). When a preset is active the + // server-config palette must stay out of --mc-role-* so the preset + // wins (preserves #1412). When NO preset is active, the cascade is: + // user override > server config > built-in :root default. + // → server config gets to write --mc-role-{role} in that case. var userNc = (userOverrides && userOverrides.nodeColors) || {}; + var presetActive = false; + try { + var presetAttr = document.body && document.body.getAttribute && document.body.getAttribute('data-cb-preset'); + presetActive = !!(presetAttr && presetAttr !== 'none'); + } catch (e) {} for (var role in nc) { root.setProperty('--node-' + role, nc[role]); if (Object.prototype.hasOwnProperty.call(userNc, role)) { @@ -568,7 +574,13 @@ // pick it up on every page load. Without this the user pick // sits in localStorage but --mc-role-{role} falls back to the // active preset on reload, reverting marker fills. - root.setProperty('--mc-role-' + role, userNc[role]); + root.setProperty('--mc-role-' + role, nc[role]); + } else if (!presetActive) { + // #1446 — no preset is active; server config is the legitimate + // source of role colors. Write --mc-role-{role} so marker SVGs + // honor operator's config.json without forcing visitors to pick + // a CB preset to "unlock" their server palette. + root.setProperty('--mc-role-' + role, nc[role]); } } } @@ -1147,7 +1159,9 @@ function _renderColorblindPresetSelector() { var MCP = (typeof window !== 'undefined') && window.MeshCorePresets; if (!MCP || !Array.isArray(MCP.list)) return ''; - var current = MCP.currentPreset ? MCP.currentPreset() : 'default'; + // #1446 — currentPreset() now returns null when no preset is stored. + var current = MCP.currentPreset ? MCP.currentPreset() : null; + var clearOpt = _renderCbPresetClearOption(current); var options = MCP.list.map(function (p) { var checked = p.id === current ? ' checked' : ''; return ''; }).join(''); - return '

Colorblind Preset

' + - '

Switch the role/status palette for color-vision variants. Achromatopsia uses a luminance-only ramp and relies on the shape/letter/glyph carriers from #1356/#1357.

' + - '
' + options + '
' + + return '

Optional: Colorblind-Safe Preset

' + + '

A CB preset is an end-user opt-in that swaps the role/status palette for color-vision variants. ' + + 'Leave unset to use the operator\'s configured colors (or pick from above). ' + + 'Achromatopsia uses a luminance-only ramp and relies on the shape/letter/glyph carriers from #1356/#1357.

' + + '
' + clearOpt + options + '
' + '
'; } + function _renderCbPresetClearOption(current) { + var checked = !current ? ' checked' : ''; + return ''; + } + function _renderCbPresetWarning(id) { var MCP = window.MeshCorePresets; if (!MCP || typeof MCP.validatePreset !== 'function') return ''; @@ -1213,9 +1240,11 @@ var liveHeatPct = Math.round(liveHeatOpacity * 100); return '
' + - _renderColorblindPresetSelector() + - '

Node Role Colors

' + rows + + '

Node Role Colors

' + + '

These are the canonical role colors used across the app. They inherit from your server config (or built-in defaults), and can be optionally remapped by a colorblind-safe preset below.

' + + rows + '
' + + _renderColorblindPresetSelector() + '

Packet Type Colors

' + typeRows + '
' + '

Heatmap Opacity

' + @@ -1833,14 +1862,21 @@ if (_activeTab === 'geofilter') _initGeoFilterTab(container); // #1361 Colorblind preset radio — switches preset via MeshCorePresets.applyPreset + // #1446 — empty-value radio = "no preset" → clearPreset(), then re-run + // the customizer pipeline so server-config colors take over. container.querySelectorAll('[data-cv2-cb-preset]').forEach(function (radio) { radio.addEventListener('change', function () { if (!radio.checked) return; var id = radio.value; - if (window.MeshCorePresets && typeof window.MeshCorePresets.applyPreset === 'function') { - window.MeshCorePresets.applyPreset(id); - _refreshPanel(); + var MCP = window.MeshCorePresets; + if (!MCP) return; + if (!id) { + if (typeof MCP.clearPreset === 'function') MCP.clearPreset(); + _runPipeline(); + } else if (typeof MCP.applyPreset === 'function') { + MCP.applyPreset(id); } + _refreshPanel(); }); }); @@ -2166,6 +2202,18 @@ // 1. Migration check migrateOldKeys(); + // #1446 — when a CB preset is cleared (or applied), re-run the customizer + // pipeline so server-config nodeColors take over the --mc-role-{role} + // CSS vars (the gating logic in applyCSS checks the body[data-cb-preset] + // attribute to decide whether to write them). + try { + if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('cb-preset-changed', function () { + if (_initDone) _runPipeline(); + }); + } + } catch (e) {} + // 2. Read overrides and apply CSS immediately (before DOMContentLoaded) // Server defaults will be set later when /api/config/theme completes. // For now, apply whatever overrides exist on top of current SITE_CONFIG. diff --git a/public/roles.js b/public/roles.js index a607217d..68b5d2c6 100644 --- a/public/roles.js +++ b/public/roles.js @@ -138,7 +138,23 @@ // pick up the operator's hex without a page reload. Writing to // body inline style is necessary because body[data-cb-preset="..."] // selectors beat :root inheritance. - targets.forEach(function (s) { s.setProperty(varName, hex); }); + // + // #1446: write with !important so the inline body declaration also + // beats the body[data-cb-preset="X"] CSS rule on equal specificity. + // Without !important, the cascade order picks the later-defined + // stylesheet rule in some browser versions even though specificity + // (1,0,1) matches the inline body style — operator pick visibly + // loses to active preset (root cause of #1444). + targets.forEach(function (s) { + // documentElement gets the value without !important (used as the + // canonical readout for the JS getter); body gets !important so it + // wins the CSS cascade against body[data-cb-preset="X"]. + if (s === (document.body && document.body.style)) { + s.setProperty(varName, hex, 'important'); + } else { + s.setProperty(varName, hex); + } + }); }; // Back-compat: also export the writable override map so customize.js's // `window.ROLE_COLORS[key] = inp.value` style mutation works. diff --git a/public/style.css b/public/style.css index 8f6787e0..e711a4ec 100644 --- a/public/style.css +++ b/public/style.css @@ -3479,21 +3479,12 @@ th.sort-active { color: var(--accent, #60a5fa); } * truth so a regression that drops one is still caught by the other * (mirrors the #1356 "pill color: defense-in-depth via CSS + inline" pattern). * ─────────────────────────────────────────────────────────────────────── */ -body[data-cb-preset="default"] { - --mc-role-repeater: #D55E00; - --mc-role-companion: #56B4E9; - --mc-role-room: #009E73; - --mc-role-sensor: #F0E442; - --mc-role-observer: #CC79A7; - --mc-role-repeater-text: #1a1a1a; - --mc-role-companion-text: #1a1a1a; - --mc-role-room-text: #1a1a1a; - --mc-role-sensor-text: #1a1a1a; - --mc-role-observer-text: #1a1a1a; - --mc-mb-confirmed: #56F0A0; - --mc-mb-suspected: #FFD966; - --mc-mb-unknown: #FF8888; -} +/* #1446 — the body data-cb-preset rule for the default (Wong) palette is + * intentionally OMITTED. Wong is the :root baseline; selecting "default" + * from the customizer is identical to "no preset". Writing the Wong + * palette here would mask the server config's nodeColors (see #1446 + * cascade). Wong values remain available via :root above. + */ body[data-cb-preset="deut"] { /* IBM 5-class deut variant — anchors shifted out of red/green collision. */ --mc-role-repeater: #FE6100; diff --git a/test-all.sh b/test-all.sh index 50d4ce5d..d5011274 100755 --- a/test-all.sh +++ b/test-all.sh @@ -39,6 +39,7 @@ node test-issue-1418-polish-review.js node test-issue-1420-tile-providers.js node test-issue-1438-marker-css-vars.js node test-issue-1438-customizer-mcrole.js +node test-issue-1446-cb-preset-cascade.js echo "" echo "═══════════════════════════════════════" diff --git a/test-issue-1446-cb-preset-cascade.js b/test-issue-1446-cb-preset-cascade.js new file mode 100644 index 00000000..078ec7e1 --- /dev/null +++ b/test-issue-1446-cb-preset-cascade.js @@ -0,0 +1,258 @@ +/** + * #1446 — CB preset is an end-user opt-in, NOT the canonical color source. + * + * Cascade (top wins): + * user per-role override > active CB preset > server config.nodeColors > built-in :root defaults + * + * 5 acceptance scenarios from the issue. Source-grep + vm-sandbox assertions + * because the cascade is enforced by a mix of JS (cb-presets.js setting + * body[data-cb-preset]), CSS (body[data-cb-preset="X"] selectors), and inline + * style writes (setRoleColorOverride + applyCSS). + * + * If these fail, reverting the fix on customize-v2.js / cb-presets.js / roles.js + * brings them back red. + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +let passed = 0, failed = 0; +function assert(cond, msg) { + if (cond) { passed++; console.log(' ✓ ' + msg); } + else { failed++; console.error(' ✗ ' + msg); } +} + +const ROOT = __dirname; +const cv2Src = fs.readFileSync(path.join(ROOT, 'public', 'customize-v2.js'), 'utf8'); +const rolesSrc = fs.readFileSync(path.join(ROOT, 'public', 'roles.js'), 'utf8'); +const presetsSrc = fs.readFileSync(path.join(ROOT, 'public', 'cb-presets.js'), 'utf8'); +const styleSrc = fs.readFileSync(path.join(ROOT, 'public', 'style.css'), 'utf8'); + +function makeSandbox(localStorageMap) { + localStorageMap = localStorageMap || {}; + function makeStyle() { + return { + _vars: {}, // map var → value + _imp: {}, // map var → priority ('important' or '') + setProperty(k, v, prio) { + this._vars[k] = String(v); + this._imp[k] = prio || ''; + }, + getPropertyValue(k) { return this._vars[k] || ''; }, + getPropertyPriority(k) { return this._imp[k] || ''; }, + removeProperty(k) { delete this._vars[k]; delete this._imp[k]; } + }; + } + const root = { style: makeStyle(), getAttribute() { return null; }, setAttribute() {} }; + const body = { + _attrs: {}, + setAttribute(k, v) { this._attrs[k] = v; }, + getAttribute(k) { return Object.prototype.hasOwnProperty.call(this._attrs, k) ? this._attrs[k] : null; }, + removeAttribute(k) { delete this._attrs[k]; }, + hasAttribute(k) { return Object.prototype.hasOwnProperty.call(this._attrs, k); }, + dataset: {}, + style: makeStyle() + }; + const sandbox = { + window: null, + _listeners: {}, + localStorage: { + _m: Object.assign({}, localStorageMap), + getItem(k) { return Object.prototype.hasOwnProperty.call(this._m, k) ? this._m[k] : null; }, + setItem(k, v) { this._m[k] = String(v); }, + removeItem(k) { delete this._m[k]; } + }, + document: { + documentElement: root, + body: body, + readyState: 'complete', + getElementById() { return null; }, + querySelector() { return null; }, + querySelectorAll() { return []; }, + createElement() { return { style: {}, setAttribute() {}, appendChild() {} }; }, + head: { appendChild() {} }, + addEventListener() {} + }, + console: console, + setTimeout: setTimeout, clearTimeout: clearTimeout, + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + }, + dispatchEvent(ev) { + var arr = this._listeners[ev && ev.type] || []; + arr.forEach(function (fn) { try { fn(ev); } catch (e) {} }); + return true; + }, + fetch: function () { return Promise.resolve({ json: function () { return Promise.resolve({}); } }); }, + matchMedia: function () { return { matches: false, addEventListener() {} }; }, + CustomEvent: function (t, o) { this.type = t; this.detail = o && o.detail; }, + Event: function (t) { this.type = t; }, + Proxy: Proxy, + getComputedStyle: function () { + // Pretend body[data-cb-preset]=X CSS rule applies if attr is set: but + // the JS apply also writes the var to root, so just return root's view. + return { + getPropertyValue: function (k) { return root.style._vars[k] || ''; } + }; + } + }; + sandbox.window = sandbox; + return { sandbox, root, body }; +} + +// ─── SCENARIO 1: Default load with NO localStorage preset → data-cb-preset must NOT be set ─── +console.log('\n=== #1446 Scenario 1: cold boot with empty localStorage → no preset active ==='); +{ + const env = makeSandbox({}); + vm.createContext(env.sandbox); + vm.runInContext(rolesSrc, env.sandbox); + vm.runInContext(presetsSrc, env.sandbox); + // After cb-presets.js auto-init: with no stored preset, body must NOT carry + // data-cb-preset (or it must be "none"), so the body[data-cb-preset="X"] + // CSS rules in style.css do not apply. + const attr = env.body.getAttribute('data-cb-preset'); + assert(attr === null || attr === 'none' || attr === '', + 'cold boot: body[data-cb-preset] not forced to "default" (got: ' + JSON.stringify(attr) + ')'); + // And cb-presets must NOT have stomped --mc-role-repeater on root with the + // Wong default, because that would lock out server config. + const repAtBoot = env.root.style.getPropertyValue('--mc-role-repeater'); + assert(!repAtBoot, + 'cold boot: cb-presets did NOT auto-write --mc-role-repeater (got: ' + JSON.stringify(repAtBoot) + ')'); +} + +// ─── SCENARIO 2: Server config nodeColors land on --mc-role-X when no preset is active ─── +console.log('\n=== #1446 Scenario 2: server config.nodeColors → --mc-role-X (no preset active) ==='); +{ + const env = makeSandbox({}); + vm.createContext(env.sandbox); + vm.runInContext(rolesSrc, env.sandbox); + vm.runInContext(presetsSrc, env.sandbox); + vm.runInContext(cv2Src, env.sandbox); + env.sandbox.window._customizerV2.init({ nodeColors: { repeater: '#aaaaaa', companion: '#56B4E9' } }); + const rep = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase(); + assert(rep === '#aaaaaa', + 'server-only nodeColors with NO active preset writes --mc-role-repeater = #aaaaaa (got: ' + JSON.stringify(rep) + ')'); +} + +// ─── SCENARIO 3: User per-role override beats active preset (writes to body inline w/ !important) ─── +console.log('\n=== #1446 Scenario 3: user override > active CB preset (body inline !important) ==='); +{ + const env = makeSandbox({}); + vm.createContext(env.sandbox); + vm.runInContext(rolesSrc, env.sandbox); + vm.runInContext(presetsSrc, env.sandbox); + env.sandbox.window.MeshCorePresets.applyPreset('deut'); + env.sandbox.window.setRoleColorOverride('repeater', '#ff00ff'); + const bodyVal = env.body.style.getPropertyValue('--mc-role-repeater').toLowerCase(); + const bodyPrio = env.body.style.getPropertyPriority('--mc-role-repeater'); + assert(bodyVal === '#ff00ff', + 'setRoleColorOverride writes --mc-role-repeater on body.style (got: ' + JSON.stringify(bodyVal) + ')'); + assert(bodyPrio === 'important', + 'setRoleColorOverride writes with !important so it wins against body[data-cb-preset] cascade (got: ' + JSON.stringify(bodyPrio) + ')'); +} + +// ─── SCENARIO 4: Clear per-role override → reverts to active preset ─── +console.log('\n=== #1446 Scenario 4: clear override → reverts to active CB preset ==='); +{ + const env = makeSandbox({}); + vm.createContext(env.sandbox); + vm.runInContext(rolesSrc, env.sandbox); + vm.runInContext(presetsSrc, env.sandbox); + env.sandbox.window.MeshCorePresets.applyPreset('deut'); + env.sandbox.window.setRoleColorOverride('repeater', '#ff00ff'); + env.sandbox.window.setRoleColorOverride('repeater', null); + const bodyVal = env.body.style.getPropertyValue('--mc-role-repeater').toLowerCase(); + // After clear: body inline should be restored to the snapshot (deut #FE6100) OR removed. + // The snapshot logic in roles.js restores the prior body inline value (which + // was '' before override since applyPreset writes only to root). + // Either way, after clear, the body inline override must NOT shadow the preset. + // Effective truth: root still carries #fe6100 from applyPreset. + const rootVal = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase(); + assert(rootVal === '#fe6100', + 'after clearing override, --mc-role-repeater on root still shows preset deut #FE6100 (got: ' + JSON.stringify(rootVal) + ')'); + assert(bodyVal !== '#ff00ff', + 'after clearing override, body inline no longer carries the user pick (got: ' + JSON.stringify(bodyVal) + ')'); +} + +// ─── SCENARIO 5: Clear preset → reverts to server config / built-in default ─── +console.log('\n=== #1446 Scenario 5: clear preset → reverts to server config / default ==='); +{ + const env = makeSandbox({ 'meshcore-cb-preset': 'deut' }); + vm.createContext(env.sandbox); + vm.runInContext(rolesSrc, env.sandbox); + vm.runInContext(presetsSrc, env.sandbox); + vm.runInContext(cv2Src, env.sandbox); + env.sandbox.window._customizerV2.init({ nodeColors: { repeater: '#aaaaaa' } }); + // Confirm deut is active first. + const repWithPreset = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase(); + assert(repWithPreset === '#fe6100', + 'precondition: deut active → --mc-role-repeater = #FE6100 (got: ' + JSON.stringify(repWithPreset) + ')'); + // Now clear the preset. + const hasClear = typeof env.sandbox.window.MeshCorePresets.clearPreset === 'function'; + assert(hasClear, 'MeshCorePresets.clearPreset() exists'); + if (hasClear) env.sandbox.window.MeshCorePresets.clearPreset(); + else { env.body.removeAttribute('data-cb-preset'); /* fallback to keep test going */ } + const attr = env.body.getAttribute('data-cb-preset'); + assert(attr === null || attr === 'none' || attr === '', + 'after clearPreset, body[data-cb-preset] is unset/none (got: ' + JSON.stringify(attr) + ')'); + const repAfter = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase(); + assert(repAfter === '#aaaaaa', + 'after clearPreset, --mc-role-repeater reverts to server config #aaaaaa (got: ' + JSON.stringify(repAfter) + ')'); +} + +// ─── SCENARIO 6: existing localStorage preset still applies (backward compat) ─── +console.log('\n=== #1446 Scenario 6: backward compat — existing localStorage preset still applies ==='); +{ + const env = makeSandbox({ 'meshcore-cb-preset': 'deut' }); + vm.createContext(env.sandbox); + vm.runInContext(rolesSrc, env.sandbox); + vm.runInContext(presetsSrc, env.sandbox); + // cb-presets auto-init must have applied deut from storage. + const attr = env.body.getAttribute('data-cb-preset'); + assert(attr === 'deut', 'stored "deut" preset auto-applies on boot (got: ' + JSON.stringify(attr) + ')'); + const rep = env.root.style.getPropertyValue('--mc-role-repeater').toLowerCase(); + assert(rep === '#fe6100', 'stored preset writes --mc-role-repeater = #FE6100 (got: ' + JSON.stringify(rep) + ')'); +} + +// ─── Source-grep: customizer UI puts per-role pickers FIRST, then preset selector ─── +console.log('\n=== #1446 Scenario 7: customizer UI re-org — node-color pickers come BEFORE preset selector ==='); +{ + // Find _renderNodes() return string: it concatenates _renderColorblindPresetSelector() + node rows + ... + // After fix: node rows come first, preset selector is in a labelled-secondary block. + const renderNodesIdx = cv2Src.indexOf('function _renderNodes()'); + const after = cv2Src.slice(renderNodesIdx, renderNodesIdx + 4000); + const presetIdx = after.indexOf('_renderColorblindPresetSelector'); + const rolesIdx = after.indexOf("Node Role Colors"); + assert(presetIdx > rolesIdx && rolesIdx > 0, + 'Node Role Colors section appears BEFORE Colorblind Preset block in _renderNodes (rolesIdx=' + rolesIdx + ', presetIdx=' + presetIdx + ')'); + // Preset section labelled as optional. + assert(/Optional[^<]*colorblind|colorblind[^<]*\(optional\)/i.test(cv2Src), + 'preset section labelled "Optional" / "(optional)" in customizer UI'); +} + +// ─── Source-grep: style.css must NOT define a body[data-cb-preset="default"] rule +// that locks --mc-role-X to Wong — Wong is the :root default already, and the +// "default" preset selection is the same as "no preset". ─── +console.log('\n=== #1446 Scenario 8: style.css does not redundantly clamp Wong via body[data-cb-preset="default"] ==='); +{ + // Either the body[data-cb-preset="default"] block is removed, OR the rule body + // does not set --mc-role-* (only resets text). Either way, when default is + // active the rule must not write --mc-role-repeater = #D55E00 (which would + // mask server config). The safest implementation: drop the "default" block. + const re = /body\[data-cb-preset="default"\][^{]*\{([^}]*)\}/; + const m = styleSrc.match(re); + if (!m) { + assert(true, 'no body[data-cb-preset="default"] block in style.css (preferred)'); + } else { + const blockBody = m[1]; + assert(!/--mc-role-(repeater|companion|room|sensor|observer)\s*:/.test(blockBody), + 'body[data-cb-preset="default"] block does not redefine --mc-role-{role} (would mask server config)'); + } +} + +console.log('\n──────────────────────────'); +console.log('passed: ' + passed + ', failed: ' + failed); +process.exit(failed === 0 ? 0 : 1);