@bongos/core 1.19.971 → 1.19.973
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 +116 -76
- package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
- package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
- package/docs/adr/0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md +1 -1
- package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +107 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -1
- package/docs/api-reference.md +1 -1
- package/docs/architecture.md +6 -6
- package/docs/branding-contract.md +1 -1
- package/docs/canonical-permissions.md +24 -24
- package/docs/copy-inventory.md +193 -187
- package/docs/copy-registry.json +320 -276
- package/docs/design/projects-hub-direction-v2.md +1 -1
- package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -2
- package/docs/file-map.md +7 -7
- package/docs/handoff-template.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/modules-contract.md +1 -1
- package/docs/onboarding/drachmae-and-karma.md +2 -2
- package/docs/packs/engineer.md +5 -5
- package/docs/packs/ideator.md +1 -1
- package/docs/recipes/README.md +1 -1
- package/docs/recipes/audit-replay.md +2 -2
- package/docs/recipes/gds-db-backup.md +3 -3
- package/docs/recipes/managed-settings-remote-control.md +9 -9
- package/docs/recipes/memory-sync-conflicts.md +2 -2
- package/docs/recipes/ops-gotchas.md +24 -5
- package/docs/recipes/overnight-builder.md +3 -3
- package/docs/recipes/parallel-goal-waves.md +3 -3
- package/docs/recipes/reranking-a-goals-board.md +2 -2
- package/docs/recipes/search-index-ingestion.md +1 -1
- package/docs/recipes/ship-and-regrade.md +2 -2
- package/docs/recipes/staging-environment.md +1 -1
- package/docs/recipes/windows-builders.md +1 -1
- package/modules/hall-ui/public/deploy.html +10 -5
- package/modules/hall-ui/public/deploy.js +199 -110
- package/modules/hall-ui/public/goals-page.js +6 -0
- package/modules/hall-ui/public/goals-render.js +16 -0
- package/modules/hall-ui/public/mine-lens.js +172 -0
- package/modules/hall-ui/public/project-settings.css +36 -0
- package/modules/hall-ui/public/work-summary.js +90 -0
- package/modules/hall-ui/public/work.css +50 -0
- package/modules/hall-ui/public/work.html +24 -0
- package/modules/hall-ui/public/work.js +38 -51
- package/modules/lifecycle/db-goals.js +20 -3
- package/modules/lifecycle/routes/goals.js +2 -2
- package/modules/provisioning/core-upgrade.js +61 -2
- package/modules/public-landing/public/contact.html +1 -1
- package/modules/public-landing/public/index.html +1 -1
- package/modules/public-landing/public/privacy.html +1 -1
- package/modules/public-landing/public/projects.html +1 -1
- package/modules/public-landing/public/terms.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/api-alias-caller-scan.js +47 -9
- package/scripts/gds/cloudbongos-token-isolation.conf +33 -0
- package/scripts/gds/fitness.js +3 -0
- package/scripts/gds/provision-core-upgrade.js +132 -1
- package/scripts/gds/run-unit-tests.js +14 -9
- package/scripts/gds/scope-roster-guard.js +118 -0
- package/scripts/hall-preview/server.js +5 -0
- package/src/bongos/module-scope-map.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/api_alias_caller_ratchet.mjs +68 -2
- package/tests/core_upgrade_door.mjs +81 -0
- package/tests/core_upgrade_runner.mjs +132 -0
- package/tests/deploy_page_projects.mjs +234 -79
- package/tests/fitness_scope_roster.mjs +117 -0
- package/tests/goal_mine_lens.mjs +233 -0
- package/tests/goal_tier.mjs +30 -4
- package/tests/hall_mine_lens.mjs +381 -0
- package/tests/idea_spark_hall.mjs +101 -12
- package/tests/known_failing_ratchet.mjs +19 -9
- package/tests/landing_legal_pages.mjs +5 -1
- package/tests/projects_hub_pre_uat.mjs +12 -6
- package/tests/served_paths_shipped.mjs +36 -2
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// tests/hall_mine_lens.mjs — the hall's "Only mine" lens: goals you are added
|
|
2
|
+
// on, and tasks you have claimed.
|
|
3
|
+
//
|
|
4
|
+
// The lens is ONE checkbox that four tabs answer differently — Ready and Backlog
|
|
5
|
+
// by goal membership, In Progress by who holds the claim, Completed by who
|
|
6
|
+
// shipped it. That is exactly the shape a source-grep test cannot prove: a regex
|
|
7
|
+
// can see that `mineOn()` is called and still miss that a renderer filters the
|
|
8
|
+
// wrong list, or counts the unfiltered one, or silently empties every tab when
|
|
9
|
+
// the membership read fails. So work.js is BOOTED here, against the stub DOM
|
|
10
|
+
// tests/helpers.mjs already uses for the profile and idea pages, and the
|
|
11
|
+
// assertions read the HTML the page actually wrote.
|
|
12
|
+
//
|
|
13
|
+
// Each test below was verified by deleting the line it guards and watching it go
|
|
14
|
+
// red — the vacuous-guard check this suite has been bitten by before.
|
|
15
|
+
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import vm from 'node:vm';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { settle } from './helpers.mjs';
|
|
23
|
+
|
|
24
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
25
|
+
const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
26
|
+
const WORK_JS = fs.readFileSync(path.join(HALL, 'work.js'), 'utf8');
|
|
27
|
+
// The two files work.js was carved into. Run the REAL ones rather than stubbing
|
|
28
|
+
// them: mine-lens.js IS the subject here, and work-summary.js is the piece that
|
|
29
|
+
// left work.js to make room for it — a stub of either would let the carve rot.
|
|
30
|
+
const MINE_LENS_JS = fs.readFileSync(path.join(HALL, 'mine-lens.js'), 'utf8');
|
|
31
|
+
const WORK_SUMMARY_JS = fs.readFileSync(path.join(HALL, 'work-summary.js'), 'utf8');
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// The fixture. Two goals, and the viewer (builder 7, @percy) is on ONE of them.
|
|
35
|
+
// Every list below is built so that "mine" and "not mine" are both non-empty —
|
|
36
|
+
// a lens that returned everything and a lens that returned nothing would both
|
|
37
|
+
// pass against a one-sided fixture.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
const MINE_GOAL = 1000101;
|
|
40
|
+
const THEIRS_GOAL = 1000102;
|
|
41
|
+
|
|
42
|
+
const GOALS = [
|
|
43
|
+
{ id: MINE_GOAL, title: 'A goal I am on', viewer_is_member: true },
|
|
44
|
+
{ id: THEIRS_GOAL, title: 'A goal I am not on', viewer_is_member: false },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const CLAIMABLE = [
|
|
48
|
+
{ id: 501, title: 'ready in my goal', goal_id: MINE_GOAL, status: 'ready', version_id: 'V1' },
|
|
49
|
+
{ id: 502, title: 'ready in their goal', goal_id: THEIRS_GOAL, status: 'ready', version_id: 'V1' },
|
|
50
|
+
// A task with NO goal at all. It is nobody's by membership, and it exists in
|
|
51
|
+
// the fixture to pin that the lens excludes it rather than letting the
|
|
52
|
+
// ungoaled backlog ride along under the word "mine".
|
|
53
|
+
{ id: 503, title: 'ready with no goal', goal_id: null, status: 'ready', version_id: 'V1' },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const BACKLOG = [
|
|
57
|
+
{ id: 601, title: 'backlog in my goal', goal_id: MINE_GOAL, status: 'backlog', version_id: 'V1' },
|
|
58
|
+
{ id: 602, title: 'backlog in their goal', goal_id: THEIRS_GOAL, status: 'backlog', version_id: 'V1' },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const ACTIVE_CLAIMS = [
|
|
62
|
+
{ claim_id: 9001, task_id: 701, title: 'a claim of mine', builder_id: 7, github_login: 'percy', display_name: 'Percy', claimed_at: '2026-09-20T00:00:00Z' },
|
|
63
|
+
{ claim_id: 9002, task_id: 702, title: 'someone else holds this', builder_id: 8, github_login: 'annabeth', display_name: 'Annabeth', claimed_at: '2026-09-20T00:00:00Z' },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const SHIPPED = [
|
|
67
|
+
{ id: 801, title: 'I shipped this', github_login: 'percy', shipped_at: '2026-09-21T00:00:00Z' },
|
|
68
|
+
{ id: 802, title: 'they shipped this', github_login: 'annabeth', shipped_at: '2026-09-21T00:00:00Z' },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// bootWorkBoard — run the REAL work.js in a vm.
|
|
73
|
+
//
|
|
74
|
+
// `goalsFail: true` makes GET /goals reject, which is the failure mode the lens
|
|
75
|
+
// has to survive honestly: with no membership evidence it must switch itself off
|
|
76
|
+
// rather than narrow both queue tabs to nothing.
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
function bootWorkBoard({ signedIn = true, goalsFail = false } = {}) {
|
|
79
|
+
const els = new Map();
|
|
80
|
+
const makeEl = (id = '') => ({
|
|
81
|
+
id,
|
|
82
|
+
dataset: {}, style: {}, textContent: '', hidden: false, disabled: false,
|
|
83
|
+
checked: false, value: '', title: '',
|
|
84
|
+
_html: '',
|
|
85
|
+
classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
|
|
86
|
+
setAttribute() {}, getAttribute() { return null; },
|
|
87
|
+
listeners: {},
|
|
88
|
+
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); },
|
|
89
|
+
fire(type, ev = {}) { (this.listeners[type] || []).forEach((fn) => fn(ev)); },
|
|
90
|
+
querySelector() { return null; }, querySelectorAll() { return []; },
|
|
91
|
+
appendChild() {}, remove() {}, closest() { return null; },
|
|
92
|
+
set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
|
|
93
|
+
});
|
|
94
|
+
// Keyed by the selector text with the leading '#' stripped, so a compound
|
|
95
|
+
// selector like '#wip-table tbody' gets its own stable element — which is
|
|
96
|
+
// what work.js reads the in-flight rows out of.
|
|
97
|
+
const el = (key) => { if (!els.has(key)) els.set(key, makeEl(key)); return els.get(key); };
|
|
98
|
+
const documentObj = {
|
|
99
|
+
getElementById: (id) => el(id),
|
|
100
|
+
querySelector: (sel) => el(String(sel).replace(/^#/, '')),
|
|
101
|
+
querySelectorAll: () => [],
|
|
102
|
+
createElement: () => makeEl(),
|
|
103
|
+
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); },
|
|
104
|
+
listeners: {},
|
|
105
|
+
body: { dataset: {} }, documentElement: { dataset: {} },
|
|
106
|
+
hidden: false,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const requests = [];
|
|
110
|
+
const client = {
|
|
111
|
+
request: async (method, url) => {
|
|
112
|
+
requests.push(`${method} ${url}`);
|
|
113
|
+
const ok = (data) => ({ status: 200, ok: true, data });
|
|
114
|
+
if (url.endsWith('/me')) {
|
|
115
|
+
// `signedIn: false` is a 200 that carries NO builder, not a 401: a 401
|
|
116
|
+
// bounces this page to sign-in before anything renders (and the server
|
|
117
|
+
// page gate bars an unauthenticated visitor from /work outright), so the
|
|
118
|
+
// identity-less state the lens actually has to survive is this one.
|
|
119
|
+
return signedIn
|
|
120
|
+
? ok({ builder: { id: 7, display_name: 'Percy', github_login: 'percy', rank: 'metic' }, task_kinds: ['build'] })
|
|
121
|
+
: ok({ builder: null });
|
|
122
|
+
}
|
|
123
|
+
if (url.endsWith('/goals')) {
|
|
124
|
+
return goalsFail ? { status: 500, ok: false, data: null } : ok({ goals: GOALS });
|
|
125
|
+
}
|
|
126
|
+
if (url.includes('/tasks/claimable')) return ok({ claimable: CLAIMABLE, active_claims: ACTIVE_CLAIMS });
|
|
127
|
+
if (url.includes('/tasks?status=backlog')) return ok({ tasks: BACKLOG });
|
|
128
|
+
if (url.includes('/public/recent-shipped')) return ok({ tasks: SHIPPED });
|
|
129
|
+
if (url.includes('/versions')) return ok({ versions: [{ version_id: 'V1' }] });
|
|
130
|
+
if (url.includes('/public/progress')) return ok({ progress: [{ version_id: 'V1', lifecycle_shipped_count: 42 }] });
|
|
131
|
+
if (url.includes('/public/cost-summary')) return ok({ total_value_credits: 1234 });
|
|
132
|
+
return ok({});
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
137
|
+
|
|
138
|
+
let tabsOnChange = null;
|
|
139
|
+
const kit = {
|
|
140
|
+
makePager: (prefix) => ({ prefix, page: 1, perPage: 100 }),
|
|
141
|
+
paginate: (rows) => rows,
|
|
142
|
+
wirePager() {},
|
|
143
|
+
pagerBarHtml: () => '',
|
|
144
|
+
makeTabs: ({ tabs, onChange }) => {
|
|
145
|
+
let current = tabs[0].id;
|
|
146
|
+
tabsOnChange = onChange;
|
|
147
|
+
return { active: () => current, setCount() {}, activate(id) { current = id; onChange(id); } };
|
|
148
|
+
},
|
|
149
|
+
// The real bar is hall-kit's; here it only has to MAKE the controls readable,
|
|
150
|
+
// because what this suite asserts is what the renderers do with their values.
|
|
151
|
+
makeFilterBar: ({ search, primary = [], overflow = [] }) => {
|
|
152
|
+
[search, ...primary, ...overflow].filter(Boolean).forEach((c) => {
|
|
153
|
+
el(c.id).value = c.value ?? c.default ?? '';
|
|
154
|
+
});
|
|
155
|
+
return { values: () => ({}), get: () => '', set() {} };
|
|
156
|
+
},
|
|
157
|
+
ageChipHtml: () => '',
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const windowObj = {
|
|
161
|
+
__BRANDING__: { currency: { label: 'credits' } },
|
|
162
|
+
BongosClient: { createClient: () => client },
|
|
163
|
+
OTBKit: kit,
|
|
164
|
+
OTBTaskDetail: { href: (id) => `/task/${id}`, open() {} },
|
|
165
|
+
OTBTaskLib: {
|
|
166
|
+
priorityRank: () => 0, touchesConflict: () => false, rankTagHtml: () => '',
|
|
167
|
+
creditsPerMinute: () => 0, buildPromptForTask: () => '', friendlyBatchReason: () => '',
|
|
168
|
+
taskActivityMs: () => 0, STALE_CHOICES: [{ value: '', label: '— any —' }], isStale: () => false,
|
|
169
|
+
},
|
|
170
|
+
OTB: {
|
|
171
|
+
escapeHtml,
|
|
172
|
+
$: (sel) => documentObj.querySelector(sel),
|
|
173
|
+
fmtNum: (n) => String(n ?? 0),
|
|
174
|
+
fmtDate: (d) => String(d ?? '').slice(0, 10),
|
|
175
|
+
errCode: () => null, errDetails: () => ({}),
|
|
176
|
+
toast() {},
|
|
177
|
+
categoryChip: () => '',
|
|
178
|
+
fillKindFilter() {},
|
|
179
|
+
kindIsUnset: () => true, KIND_UNSET: 'unclassified',
|
|
180
|
+
roleAxisLabel: () => 'Role',
|
|
181
|
+
roleOptions: () => [{ value: '', label: '— any —' }],
|
|
182
|
+
roleIsUnset: () => true, roleLabel: () => '', ROLE_UNSET: 'unclassified',
|
|
183
|
+
},
|
|
184
|
+
location: { hostname: 'builders.example.com', pathname: '/work', search: '', hash: '', href: 'https://builders.example.com/work' },
|
|
185
|
+
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
|
|
186
|
+
confirm: () => true,
|
|
187
|
+
addEventListener() {},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const sandbox = {
|
|
191
|
+
window: windowObj, document: documentObj, location: windowObj.location,
|
|
192
|
+
localStorage: windowObj.localStorage, console, setTimeout, clearTimeout,
|
|
193
|
+
setInterval: () => 0, clearInterval() {}, Date, URL, URLSearchParams, navigator: {},
|
|
194
|
+
history: { replaceState() {} },
|
|
195
|
+
};
|
|
196
|
+
sandbox.globalThis = sandbox;
|
|
197
|
+
vm.createContext(sandbox);
|
|
198
|
+
// Both are UMD modules that attach to the sandbox global rather than to
|
|
199
|
+
// `window` (there is no `module` here), so they are bridged across by hand.
|
|
200
|
+
vm.runInContext(MINE_LENS_JS, sandbox, { filename: 'mine-lens.js' });
|
|
201
|
+
vm.runInContext(WORK_SUMMARY_JS, sandbox, { filename: 'work-summary.js' });
|
|
202
|
+
windowObj.OTBMineLens = sandbox.OTBMineLens;
|
|
203
|
+
windowObj.OTBWorkSummary = sandbox.OTBWorkSummary;
|
|
204
|
+
vm.runInContext(WORK_JS, sandbox, { filename: 'work.js' });
|
|
205
|
+
(documentObj.listeners.DOMContentLoaded || []).forEach((fn) => fn());
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
el,
|
|
209
|
+
requests,
|
|
210
|
+
// Flip the lens the way a click does: set the box, then fire its listener.
|
|
211
|
+
setMine: (on) => { el('f-mine').checked = on; el('f-mine').fire('change'); },
|
|
212
|
+
switchTab: (id) => { if (tabsOnChange) tabsOnChange(id); },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// settle() twice: loadAll awaits /me, then the goals read, then the claimable
|
|
217
|
+
// read, and the history feed lands on its own promise chain.
|
|
218
|
+
const ready = async (board) => { for (let i = 0; i < 6; i += 1) await settle(); return board; };
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// 1. The queue tabs: "mine" is GOAL MEMBERSHIP
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
test('Ready narrows to tasks in a goal the viewer is a member of', async () => {
|
|
225
|
+
const b = await ready(bootWorkBoard());
|
|
226
|
+
const before = b.el('task-list').innerHTML;
|
|
227
|
+
assert.match(before, /ready in my goal/, 'the unfiltered board shows both goals');
|
|
228
|
+
assert.match(before, /ready in their goal/);
|
|
229
|
+
|
|
230
|
+
b.setMine(true);
|
|
231
|
+
const after = b.el('task-list').innerHTML;
|
|
232
|
+
assert.match(after, /ready in my goal/, 'the task in my goal survives');
|
|
233
|
+
assert.doesNotMatch(after, /ready in their goal/, 'the task in a goal I am not on is gone');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('a task with NO goal is not "mine" — the lens is membership, not leftovers', async () => {
|
|
237
|
+
const b = await ready(bootWorkBoard());
|
|
238
|
+
assert.match(b.el('task-list').innerHTML, /ready with no goal/, 'it is there unfiltered');
|
|
239
|
+
b.setMine(true);
|
|
240
|
+
assert.doesNotMatch(b.el('task-list').innerHTML, /ready with no goal/,
|
|
241
|
+
'an ungoaled task belongs to nobody, so it cannot arrive under "only mine"');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('Backlog narrows through the SAME predicate, so the lens cannot reach one tab only', async () => {
|
|
245
|
+
const b = await ready(bootWorkBoard());
|
|
246
|
+
assert.match(b.el('backlog-list').innerHTML, /backlog in their goal/);
|
|
247
|
+
b.setMine(true);
|
|
248
|
+
assert.match(b.el('backlog-list').innerHTML, /backlog in my goal/);
|
|
249
|
+
assert.doesNotMatch(b.el('backlog-list').innerHTML, /backlog in their goal/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// 2. In Progress: "mine" is THE CLAIM
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
test('In Progress narrows to the claims the viewer is holding', async () => {
|
|
257
|
+
const b = await ready(bootWorkBoard());
|
|
258
|
+
const tbody = b.el('wip-table tbody');
|
|
259
|
+
assert.match(tbody.innerHTML, /someone else holds this/, 'unfiltered, the whole board is in flight');
|
|
260
|
+
|
|
261
|
+
b.setMine(true);
|
|
262
|
+
assert.match(tbody.innerHTML, /a claim of mine/);
|
|
263
|
+
assert.doesNotMatch(tbody.innerHTML, /someone else holds this/,
|
|
264
|
+
"another builder's live claim is not mine");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('the in-flight SECTION pill counts what is shown; the TAB pill keeps the whole board', async () => {
|
|
268
|
+
// The distinction is the point: narrowing to my own claims must not make the
|
|
269
|
+
// board look idle. The tab pill is the project's in-flight count either way.
|
|
270
|
+
const b = await ready(bootWorkBoard());
|
|
271
|
+
assert.equal(b.el('wip-count').textContent, '2');
|
|
272
|
+
b.setMine(true);
|
|
273
|
+
assert.equal(b.el('wip-count').textContent, '1', 'the section pill follows the narrowed list');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('holding nothing says so, instead of reading as a quiet board', async () => {
|
|
277
|
+
const b = await ready(bootWorkBoard());
|
|
278
|
+
// Hand the page an in-flight list with none of the viewer's own claims by
|
|
279
|
+
// flipping the lens on a fixture where builder 7 holds one — then assert the
|
|
280
|
+
// OTHER direction: a viewer who holds nothing gets the specific sentence.
|
|
281
|
+
ACTIVE_CLAIMS[0].builder_id = 8;
|
|
282
|
+
try {
|
|
283
|
+
b.setMine(true);
|
|
284
|
+
assert.match(b.el('wip-table tbody').innerHTML, /You are not holding any claims right now/,
|
|
285
|
+
'the empty state names the lens, not the project');
|
|
286
|
+
} finally {
|
|
287
|
+
ACTIVE_CLAIMS[0].builder_id = 7;
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// 3. Completed: "mine" is WHO SHIPPED IT
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
test('Completed narrows to the viewer\'s own ships, matched on the login the public feed carries', async () => {
|
|
296
|
+
const b = await ready(bootWorkBoard());
|
|
297
|
+
assert.match(b.el('history-list').innerHTML, /they shipped this/);
|
|
298
|
+
b.setMine(true);
|
|
299
|
+
assert.match(b.el('history-list').innerHTML, /I shipped this/);
|
|
300
|
+
assert.doesNotMatch(b.el('history-list').innerHTML, /they shipped this/);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
// 4. The two ways the lens must refuse rather than lie
|
|
305
|
+
// ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
test('with no identity the lens is disabled — never a checkbox that empties every tab', async () => {
|
|
308
|
+
const b = await ready(bootWorkBoard({ signedIn: false }));
|
|
309
|
+
assert.equal(b.el('f-mine').disabled, true);
|
|
310
|
+
assert.equal(b.el('f-mine').checked, false);
|
|
311
|
+
assert.match(b.el('f-mine').title, /Sign in/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('a failed membership read disarms the lens instead of answering "you are on no goals"', async () => {
|
|
315
|
+
const b = await ready(bootWorkBoard({ goalsFail: true }));
|
|
316
|
+
assert.equal(b.el('f-mine').disabled, true,
|
|
317
|
+
'with no evidence the lens is off the table');
|
|
318
|
+
assert.match(b.el('f-mine').title, /could not be loaded/,
|
|
319
|
+
'and it says the read failed rather than implying an answer');
|
|
320
|
+
// The board itself is untouched — losing the lens must not lose the queue.
|
|
321
|
+
assert.match(b.el('task-list').innerHTML, /ready in their goal/);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// 5. The word means something different on each tab, so the hint is rewritten
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
test('the hint beside the checkbox is re-read from the tab, not left generic', async () => {
|
|
329
|
+
const b = await ready(bootWorkBoard());
|
|
330
|
+
const hint = b.el('work-mine-hint');
|
|
331
|
+
assert.equal(hint.textContent, 'tasks in goals you are on', 'Ready opens the board');
|
|
332
|
+
b.switchTab('in-progress');
|
|
333
|
+
assert.equal(hint.textContent, 'claims you are holding');
|
|
334
|
+
b.switchTab('completed');
|
|
335
|
+
assert.equal(hint.textContent, 'tasks you shipped');
|
|
336
|
+
b.switchTab('backlog');
|
|
337
|
+
assert.equal(hint.textContent, 'tasks in goals you are on');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// 6. It costs nothing to ask
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
test('the lens is answered from data the board already fetched — no read per goal', async () => {
|
|
345
|
+
const b = await ready(bootWorkBoard());
|
|
346
|
+
const before = b.requests.length;
|
|
347
|
+
b.setMine(true);
|
|
348
|
+
assert.equal(b.requests.length, before,
|
|
349
|
+
'flipping the lens makes no request: viewer_is_member rides the goal list');
|
|
350
|
+
assert.ok(b.requests.some((r) => r.endsWith('/goals')),
|
|
351
|
+
'and the goal list is the one read it depends on');
|
|
352
|
+
assert.ok(!b.requests.some((r) => /\/goals\/\d+\/members/.test(r)),
|
|
353
|
+
'never a membership read per goal');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// 7. The carve: work-summary.js
|
|
358
|
+
//
|
|
359
|
+
// The momentum card left work.js so the lens could fit under the size ceiling.
|
|
360
|
+
// Two things have to stay true across that move — it still paints, and it still
|
|
361
|
+
// reports the PROJECT, not the viewer. The second is the risk the carve created:
|
|
362
|
+
// the card sits directly above a list the lens narrows, and a card that quietly
|
|
363
|
+
// restated one builder's output as the project's totals would look right.
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
test('the momentum card still renders the project-wide figures after the carve', async () => {
|
|
367
|
+
const b = await ready(bootWorkBoard());
|
|
368
|
+
const card = b.el('works-summary');
|
|
369
|
+
assert.equal(card.hidden, false, 'the card is shown once the shipped counts land');
|
|
370
|
+
assert.match(card.innerHTML, /Works shipped/);
|
|
371
|
+
assert.match(card.innerHTML, /42/, 'the all-time shipped count');
|
|
372
|
+
assert.match(card.innerHTML, /1234/, 'and the credits awarded');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('"Only mine" does NOT narrow the project totals above the list it narrows', async () => {
|
|
376
|
+
const b = await ready(bootWorkBoard());
|
|
377
|
+
const before = b.el('works-summary').innerHTML;
|
|
378
|
+
b.setMine(true);
|
|
379
|
+
assert.equal(b.el('works-summary').innerHTML, before,
|
|
380
|
+
'the card reports the project; the lens reports the reader');
|
|
381
|
+
});
|
|
@@ -65,6 +65,15 @@ function boot({
|
|
|
65
65
|
// `{ outcome: null }`, which is the server saying "nothing to state yet"; the
|
|
66
66
|
// line must go quiet for both, and there is a case below for each.
|
|
67
67
|
landing = undefined,
|
|
68
|
+
// OUT-OF-ORDER READS (task 1004108). Names of fakes whose answers are PARKED
|
|
69
|
+
// instead of returned: the call is recorded and its response computed at once,
|
|
70
|
+
// but the caller's `await` does not resolve until the test releases it. That is
|
|
71
|
+
// the only way two reads of the same endpoint are ever in flight together here
|
|
72
|
+
// — every other fake is an `async` function that settles before the next call
|
|
73
|
+
// is made, so a monotonic-sequence guard sits on an unreachable branch and
|
|
74
|
+
// deleting it reds nothing. Pass e.g. `defer: ['preview']` and drive
|
|
75
|
+
// `r.deferred` in whatever order the race being pinned requires.
|
|
76
|
+
defer = [],
|
|
68
77
|
} = {}) {
|
|
69
78
|
const els = new Map();
|
|
70
79
|
const makeEl = () => ({
|
|
@@ -88,6 +97,21 @@ function boot({
|
|
|
88
97
|
};
|
|
89
98
|
|
|
90
99
|
const calls = [];
|
|
100
|
+
|
|
101
|
+
// Parked calls, in REQUEST order. Each carries the name it was made under, the
|
|
102
|
+
// arguments it was made with, and `release()` — which hands the caller the
|
|
103
|
+
// answer the fake had already computed. Releasing out of order is the point.
|
|
104
|
+
const deferred = [];
|
|
105
|
+
const deferSet = new Set(defer);
|
|
106
|
+
const gate = (name, fn) => (...args) => {
|
|
107
|
+
const answer = fn(...args); // record the call and compute the reply NOW...
|
|
108
|
+
if (!deferSet.has(name)) return answer;
|
|
109
|
+
let release;
|
|
110
|
+
const held = new Promise((resolve) => { release = resolve; });
|
|
111
|
+
deferred.push({ name, args, release: () => release(answer) }); // ...hand it over LATER
|
|
112
|
+
return held;
|
|
113
|
+
};
|
|
114
|
+
|
|
91
115
|
const client = {
|
|
92
116
|
inbox: {
|
|
93
117
|
getInboxTemplate: async () => ({ status: 200, ok: true, data: { template: TEMPLATE } }),
|
|
@@ -117,7 +141,7 @@ function boot({
|
|
|
117
141
|
// real matrix for the fixtures this file already files with: a goal named
|
|
118
142
|
// ⇒ it lands there, no goal ⇒ it floats. A test that cares about a
|
|
119
143
|
// particular verdict passes `landing` and overrides the lot.
|
|
120
|
-
postInboxPreviewLanding: async (a) => {
|
|
144
|
+
postInboxPreviewLanding: gate('preview', async (a) => {
|
|
121
145
|
calls.push(['preview', a]);
|
|
122
146
|
if (landing === null) return { status: 500, ok: false, data: null };
|
|
123
147
|
if (landing !== undefined) return { status: 200, ok: true, data: landing };
|
|
@@ -127,7 +151,7 @@ function boot({
|
|
|
127
151
|
return { status: 200, ok: true, data: { outcome: 'routed', reason: 'bug_trusted_filer', status: 'ready', goal: { id: String(gid), title: g ? g.title : `goal ${gid}` }, version_id: 'BV1', refusal: null } };
|
|
128
152
|
}
|
|
129
153
|
return { status: 200, ok: true, data: { outcome: 'floats', reason: 'no_goal', status: null, goal: null, version_id: null, refusal: null } };
|
|
130
|
-
},
|
|
154
|
+
}),
|
|
131
155
|
},
|
|
132
156
|
// task 1003069 (R08) — the quick-filing goal picker's reads: the building
|
|
133
157
|
// versions (to scope the suggester) and the open goal list (the picker's
|
|
@@ -137,7 +161,7 @@ function boot({
|
|
|
137
161
|
},
|
|
138
162
|
goals: {
|
|
139
163
|
getGoals: async () => { calls.push(['goals', null]); return { status: 200, ok: true, data: { goals } }; },
|
|
140
|
-
getGoalsSuggestions: async (a) => { calls.push(['suggest', a]); return { status: 200, ok: true, data: { suggestions } }; },
|
|
164
|
+
getGoalsSuggestions: gate('suggest', async (a) => { calls.push(['suggest', a]); return { status: 200, ok: true, data: { suggestions } }; }),
|
|
141
165
|
},
|
|
142
166
|
};
|
|
143
167
|
|
|
@@ -173,7 +197,7 @@ function boot({
|
|
|
173
197
|
|
|
174
198
|
const el = (id) => documentObj.getElementById(id);
|
|
175
199
|
return {
|
|
176
|
-
calls, toasts, store, el,
|
|
200
|
+
calls, toasts, store, el, deferred,
|
|
177
201
|
queueHtml: () => el('fi-queue').innerHTML,
|
|
178
202
|
fill: () => { for (const f of TEMPLATE.fields) el(`fi-f-${f.key}`).value = `an answer for ${f.key} long enough to count`; },
|
|
179
203
|
submit: () => el('fi-form')._handlers.submit({ preventDefault() {} }),
|
|
@@ -678,11 +702,67 @@ await test('filing clears the line — gravity describes a draft, and the draft
|
|
|
678
702
|
assert.equal(r.el('fq-receipts').hidden, false, 'the RECEIPT is what survives instead (task 1004082)');
|
|
679
703
|
});
|
|
680
704
|
|
|
705
|
+
// --- out-of-order reads (task 1004108) -----------------------------------
|
|
706
|
+
//
|
|
707
|
+
// Everything above this line drives fakes that settle before the next call is
|
|
708
|
+
// made, so `refreshGravity`'s monotonic `gravitySeq` guard sat on a branch no
|
|
709
|
+
// case could reach: deleting it redded NOTHING, which the closing note below
|
|
710
|
+
// used to record as an honest gap. `boot({ defer: [...] })` parks a named fake's
|
|
711
|
+
// answer until the test hands it back, so two reads are genuinely in flight at
|
|
712
|
+
// once and the slower one can land last.
|
|
713
|
+
|
|
714
|
+
await test('two previews are genuinely in flight at once — the harness can interleave them', async () => {
|
|
715
|
+
// The non-vacuity guard for the case beneath it. If `defer` ever stopped
|
|
716
|
+
// parking, the race case below would still pass — it would just be testing two
|
|
717
|
+
// sequential reads again, which is the exact hole this pair exists to close.
|
|
718
|
+
const r = boot({ defer: ['preview'] });
|
|
719
|
+
await settle(); await settle();
|
|
720
|
+
r.el('fq-title').value = 'a loose thought';
|
|
721
|
+
r.el('fq-goal')._handlers.change();
|
|
722
|
+
await settle(); await settle();
|
|
723
|
+
assert.equal(r.deferred.length, 1, 'the first read is parked, not answered');
|
|
724
|
+
assert.equal(r.el('fq-gravity').textContent, '', 'and nothing is painted while it is in flight');
|
|
725
|
+
r.el('fq-goal').value = '77';
|
|
726
|
+
r.el('fq-goal')._handlers.change();
|
|
727
|
+
await settle(); await settle();
|
|
728
|
+
assert.equal(r.deferred.length, 2, 'the second read starts while the first is still unanswered');
|
|
729
|
+
assert.deepEqual(r.deferred.map((d) => d.name), ['preview', 'preview']);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
await test('a slow preview answering after a faster one never repaints the stale verdict', async () => {
|
|
733
|
+
// The race a builder hits by picking a goal and then changing their mind
|
|
734
|
+
// faster than the server answers. The FIRST request (no goal ⇒ "floats") comes
|
|
735
|
+
// back LAST; the line must keep the SECOND request's verdict.
|
|
736
|
+
const r = boot({ defer: ['preview'] });
|
|
737
|
+
await settle(); await settle();
|
|
738
|
+
r.el('fq-title').value = 'the widget is broken';
|
|
739
|
+
r.el('fq-goal').value = '';
|
|
740
|
+
r.el('fq-goal')._handlers.change();
|
|
741
|
+
await settle();
|
|
742
|
+
r.el('fq-goal').value = '77';
|
|
743
|
+
r.el('fq-goal')._handlers.change();
|
|
744
|
+
await settle();
|
|
745
|
+
assert.equal(r.deferred.length, 2, 'both reads are in flight');
|
|
746
|
+
assert.equal(r.deferred[0].args[0].body.goal_id, undefined, 'the first asked with no goal');
|
|
747
|
+
assert.equal(r.deferred[1].args[0].body.goal_id, 77, 'the second asked with the goal picked');
|
|
748
|
+
|
|
749
|
+
r.deferred[1].release(); // the NEWER answer lands first
|
|
750
|
+
await settle(); await settle();
|
|
751
|
+
assert.match(r.el('fq-gravity').textContent, /Goal Seventy Seven/, 'the current verdict paints');
|
|
752
|
+
|
|
753
|
+
r.deferred[0].release(); // and now the older, slower one arrives
|
|
754
|
+
await settle(); await settle();
|
|
755
|
+
assert.match(r.el('fq-gravity').textContent, /Goal Seventy Seven/, 'the stale answer does not repaint the line');
|
|
756
|
+
assert.doesNotMatch(r.el('fq-gravity').textContent, /floats in the inbox/, 'the superseded verdict never reaches the builder');
|
|
757
|
+
assert.equal(r.el('fq-gravity').dataset.tone, 'lands');
|
|
758
|
+
});
|
|
759
|
+
|
|
681
760
|
// Every guard above was reverted and each redded its named case (RUN, and these
|
|
682
761
|
// are the measured counts, not the predicted ones):
|
|
683
762
|
// drop renderGravity from the goal-change handler -> 2 red (named-goal + filing-clears)
|
|
684
763
|
// infer from the select instead of the verdict -> 4 red (both lies, + backlog + refusal)
|
|
685
|
-
// drop the seq guard and paint every answer ->
|
|
764
|
+
// drop the seq guard and paint every answer -> 1 red (the race case; task 1004108)
|
|
765
|
+
// make boot's `defer` never park (gate returns at once) -> 2 red (both race cases)
|
|
686
766
|
// rename the id in ideas.html -> 1 red (the markup pin ONLY)
|
|
687
767
|
//
|
|
688
768
|
// THE FIRST LINE COST A TEST FIX, and it is the reason to run these rather than
|
|
@@ -692,13 +772,22 @@ await test('filing clears the line — gravity describes a draft, and the draft
|
|
|
692
772
|
// rendered at all. It asserts on the TEXT now. A stub's defaults can agree with
|
|
693
773
|
// a broken page, which is the same lesson the markup pin below teaches twice.
|
|
694
774
|
//
|
|
695
|
-
// THE THIRD LINE
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
775
|
+
// THE THIRD LINE WAS AN HONEST GAP UNTIL TASK 1004108, and the shape of the fix
|
|
776
|
+
// is worth keeping: the guard was not wrong, it was UNREACHABLE. Every fake here
|
|
777
|
+
// was an `async` function that settled before the next call was made, so two
|
|
778
|
+
// previews could never overlap and deleting `if (seq !== gravitySeq) return;`
|
|
779
|
+
// redded nothing — a green suite over a branch no case could enter. `boot({
|
|
780
|
+
// defer: ['preview'] })` parks a named fake's answer until the test hands it
|
|
781
|
+
// back, which makes the overlap real and the guard a tested property. The FOURTH
|
|
782
|
+
// line above is the guard on the guard: if `defer` ever stops parking, the race
|
|
783
|
+
// case would quietly go back to testing two sequential reads, so a case asserts
|
|
784
|
+
// that both calls are in flight before the ordering is asserted at all.
|
|
785
|
+
//
|
|
786
|
+
// `getGoalsSuggestions` is gated the same way and for the same reason, but has
|
|
787
|
+
// NO case yet: `refreshGoalSuggestions` carries no sequence guard — only
|
|
788
|
+
// `goalTouchedByUser` — so a test of its ordering would red against the current
|
|
789
|
+
// page rather than pin it. That is a production bug to file, not a test to
|
|
790
|
+
// write, and the harness is now able to reach it the day it is fixed.
|
|
702
791
|
|
|
703
792
|
console.log(`\nidea_spark_hall: ${passed} passed, ${failed} failed`);
|
|
704
793
|
if (failed > 0) process.exit(1);
|
|
@@ -38,16 +38,26 @@ const TESTS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
38
38
|
// right all along. The apex flip removed the redirect, so the test passes and
|
|
39
39
|
// the entry had to go in the same change — this file fails the suite the moment
|
|
40
40
|
// a parked test starts passing.
|
|
41
|
-
|
|
41
|
+
//
|
|
42
|
+
// Ratcheted down 1 → 0 by task 1002617, which unparked served_paths_shipped — the last
|
|
43
|
+
// entry. Its own note here had PREDICTED the resolution ("whoever lands that claim should
|
|
44
|
+
// re-check this assertion and remove this entry"); the claim landed (task 1002200, the
|
|
45
|
+
// self-healing /docs/openapi.json endpoint) and the entry outlived it by months. That is
|
|
46
|
+
// the graveyard this file exists to prevent, and it took this ratchet going red to close
|
|
47
|
+
// the loop — which is the ratchet working, just later than it should have.
|
|
48
|
+
//
|
|
49
|
+
// AT ZERO, this file now asserts something stronger than a cap: that NOTHING is parked.
|
|
50
|
+
// Parking the next failure means raising this number in the same diff, which is exactly
|
|
51
|
+
// the deliberate, reviewable act it is meant to be.
|
|
52
|
+
const MAX_KNOWN_FAILING = 0;
|
|
42
53
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
};
|
|
54
|
+
// EMPTY — nothing is parked, and that is the intended steady state.
|
|
55
|
+
//
|
|
56
|
+
// An entry here is invisible coverage: the test still runs, but its failure is expected,
|
|
57
|
+
// so nobody looks at it. The last one sat for months while the very change it was waiting
|
|
58
|
+
// on had already landed. If you add one, give it an owner and a concrete condition for
|
|
59
|
+
// removal, and raise MAX_KNOWN_FAILING in the same diff.
|
|
60
|
+
const KNOWN_FAILING = {};
|
|
51
61
|
|
|
52
62
|
function runsRed(name) {
|
|
53
63
|
try {
|
|
@@ -103,7 +103,11 @@ test('the landing page and the hub footers link privacy, terms and contact', ()
|
|
|
103
103
|
for (const href of ['/privacy', '/terms', '/contact']) {
|
|
104
104
|
assert.match(foot, new RegExp(`<a href="${href}">`), `${file}: footer links ${href}`);
|
|
105
105
|
}
|
|
106
|
-
|
|
106
|
+
// task 1003920 split one link into two: the WORDS now open the published code and the licence
|
|
107
|
+
// sits behind its own link beside it, so the claim can be checked by whoever clicks it. Both
|
|
108
|
+
// halves are asserted — a bare /Open source/ would pass again the moment the licence link went.
|
|
109
|
+
assert.match(foot, /<a href="https:\/\/www\.npmjs\.com\/package\/@bongos\/core"[^>]*>Open source<\/a>/, `${file}: the words open the actual code`);
|
|
110
|
+
assert.match(foot, /<a href="https:\/\/www\.gnu\.org\/licenses\/agpl-3\.0\.html"[^>]*>AGPL-3\.0<\/a>/, `${file}: the licence link stays`);
|
|
107
111
|
assert.match(foot, /Built by agents/, `${file}: the attribution half stays`);
|
|
108
112
|
}
|
|
109
113
|
});
|
|
@@ -474,16 +474,22 @@ test('decision 9 — only mouse-driven focus on a wizard heading goes ringless',
|
|
|
474
474
|
});
|
|
475
475
|
|
|
476
476
|
test('decision 10 — both footers carry the open-source (AGPL-3.0) link, in link ink', () => {
|
|
477
|
-
|
|
477
|
+
// task 1003920 split this into TWO links: the words "Open source" open the published package and
|
|
478
|
+
// the licence sits behind its own link beside it. Both are asserted, and CODE is anchored first
|
|
479
|
+
// because it is the half 1003920 added and the half nothing else in the repo pins.
|
|
480
|
+
const CODE = /<a href="https:\/\/www\.npmjs\.com\/package\/@bongos\/core" target="_blank" rel="noopener">Open source<\/a>/;
|
|
481
|
+
const AGPL = /\(<a href="https:\/\/www\.gnu\.org\/licenses\/agpl-3\.0\.html" target="_blank" rel="noopener">AGPL-3\.0<\/a>\)\./;
|
|
478
482
|
const hubFoot = (HUB.match(/<footer class="foot">[\s\S]*?<\/footer>/) || [''])[0];
|
|
479
483
|
// task 1003298: the chrome-world landing's footer is a real <footer>; the
|
|
480
484
|
// pre-world page used <div class="foot">. Either element is the footer.
|
|
481
485
|
const indexFoot = (INDEX.match(/<footer(?:\s[^>]*)?>[\s\S]*?<\/footer>|<div class="foot">[\s\S]*?<\/div>/) || [''])[0];
|
|
482
|
-
assert.match(hubFoot,
|
|
483
|
-
assert.match(indexFoot,
|
|
484
|
-
|
|
485
|
-
assert.
|
|
486
|
-
|
|
486
|
+
assert.match(hubFoot, CODE, 'projects.html: the words open the actual code');
|
|
487
|
+
assert.match(indexFoot, CODE, 'index.html: the words open the actual code');
|
|
488
|
+
assert.match(hubFoot, AGPL, 'projects.html: the licence sits beside it');
|
|
489
|
+
assert.match(indexFoot, AGPL, 'index.html: the licence sits beside it');
|
|
490
|
+
// both links sit OUTSIDE the apex-only span, so a child instance keeps them too
|
|
491
|
+
assert.ok(hubFoot.indexOf('<!--apex-only:end-->') < hubFoot.search(CODE), 'projects.html: after the apex-only block');
|
|
492
|
+
assert.ok(indexFoot.indexOf('<!--apex-only:end-->') < indexFoot.search(CODE), 'index.html: after the apex-only block');
|
|
487
493
|
assert.match(COSMOS, /\.foot a\{color:var\(--link-ink\);font-weight:700;text-decoration:none;\}/, 'never the browser’s blue');
|
|
488
494
|
});
|
|
489
495
|
|