openvibers/openvibe.shared release-compat.js
release-compat.js
| 1 | ; |
| 2 | // ═══════════════════════════════════════════════════════════════ |
| 3 | // openvibe-shared/release-compat — the mixed-version (N/N-1) test harness (ADR-016, Track R, D45). |
| 4 | // Node only; services use it in their own tests. |
| 5 | // |
| 6 | // const compat = require('openvibe-shared/release-compat'); |
| 7 | // await compat.assertMixedVersion({ releases: [ |
| 8 | // { name: 'N-1', manifest: require('./fixtures/release-prev.json'), client: compat.replay(prevCalls) }, |
| 9 | // { name: 'N', manifest: release.full(), server: startServer, client: compat.replay(calls) }, |
| 10 | // ] }); |
| 11 | // |
| 12 | // For every page release P and server release S it checks what the release manifests promise: |
| 13 | // - compatible(P, S): S still accepts every contract P produces and P accepts S's versions |
| 14 | // (contract_ranges); the same rule release-watch applies in a tab; |
| 15 | // - when they say compatible and S has a server, P's client runs against it and must succeed. A failure |
| 16 | // means the manifest claims a compatibility the server does not honour; |
| 17 | // - when they say incompatible, the tab's plan must be a reload (reason "contract"), never a silent |
| 18 | // in-place update or nothing; |
| 19 | // - releases next to each other in `releases` must be compatible both ways (the supported window of |
| 20 | // ADR-016: the current and the previous release), unless requireAdjacent is false. |
| 21 | // openPage() runs release-watch.js and release-update.js against an HTML page in linkedom (a |
| 22 | // devDependency the service installs) with a scripted /release.json, for in-place update tests. |
| 23 | // ═══════════════════════════════════════════════════════════════ |
| 24 | |
| 25 | const fs = require('fs'); |
| 26 | const path = require('path'); |
| 27 | const vm = require('vm'); |
| 28 | const { plan, compatible, satisfies } = require('./release-update'); |
| 29 | const { normalizeRange, compareVersions, contractRanges } = require('./release'); |
| 30 | |
| 31 | /** Service to service: every contract `consumer` consumes is produced by `producer`, both ranges holding. */ |
| 32 | function consumerCompatible(consumer, producer) { |
| 33 | const problems = []; |
| 34 | const mine = (consumer && consumer.contract_ranges) || {}; |
| 35 | const theirs = (producer && producer.contract_ranges) || {}; |
| 36 | for (const [id, a] of Object.entries(mine)) { |
| 37 | if (a.role !== 'consumes') continue; |
| 38 | const b = theirs[id]; |
| 39 | if (!b || b.role === 'consumes') problems.push(`${id}: ${producer && producer.service} does not produce it`); |
| 40 | else if (!satisfies(a.version, b.accepts)) problems.push(`${id}: ${producer.service} accepts ${b.accepts}, ${consumer.service} speaks ${a.version}`); |
| 41 | else if (!satisfies(b.version, a.accepts)) problems.push(`${id}: ${consumer.service} accepts ${a.accepts}, ${producer.service} speaks ${b.version}`); |
| 42 | } |
| 43 | return { ok: !problems.length, problems }; |
| 44 | } |
| 45 | |
| 46 | /** |
| 47 | * Rolling back from release `from` to release `to` keeps data safe when `to`'s code expects a schema |
| 48 | * generation `from` still supports: to.schema_generation >= from.schema_compatible_from. |
| 49 | */ |
| 50 | function rollbackSafe(from, to) { |
| 51 | const need = from && from.schema_compatible_from; |
| 52 | const have = to && to.schema_generation; |
| 53 | if (need == null || have == null) return { ok: true, unknown: true }; |
| 54 | return have >= need ? { ok: true } : { ok: false, problem: `${to.release} expects schema generation ${have}; ${from.release} is only readable from ${need}` }; |
| 55 | } |
| 56 | |
| 57 | /** |
| 58 | * A client made of recorded calls: [{ method?, path, body?, headers?, status?: n | [n…] | (n) => bool, |
| 59 | * check?: (json, res) => void }]. Each call must answer 2xx (or `status`) and pass `check`. |
| 60 | */ |
| 61 | function replay(calls, { fetchImpl = globalThis.fetch } = {}) { |
| 62 | return async (baseUrl) => { |
| 63 | for (const c of calls) { |
| 64 | const method = (c.method || 'GET').toUpperCase(); |
| 65 | const headers = { accept: 'application/json', ...(c.body !== undefined ? { 'content-type': 'application/json' } : {}), ...(c.headers || {}) }; |
| 66 | const res = await fetchImpl(new URL(c.path, baseUrl), { method, headers, body: c.body !== undefined ? JSON.stringify(c.body) : undefined }); |
| 67 | const want = c.status; |
| 68 | const ok = want == null ? res.status >= 200 && res.status < 300 |
| 69 | : typeof want === 'function' ? want(res.status) : [].concat(want).includes(res.status); |
| 70 | const text = await res.text(); |
| 71 | if (!ok) throw new Error(`${method} ${c.path} answered ${res.status}: ${text.slice(0, 200)}`); |
| 72 | if (c.check) { |
| 73 | let json = null; |
| 74 | try { json = text ? JSON.parse(text) : null; } catch { throw new Error(`${method} ${c.path} did not answer JSON`); } |
| 75 | await c.check(json, res); |
| 76 | } |
| 77 | } |
| 78 | }; |
| 79 | } |
| 80 | |
| 81 | /** A manifest for a test release (registry.release-manifest 1.1.0 fields), no git needed. */ |
| 82 | function manifestFor({ service = 'test', release, releasedAt = new Date().toISOString(), windowHours = 24, minClientRelease = null, components = null, assets = null, contracts = null, schemaGeneration = null, schemaCompatibleFrom = null, metricsUrl = null } = {}) { |
| 83 | if (!/^[0-9a-f]{7,40}$/.test(String(release || ''))) throw new TypeError('manifestFor: release must be a hex sha'); |
| 84 | const m = { |
| 85 | service, release, released_at: releasedAt, booted_at: releasedAt, contracts_version: null, packages: {}, |
| 86 | min_client_release: minClientRelease, mixed_version_window_hours: windowHours, |
| 87 | }; |
| 88 | if (components) m.components = components; |
| 89 | if (assets) m.assets = assets; |
| 90 | if (schemaGeneration != null) { m.schema_generation = schemaGeneration; m.schema_compatible_from = schemaCompatibleFrom == null ? schemaGeneration : schemaCompatibleFrom; } |
| 91 | if (contracts) m.contract_ranges = contractRanges(contracts); |
| 92 | if (metricsUrl) m.metrics_url = metricsUrl; |
| 93 | return m; |
| 94 | } |
| 95 | |
| 96 | /** |
| 97 | * Runs the page × server matrix. releases: [{ name, manifest, server?: async () => ({ url, close? }), |
| 98 | * client?: async (url) => void }], oldest first. Returns rows { page, server, adjacent, compatible, |
| 99 | * problems, action, reason, ran, ok, error }. |
| 100 | */ |
| 101 | async function runMixedVersion({ releases, now = Date.now(), requireAdjacent = true } = {}) { |
| 102 | if (!Array.isArray(releases) || releases.length < 2) throw new TypeError('runMixedVersion: at least two releases'); |
| 103 | const started = new Map(); |
| 104 | const serverOf = async (r) => { |
| 105 | if (!r.server) return null; |
| 106 | if (!started.has(r.name)) started.set(r.name, await r.server()); |
| 107 | return started.get(r.name); |
| 108 | }; |
| 109 | const rows = []; |
| 110 | try { |
| 111 | for (let i = 0; i < releases.length; i++) { |
| 112 | for (let j = 0; j < releases.length; j++) { |
| 113 | if (i === j) continue; |
| 114 | const P = releases[i]; const S = releases[j]; |
| 115 | const c = compatible(P.manifest, S.manifest); |
| 116 | const p = plan(P.manifest, S.manifest, now); |
| 117 | const row = { page: P.name, server: S.name, adjacent: Math.abs(i - j) === 1, compatible: c.ok, problems: c.problems, action: p.action, reason: p.reason || null, ran: false, ok: true, error: null }; |
| 118 | if (!c.ok) { |
| 119 | if (p.action !== 'reload' || p.reason !== 'contract') { row.ok = false; row.error = `incompatible, but the tab would ${p.action}`; } |
| 120 | if (row.adjacent && requireAdjacent) { row.ok = false; row.error = `adjacent releases must be compatible: ${c.problems.join('; ')}`; } |
| 121 | } else if (P.client) { |
| 122 | const s = await serverOf(S); |
| 123 | if (s) { |
| 124 | row.ran = true; |
| 125 | try { await P.client(s.url); } catch (err) { row.ok = false; row.error = `declared compatible, but the ${P.name} client failed against the ${S.name} server: ${err && err.message}`; } |
| 126 | } |
| 127 | } |
| 128 | rows.push(row); |
| 129 | } |
| 130 | } |
| 131 | } finally { |
| 132 | for (const s of started.values()) { try { if (s && s.close) await s.close(); } catch { /* */ } } |
| 133 | } |
| 134 | return rows; |
| 135 | } |
| 136 | |
| 137 | function formatMatrix(rows) { |
| 138 | const head = ['page', 'server', 'compatible', 'tab', 'ran', 'ok']; |
| 139 | const body = rows.map((r) => [r.page, r.server, r.compatible ? 'yes' : 'no', r.action + (r.reason ? ` (${r.reason})` : ''), r.ran ? 'yes' : 'no', r.ok ? 'ok' : `FAIL: ${r.error}`]); |
| 140 | const w = head.map((h, i) => Math.max(h.length, ...body.map((b) => String(b[i]).length))); |
| 141 | return [head, ...body].map((r) => r.map((c, i) => (i === r.length - 1 ? String(c) : String(c).padEnd(w[i]))).join(' ')).join('\n'); |
| 142 | } |
| 143 | |
| 144 | async function assertMixedVersion(opts) { |
| 145 | const rows = await runMixedVersion(opts); |
| 146 | if (rows.some((r) => !r.ok)) { |
| 147 | const err = new Error(`mixed-version check failed\n${formatMatrix(rows)}`); |
| 148 | err.rows = rows; |
| 149 | throw err; |
| 150 | } |
| 151 | return rows; |
| 152 | } |
| 153 | |
| 154 | // ── openPage: release-watch in a linkedom page ──────────────── |
| 155 | |
| 156 | const WATCH_SRC = () => fs.readFileSync(path.join(__dirname, 'release-watch.js'), 'utf8'); |
| 157 | const UPDATE_SRC = () => fs.readFileSync(path.join(__dirname, 'release-update.js'), 'utf8'); |
| 158 | |
| 159 | function toResponse(out) { |
| 160 | if (out && typeof out.status === 'number' && typeof out.text === 'function') return out; // a Response |
| 161 | const o = out || { status: 404 }; |
| 162 | const body = o.json !== undefined ? JSON.stringify(o.json) : o.body != null ? String(o.body) : o.text != null ? String(o.text) : ''; |
| 163 | return new Response(o.status === 204 ? null : body, { status: o.status || 200, headers: { 'content-type': o.json !== undefined ? 'application/json' : 'text/html' } }); |
| 164 | } |
| 165 | |
| 166 | /** |
| 167 | * A page at `url` rendered from `html`, running release-watch.js. `serve(url, init)` answers every fetch |
| 168 | * the scripts make (return { json } | { body } | { status } | a Response). Options: config |
| 169 | * (OVReleaseConfig), updateScript: 'lazy' (default: loaded when release-watch asks) | 'fail', hidden, |
| 170 | * protectedFn (window.OVProtected), globals (extra window properties, e.g. a fake EventSource or a Math |
| 171 | * with a fixed random()). |
| 172 | * Returns controls to drive it: settle(), settleStyles(ok), focus(el), setHidden(bool), tick(), |
| 173 | * timeouts() (the pending setTimeout delays), fireTimeouts(filter?), dispatch(type), and what it did: |
| 174 | * reloads, beacons, toasts, requests, timeoutsSet. |
| 175 | */ |
| 176 | async function openPage({ url = 'https://site.test/', html, serve, config = null, updateScript = 'lazy', protectedFn = null, hidden: startHidden = false, globals = null } = {}) { |
| 177 | let linkedom; |
| 178 | try { linkedom = require('linkedom'); } catch { throw new Error('openPage needs linkedom (npm i -D linkedom)'); } |
| 179 | const { window: lw, document } = linkedom.parseHTML(html || '<!doctype html><html><head></head><body></body></html>'); |
| 180 | const origin = new URL(url).origin; |
| 181 | const out = { reloads: 0, beacons: [], toasts: [], requests: [], intervals: [], timeoutsSet: 0 }; |
| 182 | const timers = new Map(); let tid = 0; |
| 183 | let focused = null; let hidden = !!startHidden; |
| 184 | Object.defineProperty(document, 'activeElement', { configurable: true, get: () => focused || document.body }); |
| 185 | Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden }); |
| 186 | Object.defineProperty(document, 'currentScript', { configurable: true, get: () => ({ src: `${origin}/shared/release-watch.js` }) }); |
| 187 | const listeners = {}; |
| 188 | const ctx = { |
| 189 | document, console, URL, JSON, Promise, |
| 190 | DOMParser: linkedom.DOMParser, CustomEvent: lw.CustomEvent || linkedom.CustomEvent, Event: lw.Event || linkedom.Event, |
| 191 | location: { href: url, origin, reload: () => { out.reloads++; } }, |
| 192 | navigator: { sendBeacon: (to, body) => { out.beacons.push({ to, body: JSON.parse(body) }); return true; } }, |
| 193 | OpenVibeUI: { toast: (msg, o) => out.toasts.push({ msg, o }) }, |
| 194 | setInterval: (f) => { out.intervals.push(f); return out.intervals.length; }, |
| 195 | clearInterval: () => {}, |
| 196 | setTimeout: (f, ms) => { out.timeoutsSet++; timers.set(++tid, { f, ms: Number(ms) || 0 }); return tid; }, |
| 197 | clearTimeout: (id) => { timers.delete(id); }, |
| 198 | addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); }, |
| 199 | removeEventListener: () => {}, |
| 200 | dispatchEvent: (e) => { for (const f of listeners[e.type] || []) f(e); return true; }, |
| 201 | scrollBy: () => {}, |
| 202 | fetch: async (u, init = {}) => { |
| 203 | const abs = new URL(String(u), url).href; |
| 204 | out.requests.push({ url: abs, method: (init.method || 'GET').toUpperCase() }); |
| 205 | return toResponse(await serve(abs, init)); |
| 206 | }, |
| 207 | }; |
| 208 | if (config) ctx.OVReleaseConfig = config; |
| 209 | if (protectedFn) ctx.OVProtected = protectedFn; |
| 210 | if (globals) Object.assign(ctx, globals); |
| 211 | ctx.window = ctx; ctx.globalThis = ctx; |
| 212 | vm.createContext(ctx); |
| 213 | // A script element release-watch appends is "loaded": release-update.js runs, then onload fires. |
| 214 | const append = document.head.appendChild.bind(document.head); |
| 215 | document.head.appendChild = (node) => { |
| 216 | const r = append(node); |
| 217 | const src = node && node.tagName === 'SCRIPT' ? String(node.src || node.getAttribute('src') || '') : ''; |
| 218 | if (src) { |
| 219 | setImmediate(() => { |
| 220 | if (updateScript === 'fail' || !/release-update\.js/.test(src)) { if (node.onerror) node.onerror(new ctx.Event('error')); return; } |
| 221 | vm.runInContext(UPDATE_SRC(), ctx, { filename: 'release-update.js' }); |
| 222 | if (node.onload) node.onload(new ctx.Event('load')); |
| 223 | }); |
| 224 | } |
| 225 | return r; |
| 226 | }; |
| 227 | const initialLinks = new Set(document.querySelectorAll('link')); |
| 228 | const settled = new Set(); |
| 229 | vm.runInContext(WATCH_SRC(), ctx, { filename: 'release-watch.js' }); |
| 230 | const page = { |
| 231 | window: ctx, document, ...out, |
| 232 | get reloads() { return out.reloads; }, |
| 233 | /** How many timeouts the page has set so far. */ |
| 234 | get timeoutsSet() { return out.timeoutsSet; }, |
| 235 | get release() { return ctx.OVRelease; }, |
| 236 | /** The tab's outcome counts so far (a plain copy). */ |
| 237 | metrics() { return JSON.parse(JSON.stringify(ctx.OVRelease ? ctx.OVRelease.state().metrics : {})); }, |
| 238 | async settle(turns = 8) { for (let i = 0; i < turns; i++) await new Promise((r) => setImmediate(r)); }, |
| 239 | /** Fires load (or error) on every stylesheet release-update inserted and that has not loaded yet. */ |
| 240 | settleStyles(ok = true) { |
| 241 | let n = 0; |
| 242 | for (const l of document.querySelectorAll('link')) { |
| 243 | if (initialLinks.has(l) || settled.has(l)) continue; |
| 244 | settled.add(l); n++; |
| 245 | l.dispatchEvent(new ctx.Event(ok ? 'load' : 'error')); |
| 246 | } |
| 247 | return n; |
| 248 | }, |
| 249 | /** Runs the timeouts still pending (release-update's 15 s stylesheet timeout), or those whose delay `filter(ms)` accepts. */ |
| 250 | fireTimeouts(filter = null) { |
| 251 | const due = [...timers].filter(([, x]) => !filter || filter(x.ms)); |
| 252 | for (const [id] of due) timers.delete(id); |
| 253 | due.forEach(([, x]) => x.f()); |
| 254 | return due.length; |
| 255 | }, |
| 256 | /** The delays (ms) of the timeouts still pending, in the order they were set. */ |
| 257 | timeouts() { return [...timers.values()].map((x) => x.ms); }, |
| 258 | /** Dispatches a plain event of `type` on the window (e.g. openvibe-auth-changed, online). */ |
| 259 | dispatch(type) { ctx.dispatchEvent(new ctx.Event(type)); }, |
| 260 | focus(el) { focused = el; }, |
| 261 | blur() { focused = null; }, |
| 262 | setHidden(h) { hidden = !!h; document.dispatchEvent(new ctx.Event('visibilitychange')); }, |
| 263 | tick() { out.intervals.forEach((f) => f()); }, |
| 264 | stop() { if (ctx.OVRelease) ctx.OVRelease.stop(); }, |
| 265 | }; |
| 266 | return page; |
| 267 | } |
| 268 | |
| 269 | module.exports = { |
| 270 | plan, compatible, satisfies, normalizeRange, compareVersions, |
| 271 | consumerCompatible, rollbackSafe, replay, manifestFor, |
| 272 | runMixedVersion, assertMixedVersion, formatMatrix, openPage, |
| 273 | }; |
| 274 |