openvibers/openvibe.shared release-compat.js

release-compat.js

main 15.8 KB · 274 lines · javascript

1'use strict';
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
25const fs = require('fs');
26const path = require('path');
27const vm = require('vm');
28const { plan, compatible, satisfies } = require('./release-update');
29const { normalizeRange, compareVersions, contractRanges } = require('./release');
30
31/** Service to service: every contract `consumer` consumes is produced by `producer`, both ranges holding. */
32function 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 */
50function 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 */
61function 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. */
82function 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 */
101async 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
137function 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
144async 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
156const WATCH_SRC = () => fs.readFileSync(path.join(__dirname, 'release-watch.js'), 'utf8');
157const UPDATE_SRC = () => fs.readFileSync(path.join(__dirname, 'release-update.js'), 'utf8');
158
159function 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 */
176async 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
269module.exports = {
270 plan, compatible, satisfies, normalizeRange, compareVersions,
271 consumerCompatible, rollbackSafe, replay, manifestFor,
272 runMixedVersion, assertMixedVersion, formatMatrix, openPage,
273};
274