@bongos/core 1.19.100 → 1.19.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +31 -21
- package/bin/bongos.js +3 -0
- package/docs/adr/0169-bongos-serve-server-bring-up.md +52 -0
- package/docs/adr/README.md +1 -0
- package/docs/recipes/instance-deploy.md +1 -0
- package/docs/recipes/local-dev.md +3 -0
- package/docs/recipes/server-bring-up.md +112 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +0 -5
- package/public-landing/projects.html +0 -11
- package/scripts/gds/dev-lib.js +209 -3
- package/scripts/gds/dev.js +292 -21
- package/src/module-api.js +5 -1
- package/tests/bongos_cli.mjs +3 -1
- package/tests/dev_lib.mjs +192 -2
- package/tests/projects_hub.mjs +40 -0
package/src/module-api.js
CHANGED
|
@@ -627,8 +627,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
627
627
|
// landed since 1.19.98 with no explicit bump. run 31750941866. (task 1002620)
|
|
628
628
|
// 1.19.100 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
629
629
|
// landed since 1.19.99 with no explicit bump. run 31751275973. (task 1002620)
|
|
630
|
+
// 1.19.101 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
631
|
+
// landed since 1.19.100 with no explicit bump. run 31752022322. (task 1002620)
|
|
632
|
+
// 1.19.102 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
633
|
+
// landed since 1.19.101 with no explicit bump. run 31753496152. (task 1002620)
|
|
630
634
|
// ---------------------------------------------------------------------------
|
|
631
|
-
const CORE_VERSION = '1.19.
|
|
635
|
+
const CORE_VERSION = '1.19.102'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
632
636
|
|
|
633
637
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
634
638
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/bongos_cli.mjs
CHANGED
|
@@ -47,8 +47,10 @@ test('VERBS: selfHelp is only claimed by scripts that really print their own usa
|
|
|
47
47
|
// Verified by reading each script (they all branch on --help/-h to a usage()
|
|
48
48
|
// printer). If a verb is added here, verify its script first — a wrong
|
|
49
49
|
// selfHelp:true recreates the blocking-interview bug for that verb.
|
|
50
|
+
// `serve` is dev.js with --server pinned on, so it inherits dev's own usage()
|
|
51
|
+
// printer — verified: `bongos serve --help` prints the card and exits 0.
|
|
50
52
|
const selfHelp = Object.entries(cli.VERBS).filter(([, d]) => d.selfHelp).map(([v]) => v).sort();
|
|
51
|
-
assert.deepEqual(selfHelp, ['dev', 'module', 'status', 'task', 'upgrade']);
|
|
53
|
+
assert.deepEqual(selfHelp, ['dev', 'module', 'serve', 'status', 'task', 'upgrade']);
|
|
52
54
|
});
|
|
53
55
|
|
|
54
56
|
// ---- spawned: the dispatcher answers --help without touching the script ----
|
package/tests/dev_lib.mjs
CHANGED
|
@@ -20,12 +20,18 @@ async function ta(name, fn) {
|
|
|
20
20
|
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); }
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// The lib builds paths with path.join, so on Windows they come back with `\`
|
|
24
|
+
// while these fixtures are written POSIX-style. Compare on a normalized form —
|
|
25
|
+
// otherwise every fixture silently "misses" and the lib reads its neutral
|
|
26
|
+
// fallback, which looks like a logic failure and is really a separator mismatch.
|
|
27
|
+
const norm = (p) => String(p).replace(/\\/g, '/');
|
|
28
|
+
|
|
23
29
|
// A fake fs.readFileSync that maps by path suffix; unknown paths throw ENOENT.
|
|
24
30
|
function fakeFs(map) {
|
|
25
31
|
return {
|
|
26
32
|
readFileSync(p) {
|
|
27
33
|
for (const [suffix, body] of Object.entries(map)) {
|
|
28
|
-
if (
|
|
34
|
+
if (norm(p).endsWith(suffix)) return body;
|
|
29
35
|
}
|
|
30
36
|
const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e;
|
|
31
37
|
},
|
|
@@ -184,7 +190,7 @@ t('startDetached: spawns the server entry with the server env, detached', () =>
|
|
|
184
190
|
const r = lib.startDetached(params, deps);
|
|
185
191
|
assert.equal(r.already, false);
|
|
186
192
|
assert.equal(r.pid, 555);
|
|
187
|
-
assert.equal(spawnCall.args[0], '/core/src/platform-server.js');
|
|
193
|
+
assert.equal(norm(spawnCall.args[0]), '/core/src/platform-server.js');
|
|
188
194
|
assert.equal(spawnCall.opts.cwd, '/x/test4');
|
|
189
195
|
assert.equal(spawnCall.opts.detached, true);
|
|
190
196
|
assert.equal(spawnCall.opts.env.PORT, '3006');
|
|
@@ -249,5 +255,189 @@ t('depsInstallPlan: no node_modules + no lockfile → npm install', () => {
|
|
|
249
255
|
assert.deepEqual(plan.args, ['install']);
|
|
250
256
|
});
|
|
251
257
|
|
|
258
|
+
// ---------- server mode (task 1002398) ----------
|
|
259
|
+
// A fake `has(bin)`: the named binaries are on PATH, nothing else is.
|
|
260
|
+
const hasOnly = (...bins) => (b) => bins.includes(b);
|
|
261
|
+
|
|
262
|
+
t('parseSub: flag order never changes the verb', () => {
|
|
263
|
+
assert.equal(lib.parseSub([]), 'up');
|
|
264
|
+
assert.equal(lib.parseSub(['--server']), 'up');
|
|
265
|
+
assert.equal(lib.parseSub(['--server', 'stop']), 'stop'); // what `bongos serve stop` sends
|
|
266
|
+
assert.equal(lib.parseSub(['status', '--server']), 'status');
|
|
267
|
+
assert.equal(lib.parseSub(['--port', '3006', 'status']), 'status'); // 3006 is a VALUE, not the verb
|
|
268
|
+
assert.equal(lib.parseSub(['--db', 'stop', 'status']), 'status'); // a db literally named "stop"
|
|
269
|
+
assert.equal(lib.parseSub(['--foreground']), 'up'); // boolean flag consumes nothing
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
t('deriveBindHost: local is always loopback; server derives from the origin', () => {
|
|
273
|
+
assert.equal(lib.deriveBindHost({ publicOrigin: 'https://example.com', mode: 'local' }), '127.0.0.1');
|
|
274
|
+
// https => something terminates TLS in front of us => stay off the public NIC.
|
|
275
|
+
assert.equal(lib.deriveBindHost({ publicOrigin: 'https://example.com', mode: 'server' }), '127.0.0.1');
|
|
276
|
+
// plain http on a public name => nothing is in front yet => serve it ourselves.
|
|
277
|
+
assert.equal(lib.deriveBindHost({ publicOrigin: 'http://example.com:3006', mode: 'server' }), '0.0.0.0');
|
|
278
|
+
assert.equal(lib.deriveBindHost({ publicOrigin: 'http://localhost:3006', mode: 'server' }), '127.0.0.1');
|
|
279
|
+
assert.equal(lib.deriveBindHost({ publicOrigin: 'garbage', mode: 'server' }), '127.0.0.1');
|
|
280
|
+
assert.equal(lib.deriveBindHost({ publicOrigin: 'http://example.com', mode: 'server', override: '10.0.0.4' }), '10.0.0.4');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
t('probeHost: probe what we bound (0.0.0.0 still answers on loopback)', () => {
|
|
284
|
+
assert.equal(lib.probeHost({ host: '0.0.0.0' }), '127.0.0.1');
|
|
285
|
+
assert.equal(lib.probeHost({ host: '10.0.0.4' }), '10.0.0.4');
|
|
286
|
+
assert.equal(lib.probeHost({}), '127.0.0.1');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
t('resolveRunParams: --server flips mode and the bind address', () => {
|
|
290
|
+
const f = fakeFs({
|
|
291
|
+
'config/branding.json': JSON.stringify({ domains: { publicOrigin: 'http://box.example.com:3006' } }),
|
|
292
|
+
'config/branding.neutral.json': NEUTRAL,
|
|
293
|
+
'config/modules.json': JSON.stringify({ modules: {} }),
|
|
294
|
+
});
|
|
295
|
+
const base = { instanceRoot: '/srv/app', coreRoot: '/core', f, env: {}, userInfo: () => ({ username: 'deploy' }) };
|
|
296
|
+
const local = lib.resolveRunParams({ ...base, argv: [] });
|
|
297
|
+
assert.equal(local.mode, 'local');
|
|
298
|
+
assert.equal(local.host, '127.0.0.1');
|
|
299
|
+
const server = lib.resolveRunParams({ ...base, argv: ['--server'] });
|
|
300
|
+
assert.equal(server.mode, 'server');
|
|
301
|
+
assert.equal(server.host, '0.0.0.0');
|
|
302
|
+
assert.equal(server.port, 3006);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
t('resolveRunParams: an https origin keeps the server on loopback (proxy in front)', () => {
|
|
306
|
+
const f = fakeFs({
|
|
307
|
+
'config/branding.json': JSON.stringify({ domains: { publicOrigin: 'https://app.example.com' } }),
|
|
308
|
+
'config/branding.neutral.json': NEUTRAL,
|
|
309
|
+
'config/modules.json': JSON.stringify({ modules: {} }),
|
|
310
|
+
});
|
|
311
|
+
const p = lib.resolveRunParams({ instanceRoot: '/srv/app', coreRoot: '/core', f, env: {}, userInfo: () => ({ username: 'deploy' }), argv: ['--server'] });
|
|
312
|
+
assert.equal(p.host, '127.0.0.1');
|
|
313
|
+
assert.equal(p.port, 3000); // no explicit port => the proxy's upstream
|
|
314
|
+
assert.equal(p.publicOrigin, 'https://app.example.com');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ---------- systemd unit ----------
|
|
318
|
+
const UNIT_PARAMS = {
|
|
319
|
+
instanceRoot: '/srv/app', coreRoot: '/core', dbName: 'app', port: 3006, host: '127.0.0.1',
|
|
320
|
+
pgHost: 'localhost', pgUser: 'deploy', gdsOnly: true, envPrefix: 'CLOUDBONGOS',
|
|
321
|
+
publicOrigin: 'https://app.example.com', mode: 'server',
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
t('serviceName / unitPath / envFilePath are keyed per instance', () => {
|
|
325
|
+
assert.equal(lib.serviceName(UNIT_PARAMS), 'bongos-app');
|
|
326
|
+
assert.equal(lib.unitPath(UNIT_PARAMS), '/etc/systemd/system/bongos-app.service');
|
|
327
|
+
assert.equal(lib.envFilePath(UNIT_PARAMS), '/etc/bongos/bongos-app.env');
|
|
328
|
+
assert.notEqual(lib.serviceName({ dbName: 'other' }), lib.serviceName(UNIT_PARAMS));
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
t('unitEnvironment: only resolved keys — never the caller’s environment', () => {
|
|
332
|
+
const keys = lib.unitEnvironment(UNIT_PARAMS).map(([k]) => k);
|
|
333
|
+
assert.deepEqual(keys.sort(), ['CLOUDBONGOS_INSTANCE_ROOT', 'HOST', 'MEDUSA_GDS_ONLY', 'NODE_ENV', 'PGDATABASE', 'PGHOST', 'PGUSER', 'PORT']);
|
|
334
|
+
const env = Object.fromEntries(lib.unitEnvironment(UNIT_PARAMS));
|
|
335
|
+
assert.equal(env.PORT, '3006');
|
|
336
|
+
assert.equal(env.NODE_ENV, 'production');
|
|
337
|
+
assert.equal(env.CLOUDBONGOS_INSTANCE_ROOT, '/srv/app');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
t('unitEnvironment: no secret ever lands in an Environment= line', () => {
|
|
341
|
+
const withSecret = { ...UNIT_PARAMS, pgPassword: 'hunter2' };
|
|
342
|
+
const rendered = lib.renderSystemdUnit(withSecret, { nodePath: '/usr/bin/node', user: 'deploy' });
|
|
343
|
+
assert.ok(!rendered.includes('hunter2'), 'PGPASSWORD must go to the 0600 EnvironmentFile, not the unit');
|
|
344
|
+
assert.match(rendered, /EnvironmentFile=-\/etc\/bongos\/bongos-app\.env/);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
t('renderSystemdUnit: boot-survival + restart policy + the right ExecStart', () => {
|
|
348
|
+
// The unit is only ever GENERATED on the host that runs it (linux), so
|
|
349
|
+
// serverEntry's separators are native there; normalize for a Windows test box.
|
|
350
|
+
const u = norm(lib.renderSystemdUnit(UNIT_PARAMS, { nodePath: '/usr/bin/node', user: 'deploy' }));
|
|
351
|
+
assert.match(u, /ExecStart=\/usr\/bin\/node \/core\/src\/platform-server\.js/);
|
|
352
|
+
assert.match(u, /WorkingDirectory=\/srv\/app/);
|
|
353
|
+
assert.match(u, /^Restart=always$/m);
|
|
354
|
+
assert.match(u, /^WantedBy=multi-user\.target$/m); // = survives reboot
|
|
355
|
+
assert.match(u, /^User=deploy$/m);
|
|
356
|
+
assert.match(u, /Environment="PORT=3006"/);
|
|
357
|
+
assert.match(u, /After=network-online\.target postgresql\.service/);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
t('renderSystemdUnit: values are quoted, so a path with a space cannot split the line', () => {
|
|
361
|
+
const u = lib.renderSystemdUnit({ ...UNIT_PARAMS, instanceRoot: '/srv/my app' }, { nodePath: '/usr/bin/node' });
|
|
362
|
+
assert.match(u, /Environment="CLOUDBONGOS_INSTANCE_ROOT=\/srv\/my app"/);
|
|
363
|
+
assert.ok(!/^User=/m.test(u), 'no User= line when no user was given (runs as root)');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
t('systemdPlan: available only on linux with systemctl; sudo unless root', () => {
|
|
367
|
+
assert.equal(lib.systemdPlan({ platform: 'darwin', has: hasOnly('systemctl') }).available, false);
|
|
368
|
+
assert.equal(lib.systemdPlan({ platform: 'linux', has: hasOnly() }).available, false);
|
|
369
|
+
assert.deepEqual(lib.systemdPlan({ platform: 'linux', has: hasOnly('systemctl'), isRoot: true }), { available: true, sudo: false });
|
|
370
|
+
assert.deepEqual(lib.systemdPlan({ platform: 'linux', has: hasOnly('systemctl', 'sudo') }), { available: true, sudo: true });
|
|
371
|
+
// systemctl present but no way to become root -> unavailable, with a reason.
|
|
372
|
+
const p = lib.systemdPlan({ platform: 'linux', has: hasOnly('systemctl') });
|
|
373
|
+
assert.equal(p.available, false);
|
|
374
|
+
assert.match(p.reason, /sudo/);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ---------- Postgres bring-up ----------
|
|
378
|
+
t('pgStartPlan: platform-correct candidates, sudo-prefixed when not root', () => {
|
|
379
|
+
const mac = lib.pgStartPlan({ platform: 'darwin', has: hasOnly('brew'), env: {} });
|
|
380
|
+
assert.equal(mac[0].cmd, 'brew');
|
|
381
|
+
assert.equal(mac[0].needsFormula, true);
|
|
382
|
+
|
|
383
|
+
const linux = lib.pgStartPlan({ platform: 'linux', has: hasOnly('systemctl', 'sudo'), env: {} });
|
|
384
|
+
assert.deepEqual(linux[0].args, ['-n', 'systemctl', 'start', 'postgresql']);
|
|
385
|
+
assert.equal(linux[0].cmd, 'sudo');
|
|
386
|
+
|
|
387
|
+
const asRoot = lib.pgStartPlan({ platform: 'linux', has: hasOnly('systemctl'), env: {}, isRoot: true });
|
|
388
|
+
assert.equal(asRoot[0].cmd, 'systemctl');
|
|
389
|
+
|
|
390
|
+
// No systemd: fall back to SysV, then to pg_ctl when PGDATA names a cluster.
|
|
391
|
+
const sysv = lib.pgStartPlan({ platform: 'linux', has: hasOnly('service'), env: {}, isRoot: true });
|
|
392
|
+
assert.equal(sysv[0].cmd, 'service');
|
|
393
|
+
const ctl = lib.pgStartPlan({ platform: 'linux', has: hasOnly('pg_ctl'), env: { PGDATA: '/var/lib/pg' }, isRoot: true });
|
|
394
|
+
assert.deepEqual(ctl[0].args, ['start', '-w', '-D', '/var/lib/pg']);
|
|
395
|
+
assert.deepEqual(lib.pgStartPlan({ platform: 'linux', has: hasOnly('pg_ctl'), env: {} }), []); // pg_ctl needs PGDATA
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
t('pgBootstrapPlan: creates the login role + its db as the postgres superuser', () => {
|
|
399
|
+
const plan = lib.pgBootstrapPlan({ pgUser: 'deploy', dbName: 'app', has: hasOnly('sudo') });
|
|
400
|
+
assert.equal(plan.ok, true);
|
|
401
|
+
const byId = Object.fromEntries(plan.steps.map((s) => [s.id, s]));
|
|
402
|
+
assert.deepEqual(byId['check-role'].args.slice(0, 4), ['-n', '-u', 'postgres', 'psql']);
|
|
403
|
+
assert.ok(byId['create-role'].args.join(' ').includes('CREATE ROLE "deploy" LOGIN CREATEDB'));
|
|
404
|
+
assert.deepEqual(byId['create-db'].args, ['-n', '-u', 'postgres', 'createdb', '-O', 'deploy', 'app']);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
t('pgBootstrapPlan: root on a sudo-less image uses `su postgres -c`', () => {
|
|
408
|
+
const plan = lib.pgBootstrapPlan({ pgUser: 'deploy', dbName: 'app', isRoot: true, has: hasOnly() });
|
|
409
|
+
assert.equal(plan.ok, true);
|
|
410
|
+
assert.equal(plan.steps[0].cmd, 'su');
|
|
411
|
+
assert.equal(plan.steps[0].args[0], 'postgres');
|
|
412
|
+
assert.equal(plan.steps[0].args[1], '-c');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
t('pgBootstrapPlan: refuses injection-shaped identifiers and a rootless host', () => {
|
|
416
|
+
assert.equal(lib.pgBootstrapPlan({ pgUser: 'a"; DROP DATABASE x; --', dbName: 'app', has: hasOnly('sudo') }).ok, false);
|
|
417
|
+
assert.equal(lib.pgBootstrapPlan({ pgUser: 'deploy', dbName: 'bad-name', has: hasOnly('sudo') }).ok, false);
|
|
418
|
+
assert.equal(lib.pgBootstrapPlan({ pgUser: '', dbName: 'app', has: hasOnly('sudo') }).ok, false);
|
|
419
|
+
const noRoot = lib.pgBootstrapPlan({ pgUser: 'deploy', dbName: 'app', isRoot: false, has: hasOnly() });
|
|
420
|
+
assert.equal(noRoot.ok, false);
|
|
421
|
+
assert.match(noRoot.reason, /root or sudo/);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ---------- outward health ----------
|
|
425
|
+
await ta('publicHealth: proves the path THROUGH the proxy, not just loopback', async () => {
|
|
426
|
+
const params = { publicOrigin: 'https://app.example.com' };
|
|
427
|
+
const up = await lib.publicHealth(params, { fetchImpl: async (u) => { assert.equal(u, 'https://app.example.com/healthz'); return { status: 200 }; } });
|
|
428
|
+
assert.equal(up.reachable, true);
|
|
429
|
+
const down = await lib.publicHealth(params, { fetchImpl: async () => { throw new Error('ENOTFOUND'); } });
|
|
430
|
+
assert.equal(down.reachable, false);
|
|
431
|
+
assert.match(down.error, /ENOTFOUND/);
|
|
432
|
+
const bad = await lib.publicHealth(params, { fetchImpl: async () => ({ status: 502 }) });
|
|
433
|
+
assert.equal(bad.reachable, false); // proxy up, app not wired through it
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
await ta('health: probes the address we actually bound', async () => {
|
|
437
|
+
let seen = null;
|
|
438
|
+
await lib.health({ port: 3006, host: '0.0.0.0' }, { fetchImpl: async (u) => { seen = u; return { status: 200, json: async () => ({}) }; } });
|
|
439
|
+
assert.match(seen, /^http:\/\/127\.0\.0\.1:3006\//);
|
|
440
|
+
});
|
|
441
|
+
|
|
252
442
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
253
443
|
process.exit(failed > 0 ? 1 : 0);
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -370,3 +370,43 @@ test('the row keeps its own Open link working underneath the card', () => {
|
|
|
370
370
|
assert.match(SCRIPT, /if \(ev\.target\.closest && ev\.target\.closest\('a'\)\) return;/,
|
|
371
371
|
'a click on the row\'s own anchor must not be swallowed by the card handler');
|
|
372
372
|
});
|
|
373
|
+
|
|
374
|
+
/* ── The table advertises only what it can fill (task 1002764) ──────────────
|
|
375
|
+
The mock's columns shipped whole — Project / Builders / Type / Tags / Status
|
|
376
|
+
— but three of them rendered an em dash for every row, forever, because the
|
|
377
|
+
public projection carries four fields and Type/Tags are not in the catalog
|
|
378
|
+
schema at all. A header promising a column is a claim like any other. ── */
|
|
379
|
+
|
|
380
|
+
test('the table declares no column it has no source for', () => {
|
|
381
|
+
const thead = (HUB.match(/<thead>([\s\S]*?)<\/thead>/) || [, ''])[1];
|
|
382
|
+
for (const dead of [/>Builders</, />Type</, />Tags</]) {
|
|
383
|
+
assert.doesNotMatch(thead, dead, `a permanently-empty column must not be advertised`);
|
|
384
|
+
}
|
|
385
|
+
assert.match(thead, />Project</, 'the name column is real');
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('header and body agree on the column count', () => {
|
|
389
|
+
// a stray <td> shifts every cell after it — silent, and only visible on screen
|
|
390
|
+
const n = ((HUB.match(/<thead>([\s\S]*?)<\/thead>/) || [, ''])[1].match(/<th/g) || []).length;
|
|
391
|
+
const selfRow = (HUB.match(/<tr id="selfRow">([\s\S]*?)<\/tr>/) || [, ''])[1];
|
|
392
|
+
const generated = (SCRIPT.match(/return '<tr data-generated([\s\S]*?)'<\/tr>';/) || [, ''])[1];
|
|
393
|
+
assert.equal((selfRow.match(/<td/g) || []).length, n, 'the self row must match the header');
|
|
394
|
+
assert.equal((generated.match(/<td/g) || []).length, n, 'a generated row must match the header');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('no sort control is offered for a column that cannot sort', () => {
|
|
398
|
+
// `data-sort="builders"` was declared sortable while the comparator handled
|
|
399
|
+
// only 'name' — clicking it did nothing at all
|
|
400
|
+
assert.doesNotMatch(HUB, /data-sort="builders"/);
|
|
401
|
+
const sorts = [...HUB.matchAll(/data-sort="([^"]+)"/g)].map((m) => m[1]);
|
|
402
|
+
const comparator = (SCRIPT.match(/list\.sort\(function[\s\S]*?\n \}\);/) || [''])[0];
|
|
403
|
+
for (const key of sorts) {
|
|
404
|
+
assert.match(comparator, new RegExp(`'${key}'`), `${key} is offered but the comparator ignores it`);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test('the CSS for the dropped columns went with them', () => {
|
|
409
|
+
for (const orphan of ['.colType', '.colTags', '.tbl .num', '.tbl .tags']) {
|
|
410
|
+
assert.ok(!COSMOS.includes(orphan), `${orphan} is dead once its column is gone`);
|
|
411
|
+
}
|
|
412
|
+
});
|