@bongos/core 1.19.578 → 1.19.580
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 +68 -38
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md +4 -1
- package/docs/adr/0259-a-projects-departure-from-the-public-list-is-public.md +128 -0
- package/docs/adr/0260-the-application-is-the-consent-and-the-echo-is-the-gate.md +146 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +73 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +23 -21
- package/docs/copy-registry.json +51 -33
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/oversight.css +7 -0
- package/modules/hall-ui/public/watch.js +75 -0
- package/modules/onboarding/routes/access-requests.js +56 -1
- package/modules/platform-identity/applicant-profile.js +158 -0
- package/modules/platform-identity/application-echo.js +50 -0
- package/modules/platform-identity/recruiter-sliver.js +19 -0
- package/modules/platform-identity/routes/sso.js +126 -0
- package/modules/public-landing/public/projects.html +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth-admission.js +61 -0
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +11 -1
- package/tests/adr_renumber_integrity.mjs +84 -0
- package/tests/applicant_profile_boundary.mjs +641 -0
- package/tests/applicant_profile_read_bounds.mjs +95 -0
- package/tests/module_api.mjs +1 -0
- package/tests/projects_hub.mjs +24 -0
- package/tests/recruiter_sliver_boundary.mjs +35 -2
- package/tests/watch_applications_queue.mjs +94 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// tests/applicant_profile_read_bounds.mjs — the one hub call that FANS OUT is
|
|
2
|
+
// bounded in time (task 1003681, from a grader finding).
|
|
3
|
+
//
|
|
4
|
+
// THE DEFECT. `readApplicantProfileFromHub()` had no timeout on its fetch to the
|
|
5
|
+
// hub. Its own doc-comment already promised the outcome — "a queue that fails to
|
|
6
|
+
// load because the hub is slow is a broken one" — but nothing enforced it.
|
|
7
|
+
//
|
|
8
|
+
// WHY IT MATTERS MORE HERE THAN FOR ITS TWO SIBLINGS. reportMembershipCheckIn
|
|
9
|
+
// and reportActivityRollup fire at most once per sign-in or per ship. This one
|
|
10
|
+
// runs up to MAX_LIVE_PROFILE_READS (25) times CONCURRENTLY inside the
|
|
11
|
+
// Promise.all that `GET /access-requests` awaits before responding (D7 resolves
|
|
12
|
+
// the profile live on every render, deliberately — a snapshot would keep
|
|
13
|
+
// publishing a consent since withdrawn). So one unresponsive hub held the whole
|
|
14
|
+
// reviewer queue open for as long as the sockets lived, 25 at a time.
|
|
15
|
+
//
|
|
16
|
+
// The fix is a bound, not a behaviour change: every other failure mode here
|
|
17
|
+
// already answers `null`, so a timeout simply joins them.
|
|
18
|
+
//
|
|
19
|
+
// DB-free and network-free: auth-config is pinned in require.cache so the
|
|
20
|
+
// federation config resolves without env, and global fetch is a spy.
|
|
21
|
+
//
|
|
22
|
+
// Run: node --test --test-reporter=tap tests/applicant_profile_read_bounds.mjs
|
|
23
|
+
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
|
|
34
|
+
// Pin the federation config BEFORE auth-admission loads, so loadIdpConfig()
|
|
35
|
+
// answers without HUB_* env (the tests/profile_route.mjs require.cache idiom).
|
|
36
|
+
const cfgPath = require.resolve('../src/bongos/auth-config.js');
|
|
37
|
+
const realCfg = require('../src/bongos/auth-config.js');
|
|
38
|
+
require.cache[cfgPath] = {
|
|
39
|
+
id: cfgPath,
|
|
40
|
+
filename: cfgPath,
|
|
41
|
+
loaded: true,
|
|
42
|
+
exports: {
|
|
43
|
+
...realCfg,
|
|
44
|
+
loadIdpConfig: () => ({ origin: 'https://hub.example', clientId: 'cid', clientSecret: 'sec' }),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const admission = require('../src/bongos/auth-admission.js');
|
|
49
|
+
|
|
50
|
+
function withFetch(impl, run) {
|
|
51
|
+
const real = globalThis.fetch;
|
|
52
|
+
globalThis.fetch = impl;
|
|
53
|
+
return Promise.resolve(run()).finally(() => { globalThis.fetch = real; });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test('the hub read carries an abort signal — the queue can never wait on it forever', async () => {
|
|
57
|
+
// Arrange
|
|
58
|
+
let seen = null;
|
|
59
|
+
const ok = { ok: true, json: async () => ({ profile: { bio: 'hi' } }) };
|
|
60
|
+
|
|
61
|
+
// Act
|
|
62
|
+
const profile = await withFetch(async (_url, init) => { seen = init; return ok; },
|
|
63
|
+
() => admission.readApplicantProfileFromHub(42));
|
|
64
|
+
|
|
65
|
+
// Assert
|
|
66
|
+
assert.deepEqual(profile, { bio: 'hi' }, 'the happy path still returns the hub’s view');
|
|
67
|
+
assert.ok(seen, 'fetch was called');
|
|
68
|
+
assert.ok(seen.signal, 'an AbortSignal must be passed — without one the call is unbounded');
|
|
69
|
+
assert.equal(typeof seen.signal.aborted, 'boolean', 'and it must be a real AbortSignal');
|
|
70
|
+
assert.equal(seen.signal.aborted, false, 'not already aborted when the request goes out');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a timed-out hub answers null, exactly like every other failure here', async () => {
|
|
74
|
+
// Arrange — what AbortSignal.timeout produces when the budget expires
|
|
75
|
+
const abortErr = Object.assign(new Error('This operation was aborted'), { name: 'TimeoutError' });
|
|
76
|
+
|
|
77
|
+
// Act
|
|
78
|
+
const profile = await withFetch(async () => { throw abortErr; },
|
|
79
|
+
() => admission.readApplicantProfileFromHub(42));
|
|
80
|
+
|
|
81
|
+
// Assert — a degraded queue, never a broken one
|
|
82
|
+
assert.equal(profile, null, 'an abort must fail soft to null, not reject into the route');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('the timeout is a real, small budget — a 25-wide fan-out cannot outlive it', () => {
|
|
86
|
+
// Structural: the constant is the bound the route inherits, so it is pinned
|
|
87
|
+
// rather than measured (a real 5s wait would make this suite sleep).
|
|
88
|
+
const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth-admission.js'), 'utf8');
|
|
89
|
+
const m = /const APPLICANT_PROFILE_TIMEOUT_MS = (\d+);/.exec(src);
|
|
90
|
+
assert.ok(m, 'the budget must be a named constant, not an inline number');
|
|
91
|
+
const ms = Number(m[1]);
|
|
92
|
+
assert.ok(ms > 0 && ms <= 10000, `the budget must be small and real (got ${ms}ms)`);
|
|
93
|
+
assert.match(src, /signal: AbortSignal\.timeout\(APPLICANT_PROFILE_TIMEOUT_MS\)/,
|
|
94
|
+
'and the fetch must actually use it — the constant alone bounds nothing');
|
|
95
|
+
});
|
package/tests/module_api.mjs
CHANGED
|
@@ -38,6 +38,7 @@ const PUBLISHED_SURFACE = [
|
|
|
38
38
|
'resolveRotDays', 'projectSettings', // added by task 1003279 (ADR 0232; goal 1000072): the ROT timer + the live knob store behind it. A DIFFERENT question from the stale-claim timer above — days not hours, DB not env, work nobody picked up rather than a builder who went quiet, and it only ever ASKS a human where that one ACTS. Both ride the doorway so no module hardcodes either.
|
|
39
39
|
'joinabilityMode', // added by task 1003044 (ADR 0194; the carrier after 1.19.374): the project's membership door, one reader for the sign-in gate AND the onboarding module's public POST
|
|
40
40
|
'projectJoinDoor', // added by task 1002331 (ADR 0182 D6 / ADR 0247): the COMPOSED join/apply door — visibility × join grant × joinability, one answer, so a module reading a single knob cannot re-open a door the other two closed
|
|
41
|
+
'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
|
|
41
42
|
'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
|
|
42
43
|
'registerProvider', 'hasProvider', 'resolve', 'resolveOptional', 'listPorts', 'verifyPortsSatisfied',
|
|
43
44
|
'on', 'emit', 'emitAsync', 'listEvents', 'seamSnapshot',
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -1543,6 +1543,30 @@ test('the join-door labels are PLAIN — the space metaphor is the orb’s, neve
|
|
|
1543
1543
|
assert.doesNotMatch(options, /unlisted/i, 'dark matter is DERIVED — there is deliberately no way to choose it');
|
|
1544
1544
|
});
|
|
1545
1545
|
|
|
1546
|
+
/* ADR 0259: the one thing Stealth does NOT hide is the switch itself. A project
|
|
1547
|
+
leaving the named list while the anonymous count rises names it to anyone
|
|
1548
|
+
holding yesterday's feed — inherent in publishing a named list, and the owner
|
|
1549
|
+
chose to accept it and say so rather than pay for a delay or decoys. The
|
|
1550
|
+
copy is the whole fix, so the copy is what gets pinned: without this, a
|
|
1551
|
+
future edit tightening the blurb drops the caveat and the page goes back to
|
|
1552
|
+
implying a guarantee the feed does not make. */
|
|
1553
|
+
test('the Stealth copy names its own limit — switching is visible, even though the project is not (ADR 0259)', () => {
|
|
1554
|
+
const options = (SCRIPT.match(/var DOOR_OPTIONS = \{[\s\S]*?\n \};/) || [''])[0];
|
|
1555
|
+
const fold = (HUB.match(/<details class="finePrint" id="mDoorWhat">[\s\S]*?<\/details>/) || [''])[0];
|
|
1556
|
+
assert.ok(options && fold, 'both the option table and the fold must be extractable');
|
|
1557
|
+
for (const [where, text] of [['the choice’s blurb', options], ['the what-changes fold', fold]]) {
|
|
1558
|
+
assert.match(text, /Switching to Stealth is itself visible/,
|
|
1559
|
+
`${where} states the boundary — a privacy control that oversells is worse than one that admits a limit`);
|
|
1560
|
+
assert.match(text, /comparing the map before and after can tell which project went quiet/,
|
|
1561
|
+
`${where} says HOW it is visible, so the owner can judge it rather than take our word`);
|
|
1562
|
+
}
|
|
1563
|
+
/* the plain-register rule this card is held to still binds the new sentence
|
|
1564
|
+
(the space metaphor is the drawn orb's, never the copy's) */
|
|
1565
|
+
for (const word of ['planet', 'star', 'black hole', 'dark matter', 'feed', 'snapshot']) {
|
|
1566
|
+
assert.doesNotMatch(options.toLowerCase(), new RegExp(`\\b${word}\\b`), `"${word}" must not reach a choice’s blurb`);
|
|
1567
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1546
1570
|
test('the join-door pick is a real radio group built from the server’s own options list', () => {
|
|
1547
1571
|
const render = fnHub('renderDoor');
|
|
1548
1572
|
assert.match(render, /\(s\.options \|\| \[\]\)\.filter\(function \(v\) \{ return !!DOOR_OPTIONS\[v\]; \}\)/,
|
|
@@ -803,6 +803,25 @@ test('each route spells its OWN gate in its OWN middleware list', () => {
|
|
|
803
803
|
// 7. IT GRANTS NOTHING (ADR 0016)
|
|
804
804
|
// ===========================================================================
|
|
805
805
|
|
|
806
|
+
// The first-party files allowed to read the sliver module, each with the reason
|
|
807
|
+
// it is not a DECIDER. The list is the assertion: a new name here is a claim
|
|
808
|
+
// that must be argued, not a line added to make a red test green.
|
|
809
|
+
//
|
|
810
|
+
// routes/scouting.js serializes the sliver onto its one archon-gated
|
|
811
|
+
// route. The original sole reader.
|
|
812
|
+
// applicant-profile.js reuses the PROJECTION for the D7 applicant view
|
|
813
|
+
// (task 1002972) and supplies its own consent floor —
|
|
814
|
+
// see sliverShapeFor's contract. Sharing the
|
|
815
|
+
// projection is what keeps D7's "no new disclosure
|
|
816
|
+
// class" literally true: the alternative was a second
|
|
817
|
+
// five-key object free to drift from this one field
|
|
818
|
+
// by field, which this whitelist proof would not
|
|
819
|
+
// cover. It reads the shape; it decides nothing.
|
|
820
|
+
const SLIVER_READERS = [
|
|
821
|
+
'modules/platform-identity/applicant-profile.js',
|
|
822
|
+
'modules/platform-identity/routes/scouting.js',
|
|
823
|
+
];
|
|
824
|
+
|
|
806
825
|
test('the sliver module is read by exactly one first-party server file — the one that serializes it', () => {
|
|
807
826
|
const files = [];
|
|
808
827
|
const walk = (dir) => {
|
|
@@ -817,8 +836,22 @@ test('the sliver module is read by exactly one first-party server file — the o
|
|
|
817
836
|
const readers = files
|
|
818
837
|
.filter((f) => /require\(\s*['"][^'"]*recruiter-sliver['"]\s*\)/.test(fs.readFileSync(f, 'utf8')))
|
|
819
838
|
.map((f) => path.relative(ROOT, f).split(path.sep).join('/'));
|
|
820
|
-
assert.deepEqual(readers,
|
|
821
|
-
'nothing may consult the sliver to DECIDE anything: cross-project numbers are descriptive analytics, never authority (ADR 0016)'
|
|
839
|
+
assert.deepEqual(readers, SLIVER_READERS,
|
|
840
|
+
'nothing may consult the sliver to DECIDE anything: cross-project numbers are descriptive analytics, never authority (ADR 0016). '
|
|
841
|
+
+ 'A new reader needs a stated reason in SLIVER_READERS — and it must serialize the shape, never branch on it.');
|
|
842
|
+
|
|
843
|
+
// The reason each allowance rests on, enforced rather than trusted: a reader
|
|
844
|
+
// may PROJECT the sliver, never READ A VALUE OUT OF IT to decide something. A
|
|
845
|
+
// comparison against a sliver field is what "authority" would look like here,
|
|
846
|
+
// so the shape of that mistake is what this refuses.
|
|
847
|
+
for (const rel of SLIVER_READERS) {
|
|
848
|
+
const code = fs.readFileSync(path.join(ROOT, rel), 'utf8')
|
|
849
|
+
.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
|
|
850
|
+
for (const key of SLIVER_KEYS) {
|
|
851
|
+
assert.doesNotMatch(code, new RegExp(`\\.${key}\\s*(?:===|!==|==|!=|>|<)`),
|
|
852
|
+
`${rel} compares a sliver's \`${key}\` — a reader that BRANCHES on this data is consulting descriptive analytics as authority (ADR 0016)`);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
822
855
|
});
|
|
823
856
|
|
|
824
857
|
test('no rank, role, permission or membership travels with the sliver, under any name', async () => {
|
|
@@ -378,4 +378,98 @@ await test('the section lede tells the owner the queue is more than one decision
|
|
|
378
378
|
assert.match(h.lede(), /decided/i);
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
+
// ===========================================================================
|
|
382
|
+
// The applicant's own profile beside their ask (task 1002972, privacy spec D7)
|
|
383
|
+
// ===========================================================================
|
|
384
|
+
//
|
|
385
|
+
// The SERVER decides what may be disclosed — tests/applicant_profile_boundary.mjs
|
|
386
|
+
// is that proof. What is asserted HERE is the half a reviewer actually sees: that
|
|
387
|
+
// the row draws whichever shape it was handed, draws NOTHING when handed null,
|
|
388
|
+
// and escapes the applicant's own words. A source-text assertion cannot see any
|
|
389
|
+
// of that; this runs the real watch.js over the real markup.
|
|
390
|
+
|
|
391
|
+
const PUBLIC_PROFILE = {
|
|
392
|
+
view: 'public',
|
|
393
|
+
account: {
|
|
394
|
+
handle: 'mona', github_login: 'octocat', display_name: 'Mona Lisa',
|
|
395
|
+
avatar_url: 'https://avatars.githubusercontent.com/u/583231?v=4',
|
|
396
|
+
member_since: '2025-01-01T00:00:00Z', bio: 'I draw maps.', links: null,
|
|
397
|
+
},
|
|
398
|
+
cross_project: {
|
|
399
|
+
totals: { projects: 3, credits: 4200, works_shipped: 41, karma: 7 },
|
|
400
|
+
disciplines: ['engineer', 'artist'], last_active: '2026-08-30T00:00:00Z',
|
|
401
|
+
projects: [], more_projects: 0,
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
const SLIVER_PROFILE = {
|
|
405
|
+
view: 'sliver',
|
|
406
|
+
sliver: { handle: 'quiet', display_name: 'Quiet Person', avatar_url: null, disciplines: ['engineer'], active_recently: true },
|
|
407
|
+
};
|
|
408
|
+
const withProfile = (profile, over = {}) => [{ ...WAITING[0], profile, ...over }];
|
|
409
|
+
|
|
410
|
+
await test('a PUBLIC applicant renders their record beside the ask', async () => {
|
|
411
|
+
const h = await boot({ rows: { pending: withProfile(PUBLIC_PROFILE), invited: [], dismissed: [] } });
|
|
412
|
+
const block_ = h.rows()[0].querySelector('.ov-row__profile');
|
|
413
|
+
assert.ok(block_, 'the public applicant view rendered no profile block at all');
|
|
414
|
+
const text = block_.textContent;
|
|
415
|
+
assert.match(text, /I draw maps\./, 'the bio is part of what the public page already shows');
|
|
416
|
+
assert.match(text, /3 projects/);
|
|
417
|
+
assert.match(text, /41 shipped/);
|
|
418
|
+
assert.match(text, /engineer/);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
await test('a PRIVATE applicant renders the sliver AND says why it is thin', async () => {
|
|
422
|
+
const h = await boot({ rows: { pending: withProfile(SLIVER_PROFILE), invited: [], dismissed: [] } });
|
|
423
|
+
const block_ = h.rows()[0].querySelector('.ov-row__profile');
|
|
424
|
+
assert.ok(block_, 'the sliver rendered no profile block');
|
|
425
|
+
const text = block_.textContent;
|
|
426
|
+
assert.match(text, /Private account/,
|
|
427
|
+
'a reviewer must not read the thinness as a poor record — it is a boundary, and applying is what opened even this much');
|
|
428
|
+
assert.match(text, /engineer/);
|
|
429
|
+
assert.match(text, /Active recently/);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
await test('hidden stats: a public applicant keeps their name and says the record is hidden', async () => {
|
|
433
|
+
const hidden = { ...PUBLIC_PROFILE, cross_project: null };
|
|
434
|
+
const h = await boot({ rows: { pending: withProfile(hidden), invited: [], dismissed: [] } });
|
|
435
|
+
const text = h.rows()[0].querySelector('.ov-row__profile').textContent;
|
|
436
|
+
assert.match(text, /hides their cross-project record/,
|
|
437
|
+
'a blank would read as "this applicant has done nothing", which is a different claim entirely');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
await test('NO profile is not an error — the row renders exactly as it always did', async () => {
|
|
441
|
+
const h = await boot({ rows: { pending: withProfile(null), invited: [], dismissed: [] } });
|
|
442
|
+
const row = h.rows()[0];
|
|
443
|
+
assert.equal(row.querySelector('.ov-row__profile'), null,
|
|
444
|
+
'null covers unvouched, no hub, hub down and hidden alike — none of them may draw a box');
|
|
445
|
+
assert.ok(row.querySelector('.fact-pill'), 'and the vouch pill still says whether anyone confirmed the name');
|
|
446
|
+
assert.match(row.textContent, /octocat/);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
await test('an unrecognised view shape draws nothing rather than guessing', async () => {
|
|
450
|
+
const h = await boot({ rows: { pending: withProfile({ view: 'everything', account: { bio: 'x' } }), invited: [], dismissed: [] } });
|
|
451
|
+
assert.equal(h.rows()[0].querySelector('.ov-row__profile'), null,
|
|
452
|
+
'a shape this page does not know is a shape it must not render — fail closed');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
await test('an applicant own words are ESCAPED, never linkified (the render contract)', async () => {
|
|
456
|
+
const nasty = {
|
|
457
|
+
...PUBLIC_PROFILE,
|
|
458
|
+
account: { ...PUBLIC_PROFILE.account, bio: '<img src=x onerror=alert(1)>ping http://evil.example' },
|
|
459
|
+
};
|
|
460
|
+
const h = await boot({ rows: { pending: withProfile(nasty), invited: [], dismissed: [] } });
|
|
461
|
+
const block_ = h.rows()[0].querySelector('.ov-row__profile');
|
|
462
|
+
assert.equal(block_.querySelectorAll('img').length, 0, 'a bio is user content on a page an Archon reads');
|
|
463
|
+
assert.equal(block_.querySelectorAll('a').length, 0, 'the contract says never linkify — an Archon must not be handed a one-click link a stranger wrote');
|
|
464
|
+
assert.match(block_.textContent, /evil\.example/, 'the text itself is still shown, just inert');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await test('the profile changes what the row SAYS, never what it offers (ADR 0016)', async () => {
|
|
468
|
+
const rich = await boot({ rows: { pending: withProfile(PUBLIC_PROFILE), invited: [], dismissed: [] } });
|
|
469
|
+
const bare = await boot({ rows: { pending: withProfile(null), invited: [], dismissed: [] } });
|
|
470
|
+
const actionsOf = (h) => [...h.rows()[0].querySelectorAll('button')].map((b) => b.textContent.trim()).sort();
|
|
471
|
+
assert.deepEqual(actionsOf(rich), actionsOf(bare),
|
|
472
|
+
'a rich record is not a reason to admit and an empty one is not a reason to refuse — the decisions must be identical');
|
|
473
|
+
});
|
|
474
|
+
|
|
381
475
|
summary();
|