@bongos/core 1.19.524 → 1.19.526
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 +40 -20
- package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +185 -0
- package/docs/adr/0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md +107 -0
- package/docs/adr/README.md +2 -0
- package/docs/copy-inventory.md +8 -9
- package/docs/copy-registry.json +9 -18
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/collab-lib.js +37 -13
- package/modules/hall-ui/public/collab.js +31 -8
- package/modules/lifecycle/cascade.js +238 -0
- package/modules/lifecycle/routes/lifecycle.js +25 -0
- package/modules/lifecycle/tests/cascade.mjs +197 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +60 -3
- package/tests/hall_collab_world.mjs +22 -3
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// modules/lifecycle/tests/cascade.mjs — the cross-role cascade (task 1003549 /
|
|
2
|
+
// goal 1000074, the artist's loop).
|
|
3
|
+
//
|
|
4
|
+
// The mechanism's whole value is that a ship in one role can generate work in
|
|
5
|
+
// another WITHOUT ever putting that generation in the ship's way. So the four
|
|
6
|
+
// properties the header of cascade.js promises are what these pin, and three of
|
|
7
|
+
// them are about what must NOT happen:
|
|
8
|
+
//
|
|
9
|
+
// 1. it never blocks the ship — every failure shape resolves to a report
|
|
10
|
+
// 2. it is idempotent — the same ship twice files one task
|
|
11
|
+
// 3. it says where it came from
|
|
12
|
+
// 4. it files at backlog, never ready
|
|
13
|
+
//
|
|
14
|
+
// The production table ships EMPTY (R04 writes the first rule), so the rules
|
|
15
|
+
// below are the test's OWN declarations — which is also the point: a mechanism
|
|
16
|
+
// proved by a rule its consumer has not written yet is proved against the
|
|
17
|
+
// contract rather than against one example.
|
|
18
|
+
//
|
|
19
|
+
// DB-free: the runner takes its three database calls as injected functions.
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import url from 'node:url';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
|
|
27
|
+
const require_ = createRequire(import.meta.url);
|
|
28
|
+
const cascade = require_(path.join(HERE, '..', 'cascade.js'));
|
|
29
|
+
|
|
30
|
+
let passed = 0;
|
|
31
|
+
let failed = 0;
|
|
32
|
+
async function test(name, fn) {
|
|
33
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
34
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log('\nlifecycle: a ship in one role generates work in another');
|
|
38
|
+
|
|
39
|
+
const shipped = (over = {}) => ({
|
|
40
|
+
id: '9001',
|
|
41
|
+
version_id: 'BONGOS-V1',
|
|
42
|
+
goal_id: '1000074',
|
|
43
|
+
title: 'R09 · Reword the hall empty states',
|
|
44
|
+
discipline: 'engineer',
|
|
45
|
+
kind: 'feature',
|
|
46
|
+
status: 'shipped',
|
|
47
|
+
...over,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// A rule of the shape R04 will write: an engineer's ship becomes an artist's task.
|
|
51
|
+
const REVIEW_RULE = {
|
|
52
|
+
key: 'test-artist-review',
|
|
53
|
+
discipline: 'artist',
|
|
54
|
+
when: (t) => t.discipline === 'engineer',
|
|
55
|
+
title: (t) => `Review the copy that shipped in task ${t.id}`,
|
|
56
|
+
body: (t) => `Look at what ${t.title} put in front of a person.`,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const stubs = (over = {}) => {
|
|
60
|
+
const created = [];
|
|
61
|
+
return {
|
|
62
|
+
created,
|
|
63
|
+
deps: {
|
|
64
|
+
rules: [REVIEW_RULE],
|
|
65
|
+
getTask: async () => shipped(),
|
|
66
|
+
findTaskBySourceRef: async () => null,
|
|
67
|
+
createTask: async (spec) => { created.push(spec); return { id: String(9100 + created.length) }; },
|
|
68
|
+
...over,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
await test('an engineer ship files a task FOR THE ARTIST — the push the queues never had', async () => {
|
|
74
|
+
const { created, deps } = stubs();
|
|
75
|
+
const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
76
|
+
|
|
77
|
+
assert.equal(report.errors.length, 0, report.errors.join(' | '));
|
|
78
|
+
assert.equal(created.length, 1);
|
|
79
|
+
const spec = created[0];
|
|
80
|
+
assert.equal(spec.discipline, 'artist', 'the generated work is for a DIFFERENT role than the ship');
|
|
81
|
+
assert.equal(report.created[0].discipline, 'artist');
|
|
82
|
+
assert.match(spec.title, /9001/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await test('the generated task files at backlog and inherits its goal and version', async () => {
|
|
86
|
+
const { created, deps } = stubs();
|
|
87
|
+
await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
88
|
+
const spec = created[0];
|
|
89
|
+
assert.equal(spec.status, 'backlog', 'generated work is a REQUEST — auto-filing claimable work mints claims nobody asked for');
|
|
90
|
+
assert.equal(spec.goalId, 1000074);
|
|
91
|
+
assert.equal(spec.versionId, 'BONGOS-V1');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await test('it says where it came from, in the ref and in the prose', async () => {
|
|
95
|
+
const { created, deps } = stubs();
|
|
96
|
+
await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
97
|
+
const spec = created[0];
|
|
98
|
+
assert.equal(spec.source, 'cascade');
|
|
99
|
+
assert.equal(spec.sourceRef, 'cascade-test-artist-review-9001');
|
|
100
|
+
assert.match(spec.description, /task 9001 shipped/);
|
|
101
|
+
assert.match(spec.description, /test-artist-review/);
|
|
102
|
+
assert.match(spec.description, /Look at what/, 'the rule\'s own body survives the preamble');
|
|
103
|
+
// NOT a subtask: cascaded work outlives its trigger and belongs to another role.
|
|
104
|
+
assert.equal(spec.parentTaskId, undefined);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await test('the same ship twice files ONE task — a reconciler re-sweep is a no-op', async () => {
|
|
108
|
+
const { created, deps } = stubs();
|
|
109
|
+
await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
110
|
+
assert.equal(created.length, 1);
|
|
111
|
+
|
|
112
|
+
// Second pass: the row now exists under the same (source, source_ref).
|
|
113
|
+
deps.findTaskBySourceRef = async ({ source, sourceRef }) => (
|
|
114
|
+
source === 'cascade' && sourceRef === 'cascade-test-artist-review-9001' ? { id: '9101' } : null
|
|
115
|
+
);
|
|
116
|
+
const again = await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
117
|
+
assert.equal(created.length, 1, 'no second row');
|
|
118
|
+
assert.equal(again.created.length, 0);
|
|
119
|
+
assert.deepEqual(again.skipped[0], { key: 'test-artist-review', taskId: '9101', reason: 'already filed' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('a rule that does not match files nothing, quietly', async () => {
|
|
123
|
+
const { created, deps } = stubs({ getTask: async () => shipped({ discipline: 'artist' }) });
|
|
124
|
+
const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
125
|
+
assert.equal(created.length, 0);
|
|
126
|
+
assert.equal(report.errors.length, 0, 'a non-match is not a problem');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// --- property 1: nothing here may reach the ship ----------------------------
|
|
130
|
+
|
|
131
|
+
await test('NOTHING a cascade does can fail the ship — every failure shape resolves', async () => {
|
|
132
|
+
const shapes = {
|
|
133
|
+
'when() throws': stubs({ rules: [{ ...REVIEW_RULE, when: () => { throw new Error('boom'); } }] }),
|
|
134
|
+
'title() throws': stubs({ rules: [{ ...REVIEW_RULE, title: () => { throw new Error('boom'); } }] }),
|
|
135
|
+
'createTask rejects': stubs({ createTask: async () => { throw new Error('db down'); } }),
|
|
136
|
+
'findTaskBySourceRef rejects': stubs({ findTaskBySourceRef: async () => { throw new Error('db down'); } }),
|
|
137
|
+
'getTask rejects': stubs({ getTask: async () => { throw new Error('db down'); } }),
|
|
138
|
+
'getTask returns nothing': stubs({ getTask: async () => null }),
|
|
139
|
+
'a malformed rule': stubs({ rules: [{ key: 'broken' }] }),
|
|
140
|
+
};
|
|
141
|
+
for (const [name, { created, deps }] of Object.entries(shapes)) {
|
|
142
|
+
const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
143
|
+
assert.ok(report && Array.isArray(report.errors), `${name}: returned a report`);
|
|
144
|
+
assert.ok(report.errors.length > 0, `${name}: and said what went wrong`);
|
|
145
|
+
assert.equal(created.length, 0, `${name}: filed nothing`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The payload itself being junk is the same story.
|
|
149
|
+
for (const junk of [null, undefined, {}, { id: null }]) {
|
|
150
|
+
const { deps } = stubs();
|
|
151
|
+
const report = await cascade.runCascadesForShip(junk, deps);
|
|
152
|
+
assert.ok(report.errors.length > 0, `${JSON.stringify(junk)}: reported rather than threw`);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('one broken rule costs only its own task', async () => {
|
|
157
|
+
const good = { ...REVIEW_RULE, key: 'good-one' };
|
|
158
|
+
const bad = { ...REVIEW_RULE, key: 'bad-one', when: () => { throw new Error('boom'); } };
|
|
159
|
+
const { created, deps } = stubs({ rules: [bad, good] });
|
|
160
|
+
const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
161
|
+
assert.equal(created.length, 1, 'the healthy rule still filed');
|
|
162
|
+
assert.equal(created[0].sourceRef, 'cascade-good-one-9001');
|
|
163
|
+
assert.equal(report.errors.length, 1);
|
|
164
|
+
assert.match(report.errors[0], /bad-one/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// --- the declaration itself --------------------------------------------------
|
|
168
|
+
|
|
169
|
+
await test('the production table is valid, and a duplicate key is refused', () => {
|
|
170
|
+
assert.deepEqual(cascade.validateCascades(), [], 'the shipped CASCADES table must be well-formed');
|
|
171
|
+
assert.ok(Array.isArray(cascade.CASCADES));
|
|
172
|
+
assert.equal(Object.isFrozen(cascade.CASCADES), true);
|
|
173
|
+
|
|
174
|
+
// Two rules sharing a key would collide on source_ref and silently suppress
|
|
175
|
+
// one another — the failure that is invisible in production.
|
|
176
|
+
const errs = cascade.validateCascades([REVIEW_RULE, { ...REVIEW_RULE }]);
|
|
177
|
+
assert.equal(errs.length, 1);
|
|
178
|
+
assert.match(errs[0], /duplicate key/);
|
|
179
|
+
|
|
180
|
+
// A rule missing any required field is named, field by field.
|
|
181
|
+
const shape = cascade.assertRuleShape({ key: 'x' });
|
|
182
|
+
assert.ok(shape.some((p) => /discipline/.test(p)));
|
|
183
|
+
assert.ok(shape.some((p) => /when/.test(p)));
|
|
184
|
+
assert.ok(shape.some((p) => /title/.test(p)));
|
|
185
|
+
assert.ok(shape.some((p) => /body/.test(p)));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await test('the production table ships EMPTY, deliberately — R04 writes the first rule', () => {
|
|
189
|
+
// If this fails because a rule was added, that is fine: replace the assertion
|
|
190
|
+
// with one that pins the new rule's key and discipline. It exists so the
|
|
191
|
+
// emptiness is a DECISION on the record (see cascade.js's header) rather than
|
|
192
|
+
// something nobody noticed.
|
|
193
|
+
assert.equal(cascade.CASCADES.length, 0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
197
|
+
process.exit(failed ? 1 : 0);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.526",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.526",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.526",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.526'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/collab_page.mjs
CHANGED
|
@@ -97,10 +97,67 @@ test('the page NEVER sends a craft — matching is the server\'s job', () => {
|
|
|
97
97
|
});
|
|
98
98
|
|
|
99
99
|
test('a craft request and a by-name request read differently', () => {
|
|
100
|
+
// `addressedTo` is GONE (task 1003274) — it named the addressee only when the
|
|
101
|
+
// viewer WAS the addressee, so the asker's own list rendered anonymously, which
|
|
102
|
+
// is the whole owner report. directionLine replaced it on both axes and had to
|
|
103
|
+
// take the tonal split with it, which is what this now asserts.
|
|
100
104
|
const L = lib();
|
|
101
|
-
assert.
|
|
102
|
-
assert.match(L.
|
|
103
|
-
assert.match(L.
|
|
105
|
+
assert.equal(L.addressedTo, undefined, 'the superseded helper is not left exported');
|
|
106
|
+
assert.match(L.directionLine({ needs_craft: 'artist' }, 'out'), /artists/);
|
|
107
|
+
assert.match(L.directionLine({ needs_builder_id: 7, needs_builder_name: 'Ada' }, 'out'), /by name/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ── DIRECTION: the owner's report (task 1003274) ────────────────────────────
|
|
111
|
+
//
|
|
112
|
+
// "the ask went to me, not Lars it seems... It says Lars- as your asks but I am
|
|
113
|
+
// not Lars." Nothing was misrouted: the owner was reading their OWN outgoing ask,
|
|
114
|
+
// addressed to Lars, under "Your asks" — and the row said neither who it went to
|
|
115
|
+
// nor which way it pointed. The label read "asked of one person".
|
|
116
|
+
//
|
|
117
|
+
// The reason the old code could not name them is worth keeping: it named the
|
|
118
|
+
// addressee only when the VIEWER was the addressee, so the single most common
|
|
119
|
+
// case for that branch — the asker reading their own list — was the one case it
|
|
120
|
+
// rendered anonymously.
|
|
121
|
+
|
|
122
|
+
test('THE REPORTED CASE: your own by-name ask names the person you asked', () => {
|
|
123
|
+
const L = lib();
|
|
124
|
+
const req = { needs_builder_id: 3, needs_builder_name: 'Lars', requested_by: 1000025 };
|
|
125
|
+
const line = L.directionLine(req, 'out');
|
|
126
|
+
assert.match(line, /Lars/, 'the addressee is named — this is the whole bug');
|
|
127
|
+
assert.match(line, /^You asked/, 'and it says which way it points');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('DIRECTION cannot be mistaken: outgoing and incoming read as opposites', () => {
|
|
131
|
+
const L = lib();
|
|
132
|
+
const out = L.directionLine({ needs_builder_id: 3, needs_builder_name: 'Lars' }, 'out');
|
|
133
|
+
const inc = L.directionLine({ needs_builder_id: 9, requested_by_name: 'Lars' }, 'in');
|
|
134
|
+
assert.match(out, /^You asked Lars by name$/);
|
|
135
|
+
assert.match(inc, /^Lars asked you by name$/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('direction comes from the LIST, not from comparing ids', () => {
|
|
139
|
+
// The two server reads already answer the question by construction
|
|
140
|
+
// (/help-requests/mine vs /for-me), so directionLine takes no viewer id. That
|
|
141
|
+
// is what makes the asker's own list impossible to render anonymously — there
|
|
142
|
+
// is no comparison left to get wrong.
|
|
143
|
+
const L = lib();
|
|
144
|
+
assert.equal(L.directionLine.length, 2, 'req + dir, and no viewerId');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the craft tone survives — a craft ask is not a personal one', () => {
|
|
148
|
+
// ADR 0187 §3's split, which the fix was told to keep: "asked of builders" is a
|
|
149
|
+
// call anyone of that craft may take; a by-name ask is personal and says so.
|
|
150
|
+
const L = lib();
|
|
151
|
+
assert.match(L.directionLine({ needs_craft: 'artist' }, 'out'), /^You asked artists$/);
|
|
152
|
+
assert.match(L.directionLine({ needs_craft: 'artist', requested_by_name: 'Lars' }, 'in'),
|
|
153
|
+
/^Lars asked artists$/, 'incoming craft asks say the craft, not "you"');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('an unnamed addressee still says which way it points', () => {
|
|
157
|
+
// An older server sends no join. Losing the name must not lose the direction.
|
|
158
|
+
const L = lib();
|
|
159
|
+
assert.match(L.directionLine({ needs_builder_id: 3 }, 'out'), /^You asked one person$/);
|
|
160
|
+
assert.match(L.directionLine({ needs_builder_id: 3 }, 'in'), /^Someone asked you by name$/);
|
|
104
161
|
});
|
|
105
162
|
|
|
106
163
|
test('context links to the task or goal, and to neither when absent', () => {
|
|
@@ -77,8 +77,25 @@ test('collab renders WHO IS ASKING, as a door to their profile', () => {
|
|
|
77
77
|
assert.equal(L.askerOf({ requested_by: 92 }), 'builder 92', 'an older server\'s bare id is still a name, never nothing');
|
|
78
78
|
const js = read('collab.js');
|
|
79
79
|
assert.match(js, /L\.askerOf\(req\)/, 'the row asks the lib who is asking');
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// The link is built by personLink(id, name) now rather than inline per row —
|
|
81
|
+
// task 1003274 made BOTH people on a row a door (the asker on an incoming row,
|
|
82
|
+
// the addressee on your own), so the href moved to one helper taking an id.
|
|
83
|
+
assert.match(js, /\/builders\/profile\?id=\$\{escapeHtml\(String\(id\)\)\}/,
|
|
84
|
+
'and links the hall\'s per-builder profile');
|
|
85
|
+
// The wording is L.directionLine's, not this file's: task 1003274 replaced
|
|
86
|
+
// "asked by X" with a sentence that also says WHICH WAY the ask points, because
|
|
87
|
+
// the owner read their own outgoing ask as an incoming one. The sentence is
|
|
88
|
+
// asserted in tests/collab_page.mjs where the lib can be executed.
|
|
89
|
+
assert.match(js, /L\.directionLine\(req, dir\)/, 'as a fact on the row');
|
|
90
|
+
assert.ok(!/asked by \$\{who\}/.test(js), 'the direction-less wording is gone');
|
|
91
|
+
|
|
92
|
+
// The link is spliced with a REPLACER FUNCTION. As a replacement STRING,
|
|
93
|
+
// `$&`/`$$`/"$`"/`$'` inside it are special to String.prototype.replace — and
|
|
94
|
+
// the name it carries is builder-chosen. Measured: a display_name of `$&Bob`
|
|
95
|
+
// rendered as `$&Bobamp;Bob`. Not an injection (both halves are escaped),
|
|
96
|
+
// but a way to corrupt your own row by picking a name.
|
|
97
|
+
assert.match(js, /\.replace\(escapeHtml\(name\), \(\) => link\)/,
|
|
98
|
+
'the replacement is a function, so $-sequences in a name are literal');
|
|
82
99
|
});
|
|
83
100
|
|
|
84
101
|
test('the server names the people on a row through a join, and the writes stay bare', () => {
|
|
@@ -115,7 +132,9 @@ test('"asked of you" reads in runs — by name first, then each craft', () => {
|
|
|
115
132
|
const L = lib();
|
|
116
133
|
assert.equal(L.groupOf({ needs_builder_id: 95 }).id, 'you');
|
|
117
134
|
assert.equal(L.groupOf({ needs_craft: 'artist' }).label, 'asked of artists');
|
|
118
|
-
|
|
135
|
+
// task 1003274: the naming moved to directionLine, which also says WHICH WAY
|
|
136
|
+
// the ask points — the half the owner's report was actually about.
|
|
137
|
+
assert.match(L.directionLine({ needs_builder_id: 7, needs_builder_name: 'Preview Alpha' }, 'out'), /Preview Alpha by name/, 'a named addressee is named');
|
|
119
138
|
const js = read('collab.js');
|
|
120
139
|
assert.match(js, /ov-group__head/, 'each run has the family\'s group head');
|
|
121
140
|
assert.match(js, /Number\(b\.id === 'you'\) - Number\(a\.id === 'you'\)/, 'and the personal run sorts first');
|