@bongos/core 1.19.401 → 1.19.403
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 +50 -40
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0200-re-point-a-bring-your-own-domain.md +105 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +3 -2
- package/docs/copy-inventory.md +141 -138
- package/docs/copy-registry.json +205 -176
- package/docs/design/projects-hub-direction-v2.md +16 -0
- package/modules/platform-identity/project-stats.js +123 -38
- package/modules/platform-identity/routes/my-projects.js +13 -0
- package/modules/platform-identity/routes/projects.js +7 -4
- package/modules/provisioning/migrations/provisioning_012_domain_repoint.sql +35 -0
- package/modules/provisioning/provisioning.js +68 -8
- package/modules/provisioning/routes/provisioning.js +50 -23
- package/modules/ui-design/kit/fixtures/featured.json +1 -1
- package/modules/ui-design/kit/fixtures/my-projects.json +1 -1
- package/modules/ui-design/kit/serve.js +14 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +126 -25
- package/public-landing/projects.html +155 -27
- package/public-landing/projects.probes.json +28 -0
- package/public-landing/projects.states.json +6 -3
- package/scripts/gds/provision-net.js +33 -0
- package/scripts/gds/provision-units.js +18 -0
- package/scripts/gds/provision.js +97 -5
- package/src/module-api.js +5 -1
- package/tests/cosmos_token_contrast.mjs +5 -2
- package/tests/project_stats.mjs +128 -2
- package/tests/projects_hub.mjs +106 -5
- package/tests/projects_hub_pre_uat.mjs +28 -2
- package/tests/provision.mjs +185 -12
- package/tests/provisioning_default_domain.mjs +5 -3
- package/tests/provisioning_domain_attach.mjs +166 -17
- package/tests/public_landing_projects.mjs +5 -1
|
@@ -240,6 +240,23 @@ function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
|
240
240
|
// The settings env vars the runner pushes to a RUNNING instance (task 1003140):
|
|
241
241
|
// the module's settingsEnvVars under the prefix the instance actually resolves —
|
|
242
242
|
// the same pair webEnvBody writes at standup, so standup and push cannot drift.
|
|
243
|
+
// The four origin overrides web.env carries for an addressed instance — the
|
|
244
|
+
// domain-dependent half of webEnvBody, on its own so domain-attach can patch
|
|
245
|
+
// exactly those lines IN PLACE (task 1003237). A single-host instance has no
|
|
246
|
+
// dedicated builders./status. subdomain, so all four point at the one host.
|
|
247
|
+
// Empty for a domainless instance: there is no origin to assert, and writing
|
|
248
|
+
// `=` over a real one would take the hall down. PURE.
|
|
249
|
+
function originEnvVarsFor(inst) {
|
|
250
|
+
if (!inst || !inst.domain) return {};
|
|
251
|
+
const prefix = provisionedEnvPrefix(), origin = `https://${inst.domain}`;
|
|
252
|
+
return {
|
|
253
|
+
[`${prefix}_PUBLIC_ORIGIN`]: origin,
|
|
254
|
+
[`${prefix}_BUILDERS_ORIGIN`]: origin,
|
|
255
|
+
[`${prefix}_STATUS_ORIGIN`]: origin,
|
|
256
|
+
[`${prefix}_OAUTH_ORIGIN`]: origin,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
243
260
|
function settingsEnvVarsFor(inst) {
|
|
244
261
|
const { settingsEnvVars } = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning')); // eslint-disable-line global-require
|
|
245
262
|
return settingsEnvVars(inst, provisionedEnvPrefix());
|
|
@@ -309,6 +326,7 @@ module.exports = {
|
|
|
309
326
|
backupTimerPath,
|
|
310
327
|
backupUnitName,
|
|
311
328
|
instanceManifestCmd,
|
|
329
|
+
originEnvVarsFor,
|
|
312
330
|
provisionedEnvPrefix,
|
|
313
331
|
serviceUnit,
|
|
314
332
|
serviceUnitPath,
|
package/scripts/gds/provision.js
CHANGED
|
@@ -51,8 +51,8 @@ const crypto = require('node:crypto');
|
|
|
51
51
|
|
|
52
52
|
const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
|
|
53
53
|
const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
54
|
-
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
55
|
-
const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
54
|
+
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
55
|
+
const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
56
|
|
|
57
57
|
function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
|
|
58
58
|
// `shown` (task 1003140): what to LOG in place of the command text — for a command
|
|
@@ -464,6 +464,12 @@ async function provisionInstance(inst, deps) {
|
|
|
464
464
|
}
|
|
465
465
|
}
|
|
466
466
|
|
|
467
|
+
// A re-point on a PRE-ACTIVE instance records its release here and never
|
|
468
|
+
// reaches the attach leg (that one no-ops for anything but 'active'), so this
|
|
469
|
+
// run owns the drain — and an `error` row is frequently a partial standup that
|
|
470
|
+
// already UPSERTed the address it is now leaving (ADR 0181 §1, task 1003237).
|
|
471
|
+
await releaseAbandonedDomains(inst, { controlExec, log, apply, db, provisioning });
|
|
472
|
+
|
|
467
473
|
// ── step 6: Caddy per-instance site snippet + reload ──────────────────────
|
|
468
474
|
// Write the instance's OWN <slug>.caddy snippet (idempotent overwrite via the
|
|
469
475
|
// injected writeFile — no read-modify-append to a shared Caddyfile), then reload.
|
|
@@ -557,6 +563,57 @@ runcmd:
|
|
|
557
563
|
`;
|
|
558
564
|
}
|
|
559
565
|
|
|
566
|
+
// The addresses a re-point left behind and this run still owes a release on
|
|
567
|
+
// (task 1003237). The one guard this tier can make on its own, and the one that
|
|
568
|
+
// matters: the address the instance is CURRENTLY on is never released, so a row
|
|
569
|
+
// that somehow queued its own domain can never blackhole a live project.
|
|
570
|
+
// Keeping a platform-assigned <slug>.<apex> off the queue is the ENQUEUE site's
|
|
571
|
+
// job — the web tier knows the apex, this runner does not (ADR 0181 §2). PURE.
|
|
572
|
+
function releasableDomains(inst) {
|
|
573
|
+
const here = String((inst && inst.domain) || '').toLowerCase();
|
|
574
|
+
return (Array.isArray(inst && inst.released_domains) ? inst.released_domains : [])
|
|
575
|
+
.map((d) => String(d || '').toLowerCase())
|
|
576
|
+
.filter((d) => d && d !== here);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Park every address a re-point moved off at the blackhole sentinel, then empty
|
|
580
|
+
// the queue (task 1003237, ADR 0200). Called by BOTH legs that wire a domain —
|
|
581
|
+
// domain-attach for an ACTIVE instance, and the provision run that owns
|
|
582
|
+
// PRE-ACTIVE ones, since an `error` row is frequently a partial standup that
|
|
583
|
+
// already UPSERTed the address it is now leaving (ADR 0181 §1).
|
|
584
|
+
//
|
|
585
|
+
// BEST-EFFORT in the teardown sense: a hook that fails leaves the queue intact
|
|
586
|
+
// so the next run retries, and never fails the wiring that already landed. EVERY
|
|
587
|
+
// queued address is attempted — one host whose zone the token cannot reach must
|
|
588
|
+
// not strand the others behind it — and the queue is emptied only when all of
|
|
589
|
+
// them landed; a re-park of an address already at the sentinel is an idempotent
|
|
590
|
+
// UPSERT, so retrying the whole list costs nothing but the call. The list is
|
|
591
|
+
// capped at the enqueue site (MAX_RELEASED_DOMAINS), which is what bounds this
|
|
592
|
+
// loop. Returns the first host that failed, or null. Runs on the CONTROL PLANE —
|
|
593
|
+
// the hook needs the Cloudflare token, which never leaves it (ADR 0111 §2).
|
|
594
|
+
async function releaseAbandonedDomains(inst, { controlExec, log, apply, db, provisioning }) {
|
|
595
|
+
const hosts = releasableDomains(inst);
|
|
596
|
+
if (!hosts.length) return null;
|
|
597
|
+
if (!CONFIG.dnsHook) {
|
|
598
|
+
log(` [dns] cannot release ${hosts.join(', ')} (BOX_DNS_HOOK unset) — remove their A records manually`);
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
let firstFailed = null;
|
|
602
|
+
for (const host of hosts) {
|
|
603
|
+
const relEnv = dnsReleaseEnv(host);
|
|
604
|
+
log(` [dns] release ${host} -> ${relEnv.BOX_IP} (blackhole, zone ${relEnv.CLOUDFLARE_ZONE}) via ${CONFIG.dnsHook}`);
|
|
605
|
+
if (!apply) continue;
|
|
606
|
+
try { controlExec(CONFIG.dnsHook, { env: relEnv }); }
|
|
607
|
+
catch (e) {
|
|
608
|
+
log(` ⚠ could not release ${host}: ${(e && e.message) || e} — its A record still points here`);
|
|
609
|
+
if (!firstFailed) firstFailed = host;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (firstFailed) return firstFailed;
|
|
613
|
+
if (apply && provisioning.clearReleasedDomains) await provisioning.clearReleasedDomains(db, inst.id).catch(() => {});
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
|
|
560
617
|
// ---------------------------------------------------------------------------
|
|
561
618
|
// Attach a late-arriving domain to an ALREADY-ACTIVE instance (task 1002692):
|
|
562
619
|
// re-assert exactly the domain-dependent runbook legs — web.env origins (+ a
|
|
@@ -565,6 +622,18 @@ runcmd:
|
|
|
565
622
|
// RETURN { ok:false } instead of throwing: the drain loop's catch flips the
|
|
566
623
|
// instance to 'error', which must never happen to a healthy ACTIVE instance
|
|
567
624
|
// over a domain hiccup.
|
|
625
|
+
//
|
|
626
|
+
// This leg ALSO carries a RE-POINT (task 1003237, ADR 0200) — the same intent,
|
|
627
|
+
// because the wiring is the same wiring. Two things follow from that:
|
|
628
|
+
// • The origin lines are patched IN PLACE (upsertEnvVars), never regenerated
|
|
629
|
+
// from the row. A template re-write via webEnvBody would blank the OAuth
|
|
630
|
+
// creds placeManifestCreds placed, the App private key and the federation
|
|
631
|
+
// block — none of which the row knows (ADR 0190 §2). A first attach was
|
|
632
|
+
// MASKED by wizard order (address first, GitHub App second); a re-point runs
|
|
633
|
+
// on a project the founder is already signed in to, so the same move would
|
|
634
|
+
// break sign-in on the exact project they just corrected (idea 1000842).
|
|
635
|
+
// • Every address on the row's release queue is parked at the blackhole
|
|
636
|
+
// sentinel AFTER the new one is wired — best-effort, never fatal.
|
|
568
637
|
// ---------------------------------------------------------------------------
|
|
569
638
|
async function domainAttachInstance(inst, deps) {
|
|
570
639
|
const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
|
|
@@ -603,8 +672,22 @@ async function domainAttachInstance(inst, deps) {
|
|
|
603
672
|
? (CONFIG.controlPlaneIp || (CONFIG.remoteHost ? null : CONFIG.publicIp))
|
|
604
673
|
: CONFIG.publicIp;
|
|
605
674
|
|
|
606
|
-
|
|
607
|
-
|
|
675
|
+
// Patch ONLY the origin lines (the settingsApplyInstance shape, ADR 0190 §2).
|
|
676
|
+
// Dry-run has no file to read, so it PLANS the patch rather than writing a
|
|
677
|
+
// synthetic body. An unreadable or empty file is refused, never "patched":
|
|
678
|
+
// writing four origins over a missing PORT/PGDATABASE would take the instance
|
|
679
|
+
// down — and this leg runs against instances that are ALREADY serving.
|
|
680
|
+
const origins = originEnvVarsFor(inst);
|
|
681
|
+
const read = boxExec(`${sudoP}cat ${webEnvPath(inst)}`, { allowFail: true });
|
|
682
|
+
const currentEnv = String((read && read.stdout) || '');
|
|
683
|
+
if (!apply) {
|
|
684
|
+
log(` [config] would patch ${Object.keys(origins).join(', ')} in ${webEnvPath(inst)} in place (creds + federation untouched)`);
|
|
685
|
+
} else if (!read || read.softFailed || !currentEnv.trim()) {
|
|
686
|
+
return { ok: false, error: `cannot read ${webEnvPath(inst)} — nothing to patch (was this instance provisioned by the runner?)` };
|
|
687
|
+
} else {
|
|
688
|
+
log(' [config] patch the origin lines of web.env in place (creds + federation untouched)');
|
|
689
|
+
boxWriteFile(webEnvPath(inst), upsertEnvVars(currentEnv, origins), { mode: 0o600, sudo: true });
|
|
690
|
+
}
|
|
608
691
|
log(' [service] restart to pick up the new origins');
|
|
609
692
|
boxExec(`${sudoP}systemctl restart ${inst.slug}`);
|
|
610
693
|
|
|
@@ -629,10 +712,19 @@ async function domainAttachInstance(inst, deps) {
|
|
|
629
712
|
log(` [dns] skipped (BOX_DNS_HOOK unset) — point ${inst.domain}'s A record at ${recordIp || 'this server'} manually`);
|
|
630
713
|
}
|
|
631
714
|
|
|
715
|
+
// The snippet is per-SLUG and always carries the CURRENT domain, so a re-point
|
|
716
|
+
// takes the old vhost away in the same write — Caddy stops serving the old host
|
|
717
|
+
// (and tls-check stops vouching for it) with no separate un-wire step.
|
|
632
718
|
log(' [caddy] write site snippet + reload');
|
|
633
719
|
boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
|
|
634
720
|
boxExec(`${sudoP}systemctl reload caddy`, { allowFail: true });
|
|
635
721
|
|
|
722
|
+
// Release what the re-point moved off, AFTER the new address is wired: the DNS
|
|
723
|
+
// record is the only half of an abandoned address the platform still holds, and
|
|
724
|
+
// it points at platform infra.
|
|
725
|
+
const releaseFailed = await releaseAbandonedDomains(inst, { controlExec, log, apply, db, provisioning });
|
|
726
|
+
if (releaseFailed) return { ok: true, releaseFailed };
|
|
727
|
+
|
|
636
728
|
log(' [healthz] verify the instance answers');
|
|
637
729
|
boxExec(healthzCmd(inst), { allowFail: true });
|
|
638
730
|
|
|
@@ -1337,7 +1429,7 @@ module.exports = {
|
|
|
1337
1429
|
caddyBlock, caddySnippetPath, healthzCmd, dedicatedUserData, nextFreePort,
|
|
1338
1430
|
// executors + orchestration (injectable deps for tests)
|
|
1339
1431
|
makeExec, makeWriteFile, allocatePort, boundLocalPorts, provisionInstance, teardownInstance,
|
|
1340
|
-
domainAttachInstance, cmdRunIntents, cmdReconcile, cmdStatus,
|
|
1432
|
+
domainAttachInstance, releasableDomains, cmdRunIntents, cmdReconcile, cmdStatus,
|
|
1341
1433
|
// live settings push (task 1003140) + the in-place env patch + read-back it rides on
|
|
1342
1434
|
settingsApplyInstance, upsertEnvVars, settingsEnvVarsFor, settingsConsumed, instanceManifestCmd,
|
|
1343
1435
|
// restart an unreachable ACTIVE instance (task 1003238) — the settings leg minus the env patch
|
package/src/module-api.js
CHANGED
|
@@ -1242,8 +1242,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1242
1242
|
// landed since 1.19.399 with no explicit bump. run 33201979306. (task 1002620)
|
|
1243
1243
|
// 1.19.401 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1244
1244
|
// landed since 1.19.400 with no explicit bump. run 33205015717. (task 1002620)
|
|
1245
|
+
// 1.19.402 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1246
|
+
// landed since 1.19.401 with no explicit bump. run 33206226800. (task 1002620)
|
|
1247
|
+
// 1.19.403 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1248
|
+
// landed since 1.19.402 with no explicit bump. run 33208825538. (task 1002620)
|
|
1245
1249
|
// ---------------------------------------------------------------------------
|
|
1246
|
-
const CORE_VERSION = '1.19.
|
|
1250
|
+
const CORE_VERSION = '1.19.403'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1247
1251
|
|
|
1248
1252
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1249
1253
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -176,6 +176,8 @@ const PAIRS = [
|
|
|
176
176
|
// shipped a fired-down ember where #ffffff measured 4.61:1 — that is history now,
|
|
177
177
|
// and any prose below still naming those values is history too.)
|
|
178
178
|
['.btn label on its fill', 'var(--accent-on)', 'var(--accent)', 4.5],
|
|
179
|
+
// the poster card's door lifts to --accent-lift on hover (task 1003301)
|
|
180
|
+
['.cardOpen:hover label on its lift', 'var(--accent-on)', 'var(--accent-lift)', 4.5],
|
|
179
181
|
['.btn.ghost:hover label', 'var(--accent-deep)', CARD, 4.5],
|
|
180
182
|
['.btn.danger label', 'var(--err)', CARD, 4.5],
|
|
181
183
|
// Code chips, pills, the now-step number, the hover inks: --accent-deep is
|
|
@@ -205,8 +207,9 @@ const PAIRS = [
|
|
|
205
207
|
['.chip.live label', 'var(--ok)', 'var(--ok-soft)', 4.5],
|
|
206
208
|
['.chip.maybe label', 'var(--warn)', 'var(--warn-soft)', 4.5],
|
|
207
209
|
['.chip.down/.bad label', 'var(--err)', 'var(--err-soft)', 4.5],
|
|
208
|
-
// .chip.busy + the
|
|
209
|
-
//
|
|
210
|
+
// .chip.busy + the builder-badge family: accent-deep ink on the accent-soft
|
|
211
|
+
// tint, both modes (.tbl .pill and .cardRel are mono labels in ink-faint since
|
|
212
|
+
// tasks 1003332 / 1003301 — a fact is not pressable, so the accent left them)
|
|
210
213
|
['.chip.busy label', 'var(--accent-deep)', 'var(--accent-soft)', 4.5],
|
|
211
214
|
// lifecycle strip STATE INDICATORS (task 1002751): non-text at the WCAG
|
|
212
215
|
// 1.4.11 bar of 3.0. --sea-ink derives brighter in dark exactly the way
|
package/tests/project_stats.mjs
CHANGED
|
@@ -27,7 +27,8 @@ beforeEach(() => { stats._reset(); });
|
|
|
27
27
|
function json(body) {
|
|
28
28
|
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(body) });
|
|
29
29
|
}
|
|
30
|
-
// route a fake instance: { leaderboard: [...], enabled: {...}
|
|
30
|
+
// route a fake instance: { leaderboard: [...], enabled: {...}, tasks: [...],
|
|
31
|
+
// days: 30|undefined (whether the remote honoured ?days=), index: {...} }
|
|
31
32
|
function stub(map) {
|
|
32
33
|
globalThis.fetch = (url) => {
|
|
33
34
|
const u = new URL(url);
|
|
@@ -39,9 +40,17 @@ function stub(map) {
|
|
|
39
40
|
if (u.pathname.endsWith('/modules')) {
|
|
40
41
|
return site.enabled ? json({ enabled: site.enabled }) : Promise.reject(new Error('down'));
|
|
41
42
|
}
|
|
43
|
+
if (u.pathname.endsWith('/recent-shipped')) {
|
|
44
|
+
return site.tasks ? json({ tasks: site.tasks, days: site.days }) : Promise.reject(new Error('down'));
|
|
45
|
+
}
|
|
46
|
+
if (u.pathname === '/api/bongos') {
|
|
47
|
+
return site.index ? json(site.index) : Promise.reject(new Error('404'));
|
|
48
|
+
}
|
|
42
49
|
return Promise.reject(new Error('404'));
|
|
43
50
|
};
|
|
44
51
|
}
|
|
52
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
53
|
+
function shipped(daysAgo) { return { shipped_at: new Date(Date.now() - daysAgo * DAY).toISOString() }; }
|
|
45
54
|
|
|
46
55
|
test('system accounts are excluded — they are machines, not builders', async () => {
|
|
47
56
|
// measured live: raw leaderboards read 13/3/3 against real 11/1/1
|
|
@@ -208,7 +217,7 @@ test('concurrent requests for the same origin collapse into one fetch', async ()
|
|
|
208
217
|
stats.getProjectStats(['https://8.8.8.8']),
|
|
209
218
|
stats.getProjectStats(['https://8.8.8.8']),
|
|
210
219
|
]);
|
|
211
|
-
assert.equal(calls,
|
|
220
|
+
assert.equal(calls, 4, 'four feeds for one origin, not twelve for three callers');
|
|
212
221
|
});
|
|
213
222
|
|
|
214
223
|
test('a non-http origin is refused rather than fetched', async () => {
|
|
@@ -274,3 +283,120 @@ test('an origin named __proto__ cannot retarget the result prototype', async ()
|
|
|
274
283
|
assert.equal(Object.getPrototypeOf(out), null, 'the result map is null-prototype');
|
|
275
284
|
assert.equal(({}).builders, undefined, 'Object.prototype is untouched');
|
|
276
285
|
});
|
|
286
|
+
|
|
287
|
+
/* ── The poster card's fields (task 1003301) ────────────────────────────────
|
|
288
|
+
Faces, shipped-in-30-days and the hall address ride the SAME allSettled as
|
|
289
|
+
builders and tags, degrade to null exactly like them, and share the one
|
|
290
|
+
frozen null shape — a key that went missing would ship as undefined, which
|
|
291
|
+
JSON.stringify drops, which silently changes the wire. ── */
|
|
292
|
+
|
|
293
|
+
const KEYS = ['builders', 'tags', 'owner', 'owner_avatar', 'faces', 'shipped_30d', 'shipped_30d_capped', 'hall_origin'];
|
|
294
|
+
|
|
295
|
+
test('one frozen null shape carries every key, and both fallbacks are that shape', async () => {
|
|
296
|
+
assert.ok(Object.isFrozen(stats.NULL_STATS));
|
|
297
|
+
assert.deepEqual(Object.keys(stats.NULL_STATS).sort(), KEYS.slice().sort());
|
|
298
|
+
const failed = await stats.getProjectStats(['file:///etc/passwd']); // the cached-failure literal
|
|
299
|
+
assert.equal(failed['file:///etc/passwd'], stats.NULL_STATS);
|
|
300
|
+
stub({});
|
|
301
|
+
const unreached = await stats.getProjectStats(['https://9.9.9.9']); // the read fallback
|
|
302
|
+
for (const k of KEYS) assert.equal(unreached['https://9.9.9.9'][k], null, `${k} is null, never undefined`);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('faces are the first five people with an http avatar, in the feed\'s order; machines and bad URLs are skipped', async () => {
|
|
306
|
+
const av = (n) => `https://avatars.example.com/${n}.png`;
|
|
307
|
+
stub({
|
|
308
|
+
'https://8.8.8.8': {
|
|
309
|
+
leaderboard: [
|
|
310
|
+
{ github_login: 'a', avatar_url: av('a') }, { github_login: 'bfg-system', avatar_url: av('bot') },
|
|
311
|
+
{ github_login: 'b', avatar_url: 'javascript:alert(1)' }, { github_login: 'c', avatar_url: av('c') },
|
|
312
|
+
{ github_login: 'd', avatar_url: av('d') }, { github_login: 'e', avatar_url: av('e') },
|
|
313
|
+
{ github_login: 'f', avatar_url: av('f') }, { github_login: 'g', avatar_url: av('g') },
|
|
314
|
+
],
|
|
315
|
+
enabled: {},
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
319
|
+
assert.deepEqual(out['https://8.8.8.8'].faces, ['a', 'c', 'd', 'e', 'f'].map(av));
|
|
320
|
+
assert.equal(out['https://8.8.8.8'].builders, 7, 'the +N the card shows is counted against builders');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('a leaderboard with no avatars yields an empty faces list — asked, none — not null', async () => {
|
|
324
|
+
stub({ 'https://8.8.8.8': { leaderboard: [{ github_login: 'a' }], enabled: {} } });
|
|
325
|
+
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
326
|
+
assert.deepEqual(out['https://8.8.8.8'].faces, []);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('shipped_30d counts only rows inside the window, from shipped_at, whatever the remote returned', async () => {
|
|
330
|
+
stub({
|
|
331
|
+
'https://8.8.8.8': {
|
|
332
|
+
leaderboard: [], enabled: {},
|
|
333
|
+
tasks: [shipped(1), shipped(29), shipped(31), shipped(400), { shipped_at: null }, {}],
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
337
|
+
assert.equal(out['https://8.8.8.8'].shipped_30d, 2);
|
|
338
|
+
assert.equal(out['https://8.8.8.8'].shipped_30d_capped, false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('shipped_30d is capped ("N+") when the feed\'s row cap is full and every row is inside the window', async () => {
|
|
342
|
+
const full = (n) => Array.from({ length: n }, (_, i) => shipped(i % 20));
|
|
343
|
+
stub({
|
|
344
|
+
'https://1.1.1.1': { leaderboard: [], enabled: {}, tasks: full(200), days: 30 }, // ?days honoured: cap 200
|
|
345
|
+
'https://8.8.8.8': { leaderboard: [], enabled: {}, tasks: full(30) }, // older core, no days: cap 30
|
|
346
|
+
'https://9.9.9.9': { leaderboard: [], enabled: {}, tasks: full(199), days: 30 }, // under the cap
|
|
347
|
+
});
|
|
348
|
+
const out = await stats.getProjectStats(['https://1.1.1.1', 'https://8.8.8.8', 'https://9.9.9.9']);
|
|
349
|
+
assert.deepEqual([out['https://1.1.1.1'].shipped_30d, out['https://1.1.1.1'].shipped_30d_capped], [200, true]);
|
|
350
|
+
assert.deepEqual([out['https://8.8.8.8'].shipped_30d, out['https://8.8.8.8'].shipped_30d_capped], [30, true]);
|
|
351
|
+
assert.deepEqual([out['https://9.9.9.9'].shipped_30d, out['https://9.9.9.9'].shipped_30d_capped], [199, false]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('the shipped feed is asked for the window explicitly', async () => {
|
|
355
|
+
const urls = [];
|
|
356
|
+
globalThis.fetch = (url) => { urls.push(String(url)); return Promise.reject(new Error('down')); };
|
|
357
|
+
await stats.getProjectStats(['https://8.8.8.8']);
|
|
358
|
+
assert.ok(urls.includes(`https://8.8.8.8/api/bongos/public/recent-shipped?days=${stats.SHIPPED_WINDOW_DAYS}`));
|
|
359
|
+
assert.ok(urls.includes('https://8.8.8.8/api/bongos'), 'the discovery index answers the hall address');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('hall_origin is the discovery index\'s hall only where it is a different host from the site', async () => {
|
|
363
|
+
stub({
|
|
364
|
+
'https://1.1.1.1': { leaderboard: [], enabled: {}, index: { hall: 'https://builders.example.com' } },
|
|
365
|
+
'https://8.8.8.8': { leaderboard: [], enabled: {}, index: { hall: 'https://8.8.8.8' } }, // single host
|
|
366
|
+
'https://9.9.9.9': { leaderboard: [], enabled: {}, index: { hall: 'javascript:alert(1)' } },
|
|
367
|
+
'https://4.4.4.4': { leaderboard: [], enabled: {} }, // no index (older core)
|
|
368
|
+
});
|
|
369
|
+
const out = await stats.getProjectStats(['https://1.1.1.1', 'https://8.8.8.8', 'https://9.9.9.9', 'https://4.4.4.4']);
|
|
370
|
+
assert.equal(out['https://1.1.1.1'].hall_origin, 'https://builders.example.com');
|
|
371
|
+
assert.equal(out['https://8.8.8.8'].hall_origin, null, 'a hall at <site>/builders is the site\'s own door');
|
|
372
|
+
assert.equal(out['https://9.9.9.9'].hall_origin, null);
|
|
373
|
+
assert.equal(out['https://4.4.4.4'].hall_origin, null);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('a dead shipped feed leaves the other quarters standing', async () => {
|
|
377
|
+
stub({ 'https://8.8.8.8': { leaderboard: [{ github_login: 'a' }], enabled: { game: true }, index: { hall: 'https://h.example.com' } } });
|
|
378
|
+
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
379
|
+
assert.equal(out['https://8.8.8.8'].shipped_30d, null, 'the dead quarter is unknown');
|
|
380
|
+
assert.equal(out['https://8.8.8.8'].shipped_30d_capped, null);
|
|
381
|
+
assert.equal(out['https://8.8.8.8'].builders, 1);
|
|
382
|
+
assert.deepEqual(out['https://8.8.8.8'].tags, ['game']);
|
|
383
|
+
assert.equal(out['https://8.8.8.8'].hall_origin, 'https://h.example.com');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('the origin\'s host is resolved once per sweep, not once per feed', async () => {
|
|
387
|
+
const src = await import('node:fs').then((fs) =>
|
|
388
|
+
fs.readFileSync(new URL('../modules/platform-identity/project-stats.js', import.meta.url), 'utf8'));
|
|
389
|
+
assert.match(src, /await assertPublicHost\(siteUrl\(origin\)\.hostname\);/, 'the guard runs in fetchStats, before the allSettled');
|
|
390
|
+
const getJson = (src.match(/async function getJson\(url\)\s*\{([\s\S]*?)\n\}/) || [, ''])[1];
|
|
391
|
+
assert.ok(getJson, 'getJson() must exist');
|
|
392
|
+
assert.doesNotMatch(getJson, /assertPublicHost/, 'no per-feed lookup');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test('httpUrl accepts only http(s) strings and caps them', () => {
|
|
396
|
+
assert.equal(stats.httpUrl('https://a.example/x.png'), 'https://a.example/x.png');
|
|
397
|
+
assert.equal(stats.httpUrl('http://a.example/x.png'), 'http://a.example/x.png');
|
|
398
|
+
assert.equal(stats.httpUrl('javascript:alert(1)'), null);
|
|
399
|
+
assert.equal(stats.httpUrl('//a.example/x.png'), null);
|
|
400
|
+
assert.equal(stats.httpUrl(42), null);
|
|
401
|
+
assert.equal(stats.httpUrl('https://a.example/' + 'x'.repeat(400)).length, 300);
|
|
402
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -483,13 +483,109 @@ test('the card claims only what the catalog and the project itself answer (C6)',
|
|
|
483
483
|
for (const invented of [/\blive\b/i, /uptime/i, /\bCreated\b/]) {
|
|
484
484
|
assert.doesNotMatch(card, invented, `the card must not invent ${invented}`);
|
|
485
485
|
}
|
|
486
|
-
for (const id of ['cardBuilders', 'cardRuns', 'cardOwner', 'cardListed']) {
|
|
487
|
-
assert.match(card, new RegExp(`id="${id}"`), `the ${id}
|
|
486
|
+
for (const id of ['cardBuilders', 'cardShipped', 'cardRuns', 'cardOwner', 'cardListed', 'cardArt', 'cardFaces', 'cardFigs']) {
|
|
487
|
+
assert.match(card, new RegExp(`id="${id}"`), `the ${id} region must be real markup (C7)`);
|
|
488
488
|
}
|
|
489
489
|
assert.match(card, />Listed</, 'the catalogue date wears its honest label');
|
|
490
490
|
assert.doesNotMatch(card, /—/, 'an unanswerable card fact hides; it never dashes');
|
|
491
491
|
});
|
|
492
492
|
|
|
493
|
+
/* ── The poster (task 1003301; the v2 pick, direction §7) ───────────────────
|
|
494
|
+
Art, faces, two figures on the art, the facts as a ledger, one accent door
|
|
495
|
+
and a member-only hall entry. The load-bearing rule is the DEGRADATION
|
|
496
|
+
LADDER: every region hides on null, down to the card that shipped before. ── */
|
|
497
|
+
|
|
498
|
+
test('every card region is a direct child of the card — the banner a bare img, the faces and figures paragraphs', () => {
|
|
499
|
+
const card = (HUB.match(/<div class="projCard"[\s\S]*?<div class="cardActions" id="cardActions"><\/div>/) || [''])[0];
|
|
500
|
+
assert.ok(card, 'the card must be findable');
|
|
501
|
+
assert.equal((card.match(/<div\b/g) || []).length, 2, 'the card and its actions strip are the only divs — a nested wrapper shortens the C6 slice');
|
|
502
|
+
assert.match(card, /<img class="cardArt" id="cardArt" alt="" hidden>/, 'the banner is a bare img, hidden until art arrives');
|
|
503
|
+
assert.match(card, /<p class="cardFigs" id="cardFigs" hidden>/, 'the figures ride a paragraph');
|
|
504
|
+
assert.match(card, /<p class="cardFaces" id="cardFaces" hidden>/, 'the faces ride a paragraph');
|
|
505
|
+
assert.match(card, /<span class="cardCall a" id="cardBuildersFig" hidden>[\s\S]*?<span class="fig" id="cardBuilders"><\/span><span class="cap">builders<\/span>/);
|
|
506
|
+
assert.match(card, /<span class="cardCall b" id="cardShippedFig" hidden>[\s\S]*?<span class="fig" id="cardShipped"><\/span><span class="cap">shipped in 30 days<\/span>/);
|
|
507
|
+
assert.equal((card.match(/<svg class="lead"/g) || []).length, 2, 'each callout carries its own leader');
|
|
508
|
+
assert.doesNotMatch(card, /id="cardBuildersL"/, 'Builders left the facts list for the figure band');
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
test('the ladder: each region hides on null and the other figure stands alone', () => {
|
|
512
|
+
const fig = (SCRIPT.match(/function cardFig\(id, value\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
513
|
+
assert.ok(fig, 'cardFig() must exist');
|
|
514
|
+
assert.match(fig, /\$\(id \+ 'Fig'\)\.hidden = !value/, 'a null figure hides its whole callout, leader and caption with it');
|
|
515
|
+
assert.match(SCRIPT, /\$\('cardFigs'\)\.hidden = !figs;/, 'both null hides the band');
|
|
516
|
+
const sf = (SCRIPT.match(/function shippedFigure\(p\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
517
|
+
assert.match(sf, /p\.shipped_30d == null\) return ''/, 'null shipped hides; 0 renders');
|
|
518
|
+
assert.match(sf, /p\.shipped_30d_capped \? '\+' : ''/, 'at the feed cap the figure reads N+');
|
|
519
|
+
const faces = (SCRIPT.match(/function cardFaces\(p\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
520
|
+
assert.ok(faces, 'cardFaces() must exist');
|
|
521
|
+
assert.match(faces, /\$\('cardFaces'\)\.hidden = !faces\.length/, 'no faces, no row — absent, not empty-looking');
|
|
522
|
+
assert.match(faces, /p\.faces\.map\(safeHttpUrl\)\.filter\(Boolean\)\.slice\(0, FACES_MAX\)/, 'five scheme-checked faces at most');
|
|
523
|
+
assert.match(faces, /p\.builders - faces\.length/, 'the +N counts the builders beyond the faces shown');
|
|
524
|
+
assert.match(SCRIPT, /var FACES_MAX = 5;/);
|
|
525
|
+
const art = (SCRIPT.match(/function cardArt\(url\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
526
|
+
assert.ok(art, 'cardArt() must exist');
|
|
527
|
+
assert.match(art, /classList\.toggle\('has-art', !!url\)/, 'art switches the whole layout');
|
|
528
|
+
assert.match(SCRIPT, /\$\('cardArt'\)\.addEventListener\('error', function \(\) \{ cardArt\(''\); \}\);/,
|
|
529
|
+
'a broken image demotes to the no-art layout, never a broken glyph');
|
|
530
|
+
assert.match(SCRIPT, /cardArt\(p\.isSelf \? '' : safeHttpUrl\(p\.art_url\)\)/, 'the art URL is scheme-checked client-side too');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test('exactly one hall-URL derivation: the DOM for the hub, the feed for a project, safeHttpUrl for both, and only where the hall is elsewhere', () => {
|
|
534
|
+
assert.equal((SCRIPT.match(/function hallUrlOf\(/g) || []).length, 1);
|
|
535
|
+
const fn = (SCRIPT.match(/function hallUrlOf\(p\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
536
|
+
assert.ok(fn, 'hallUrlOf() must exist');
|
|
537
|
+
assert.match(fn, /var a = \$\('selfHall'\);/, 'the hub\'s own hall is read from the stamped DOM anchor, never assembled in script');
|
|
538
|
+
assert.match(fn, /safeHttpUrl\(a\.getAttribute\('href'\)\)/);
|
|
539
|
+
assert.match(fn, /safeHttpUrl\(p\.hall_origin\)/, 'a project\'s hall is the feed field, scheme-checked');
|
|
540
|
+
assert.match(fn, /hostOf\(hall\) !== hostOf\(site\)/, 'address-gated: no door onto the room you are in');
|
|
541
|
+
assert.doesNotMatch(SCRIPT, /'[^'\n]*\{\{buildersOrigin\}\}/, 'no brand token is ever emitted into a script string');
|
|
542
|
+
assert.equal((SCRIPT.match(/isMember\(rel\) \? hallUrlOf\(p\) : ''/g) || []).length, 2, 'member-gated on the card AND on the row, one rule');
|
|
543
|
+
assert.match(SCRIPT, /'<a class="cardHall" id="cardHall" href="' \+ esc\(hall\)/, 'the card\'s hall door is escaped');
|
|
544
|
+
assert.match(SCRIPT, /'<a class="rowHall" href="' \+ esc\(hall\) \+ '" target="_blank" rel="noopener"/, 'the row\'s hall glyph is escaped and opens a new tab');
|
|
545
|
+
assert.match(SCRIPT, /: ''\) \+ hallGlyphHtml\(p, rel\) \+ '<\/td>'/, 'the glyph rides the existing door cell — no seventh column');
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test('the platform row carries a stamped, hidden hall glyph the session unhides — same tab, no target', () => {
|
|
549
|
+
const selfRow = (HUB.match(/<tr id="selfRow">([\s\S]*?)<\/tr>/) || [, ''])[1];
|
|
550
|
+
assert.match(selfRow, /<a class="rowHall" id="selfHall" href="\{\{buildersOrigin\}\}"[^>]*hidden>/, 'server-stamped and hidden until the session says member');
|
|
551
|
+
assert.match(SCRIPT, /if \(sh\) sh\.hidden = !badge \|\| !hallUrlOf\(SELF_PROJECT\);/, 'member-gated and address-gated, and null-safe: the stamp drops the anchor whole when a pack names no hall');
|
|
552
|
+
assert.match(SCRIPT, /if \(p\.isSelf\) \{\r?\n\s*var a = \$\('selfHall'\);/);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
test('the card\'s doors: one accent door, the hall entry beside it as text, the platform\'s door same-tab', () => {
|
|
556
|
+
assert.match(SCRIPT, /'<a class="cardOpen" id="cardOpen" href="' \+ esc\(href\) \+ '"' \+\r?\n\s*\(p\.isSelf \? '' : ' target="_blank" rel="noopener"'\)/);
|
|
557
|
+
assert.match(SCRIPT, /\(p\.isSelf \? 'Home →' : 'Open site →'\)/);
|
|
558
|
+
assert.match(SCRIPT, /HALL_GLYPH \+ 'Builders\\' hall<\/a>'/, 'the hall entry carries the glyph and its name');
|
|
559
|
+
assert.match(COSMOS, /\.projCard \.cardOpen\{[^}]*background:var\(--accent\);color:var\(--accent-on\)/, 'the one accent door');
|
|
560
|
+
assert.match(COSMOS, /\.projCard \.cardHall\{[^}]*color:var\(--accent-deep\)[^}]*text-decoration:underline/, 'the hall entry is the footer-link grammar');
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test('the platform answers its own figures by the same rule: faces from its leaderboard, shipped from its own feed', () => {
|
|
564
|
+
assert.match(SCRIPT, /SELF_PROJECT\.faces = real\.map\(function \(b\) \{ return safeHttpUrl\(b\.avatar_url\); \}\)\.filter\(Boolean\)\.slice\(0, FACES_MAX\);/);
|
|
565
|
+
const fn = (SCRIPT.match(/function loadSelfShipped\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
566
|
+
assert.ok(fn, 'loadSelfShipped() must exist');
|
|
567
|
+
assert.match(fn, /\/public\/recent-shipped\?days=30/, 'the window is asked for explicitly');
|
|
568
|
+
assert.match(fn, /Date\.parse\(t && t\.shipped_at\) >= since/, 'counted from shipped_at, inside the window');
|
|
569
|
+
assert.match(fn, /d\.tasks\.length >= 200 && n === d\.tasks\.length/, 'N+ at the feed\'s row cap');
|
|
570
|
+
assert.match(SCRIPT, /\.then\(loadSelfStats, loadSelfStats\)\r?\n\s*\.then\(loadSelfShipped, loadSelfShipped\);/, 'runs once at boot beside the leaderboard read');
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test('the poster is in the world: 560 wide, one hairline, no shadow, the disc surviving over the art, the facts a ledger', () => {
|
|
574
|
+
const block = (COSMOS.match(/\.projCard\{[^}]*\}/) || [''])[0];
|
|
575
|
+
assert.match(block, /width:min\(560px,100%\)/);
|
|
576
|
+
assert.match(block, /border:1px solid var\(--rule\)/);
|
|
577
|
+
assert.match(block, /box-shadow:none/);
|
|
578
|
+
assert.match(block, /container-type:inline-size/, 'the disc and the callouts measure the card, not the viewport');
|
|
579
|
+
assert.match(COSMOS, /\.projCard \.cardArt\{[^}]*aspect-ratio:5\/2;object-fit:cover/, 'a fixed 5:2 crop');
|
|
580
|
+
assert.match(COSMOS, /\.projCard\.has-art \.cardDot\{[^}]*top:calc\(100cqw \/ 2\.5 - 27px\)[^}]*border:4px solid var\(--bg-card\)/, 'half over the banner\'s edge, ringed in the card ground');
|
|
581
|
+
assert.match(COSMOS, /\.projCard\.has-art \.cardCall::before\{[^}]*mask-image/, 'each callout carries a feathered patch of the card ground — a mask, not a shadow');
|
|
582
|
+
assert.match(COSMOS, /\.projCard \.cardMeta dt\{[^}]*font-family:var\(--label-face\)[^}]*border-top:1px solid var\(--rule-soft\)/, 'mono label on a hairline');
|
|
583
|
+
assert.match(COSMOS, /\.projCard \.cardMeta dd\{[^}]*text-align:right[^}]*border-top:1px solid var\(--rule-soft\)/, 'the value right and on the same hairline');
|
|
584
|
+
assert.match(COSMOS, /\.projCard \.cardRel\{[^}]*font-family:var\(--label-face\)/, 'the relationship is a mono label, not an accent pill');
|
|
585
|
+
assert.doesNotMatch(COSMOS, /\.projCard \.cardMeta \.tags/, 'the card no longer renders the module list');
|
|
586
|
+
assert.match(COSMOS, /\.rowHall\{[^}]*width:28px;height:28px/, 'the row glyph clears the 24px floor');
|
|
587
|
+
});
|
|
588
|
+
|
|
493
589
|
test('the row keeps its own Open link working underneath the card', () => {
|
|
494
590
|
assert.match(SCRIPT, /if \(ev\.target\.closest && ev\.target\.closest\('a'\)\) return;/,
|
|
495
591
|
'a click on the row\'s own anchor must not be swallowed by the card handler');
|
|
@@ -592,10 +688,15 @@ test('each card fact is wired to its data, not just present in the markup', () =
|
|
|
592
688
|
assert.match(SCRIPT, /function cardFact/, 'the card fact writer must exist');
|
|
593
689
|
assert.match(SCRIPT, /dd\.hidden = \$\(id \+ 'L'\)\.hidden = !value/,
|
|
594
690
|
'a fact with no answer hides label and value together — never a dash');
|
|
595
|
-
|
|
691
|
+
// since task 1003301 Builders is a FIGURE (a callout on the art), not a fact,
|
|
692
|
+
// and Runs on the poster is the project's ADDRESS — the table keeps the module list
|
|
693
|
+
assert.match(SCRIPT, /cardFig\('cardBuilders', p\.builders == null \? '' : String\(p\.builders\)\)/,
|
|
596
694
|
'builders: 0 is a real answer, null hides');
|
|
597
|
-
assert.match(SCRIPT, /
|
|
598
|
-
|
|
695
|
+
assert.match(SCRIPT, /cardFig\('cardShipped', shippedFigure\(p\)\)/, 'the second figure is the 30-day count');
|
|
696
|
+
assert.match(SCRIPT, /cardFact\('cardRuns', runsOf\(p\)\)/, 'runs is the address on the poster');
|
|
697
|
+
const runs = (SCRIPT.match(/function runsOf\(p\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
698
|
+
assert.match(runs, /hostOf\(p\.origin\)/, 'the address is the catalog origin\'s host');
|
|
699
|
+
assert.match(SCRIPT, /'<td class="colTags">' \+ tagsHtml\(p\.tags\)/, 'the table row keeps the module list');
|
|
599
700
|
assert.match(SCRIPT, /cardFact\('cardOwner', rel === 'owner' \? '' : ownerHtml\(p\), true\)/,
|
|
600
701
|
'owner comes from the enrichment and yields to the rel chip saying "yours"');
|
|
601
702
|
const oh = (SCRIPT.match(/function ownerHtml\(p\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
@@ -335,11 +335,37 @@ test('the F19 re-kick door survives the fold: open, pre-filled, one button', ()
|
|
|
335
335
|
const fold = fn('attachFold');
|
|
336
336
|
assert.match(fold, /var rekick = inst\.status === 'active' && inst\.domain && inst\.error_note;/);
|
|
337
337
|
assert.match(fold, /\(rekick \? ' open' : ''\)/, 'the fold opens on its own for a stalled address');
|
|
338
|
-
assert.match(fold, /\(
|
|
339
|
-
assert.match(fold, /
|
|
338
|
+
assert.match(fold, /\(prefill \? ' value="' \+ esc\(inst\.domain\) \+ '"' : ''\)/, 'pre-filled with the address in play');
|
|
339
|
+
assert.match(fold, /var prefill = rekick \|\| repoint;/, 'a stalled address and a correctable one both arrive in the field');
|
|
340
|
+
assert.match(fold, /rekick \? 'Try the address again' : repoint \? 'Change the address' : 'Use this domain'/, 'and the button says what it does');
|
|
340
341
|
assert.match(fn('canAttachDomain'), /inst\.status === 'active' && inst\.domain && inst\.error_note && !inst\.open_intent\) return true;/, 'the route-side re-kick gate is untouched');
|
|
341
342
|
});
|
|
342
343
|
|
|
344
|
+
test('the correction door: a bring-your-own address can be changed, and the fold says what that costs (task 1003237)', () => {
|
|
345
|
+
const fold = fn('attachFold');
|
|
346
|
+
// the three doors are told apart by what the project is actually addressed as
|
|
347
|
+
assert.match(fold, /var byo = inst\.domain && inst\.domain !== assignedDefaultFor\(inst\);/,
|
|
348
|
+
'the platform-assigned <slug>.<apex> is NOT a correction — moving off it is the upgrade the fold already offered');
|
|
349
|
+
assert.match(fold, /var repoint = !rekick && byo;/, 'a stalled address keeps the F19 door, which is louder');
|
|
350
|
+
assert.match(fold, /'Use a different domain'/, 'the summary names the correction');
|
|
351
|
+
assert.match(fold, /attachNote/, 'and the consequence is stated where the button is');
|
|
352
|
+
assert.match(fold, /won’t use that address any more/, 'letting the old address go is SAID, not discovered');
|
|
353
|
+
// the door itself: an addressed, live project is no longer excluded
|
|
354
|
+
const gate = fn('canAttachDomain');
|
|
355
|
+
assert.doesNotMatch(gate, /!inst\.domain \|\| inst\.domain === assignedDefaultFor\(inst\)/,
|
|
356
|
+
'the "only a domainless or platform-addressed project may attach" clause is what the re-point removes');
|
|
357
|
+
assert.match(gate, /inst\.status === 'requested' \|\| inst\.status === 'error' \|\| inst\.status === 'active'/,
|
|
358
|
+
'state is still the gate — a mid-flight or torn-down project has nothing to point a domain at');
|
|
359
|
+
assert.match(COSMOS, /\.attachNote\{/, 'the note is styled');
|
|
360
|
+
assert.doesNotMatch((COSMOS.match(/\.attachNote[^\n]*/g) || []).join('\n'), /#[0-9a-f]{3,8}\b/i,
|
|
361
|
+
'and paints from tokens, so dark mode needs no twin');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('the retired refusal is gone from the card: domain_already_set can no longer be returned', () => {
|
|
365
|
+
assert.doesNotMatch(HUB, /domain_already_set/,
|
|
366
|
+
'the server stopped sending it (task 1003237) — a branch for it would be unreachable copy');
|
|
367
|
+
});
|
|
368
|
+
|
|
343
369
|
test('a heartbeat re-render keeps an open fold open and a half-typed domain typed', () => {
|
|
344
370
|
const card = fn('renderCard');
|
|
345
371
|
assert.match(card, /var keepOpen = !!\(oldFold && oldFold\.open\);/);
|