@bongos/core 1.19.395 → 1.19.397
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 +139 -24
- package/.claude/skills/design/SKILL.md +1 -1
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -0
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +10 -8
- package/docs/copy-registry.json +29 -11
- package/docs/file-map.md +1 -1
- package/docs/recipes/ui-look-before-you-ship.md +113 -0
- package/modules/hall-ui/public/my-projects.css +22 -0
- package/modules/hall-ui/public/my-projects.html +7 -1
- package/modules/hall-ui/public/my-projects.js +17 -11
- package/modules/hall-ui/public/ranks.states.json +15 -0
- package/modules/platform-identity/routes/my-projects.js +50 -3
- package/modules/ui-design/kit/README.md +12 -2
- package/modules/ui-design/kit/check-mock.js +147 -0
- package/modules/ui-design/kit/contrast.js +156 -0
- package/modules/ui-design/kit/fixtures/cost-summary.json +1 -0
- package/modules/ui-design/kit/fixtures/featured.json +1 -0
- package/modules/ui-design/kit/fixtures/leaderboard.json +1 -0
- package/modules/ui-design/kit/fixtures/me.json +9 -0
- package/modules/ui-design/kit/fixtures/my-projects.json +1 -0
- package/modules/ui-design/kit/fixtures/progress.json +1 -0
- package/modules/ui-design/kit/fixtures/recent-shipped.json +1 -0
- package/modules/ui-design/kit/lib.js +389 -0
- package/modules/ui-design/kit/png.js +51 -0
- package/modules/ui-design/kit/probe.js +100 -0
- package/modules/ui-design/kit/render.js +137 -0
- package/modules/ui-design/kit/serve.js +178 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/index.probes.json +37 -0
- package/public-landing/index.states.json +16 -0
- package/public-landing/projects.probes.json +79 -0
- package/public-landing/projects.states.json +25 -0
- package/scripts/gds/ui-kit-transform.js +43 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +5 -1
- package/tests/join_application_note.mjs +112 -0
- package/tests/ui_design_kit.mjs +217 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// tests/join_application_note.mjs — a project-level application is one click plus
|
|
2
|
+
// an optional note (task 1002330, ADR 0182 D6 / the admin-console spec's D2).
|
|
3
|
+
//
|
|
4
|
+
// The hub does not admit anyone; it relays the application to the project's own
|
|
5
|
+
// public POST /access-requests, which is what the owner's queue reads. So what
|
|
6
|
+
// this file pins is the composition of that relayed row:
|
|
7
|
+
//
|
|
8
|
+
// - the applicant's own words reach the owner, with the hub's provenance line
|
|
9
|
+
// saying where the request came from;
|
|
10
|
+
// - writing nothing is still an application — the pre-1002330 behaviour;
|
|
11
|
+
// - nothing the applicant types can push the relayed fields past the project's
|
|
12
|
+
// own column caps (500 note / 100 display_name), because a 400 from the
|
|
13
|
+
// project is a status joinRelayOutcome cannot read, and the applicant would
|
|
14
|
+
// then be told the project "could not be reached" — a lie about someone
|
|
15
|
+
// else's door caused by our own field.
|
|
16
|
+
//
|
|
17
|
+
// Pure, DB-free.
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test tests/join_application_note.mjs
|
|
20
|
+
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import {
|
|
24
|
+
relayApplicationNote,
|
|
25
|
+
relayApplicantName,
|
|
26
|
+
APPLICANT_NOTE_MAX,
|
|
27
|
+
} from '../modules/platform-identity/routes/my-projects.js';
|
|
28
|
+
|
|
29
|
+
// The caps the PROJECT's public POST /access-requests enforces on the row it
|
|
30
|
+
// stores (modules/onboarding/routes/access-requests.js). Restated here so this
|
|
31
|
+
// test fails if the hub ever starts asking for more than a project will take.
|
|
32
|
+
const PROJECT_NOTE_MAX = 500;
|
|
33
|
+
const PROJECT_NAME_MAX = 100;
|
|
34
|
+
|
|
35
|
+
test('the applicant’s own words reach the owner, with where it came from', () => {
|
|
36
|
+
const note = relayApplicationNote("I'd like to help with the terrain work");
|
|
37
|
+
assert.match(note, /I'd like to help with the terrain work/);
|
|
38
|
+
assert.match(note, /via platform My Projects/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('an application with nothing written is still an application', () => {
|
|
42
|
+
for (const empty of [undefined, null, '', ' ', '\n\t']) {
|
|
43
|
+
assert.equal(relayApplicationNote(empty), 'via platform My Projects',
|
|
44
|
+
`${JSON.stringify(empty)} relays the provenance line alone`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the composed note always fits the project’s column', () => {
|
|
49
|
+
const composed = relayApplicationNote('x'.repeat(5000));
|
|
50
|
+
assert.ok(composed.length <= PROJECT_NOTE_MAX,
|
|
51
|
+
`composed note is ${composed.length} chars, project stores ${PROJECT_NOTE_MAX}`);
|
|
52
|
+
assert.match(composed, /via platform My Projects/, 'provenance survives the clamp');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('the hub asks for less note than the project will take', () => {
|
|
56
|
+
const suffix = relayApplicationNote('').length + ' ()'.length;
|
|
57
|
+
assert.ok(APPLICANT_NOTE_MAX + suffix <= PROJECT_NOTE_MAX,
|
|
58
|
+
'the validator cap plus the provenance suffix must fit the project’s 500');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a display name travels so the queue reads as a person, not a bare login', () => {
|
|
62
|
+
assert.equal(relayApplicantName(' Jane Doe '), 'Jane Doe');
|
|
63
|
+
const clamped = relayApplicantName('n'.repeat(400));
|
|
64
|
+
assert.ok(clamped.length <= PROJECT_NAME_MAX);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('no display name relays null — the queue keeps the bare @login it always had', () => {
|
|
68
|
+
for (const empty of [undefined, null, '', ' ']) {
|
|
69
|
+
assert.equal(relayApplicantName(empty), null, `${JSON.stringify(empty)}`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// The wiring — that the composed note actually travels, and that the applicant's
|
|
75
|
+
// words survive a door refusal. Source assertions (the house pattern in
|
|
76
|
+
// tests/joinability_invite_only.mjs): the relay needs a live hub session and a
|
|
77
|
+
// second running project to exercise, which no unit test can boot.
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
import fs from 'node:fs';
|
|
81
|
+
import path from 'node:path';
|
|
82
|
+
import { fileURLToPath } from 'node:url';
|
|
83
|
+
|
|
84
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
85
|
+
const src = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
86
|
+
|
|
87
|
+
test('the relay sends the composed note and the applicant’s name — not a constant', () => {
|
|
88
|
+
const route = src('modules/platform-identity/routes/my-projects.js');
|
|
89
|
+
const join = (route.match(/router\.post\('\/my-projects\/join'[\s\S]*?\n \}\);\n/) || [''])[0];
|
|
90
|
+
assert.ok(join, 'the join route must exist');
|
|
91
|
+
assert.match(join, /note: relayApplicationNote\(req\.body\.note\)/, 'the applicant’s words are composed, then relayed');
|
|
92
|
+
assert.match(join, /display_name: relayApplicantName\(req\.builder\.display_name\)/);
|
|
93
|
+
assert.doesNotMatch(join, /note: 'via platform My Projects'/, 'the hardcoded note is gone');
|
|
94
|
+
assert.match(join, /note: \{ type: 'string', maxLength: APPLICANT_NOTE_MAX \}/, 'the note is declared in the route’s schema');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('the join box offers the note as optional, and never demands it', () => {
|
|
98
|
+
const html = src('modules/hall-ui/public/my-projects.html');
|
|
99
|
+
assert.match(html, /<textarea id="join-note"/, 'the note field exists');
|
|
100
|
+
assert.match(html, /maxlength="400"/, 'the field cannot outrun the route’s cap');
|
|
101
|
+
assert.match(html, /Add a note \(optional\)/, 'and it is labelled optional');
|
|
102
|
+
assert.match(html, /id="join-status"/, 'the answer line is its own element, not the note field');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a door refusal keeps what the applicant typed; a filed request clears it', () => {
|
|
106
|
+
const js = src('modules/hall-ui/public/my-projects.js');
|
|
107
|
+
const click = (js.match(/joinBtn\.addEventListener\('click'[\s\S]*?\n \}\);\n/) || [''])[0];
|
|
108
|
+
assert.ok(click, 'the join handler must exist');
|
|
109
|
+
assert.match(click, /note \? \{ origin: origin, note: note \} : \{ origin: origin \}/, 'a blank note is omitted, not sent as ""');
|
|
110
|
+
assert.match(click, /if \(!refused\) \{ joinInput\.value = ''; joinNoteInput\.value = ''; load\(\); \}/,
|
|
111
|
+
'cleared only when the request was actually filed — a refusal keeps the words for the next try');
|
|
112
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// tests/ui_design_kit.mjs — the ui-design kit (task 1003320, ADR 0197): the platform
|
|
2
|
+
// floors are a pinned contract; a states/probes file has a shape the kit refuses to
|
|
3
|
+
// guess at; the pure audit names what a bad render did; the mock checker keeps the
|
|
4
|
+
// platform's rules apart from an instance's taste bans; the instance's own pack
|
|
5
|
+
// clears the floor; every CLI exits with its documented code on a bad input; and the
|
|
6
|
+
// stub serves a surface through the doorway's real transforms. DB-free and
|
|
7
|
+
// browser-free — the renders themselves are the recipe's job, not CI's.
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const KIT = path.join(ROOT, 'modules', 'ui-design', 'kit');
|
|
20
|
+
const lib = require(path.join(KIT, 'lib.js'));
|
|
21
|
+
const mock = require(path.join(KIT, 'check-mock.js'));
|
|
22
|
+
const contrast = require(path.join(KIT, 'contrast.js'));
|
|
23
|
+
|
|
24
|
+
const run = (file, args, env = {}) => spawnSync(process.execPath, [path.join(KIT, file), ...args], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: 60000 });
|
|
25
|
+
const tmp = (name, text) => { const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ui-kit-test-')), name); fs.writeFileSync(p, text); return p; };
|
|
26
|
+
const readJson = (rel) => JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'));
|
|
27
|
+
|
|
28
|
+
// ── the contract ──────────────────────────────────────────────────────────────
|
|
29
|
+
test('the platform floors are a pinned list — a floor cannot vanish quietly', () => {
|
|
30
|
+
assert.deepEqual([...lib.FLOORS], ['console-errors', 'failed-requests', 'horizontal-overflow', 'one-header-main-h1', 'controls-24px', 'top-bar-80px', 'reduced-motion']);
|
|
31
|
+
assert.deepEqual(lib.EXIT, { CLEAN: 0, PROBLEMS: 1, USAGE: 2, NO_BROWSER: 3 });
|
|
32
|
+
assert.deepEqual(Object.keys(lib.SIZES), ['320', '390', '1440']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('the shipped states files validate and sit beside the page they name', () => {
|
|
36
|
+
for (const rel of ['public-landing/projects.states.json', 'public-landing/index.states.json', 'modules/hall-ui/public/ranks.states.json']) {
|
|
37
|
+
const json = readJson(rel);
|
|
38
|
+
const v = lib.validateStatesFile(json);
|
|
39
|
+
assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
|
|
40
|
+
assert.equal(path.basename(rel), `${lib.pageName(json.page)}.states.json`, `${rel} is named for its page`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('the shipped probes files validate', () => {
|
|
45
|
+
for (const rel of ['public-landing/projects.probes.json', 'public-landing/index.probes.json']) {
|
|
46
|
+
const v = lib.validateProbesFile(readJson(rel));
|
|
47
|
+
assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a states file that breaks the contract is refused with the reason named', () => {
|
|
52
|
+
assert.equal(lib.validateStatesFile(null).ok, false);
|
|
53
|
+
const v = lib.validateStatesFile({});
|
|
54
|
+
assert.ok(v.errors.some((e) => e.startsWith('page: required')));
|
|
55
|
+
assert.ok(v.errors.some((e) => e.startsWith('states: required')));
|
|
56
|
+
const bad = lib.validateStatesFile({ page: '/x', states: { 'Bad Name': { actions: [['teleport', '#a']], expect: { glow: true } }, ok: { auth: 'yes' } } });
|
|
57
|
+
assert.ok(bad.errors.some((e) => /kebab-case/.test(e)));
|
|
58
|
+
assert.ok(bad.errors.some((e) => /unknown action "teleport"/.test(e)));
|
|
59
|
+
assert.ok(bad.errors.some((e) => /unknown expectation/.test(e) && /glow/.test(e)));
|
|
60
|
+
assert.ok(bad.errors.some((e) => /states\.ok\.auth/.test(e)));
|
|
61
|
+
const stub = lib.validateStatesFile({ page: '/x', stub: { prefix: 'builders' }, states: { a: {} } });
|
|
62
|
+
assert.ok(stub.errors.some((e) => /stub\.prefix/.test(e)));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('a probes file needs a name and an expectation per probe, and cannot continue from nothing', () => {
|
|
66
|
+
const v = lib.validateProbesFile({ page: '/x', probes: [{ continue: true, steps: [] }, { name: 'n', steps: [['wait', 1]] }] });
|
|
67
|
+
assert.ok(v.errors.some((e) => /probes\[0\]\.name: required/.test(e)));
|
|
68
|
+
assert.ok(v.errors.some((e) => /probes\[0\]\.continue: the first probe/.test(e)));
|
|
69
|
+
assert.ok(v.errors.some((e) => /probes\[1\]\.expect: required/.test(e)));
|
|
70
|
+
assert.ok(lib.validateProbesFile({ page: '/x', probes: [{ name: 'n', steps: [], expect: { dialog: false } }] }).ok);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── the pure audit ────────────────────────────────────────────────────────────
|
|
74
|
+
const clean = () => ({ scrollWidth: 1440, clientWidth: 1440, mode: 'dark', headers: 1, mains: 1, h1s: 1, smallControls: [], bar: { h: 64, top: 0 }, expanded: [], dialog: false, running: 0, activeId: '', activeText: '', sel: {} });
|
|
75
|
+
|
|
76
|
+
test('auditFloors names each floor a render breaks, and says nothing about a clean one', () => {
|
|
77
|
+
assert.deepEqual(lib.auditFloors(clean(), { width: 1440, mode: 'dark' }), []);
|
|
78
|
+
const p = lib.auditFloors({ ...clean(), scrollWidth: 1500, h1s: 3, smallControls: ['a.x "Docs" 40x17'], bar: { h: 96 }, mode: 'light' }, { width: 1440, mode: 'dark' });
|
|
79
|
+
assert.ok(p.some((x) => x.startsWith('horizontal-overflow')));
|
|
80
|
+
assert.ok(p.some((x) => x.startsWith('one-header-main-h1') && /h1 3/.test(x)));
|
|
81
|
+
assert.ok(p.some((x) => x.startsWith('controls-24px') && /40x17/.test(x)));
|
|
82
|
+
assert.ok(p.some((x) => x.startsWith('top-bar-80px') && /96px/.test(x)));
|
|
83
|
+
assert.ok(p.some((x) => /data-mode "light" under a dark scheme/.test(x)));
|
|
84
|
+
assert.deepEqual(lib.auditFloors({ ...clean(), mode: null }, { mode: 'dark' }), [], 'a page that sets no data-mode is not held to one');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('expectProblems compares every expectation the vocabulary allows', () => {
|
|
88
|
+
const facts = { ...clean(), expanded: ['projTrig'], dialog: true, sel: { '#a': true, '#b': false }, activeId: 'x', activeText: 'Sky', param: { view: 'mine' }, attr: ['dark'], text: ['Table'], href: ['/docs'], count: [3], checked: false, value: ['demo'], hasClass: [true] };
|
|
89
|
+
assert.deepEqual(lib.expectProblems(facts, { expanded: true, dialog: true, visible: ['#a'], hidden: ['#b'], open: '#a', closed: '#b', activeId: 'x', activeText: 'Sky', expandedIds: ['projTrig'], param: { view: 'mine' }, attr: [['html', 'data-mode', 'dark']], text: [['h1', 'Table']], href: [['a', '/docs']], count: [['li', 3]], value: [['#q', 'demo']], hasClass: [['#w', 'is-open']] }), []);
|
|
90
|
+
const p = lib.expectProblems(facts, { expanded: false, dialog: false, visible: ['#b'], hidden: ['#a'], activeText: 'Table', param: { view: null }, count: [['li', 5]], checked: '#c', hasClass: [['#w', 'is-open', false]], noOverflow: true });
|
|
91
|
+
for (const needle of ['expected no expanded trigger', 'expected no visible dialog', 'expected visible: #b', 'expected hidden: #a', 'active text "Sky", expected "Table"', '?view=mine, expected null', '3 × li, expected 5', '#c is not checked', 'still has class "is-open"']) {
|
|
92
|
+
assert.ok(p.some((x) => x.includes(needle)), `missing: ${needle} in ${p.join(' | ')}`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('reducedMotionProblems: a running animation or a rAF loop is a floor break', () => {
|
|
97
|
+
assert.deepEqual(lib.reducedMotionProblems({ running: [], frames: 1 }), []);
|
|
98
|
+
const p = lib.reducedMotionProblems({ running: ['breath'], frames: 40 });
|
|
99
|
+
assert.equal(p.length, 2);
|
|
100
|
+
assert.match(p[0], /breath/); assert.match(p[1], /requestAnimationFrame loop/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('the page argument survives Git Bash path conversion of a bare slash', () => {
|
|
104
|
+
assert.equal(lib.normalizePageArg('C:/Program Files/Git/'), '/');
|
|
105
|
+
assert.equal(lib.normalizePageArg('/projects'), '/projects');
|
|
106
|
+
assert.equal(lib.pageName('/'), 'index'); assert.equal(lib.pageName('/builders/ranks'), 'ranks'); assert.equal(lib.pageName('/projects?view=mine'), 'projects');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── the mock checker: platform rules vs the instance's taste ──────────────────
|
|
110
|
+
const GOOD_MOCK = `<!doctype html><html><head><style>
|
|
111
|
+
body{--pad:8px;color:var(--ink);background:var(--bg)}
|
|
112
|
+
:root[data-mode="dark"] body{--pad:9px}
|
|
113
|
+
@media (prefers-color-scheme: dark){:root:not([data-mode="light"]) body{--pad:9px}}
|
|
114
|
+
.a{transition:opacity 200ms}
|
|
115
|
+
</style></head><body><!-- THESIS: a mock that says what it claims, so the reviewer can check the render against the words -->
|
|
116
|
+
<a class="skip" href="#main">Skip</a><header><button aria-expanded="false" aria-controls="m">Menu</button></header>
|
|
117
|
+
<main id="main"><h1>Title</h1><p>Plain copy.</p><div id="m" hidden></div></main>
|
|
118
|
+
<script>const state = new URLSearchParams(location.search).get('state');</script></body></html>`;
|
|
119
|
+
const fails = (results) => results.filter((r) => r[0] === 'FAIL').map((r) => r[1]);
|
|
120
|
+
|
|
121
|
+
test('checkMock: a mock inside the contract passes every platform rule', () => {
|
|
122
|
+
assert.deepEqual(fails(mock.checkMock(GOOD_MOCK)), []);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('checkMock: the platform rules catch a page :root, a literal colour, a pinned pack colour, JS motion', () => {
|
|
126
|
+
const f1 = fails(mock.checkMock(GOOD_MOCK.replace('<style>', '<style>:root{--x:1}')));
|
|
127
|
+
assert.ok(f1.some((r) => r.startsWith('no page-local :root')), f1.join(' | '));
|
|
128
|
+
const f2 = fails(mock.checkMock(GOOD_MOCK.replace('color:var(--ink)', 'color:#ff0000')));
|
|
129
|
+
assert.ok(f2.some((r) => r.startsWith('no literal hex colour')), f2.join(' | '));
|
|
130
|
+
const f3 = fails(mock.checkMock(GOOD_MOCK.replace('color:var(--ink)', 'color:#fb9569'), { packColors: { accent: '#fb9569' } }));
|
|
131
|
+
assert.ok(f3.some((r) => r.startsWith('no pack colour pinned')), f3.join(' | '));
|
|
132
|
+
const f4 = fails(mock.checkMock(GOOD_MOCK.replace('<script>', '<script>requestAnimationFrame(() => {});')));
|
|
133
|
+
assert.ok(f4.some((r) => r.startsWith('no motion in JavaScript')), f4.join(' | '));
|
|
134
|
+
const f5 = fails(mock.checkMock(GOOD_MOCK.replace(':root[data-mode="dark"] body{--pad:9px}', ':root[data-mode="dark"] body{--pad:10px}')));
|
|
135
|
+
assert.ok(f5.some((r) => r.startsWith('every dark rule written twice')), f5.join(' | '));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('checkMock: a taste ban applies only when the instance declared it', () => {
|
|
139
|
+
const dashed = GOOD_MOCK.replace('Plain copy.', 'Plain — copy.');
|
|
140
|
+
const withBan = mock.checkMock(dashed, { bans: new Set(['em-dash']) });
|
|
141
|
+
assert.ok(fails(withBan).some((r) => r.startsWith('taste: no em/en dash')));
|
|
142
|
+
const withoutBan = mock.checkMock(dashed, { bans: new Set() });
|
|
143
|
+
assert.deepEqual(fails(withoutBan), []);
|
|
144
|
+
assert.ok(!withoutBan.some((r) => r[1].startsWith('taste:')), 'no taste rule is even evaluated without a ban');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('readDesignBans reads the Don\'t section — the live DESIGN.md declares the five, an empty file none', () => {
|
|
148
|
+
const live = mock.readDesignBans(fs.readFileSync(path.join(ROOT, 'DESIGN.md'), 'utf8'));
|
|
149
|
+
for (const k of ['em-dash', 'emoji', 'gradient-text', 'text-glow', 'box-shadow']) assert.ok(live.has(k), `live DESIGN.md bans ${k}`);
|
|
150
|
+
assert.equal(mock.readDesignBans('').size, 0);
|
|
151
|
+
assert.deepEqual([...mock.readDesignBans("## Do's and Don'ts\n\n### Do\n- Do use an em dash.\n\n### Don't\n- Don't use emoji.\n\n## Next\n")], ['emoji'], 'only the Don\'t section counts');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── the pack ──────────────────────────────────────────────────────────────────
|
|
155
|
+
test('the neutral pack clears the text floor on its own grounds (both pack shapes)', () => {
|
|
156
|
+
const filePack = readJson('config/branding.neutral.json');
|
|
157
|
+
const rows = contrast.packContrast(filePack);
|
|
158
|
+
const onGrounds = rows.filter((r) => r.bg === 'bg' || r.bg === 'bgCard');
|
|
159
|
+
assert.ok(onGrounds.length >= 6);
|
|
160
|
+
for (const r of onGrounds) assert.equal(r.ok, true, `${r.fg} on ${r.bg}: ${r.ratio} (need ${r.need})`);
|
|
161
|
+
const clientShape = { theme: filePack.theme.ui };
|
|
162
|
+
assert.deepEqual(contrast.packContrast(clientShape), rows, 'clientBranding() flattens theme.ui onto theme; same answer');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('the WCAG maths: black on white is 21:1, alpha composites before it is measured', () => {
|
|
166
|
+
assert.equal(+lib.contrastRatio(lib.parseColor('#000'), lib.parseColor('#ffffff')).toFixed(1), 21);
|
|
167
|
+
const half = lib.composite(lib.parseColor('rgba(0,0,0,0.5)'), lib.parseColor('#fff'));
|
|
168
|
+
assert.ok(Math.abs(half[0] - 127.5) < 0.01);
|
|
169
|
+
assert.equal(lib.parseColor('nope'), null);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── the CLIs: exit codes are the contract a caller can script against ───────────
|
|
173
|
+
test('render.js: usage and a broken states file exit 2 with the reason, before any browser', () => {
|
|
174
|
+
const noArgs = run('render.js', []);
|
|
175
|
+
assert.equal(noArgs.status, lib.EXIT.USAGE); assert.match(noArgs.stderr, /--page/);
|
|
176
|
+
const missing = run('render.js', ['--states', path.join(os.tmpdir(), 'no-such.states.json')]);
|
|
177
|
+
assert.equal(missing.status, lib.EXIT.USAGE); assert.match(missing.stderr, /not found/);
|
|
178
|
+
const bad = run('render.js', ['--states', tmp('x.states.json', JSON.stringify({ page: 'projects', states: { a: { actions: [['fly']] } } }))]);
|
|
179
|
+
assert.equal(bad.status, lib.EXIT.USAGE);
|
|
180
|
+
assert.match(bad.stderr, /page: required/); assert.match(bad.stderr, /unknown action "fly"/);
|
|
181
|
+
const noSidecar = run('render.js', ['--page', '/nowhere']);
|
|
182
|
+
assert.equal(noSidecar.status, lib.EXIT.USAGE); assert.match(noSidecar.stderr, /no states file beside the surface/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('probe.js and check-mock.js: the same discipline', () => {
|
|
186
|
+
const bad = run('probe.js', ['--probes', tmp('x.probes.json', JSON.stringify({ page: '/', probes: [{ name: 'n', steps: [] }] }))]);
|
|
187
|
+
assert.equal(bad.status, lib.EXIT.USAGE); assert.match(bad.stderr, /expect: required/);
|
|
188
|
+
assert.equal(run('check-mock.js', []).status, lib.EXIT.USAGE);
|
|
189
|
+
const failing = run('check-mock.js', ['--file', tmp('m.html', GOOD_MOCK.replace('<style>', '<style>:root{--x:1}')), '--no-taste']);
|
|
190
|
+
assert.equal(failing.status, lib.EXIT.PROBLEMS); assert.match(failing.stdout, /FAIL no page-local :root/);
|
|
191
|
+
const passing = run('check-mock.js', ['--file', tmp('m.html', GOOD_MOCK), '--no-taste']);
|
|
192
|
+
assert.equal(passing.status, lib.EXIT.CLEAN, passing.stdout); assert.match(passing.stdout, /ALL RULES PASS/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('serve.js refuses a surface that does not resolve, and serves one through the doorway transforms', async () => {
|
|
196
|
+
const bad = run('serve.js', [], { SURFACE: 'no-such-surface', STUB_PORT: '0' });
|
|
197
|
+
assert.equal(bad.status, lib.EXIT.USAGE); assert.match(bad.stderr, /neither a directory/);
|
|
198
|
+
for (const auth of [false, true]) {
|
|
199
|
+
const s = await lib.startStub({ surface: 'public-landing', auth });
|
|
200
|
+
try {
|
|
201
|
+
const html = await (await fetch(s.base + '/')).text();
|
|
202
|
+
assert.ok(html.includes('window.__BRANDING__'), 'the pack is injected');
|
|
203
|
+
assert.ok(/:root\s*\{/.test(html), 'the theme :root is spliced');
|
|
204
|
+
assert.ok(/\?v=[0-9a-f]{8}/.test(html), 'assets are content-stamped');
|
|
205
|
+
const me = await fetch(s.base + '/api/bongos/me');
|
|
206
|
+
assert.equal(me.status, auth ? 200 : 401);
|
|
207
|
+
if (auth) { const body = await me.json(); assert.ok(body.builder && body.builder.github_login, 'the real { builder } shape'); assert.match(body.builder.avatar_url, /^http:\/\/127\.0\.0\.1:\d+\/__kit\/avatar\.svg$/); }
|
|
208
|
+
assert.equal((await fetch(s.base + '/api/gds/public/progress')).status, 200, 'the public feed answers from a fixture');
|
|
209
|
+
assert.equal((await fetch(s.base + '/api/gds/nope')).status, 404);
|
|
210
|
+
} finally { s.stop(); }
|
|
211
|
+
}
|
|
212
|
+
const child = await lib.startStub({ surface: 'public-landing', child: true });
|
|
213
|
+
try {
|
|
214
|
+
const html = await (await fetch(child.base + '/')).text();
|
|
215
|
+
assert.equal((html.match(/<!--apex-only:start-->/g) || []).length, 0, 'the child shape strips the apex-only regions');
|
|
216
|
+
} finally { child.stop(); }
|
|
217
|
+
});
|