openvibers/openvibe.live Commits

N-1 harness, the version Chat, Media and Community share (roadmap WS-P task 11): test/n-1/harness.js, scripts/n-1-record.js and test/n-1.test.js are now the same file in all four repositories; only test/n-1/service.js (clients, boot, seed, samples) differs. Added for the other services and used here: pages an open tab holds are crawled at record time (Live: the shell and /@n1star, 63 more requests: every hashed script and stylesheet, links and forms, which the next release must still answer); paths built by concatenation or from a variable ('/api/x/' + id, base + '/y'); SDK-style call({ method, path }) sites; <form> actions; seeded ids that differ per run ({@name} slots); a JSON catch-all 404 counts as "the route is gone"; /ws/* sessions (sent message types from the client code, the replies' types and read fields), used by Chat; redirects compared without their query. Fixtures re-recorded from 8caa540, the release in production: 478 requests, 964 statements. docs/deploy.md

OP OpenVibers committed 26 Sept 2026 · 2ec0dc2aba · 7 files, +1181 −56


Changes

ddiff --git a/docs/deploy.md b/docs/deploy.md
iindex 1c6b635b..97623a05 100644
---- a/docs/deploy.md
++++ b/docs/deploy.md
@@@ -131,9 +131,11 @@ server, and a previous-release process may still be working on the database the
`test/n-1.test.js` (in `npm test`, so in CI) checks both from fixtures recorded from the release in
production:
--- `test/fixtures/n-1/client.json`: every call the previous release's client code makes, with the
-- status, JSON-ness and the response fields it reads. This checkout boots in the drill sandbox on a
-- database created with the previous schema, answers each call compatibly, and keeps every read field.
++- `test/fixtures/n-1/client.json`: every call the previous release's client code makes, and every
++ script, stylesheet and link of the shell and a channel page it served, with the status, JSON-ness
++ and the response fields the client reads. This checkout boots in the drill sandbox (writes let
++ through) on a database created with the previous schema, answers each call compatibly, and keeps
++ every read field. The calls nginx sends to OpenVibe.Chat are Chat's N-1 test.
- `test/fixtures/n-1/worker.json`: the previous schema, migration ledger and every SQL statement that
release ran or has as a literal. After this release's migrations each must still prepare, and no
old INSERT may miss a new NOT NULL column.
ddiff --git a/scripts/n-1-record.js b/scripts/n-1-record.js
iindex 379199fc..6000d36f 100644
---- a/scripts/n-1-record.js
++++ b/scripts/n-1-record.js
@@@ -10,7 +10,8 @@
*
* The release is checked out into a temporary git worktree (sharing node_modules), seeded and booted
* the way test/n-1/service.js says, and every call its client code makes (test/n-1/harness.js finds
-- * them) is replayed against it. Writes test/fixtures/n-1/client.json and worker.json; commit them.
++ * them) is replayed against it; a service with sockets also replays the messages its clients send.
++ * Writes test/fixtures/n-1/client.json and worker.json; commit them.
*/
const fs = require('fs');
const os = require('os');
@@@ -37,7 +38,7 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
// 1. The client: its calls, and the names it reads.
const files = svc.clientFiles(wt.dir);
-- const { calls, dynamic } = h.extractCalls({ files, callers: svc.callers, strip: svc.strip, keep: svc.keep });
++ const { calls, dynamic } = h.extractCalls({ files, callers: svc.callers, strip: svc.strip, keep: svc.keep, forms: !!svc.forms });
const ids = h.clientIdentifiers(files.map((f) => f.text));
console.log(` client: ${files.length} files, ${calls.length} call sites with a path (${dynamic} with a computed one, not replayed)`);
@@@ -45,15 +46,31 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
await svc.seed({ dir: wt.dir, dbPath, dataDir });
server = await svc.boot({ dir: wt.dir, dbPath, dataDir, sqlOut });
const manifest = await (await fetch(`${server.url}/release.json`)).json();
++ const probe = await h.notFoundProbe(server.url);
++ const requests = h.requestsFor(calls, svc.samples, { signedIn: svc.signedIn !== false });
++ // Server-rendered pages: an open tab holds the page N-1 served, and follows what it links to.
++ if (svc.crawl) {
++ const have = new Set(requests.map((r) => `${r.method} ${r.path} ${r.auth}`));
++ const push = (r) => { const k = `${r.method} ${r.path} ${r.auth}`; if (!have.has(k)) { have.add(k); requests.push(r); } };
++ for (const page of svc.crawl) {
++ push({ method: 'GET', path: page, auth: 'anon', accept: 'html', from: ['page'] });
++ const got = await h.send(server.url, { method: 'GET', path: h.fillPath(page, server.ids), headers: { accept: 'text/html' } });
++ for (const l of got.html ? h.pageLinks(got.html, svc.origins) : []) {
++ const p = h.unfillPath(l.path, server.ids);
++ if (!svc.keep || svc.keep(p.split('?')[0], l.method, `page ${page}`)) push({ method: l.method, path: p, auth: 'anon', accept: 'html', from: [`page ${page}`] });
++ }
++ }
++ requests.sort((a, b) => (a.method === 'GET' ? 0 : 1) - (b.method === 'GET' ? 0 : 1));
++ }
const recorded = [];
let unanswered = 0; let noRoute = 0;
-- for (const req of h.requestsFor(calls, svc.samples, { signedIn: svc.signedIn !== false })) {
-- const got = await h.send(server.url, { method: req.method, path: req.path, headers: server.headers(req.auth), body: req.method === 'GET' ? undefined : {} });
++ for (const req of requests) {
++ const got = await h.send(server.url, { method: req.method, path: h.fillPath(req.path, server.ids), headers: { ...(req.accept ? { accept: 'text/html' } : {}), ...server.headers(req.auth) }, body: req.method === 'GET' ? undefined : {} });
if (got.status === 0) { unanswered++; continue; }
// No route for this path on N-1 itself (a sample value its route does not take, or a dead call):
// nothing N has to keep.
-- if (got.status === 404 && got.kind !== 'json') { noRoute++; continue; }
-- const rec = { method: req.method, path: req.path, auth: req.auth, status: got.status, kind: got.kind };
++ if (h.isNoRoute(got, probe)) { noRoute++; continue; }
++ const rec = { method: req.method, path: req.path, auth: req.auth, ...(req.accept ? { accept: req.accept } : {}), status: got.status, kind: got.kind };
if (got.location) rec.location = got.location;
if (got.json != null && got.status >= 200 && got.status < 300) {
const reads = h.readsOf(got.json, ids);
@@@ -62,10 +79,24 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
rec.from = req.from;
recorded.push(rec);
}
-- const extra = svc.recordExtra ? await svc.recordExtra({ server, dir: wt.dir, files, ids }) : null;
++ console.log(` replayed: ${recorded.length} requests (left out: ${unanswered} unanswered, ${noRoute} with no route on N-1)`);
++
++ // Sockets: the messages the client sends, in one session per sign-in state; what comes back.
++ let ws = null;
++ if (svc.ws) {
++ const wsFiles = svc.ws.files ? files.filter((f) => svc.ws.files(f.name)) : files;
++ const sends = h.wsSends(wsFiles, svc.ws.helpers);
++ const handled = h.wsHandled(wsFiles);
++ ws = { path: svc.ws.path, sends, sessions: [] };
++ for (const auth of ['anon', 'user']) {
++ const messages = h.wsMessages(sends, { order: svc.ws.order, values: server.wsValues(auth) });
++ const received = await h.wsSession({ url: server.url.replace(/^http/, 'ws') + svc.ws.path, headers: svc.ws.headers, messages });
++ ws.sessions.push({ auth, received: h.wsReads(received.filter((m) => !(svc.ws.ignore || []).includes(m.type)), handled, ids) });
++ }
++ console.log(` sockets: ${sends.length} message types sent; received, of the types the client handles: ${ws.sessions.map((x) => `${x.auth} ${Object.keys(x.received).length}`).join(', ')}`);
++ }
await server.close();
server = null;
-- console.log(` replayed: ${recorded.length} requests (left out: ${unanswered} unanswered, ${noRoute} with no route on N-1)`);
// 3. The schema N-1 left, and the SQL it runs on it.
const db = new Database(dbPath);
@@@ -87,7 +118,8 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
fs.mkdirSync(OUT, { recursive: true });
const head = { service: svc.service, release: wt.sha, recorded_at: new Date().toISOString(), note: 'N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js' };
-- fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, manifest, dynamic_calls: dynamic, calls: recorded, ...(extra ? { extra } : {}) }, null, 1)}\n`);
++ const clients = svc.clientReleases ? svc.clientReleases() : null;
++ fs.writeFileSync(path.join(OUT, 'client.json'), `${JSON.stringify({ ...head, ...(clients ? { clients } : {}), manifest, dynamic_calls: dynamic, calls: recorded, ...(ws ? { ws } : {}) }, null, 1)}\n`);
fs.writeFileSync(path.join(OUT, 'worker.json'), `${JSON.stringify({ ...head, user_version: userVersion, schema, ledger, lazy, statements }, null, 1)}\n`);
console.log(` wrote ${path.relative(ROOT, OUT)}/client.json and worker.json`);
} finally {
@@@ -96,4 +128,3 @@ const OUT = path.join(ROOT, 'test', 'fixtures', 'n-1');
fs.rmSync(tmp, { recursive: true, force: true });
}
})().catch((err) => { console.error(err.stack || err.message); process.exit(1); });
--
ddiff --git a/test/fixtures/n-1/client.json b/test/fixtures/n-1/client.json
iindex 611e9923..9686c9dc 100644
---- a/test/fixtures/n-1/client.json
++++ b/test/fixtures/n-1/client.json
@@@ -1,13 +1,13 @@
{
"service": "live",
"release": "8caa540466f9af86247febdbba1339b68919a42f",
-- "recorded_at": "2026-09-26T13:44:31.818Z",
++ "recorded_at": "2026-09-26T14:07:22.139Z",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"manifest": {
"service": "live",
"release": "8caa540466f9",
"released_at": "2026-09-26T13:22:32.000Z",
-- "booted_at": "2026-09-26T13:44:29.263Z",
++ "booted_at": "2026-09-26T14:07:19.453Z",
"contracts_version": "0.56.0",
"packages": {
"openvibe-shared": "1.22.0",
@@@ -31,7 +31,7 @@
"contract_ranges": {},
"metrics_url": "/release-metrics"
},
-- "dynamic_calls": 49,
++ "dynamic_calls": 44,
"calls": [
{
"method": "GET",
@@@ -1212,6 +1212,34 @@
"public/js/app-channel.js"
]
},
++ {
++ "method": "GET",
++ "path": "/api/chat-ai/timeline",
++ "auth": "anon",
++ "status": 200,
++ "kind": "json",
++ "reads": {
++ "events": "array",
++ "hasMore": "boolean"
++ },
++ "from": [
++ "public/js/app-chatpage.js"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/api/chat-ai/timeline",
++ "auth": "user",
++ "status": 200,
++ "kind": "json",
++ "reads": {
++ "events": "array",
++ "hasMore": "boolean"
++ },
++ "from": [
++ "public/js/app-chatpage.js"
++ ]
++ },
{
"method": "GET",
"path": "/api/chat-ai/transcript/1",
@@@ -1371,6 +1399,26 @@
"public/js/stream-player.js"
]
},
++ {
++ "method": "GET",
++ "path": "/api/clips/1/job?job=1",
++ "auth": "anon",
++ "status": 401,
++ "kind": "json",
++ "from": [
++ "public/js/ov-clip-jobs.js"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/api/clips/1/job?job=1",
++ "auth": "user",
++ "status": 400,
++ "kind": "json",
++ "from": [
++ "public/js/ov-clip-jobs.js"
++ ]
++ },
{
"method": "GET",
"path": "/api/clips?limit=5&offset=0&auto_generated=0",
@@@ -1606,48 +1654,48 @@
},
{
"method": "GET",
-- "path": "/api/controls/presets",
++ "path": "/api/controls/configs/1/bridge-script?type=generic",
"auth": "anon",
-- "status": 200,
++ "status": 401,
"kind": "json",
-- "reads": {
-- "controls": "array",
-- "settings": "object"
-- },
"from": [
"public/js/dashboard.js"
]
},
{
"method": "GET",
-- "path": "/api/controls/presets",
++ "path": "/api/controls/configs/1/bridge-script?type=generic",
"auth": "user",
-- "status": 200,
++ "status": 404,
"kind": "json",
-- "reads": {
-- "controls": "array",
-- "settings": "object"
-- },
"from": [
"public/js/dashboard.js"
]
},
{
"method": "GET",
-- "path": "/api/controls/presets/1",
++ "path": "/api/controls/presets",
"auth": "anon",
-- "status": 404,
++ "status": 200,
"kind": "json",
++ "reads": {
++ "controls": "array",
++ "settings": "object"
++ },
"from": [
"public/js/dashboard.js"
]
},
{
"method": "GET",
-- "path": "/api/controls/presets/1",
++ "path": "/api/controls/presets",
"auth": "user",
-- "status": 404,
++ "status": 200,
"kind": "json",
++ "reads": {
++ "controls": "array",
++ "settings": "object"
++ },
"from": [
"public/js/dashboard.js"
]
@@@ -6815,6 +6863,705 @@
"public/js/app-media.js"
]
},
++ {
++ "method": "GET",
++ "path": "/",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/assets/logo.svg",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/manifest.webmanifest",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "json",
++ "reads": {
++ "name": "string",
++ "description": "string",
++ "scope": "string",
++ "display": "string"
++ },
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/assets/logo-192.png",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/css/style.css?v=a3843ec6bdd6",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/css/features/home.css?v=52249fa8d30a",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/css/home-fx.css?v=8f135af68e76",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/css/home-tour.css?v=3f1293159aff",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/css/i18n-star.css?v=3a854948d72f",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/css/vendor/fontawesome-6.5.1.min.css?v=d6c948f648fd",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/css/chat-shared.css?v=558d74cf2e6a",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/css/broadcast.css?v=942804f7c8a1",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/css/cosmetics.css?v=193dbe9046fb",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/css/messenger.css?v=8d3312c1f857",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/content",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/moments",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/chat",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/documentation",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/@n1star",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/tos",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/privacy",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/dmca",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/updates",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/content?type=vods",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/content?type=clips",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/content?type=pastes",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "html",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/live-notify.js",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/ov-skeleton.js?v=1e6a011f824c",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/ov-loader.js?v=d87608436703",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/shared/navbar.js?v=b50d9f47fc34",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/app.js?v=2b3a4e2cc1c9",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/ov-navbar-live.js?v=54bcbe2d01d1",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/chat-shared.js?v=8fff7cb6e502",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/chat.js?v=a35bbba644ad",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/vibes.js?v=aef505d77f34",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/opencoins.js?v=2e5a908601ff",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/draggable-fab.js?v=fd94fe405afb",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/messenger.js?v=d1d537f2289d",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/shared/footer.js?v=b99e512398d5",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/footer-live.js?v=60c31729accf",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/shared/theme-loader.js?v=d9b9af081efe",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/ov-display-menu.js?v=cb6be0b1998e",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/shared/notification-ui.js?v=a889be53ea64",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/ov-rail.js?v=57c8cd1aca1e",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/ov-live-realtime.js?v=e82b1b7a273a",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/app-home.js?v=d88ec40a35c0",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/ov-odometer.js?v=3f6dee017f2f",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/ov-density.js?v=fc239b8c3986",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/home-fx.js?v=b69411bb61ba",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/home-tour.js?v=a5ded0950973",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/shared/tooltip.js?v=096488cf4948",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/css/features/channel.css?v=64174155ceda",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /@n1star"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/css/context-cards.css?v=60ad33ae3e48",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "text",
++ "from": [
++ "page /@n1star"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/ov-clip-jobs.js?v=b674bd0b77b1",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /@n1star"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/stream-player.js?v=cd0a5b166216",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /@n1star"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/stream-pip.js?v=11ae18077afa",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /@n1star"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/controls.js?v=04c5242890ee",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /@n1star"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/context-cards.js?v=6719bbc1c432",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /@n1star"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/emotes.js?v=0099deffc3c0",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /@n1star"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/gif-picker.js?v=5ee269f4e9c1",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /@n1star"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/chat-autocomplete.js?v=b726f226680f",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /@n1star"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/channel-uploads.js?v=03eb0f258adf",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /@n1star"
++ ]
++ },
++ {
++ "method": "GET",
++ "path": "/js/app-channel.js?v=747045747200",
++ "auth": "anon",
++ "accept": "html",
++ "status": 200,
++ "kind": "other",
++ "from": [
++ "page /@n1star"
++ ]
++ },
{
"method": "PUT",
"path": "/api/admin/users/1",
ddiff --git a/test/fixtures/n-1/worker.json b/test/fixtures/n-1/worker.json
iindex c4268c38..e18eb283 100644
---- a/test/fixtures/n-1/worker.json
++++ b/test/fixtures/n-1/worker.json
@@@ -1,7 +1,7 @@
{
"service": "live",
"release": "8caa540466f9af86247febdbba1339b68919a42f",
-- "recorded_at": "2026-09-26T13:44:31.818Z",
++ "recorded_at": "2026-09-26T14:07:22.139Z",
"note": "N-1 fixture: written by `npm run n-1:record`, replayed by test/n-1.test.js",
"user_version": 0,
"schema": [
@@@ -82,6 +82,7 @@
"CREATE TABLE stream_controls (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n label TEXT NOT NULL,\n command TEXT NOT NULL, -- command string sent to hardware\n icon TEXT DEFAULT 'fa-gamepad', -- Font Awesome icon class\n control_type TEXT DEFAULT 'button' CHECK(control_type IN ('button', 'toggle', 'slider', 'dpad', 'onvif', 'keyboard')),\n key_binding TEXT, -- keyboard shortcut\n cooldown_ms INTEGER DEFAULT 100,\n is_enabled INTEGER DEFAULT 1,\n sort_order INTEGER DEFAULT 0,\n camera_id INTEGER, -- NULL for non-ONVIF controls; references camera_profiles(id)\n onvif_movement TEXT, -- 'pan_left', 'pan_right', 'tilt_up', 'tilt_down', 'zoom_in', 'zoom_out', 'preset'\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, btn_color TEXT DEFAULT '', btn_bg TEXT DEFAULT '', btn_border_color TEXT DEFAULT '',\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE,\n FOREIGN KEY (camera_id) REFERENCES camera_profiles(id) ON DELETE SET NULL\n)",
"CREATE TABLE stream_first_chats (\n chatter_key TEXT NOT NULL,\n channel_user_id INTEGER NOT NULL,\n first_chat_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n PRIMARY KEY (chatter_key, channel_user_id)\n )",
"CREATE TABLE stream_memories (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n user_id INTEGER,\n offset_seconds INTEGER DEFAULT 0, -- seconds into the stream when captured\n captured_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n description TEXT,\n tags TEXT, -- JSON array\n thumbnail_url TEXT,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, transcript_json TEXT,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE\n )",
++ "CREATE TABLE stream_recaps (\n stream_id INTEGER PRIMARY KEY,\n user_id INTEGER,\n json TEXT NOT NULL,\n ai INTEGER DEFAULT 0,\n announced_at DATETIME,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n )",
"CREATE TABLE stream_timeline_events (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n stream_id INTEGER NOT NULL,\n user_id INTEGER,\n vod_id INTEGER, -- set once the session becomes a VOD\n kind TEXT NOT NULL, -- 'speech' | 'sound'\n start_sec REAL NOT NULL, -- absolute seconds into the stream\n end_sec REAL,\n text TEXT, -- speech content\n label TEXT, -- sound event label\n confidence REAL,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, lang TEXT, text_en TEXT,\n FOREIGN KEY (stream_id) REFERENCES streams(id) ON DELETE CASCADE\n )",
"CREATE TABLE streamer_overviews (\n user_id INTEGER PRIMARY KEY,\n overview TEXT,\n model TEXT,\n sources TEXT,\n generated_at DATETIME DEFAULT CURRENT_TIMESTAMP, overview_short TEXT,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n )",
"CREATE TABLE streams (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n user_id INTEGER NOT NULL,\n channel_id INTEGER,\n managed_stream_id INTEGER,\n control_config_id INTEGER,\n title TEXT DEFAULT 'Untitled Stream',\n description TEXT DEFAULT '',\n category TEXT DEFAULT 'irl',\n tags TEXT DEFAULT '[]',\n protocol TEXT DEFAULT 'webrtc' CHECK(protocol IN ('jsmpeg', 'webrtc', 'rtmp')),\n is_live INTEGER DEFAULT 0,\n is_nsfw INTEGER DEFAULT 0,\n viewer_count INTEGER DEFAULT 0,\n peak_viewers INTEGER DEFAULT 0,\n follower_count INTEGER DEFAULT 0,\n thumbnail_url TEXT,\n multi_cam INTEGER DEFAULT 0,\n started_at DATETIME,\n ended_at DATETIME,\n last_heartbeat DATETIME,\n duration_seconds INTEGER DEFAULT 0,\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP, ai_overview TEXT, ai_title TEXT, ai_category TEXT, ai_tags TEXT, ai_overview_short TEXT,\n FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,\n FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL,\n FOREIGN KEY (managed_stream_id) REFERENCES managed_streams(id) ON DELETE SET NULL,\n FOREIGN KEY (control_config_id) REFERENCES control_configs(id) ON DELETE SET NULL\n)",
@@@ -212,6 +213,7 @@
"CREATE INDEX idx_sfc_channel ON stream_first_chats(channel_user_id)",
"CREATE UNIQUE INDEX idx_stream_memories_moment_unique ON stream_memories(stream_id, offset_seconds)",
"CREATE INDEX idx_stream_memories_stream ON stream_memories(stream_id, offset_seconds)",
++ "CREATE INDEX idx_stream_recaps_user ON stream_recaps(user_id, created_at)",
"CREATE INDEX idx_streams_channel_id ON streams(channel_id)",
"CREATE INDEX idx_streams_created ON streams(created_at)",
"CREATE INDEX idx_streams_is_live ON streams(is_live)",
@@@ -255,19 +257,19 @@
"schema_migrations": [
{
"id": "001_vibes_decimal_to_bits",
-- "applied_at": "2026-09-26 13:44:27",
++ "applied_at": "2026-09-26 14:07:18",
"mode": "adopted",
"duration_ms": 0
},
{
"id": "002_pastes_ai_columns",
-- "applied_at": "2026-09-26 13:44:27",
++ "applied_at": "2026-09-26 14:07:18",
"mode": "applied",
"duration_ms": 6
},
{
"id": "004_hot_path_indexes",
-- "applied_at": "2026-09-26 13:44:27",
++ "applied_at": "2026-09-26 14:07:18",
"mode": "applied",
"duration_ms": 1
}
@@@ -286,7 +288,6 @@
"CREATE TABLE IF NOT EXISTS search_media_pushes ( kind TEXT NOT NULL, media_id INTEGER NOT NULL, hash TEXT NOT NULL, revision INTEGER NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, pushed_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (kind, media_id) )",
"CREATE TABLE IF NOT EXISTS billing_actions ( id INTEGER PRIMARY KEY AUTOINCREMENT, action TEXT NOT NULL, idempotency_key TEXT NOT NULL UNIQUE, live_user_id INTEGER, live_ref TEXT, method TEXT NOT NULL, path TEXT NOT NULL, request_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', http_status INTEGER, billing_ref TEXT, response_json TEXT, error TEXT, attempts INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )",
"CREATE TABLE IF NOT EXISTS opencoin_admin_grants ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_key TEXT UNIQUE, admin_id INTEGER, user_id INTEGER NOT NULL, amount INTEGER NOT NULL, reason TEXT, balance INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )",
-- "CREATE TABLE IF NOT EXISTS stream_recaps ( stream_id INTEGER PRIMARY KEY, user_id INTEGER, json TEXT NOT NULL, ai INTEGER DEFAULT 0, announced_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )",
"CREATE TABLE IF NOT EXISTS tips_deliveries ( idempotency_key TEXT PRIMARY KEY, effect TEXT, state TEXT NOT NULL DEFAULT 'pending', response_json TEXT, claimed_at INTEGER NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )",
"ALTER TABLE users ADD COLUMN theme_id INTEGER",
"ALTER TABLE streams ADD COLUMN call_mode TEXT DEFAULT NULL"
@@@ -748,6 +749,7 @@
"SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ?",
"SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at >= ?",
"SELECT COUNT(*) AS n FROM follows WHERE streamer_id = ? AND created_at BETWEEN ? AND ?",
++ "SELECT COUNT(*) AS n FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE r.ai = 1 AND COALESCE(u.is_banned, 0) = 0",
"SELECT COUNT(*) AS n FROM stream_timeline_events WHERE user_id = ? AND kind = 'sound' AND (LOWER(label) LIKE '%laugh%' OR LOWER(label) LIKE '%giggle%' OR LOWER(label) LIKE '%chuckle%') AND created_at >= datetime('now', ?)",
"SELECT COUNT(*) AS n FROM streams WHERE is_live = 1",
"SELECT COUNT(*) AS n FROM streams WHERE started_at >= ?",
@@@ -921,6 +923,7 @@
"SELECT id, stream_id, vod_id, start_sec, text FROM stream_timeline_events WHERE user_id = ? AND kind = 'speech' AND created_at >= datetime('now', ?) AND LENGTH(text) BETWEEN 25 AND 220 ORDER BY created_at DESC LIMIT 1500",
"SELECT id, title, slug FROM managed_streams WHERE user_id = ? AND (? IS NULL OR id != ?) ORDER BY sort_order ASC, id ASC",
"SELECT id, title, started_at, viewer_count FROM streams WHERE user_id = ? AND is_live = 1 ORDER BY started_at DESC LIMIT 1",
++ "SELECT id, ts, label, detail FROM chat_timeline_events WHERE scope = ? AND subject_id = ? ORDER BY ts DESC, id DESC LIMIT ?",
"SELECT id, user_id FROM channels WHERE id = ?",
"SELECT id, user_id, anon_id, username, message, message_type, source_platform, reply_to_id, timestamp FROM chat_messages WHERE channel_user_id = ? AND id > ? AND is_deleted = 0 AND message_type IN ('chat','donation') AND (auto_delete_at IS NULL OR auto_delete_at > CURRENT_TIMESTAMP) ORDER BY id DESC LIMIT ?",
"SELECT id, user_id, channel_id, managed_stream_id, title, is_live, started_at, ended_at, created_at FROM streams",
@@@ -983,6 +986,7 @@
"SELECT r.rowid AS id, r.platform, r.username, r.display_name, r.message_count, r.last_seen, cs.updated_at AS last_update FROM relay_users r LEFT JOIN chat_ai_summaries cs ON cs.scope = 'relay' AND cs.window = 'rolling' AND cs.subject_id = r.rowid WHERE r.last_seen >= ? AND r.message_count >= ? AND (cs.updated_at IS NULL OR cs.updated_at < r.last_seen) ORDER BY (cs.updated_at IS NULL) DESC, r.last_seen DESC LIMIT ?",
"SELECT r.stream_id FROM stream_recaps r JOIN streams s ON s.id = r.stream_id WHERE r.user_id = ? AND s.started_at >= ? ORDER BY r.created_at DESC LIMIT 1",
"SELECT r.stream_id, r.json, r.created_at, u.username, u.display_name, u.avatar_url, u.profile_color FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE (? IS NULL OR r.user_id != ?) AND COALESCE(u.is_banned, 0) = 0 ORDER BY r.created_at DESC LIMIT ?",
++ "SELECT r.stream_id, r.user_id, r.json, r.created_at FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE r.ai = 1 AND COALESCE(u.is_banned, 0) = 0 ORDER BY r.created_at DESC, r.stream_id DESC LIMIT ? OFFSET ?",
"SELECT recorded_at AS t, viewer_count AS v, COALESCE(chat_messages_5m, 0) AS c FROM viewer_snapshots WHERE stream_id = ? ORDER BY recorded_at ASC",
"SELECT revision FROM subject_projection WHERE subject_id = ?",
"SELECT rowid AS id, * FROM relay_users WHERE platform = ? AND username = ?",
ddiff --git a/test/n-1.test.js b/test/n-1.test.js
iindex 2968d122..78bbfe98 100644
---- a/test/n-1.test.js
++++ b/test/n-1.test.js
@@@ -99,15 +99,21 @@ async function check(name, fn) {
const manifest = await (await fetch(`${server.url}/release.json`)).json();
console.log('n-1: the N-1 client against this server');
-- await check(`every call the N-1 client makes is answered compatibly (${client.calls.length} requests, in openvibe-shared's mixed-version matrix)`, async () => {
++ const sockets = client.ws ? `, and ${client.ws.sends.length} socket message types in ${client.ws.sessions.length} sessions` : '';
++ await check(`every call the N-1 client makes is answered compatibly (${client.calls.length} requests${sockets}, in openvibe-shared's mixed-version matrix)`, async () => {
const broken = [];
const n1Client = async (url) => {
++ const probe = await h.notFoundProbe(url);
for (const rec of client.calls) {
-- const got = await h.send(url, { method: rec.method, path: rec.path, headers: server.headers(rec.auth), body: rec.method === 'GET' ? undefined : {} });
-- const problems = h.callProblems(rec, got);
++ const got = await h.send(url, { method: rec.method, path: h.fillPath(rec.path, server.ids), headers: { ...(rec.accept ? { accept: 'text/html' } : {}), ...server.headers(rec.auth) }, body: rec.method === 'GET' ? undefined : {} });
++ const problems = h.callProblems(rec, got, probe);
if (problems.length) broken.push(`${rec.auth} ${rec.method} ${rec.path} (${rec.from.join(', ')}): ${problems.join('; ')}`);
}
-- if (svc.checkExtra && client.extra) broken.push(...await svc.checkExtra({ server, url, extra: client.extra }));
++ for (const session of (client.ws && client.ws.sessions) || []) {
++ const messages = h.wsMessages(client.ws.sends, { order: svc.ws.order, values: server.wsValues(session.auth) });
++ const received = await h.wsSession({ url: url.replace(/^http/, 'ws') + client.ws.path, headers: svc.ws.headers, messages });
++ broken.push(...h.wsProblems(session.received, received).map((p) => `socket ${client.ws.path} (${session.auth}): ${p}`));
++ }
if (broken.length) throw new Error(`${broken.length} broken:\n${h.summarize(broken)}`);
};
await compat.assertMixedVersion({ releases: [
ddiff --git a/test/n-1/harness.js b/test/n-1/harness.js
iindex 19d565e8..1fdd969f 100644
---- a/test/n-1/harness.js
++++ b/test/n-1/harness.js
@@@ -94,16 +94,78 @@ function callArgs(src, i) {
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
++/** src split at every top-level `sep` (outside strings and brackets). */
++function splitTop(src, sep) {
++ const out = [];
++ let depth = 0; let start = 0;
++ for (let j = 0; j < src.length; j++) {
++ const c = src[j];
++ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }
++ if (c === '(' || c === '{' || c === '[') depth++;
++ else if (c === ')' || c === '}' || c === ']') depth--;
++ else if (c === sep && depth === 0) { out.push(src.slice(start, j)); start = j + 1; }
++ }
++ out.push(src.slice(start));
++ return out.map((x) => x.trim());
++}
++
/**
-- * Every call site of the service's request functions with a literal or template path.
++ * The parts of a path expression: a literal, a template, or literals, templates and names joined with
++ * `+` ('/api/x/' + id + '?a=1'); a name in `vars` stands for the path it was assigned. → parts or null
++ */
++function pathParts(expr, vars = new Map()) {
++ const parts = [];
++ for (const piece of splitTop(expr, '+')) {
++ if (!piece) return null;
++ const lit = /^['"`]/.test(piece) ? readLiteral(piece, 0) : null;
++ if (lit && lit.end === piece.length) parts.push(...lit.parts.flatMap((x) => (x.expr && vars.has(unwrap(x.expr)) ? vars.get(unwrap(x.expr)) : [x])));
++ else if (vars.has(piece)) parts.push(...vars.get(piece));
++ else parts.push({ expr: piece });
++ }
++ const merged = [];
++ for (const p of parts) {
++ const last = merged[merged.length - 1];
++ if (p.text != null && last && last.text != null) last.text += p.text; else merged.push({ ...p });
++ }
++ return merged;
++}
++
++/** `esc(base)`, `encodeURIComponent(base)`: the name inside one escaping call. */
++function unwrap(expr) {
++ const m = /^(?:esc|enc|encodeURI|encodeURIComponent|String|escapeHtml|escHtml)\(\s*([A-Za-z_$][\w$]*)\s*\)$/.exec(String(expr).trim());
++ return m ? m[1] : String(expr).trim();
++}
++
++/** `var|let|const name = <path expression>` in a file, for names later calls use as a path base. */
++function pathVars(src) {
++ const vars = new Map();
++ for (const m of src.matchAll(/(?:var|let|const)\s+([A-Za-z_$][\w$]*)\s*=\s*(?=['"`])/g)) {
++ let depth = 0; let end = src.length;
++ for (let j = m.index + m[0].length; j < src.length; j++) {
++ const c = src[j];
++ if (c === '\'' || c === '"' || c === '`') { const lit = readLiteral(src, j); if (lit) { j = lit.end - 1; continue; } }
++ if (c === '(' || c === '{' || c === '[') depth++;
++ else if (c === ')' || c === '}' || c === ']') { if (depth === 0) { end = j; break; } depth--; } else if (depth === 0 && (c === ';' || c === '\n' || c === ',')) { end = j; break; }
++ }
++ const parts = pathParts(src.slice(m.index + m[0].length, end).trim(), vars);
++ if (parts && parts[0] && parts[0].text && parts[0].text.startsWith('/') && !parts[0].text.startsWith('//')) vars.set(m[1], parts);
++ }
++ return vars;
++}
++
++/**
++ * Every call site of the service's request functions whose path is a literal, a template or a
++ * concatenation starting with one (or with a variable assigned one).
* callers: [{ name: 'api', prefix: '/api', method?: 'POST', methodArg?: 1 }]
* strip: template expressions dropped when a path starts with them (e.g. 'API', 'location.origin')
-- * → [{ method, template, parts, from: [file] }], plus { dynamic } calls with no literal path
++ * keep(pathname, method, file): whether a call is this server's
++ * → { calls: [{ method, template, parts, from: [file] }], dynamic: calls with a computed path }
*/
--function extractCalls({ files, callers, strip = [], keep = (p) => true }) {
++function extractCalls({ files, callers, strip = [], keep = (p) => true, forms = false }) {
const found = new Map();
let dynamic = 0;
for (const { name: file, text: src } of files) {
++ const vars = pathVars(src);
for (const caller of callers) {
const re = new RegExp(`(?<![\\w$.])${esc(caller.name)}\\s*\\(`, 'g');
let m;
@@@ -111,20 +173,33 @@ function extractCalls({ files, callers, strip = [], keep = (p) => true }) {
const open = m.index + m[0].length - 1;
const args = callArgs(src, open);
if (!args || !args.length) continue;
-- const lit = /^['"`]/.test(args[0]) ? readLiteral(args[0], 0) : null;
-- if (!lit || lit.end !== args[0].length) { dynamic++; continue; }
-- let parts = lit.parts.slice();
++ // An SDK-style call: call({ method: 'POST', path: `/api/x/${id}`, … }).
++ let pathExpr = args[0];
++ let objMethod = null;
++ if (caller.object) {
++ if (!/^\{[\s\S]*\}$/.test(args[0])) continue; // some other function of that name
++ const entries = splitTop(args[0].slice(1, -1), ',');
++ const entry = (k) => { const e = entries.find((x) => new RegExp(`^${k}\\s*:`).test(x)); return e ? e.replace(new RegExp(`^${k}\\s*:\\s*`), '') : null; };
++ pathExpr = entry('path');
++ const me = entry('method');
++ const ml = me && /^['"`]/.test(me) ? readLiteral(me, 0) : null;
++ if (ml && ml.parts.length === 1 && ml.parts[0].text) objMethod = ml.parts[0].text;
++ if (!pathExpr) continue;
++ }
++ let parts = pathParts(pathExpr, vars);
++ if (!parts) { dynamic++; continue; }
while (parts.length && parts[0].expr && strip.includes(parts[0].expr)) parts.shift();
if (!parts.length || !parts[0].text || !parts[0].text.startsWith('/') || parts[0].text.startsWith('//')) {
if (parts.length && parts[0].expr) dynamic++;
continue;
}
parts = caller.prefix ? [{ text: caller.prefix }, ...parts] : parts;
++ if (parts[1] && parts[0].text != null && parts[1].text != null) parts = [{ text: parts[0].text + parts[1].text }, ...parts.slice(2)];
// `/api${path}`: a wrapper passing a path through, not a call.
if (parts.some((p, k) => p.expr && k > 0 && parts[k - 1].text != null && !parts.slice(0, k).some((x) => x.text && x.text.includes('?')) && /[A-Za-z0-9]$/.test(parts[k - 1].text))) { dynamic++; continue; }
const template = parts.map((p) => (p.text != null ? p.text : `{${p.expr}}`)).join('').replace(/^([^?#]*)\/\//, '$1/');
-- let method = caller.method || 'GET';
-- if (caller.methodArg != null) {
++ let method = objMethod || caller.method || 'GET';
++ if (caller.object) { /* the method was in the object */ } else if (caller.methodArg != null) {
const a = args[caller.methodArg];
const ml = a && /^['"`]/.test(a) ? readLiteral(a, 0) : null;
if (ml && ml.parts.length === 1 && ml.parts[0].text) method = ml.parts[0].text;
@@@ -134,7 +209,29 @@ function extractCalls({ files, callers, strip = [], keep = (p) => true }) {
if (mm) method = mm[1];
}
method = method.toUpperCase();
-- if (!keep(template.split('?')[0], method)) continue;
++ if (!keep(template.split('?')[0], method, file)) continue;
++ const key = `${method} ${template}`;
++ if (!found.has(key)) found.set(key, { method, template, parts, from: new Set() });
++ found.get(key).from.add(file);
++ }
++ }
++ }
++ // Forms the pages post (or get) to: <form action="/x/${id}" method="post"> in markup or a template.
++ if (forms) {
++ for (const { name: file, text: src } of files) {
++ const vars = pathVars(src);
++ for (const m of src.matchAll(/<form\b/gi)) {
++ const end = tagEnd(src, m.index);
++ if (end < 0) continue;
++ const tag = src.slice(m.index, end);
++ const action = attr(tag, 'action');
++ if (!action || action.startsWith('//') || /^[a-z]+:/i.test(action)) continue;
++ const parts = templateParts(action).flatMap((x) => (x.expr && vars.has(unwrap(x.expr)) ? vars.get(unwrap(x.expr)) : [x]));
++ if (!parts.length || parts[0].text == null || !parts[0].text.startsWith('/')) continue;
++ const methodAttr = attr(tag, 'method');
++ const method = /^post$/i.test(methodAttr || '') ? 'POST' : 'GET';
++ const template = parts.map((p) => (p.text != null ? p.text : `{${p.expr}}`)).join('');
++ if (!keep(template.split('?')[0], method, file)) continue;
const key = `${method} ${template}`;
if (!found.has(key)) found.set(key, { method, template, parts, from: new Set() });
found.get(key).from.add(file);
@@@ -146,6 +243,54 @@ function extractCalls({ files, callers, strip = [], keep = (p) => true }) {
return { calls, dynamic };
}
++/** Where an HTML tag that starts at src[i] ends ('>' outside quotes and ${…}); -1 if it does not. */
++function tagEnd(src, i) {
++ let q = null;
++ for (let j = i; j < src.length; j++) {
++ const c = src[j];
++ if (c === '$' && src[j + 1] === '{') { const close = skipBalanced(src, j + 1, '{', '}'); if (close < 0) return -1; j = close; continue; }
++ if (q) { if (c === q) q = null; continue; }
++ if (c === '"' || c === '\'') q = c;
++ else if (c === '>') return j + 1;
++ }
++ return -1;
++}
++
++/** An attribute's raw value in a tag (quoted, may hold ${…}). */
++function attr(tag, name) {
++ const m = new RegExp(`\\s${name}\\s*=\\s*(["'])`, 'i').exec(tag);
++ if (!m) return null;
++ const q = m[1];
++ let out = '';
++ for (let j = m.index + m[0].length; j < tag.length; j++) {
++ const c = tag[j];
++ if (c === '$' && tag[j + 1] === '{') { const close = skipBalanced(tag, j + 1, '{', '}'); if (close < 0) return null; out += tag.slice(j, close + 1); j = close; continue; }
++ if (c === q) return out;
++ out += c;
++ }
++ return null;
++}
++
++/** Text with ${expr} holes → parts. */
++function templateParts(text) {
++ const parts = [];
++ let buf = '';
++ for (let j = 0; j < text.length; j++) {
++ if (text[j] === '$' && text[j + 1] === '{') {
++ const close = skipBalanced(text, j + 1, '{', '}');
++ if (close < 0) break;
++ if (buf) parts.push({ text: buf });
++ buf = '';
++ parts.push({ expr: text.slice(j + 2, close).trim() });
++ j = close;
++ continue;
++ }
++ buf += text[j];
++ }
++ if (buf) parts.push({ text: buf.replace(/&amp;/g, '&') });
++ return parts;
++}
++
/**
* A concrete path for a template: each ${expr} becomes the first sample whose pattern matches the
* expression text, else '1' in the path and '' in the query.
@@@ -161,12 +306,55 @@ function concretePath(parts, samples = []) {
const hit = samples.find(([re]) => re.test(p.expr));
let v = hit ? hit[1] : (inQuery ? '' : '1');
if (typeof v === 'function') v = v(p.expr, inQuery);
++ // '@name': a value the booted server seeded (a random slug); filled in when the call is sent.
++ if (/^@\w+$/.test(String(v))) { out += `{${v}}`; continue; }
out += inQuery ? String(v) : encodeURIComponent(String(v)).replace(/%2F/gi, '/');
}
out = out.replace(/^([^?#]*)\/\/+/, '$1/').replace(/[?&]+$/, '').replace(/\?&+/, '?');
return out;
}
++/**
++ * The same-origin URLs a served page makes the browser fetch or offers to follow: src, href, poster,
++ * data-src and form actions (with their method). `origins` are absolute origins that count as this
++ * site (e.g. its public URL). → [{ method, path }]
++ */
++function pageLinks(html, origins = []) {
++ const out = new Map();
++ const add = (method, raw) => {
++ let u = String(raw || '').trim().replace(/&amp;/g, '&');
++ if (!u || /^(#|mailto:|javascript:|data:|tel:|blob:)/i.test(u)) return;
++ const o = origins.find((x) => u.startsWith(`${x}/`) || u === x);
++ if (o) u = u.slice(o.length) || '/';
++ if (!u.startsWith('/') || u.startsWith('//')) return;
++ u = u.split('#')[0];
++ out.set(`${method} ${u}`, { method, path: u });
++ };
++ for (const m of String(html).matchAll(/<([a-zA-Z][\w-]*)\b([^>]*)>/g)) {
++ const tag = m[1].toLowerCase(); const attrs = m[2];
++ const get = (n) => { const a = new RegExp(`\\s${n}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i').exec(attrs); return a ? (a[1] != null ? a[1] : a[2]) : null; };
++ if (tag === 'form') { const a = get('action'); if (a != null) add(/^post$/i.test(get('method') || '') ? 'POST' : 'GET', a); continue; }
++ for (const n of ['src', 'href', 'poster', 'data-src']) { const v = get(n); if (v != null) add('GET', v); }
++ }
++ return [...out.values()];
++}
++
++/** A path with the seeded ids that differ per run (random slugs, keys) put back as {@name} slots. */
++function unfillPath(p, ids = {}) {
++ let out = String(p);
++ for (const [name, v] of Object.entries(ids || {})) {
++ const s = v == null ? '' : String(v);
++ if (s.length < 6 || /^\d+$/.test(s)) continue;
++ out = out.split(encodeURIComponent(s)).join(`{@${name}}`).split(s).join(`{@${name}}`);
++ }
++ return out;
++}
++
++/** A recorded path with its {@name} slots filled from the booted server's seeded ids. */
++function fillPath(p, ids = {}) {
++ return String(p).replace(/\{@(\w+)\}/g, (m, name) => encodeURIComponent(ids[name] == null ? '0' : String(ids[name])));
++}
++
/** Property names the client code reads: `.name`, `?.name`, `['name']`, 'name' literals, destructured names. */
function clientIdentifiers(texts) {
const ids = new Set();
@@@ -304,8 +492,10 @@ async function send(baseUrl, { method, path: p, headers = {}, body }, { timeoutM
const kind = kindOf(ct, text);
let json = null;
if (kind === 'json') { try { json = JSON.parse(text); } catch { json = null; } }
++ const html = kind === 'html' ? text.slice(0, 1 << 20) : null;
const loc = res.headers.get('location');
-- return { status: res.status, kind, json, location: loc ? loc.replace(/^https?:\/\/[^/]+/, '') : null };
++ // Where a redirect goes, without its query (a sign-in `next` or `state` differs every run).
++ return { status: res.status, kind, json, html, location: loc ? loc.replace(/^https?:\/\/[^/]+/, '').split('?')[0] : null };
} catch (err) {
return { status: 0, kind: 'error', json: null, error: err.name === 'AbortError' ? 'timeout' : err.message };
} finally {
@@@ -333,10 +523,33 @@ function requestsFor(calls, samples, { signedIn = true } = {}) {
return reqs.sort((a, b) => rank(a) - rank(b));
}
--/** The failures of one replayed call against its recorded outcome. */
--function callProblems(rec, got) {
++/**
++ * What the server answers a path nobody routes (per method): an HTML 404 is always "no route"; a
++ * JSON catch-all 404 (`{ error: 'Not found' }`) is recognised by its body. → [{ method, status, kind, json }]
++ */
++async function notFoundProbe(baseUrl, headers = {}) {
++ const out = [];
++ for (const method of ['GET', 'POST']) {
++ for (const p of ['/api/n-1-no-such-route', '/n-1-no-such-page']) {
++ const got = await send(baseUrl, { method, path: p, headers, body: method === 'GET' ? undefined : {} });
++ if (got.status === 404) out.push({ method, status: got.status, kind: got.kind, json: got.json });
++ }
++ }
++ return out;
++}
++
++/** Whether an answer is the server's unknown-path 404 rather than a route's own. */
++function isNoRoute(got, probe = []) {
++ if (got.status !== 404) return false;
++ if (got.kind !== 'json') return true;
++ return probe.some((fp) => fp.kind === 'json' && JSON.stringify(fp.json) === JSON.stringify(got.json));
++}
++
++/** The failures of one replayed call against its recorded outcome (probe: N's unknown-path answers). */
++function callProblems(rec, got, probe = []) {
const problems = [];
if (got.status === 0) return [`no answer (${got.error})`];
++ if (isNoRoute(got, probe)) return ['the route is gone (it answers as an unknown path)'];
if (!statusCompatible(rec.status, got.status)) problems.push(`answered ${got.status}, N-1 answered ${rec.status}`);
else if (rec.kind === 'json' && got.kind !== 'json' && rec.status < 500) problems.push(`answered ${got.kind}, N-1 answered JSON`);
if (rec.location && got.location && rec.status >= 300 && rec.status < 400 && got.location.split('?')[0] !== rec.location.split('?')[0]) problems.push(`redirects to ${got.location}, N-1 to ${rec.location}`);
@@@ -344,6 +557,117 @@ function callProblems(rec, got) {
return problems;
}
++// ── WebSockets ───────────────────────────────────────────────
++
++/**
++ * The messages a client sends over its sockets: `x.send(JSON.stringify({ type: 'join', … }))` and
++ * `helper({ type: … })` for each helper name. → [{ type, keys: [...] }] (keys merged per type)
++ */
++function wsSends(files, helpers = []) {
++ const byType = new Map();
++ const add = (obj) => {
++ const t = obj.trim();
++ if (!t.startsWith('{') || !t.endsWith('}')) return;
++ let type = null; const keys = [];
++ for (const entry of splitTop(t.slice(1, -1), ',')) {
++ if (!entry || entry.startsWith('...')) continue;
++ const kv = /^(?:['"]?)([A-Za-z_$][\w$-]*)(?:['"]?)\s*(?::\s*([\s\S]*))?$/.exec(entry);
++ if (!kv) continue;
++ if (kv[1] === 'type') {
++ const lit = kv[2] && /^['"`]/.test(kv[2].trim()) ? readLiteral(kv[2].trim(), 0) : null;
++ if (lit && lit.parts.length === 1 && lit.parts[0].text) type = lit.parts[0].text;
++ } else keys.push(kv[1]);
++ }
++ if (!type) return;
++ if (!byType.has(type)) byType.set(type, new Set());
++ for (const k of keys) byType.get(type).add(k);
++ };
++ for (const { text } of files) {
++ for (const m of text.matchAll(/\.send\(\s*JSON\.stringify\(/g)) {
++ const args = callArgs(text, m.index + m[0].length - 1);
++ if (args && args[0]) add(args[0]);
++ }
++ for (const name of helpers) {
++ for (const m of text.matchAll(new RegExp(`(?<![\\w$.])${esc(name)}\\s*\\(\\s*(?=\\{)`, 'g'))) {
++ const args = callArgs(text, m.index + m[0].indexOf('('));
++ if (args && args[0]) add(args[0]);
++ }
++ }
++ }
++ return [...byType].map(([type, keys]) => ({ type, keys: [...keys].sort() })).sort((a, b) => (a.type < b.type ? -1 : 1));
++}
++
++/** The message types a client acts on: `case 'x':` and `.type === 'x'`. */
++function wsHandled(files) {
++ const types = new Set();
++ for (const { text } of files) {
++ for (const m of text.matchAll(/\bcase\s+['"]([\w:.-]+)['"]\s*:/g)) types.add(m[1]);
++ for (const m of text.matchAll(/\.type\s*[!=]==?\s*['"]([\w:.-]+)['"]/g)) types.add(m[1]);
++ }
++ return types;
++}
++
++/**
++ * One scripted socket session: connect to `url`, send each message in turn and wait until the server
++ * has been quiet for `quietMs`. → every message received (parsed JSON objects)
++ */
++async function wsSession({ url, headers = {}, messages, quietMs = 250, maxMs = 3000 }) {
++ const WebSocket = require('ws');
++ const ws = new WebSocket(url, { headers });
++ const got = [];
++ let last = Date.now();
++ ws.on('message', (d) => { try { const m = JSON.parse(String(d)); if (m && typeof m === 'object') got.push(m); } catch { /* not JSON */ } last = Date.now(); });
++ await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); ws.once('unexpected-response', (req, res) => reject(new Error(`upgrade refused: ${res.statusCode}`))); });
++ const settle = async () => {
++ const start = Date.now();
++ last = Date.now();
++ while (Date.now() - last < quietMs && Date.now() - start < maxMs) await new Promise((r) => setTimeout(r, 25));
++ };
++ await settle();
++ for (const m of messages) {
++ if (ws.readyState !== 1) break;
++ ws.send(JSON.stringify(m));
++ await settle();
++ }
++ await new Promise((resolve) => { ws.once('close', resolve); ws.close(); setTimeout(resolve, 500); });
++ return got;
++}
++
++/** The messages to send for a client's sends, in `order` (then the rest), with keys filled from `values`. */
++function wsMessages(sends, { order = [], values = {} } = {}) {
++ const rank = (t) => { const i = order.indexOf(t); return i < 0 ? order.length : i; };
++ return sends.slice().sort((a, b) => rank(a.type) - rank(b.type) || (a.type < b.type ? -1 : 1))
++ .map((s) => {
++ const m = { type: s.type };
++ for (const k of s.keys) if (values[k] !== undefined) m[k] = values[k];
++ return m;
++ });
++}
++
++/** Received messages grouped by type, for the types the client handles → { type: reads } */
++function wsReads(received, handled, ids) {
++ const byType = new Map();
++ for (const m of received) {
++ if (typeof m.type !== 'string' || !handled.has(m.type)) continue;
++ if (!byType.has(m.type)) byType.set(m.type, []);
++ byType.get(m.type).push(m);
++ }
++ const out = {};
++ for (const [t, list] of [...byType].sort((a, b) => (a[0] < b[0] ? -1 : 1))) out[t] = readsOf(list, ids);
++ return out;
++}
++
++/** What `recorded` ({ type: reads }) finds missing in the messages N sent. */
++function wsProblems(recorded, received) {
++ const problems = [];
++ for (const [type, reads] of Object.entries(recorded)) {
++ const list = received.filter((m) => m.type === type);
++ if (!list.length) { problems.push(`no '${type}' message any more`); continue; }
++ problems.push(...readProblems(reads, list).map((p) => `'${type}': ${p.replace(/^\[\]\.?/, '')}`));
++ }
++ return problems;
++}
++
// ── SQL ──────────────────────────────────────────────────────
const SQL_START = /^\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)\b/i;
@@@ -506,6 +830,13 @@ function worktree(root, ref) {
};
}
++/** Files of another repository at a commit (git show), e.g. Live's chat widget for Chat. → [{ name, text }] */
++function gitFiles(repo, ref, dirs, exts = ['.js']) {
++ const names = execFileSync('git', ['-C', repo, 'ls-tree', '-r', '--name-only', ref, '--', ...dirs], { encoding: 'utf8', maxBuffer: 64 << 20 })
++ .split('\n').filter((n) => n && exts.some((x) => n.endsWith(x)) && !n.includes('node_modules/'));
++ return names.sort().map((n) => ({ name: `${path.basename(repo)}:${n}`, text: execFileSync('git', ['-C', repo, 'show', `${ref}:${n}`], { encoding: 'utf8', maxBuffer: 64 << 20 }) }));
++}
++
// ── Reporting ────────────────────────────────────────────────
function summarize(problems, max = 40) {
@@@ -515,9 +846,10 @@ function summarize(problems, max = 40) {
}
module.exports = {
-- readLiteral, callArgs, extractCalls, concretePath, clientIdentifiers,
++ readLiteral, callArgs, splitTop, pathParts, extractCalls, concretePath, fillPath, unfillPath, pageLinks, clientIdentifiers,
flatten, readsOf, readProblems, statusCompatible,
-- send, requestsFor, callProblems,
++ send, requestsFor, notFoundProbe, isNoRoute, callProblems,
++ wsSends, wsHandled, wsSession, wsMessages, wsReads, wsProblems,
stringLiterals, sqlLiterals, stripSqlComments, normalizeSql, lazyDDL, readTree, schemaDDL, prepareProblems, insertProblems,
-- worktree, summarize,
++ worktree, gitFiles, summarize,
};
ddiff --git a/test/n-1/service.js b/test/n-1/service.js
iindex 0fbd43b4..239c0d87 100644
---- a/test/n-1/service.js
++++ b/test/n-1/service.js
@@@ -69,6 +69,9 @@ module.exports = {
],
strip: ['API', 'location.origin', 'window.location.origin'],
keep: (pathname) => !CHAT_PATHS.test(pathname),
++ /** Pages an open tab may hold: the shell (its scripts, styles and links) and a server-rendered channel. */
++ crawl: ['/', '/@n1star'],
++ origins: ['https://openvibe.live'],
/** Values for template expressions, first match wins: the seeded streamer, ids of seeded rows. */
samples: [
[/id\)*$/i, '1'],