@bongos/core 1.19.1003 → 1.19.1005
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 +38 -28
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +3 -1
- package/docs/copy-inventory.md +66 -58
- package/docs/copy-registry.json +154 -72
- package/docs/module-api-changelog.md +5 -1
- package/modules/hall-ui/public/goal-inbox.js +41 -0
- package/modules/hall-ui/public/style.css +9 -0
- package/modules/hall-ui/public/task-lib.js +21 -1
- package/modules/hall-ui/public/work.js +11 -2
- package/modules/lifecycle/db-claims.js +28 -1
- package/modules/lifecycle/db-goals.js +15 -1
- package/modules/lifecycle/goal-conflicts.js +57 -0
- package/modules/lifecycle/routes/claims.js +2 -2
- package/modules/lifecycle/routes/goals.js +7 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claim.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/goal_conflicts.mjs +88 -0
- package/tests/hall_mine_lens.mjs +1 -1
- package/tests/ideator_fork_pays_proof.mjs +376 -0
- package/tests/ideator_traceable_proof.mjs +315 -0
- package/tests/task_lib.mjs +25 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// tests/ideator_traceable_proof.mjs — THE PROOF for `ideator-nothing-is-lost`
|
|
2
|
+
// (task 1004100 / BV2.R21 of goal 1000095, spec R06).
|
|
3
|
+
//
|
|
4
|
+
// THE SCENARIO THIS EXISTS FOR, stated as it actually happened on 2026-09-19:
|
|
5
|
+
// a thought was filed from the hall, it silently became a task under a goal,
|
|
6
|
+
// and the person who filed it could not find it five minutes later. Everything
|
|
7
|
+
// in this scope was built so that cannot happen again. This file is the
|
|
8
|
+
// re-runnable assertion that it cannot.
|
|
9
|
+
//
|
|
10
|
+
// WHY A PROOF AND NOT MORE UNIT TESTS. Every hop of this journey is already
|
|
11
|
+
// covered on its own: the landing matrix (tests/idea_routing.mjs), the trail
|
|
12
|
+
// projection (tests/idea_trail_descent.mjs), the five states
|
|
13
|
+
// (tests/idea_state.mjs), the room's renderer (tests/hall_thinking_room.mjs).
|
|
14
|
+
// What none of them can see is the JOIN between the filing moment and the
|
|
15
|
+
// reading moment — that the goal a person is TOLD caught their thought is the
|
|
16
|
+
// same goal the room attributes it to an hour later. A break there passes every
|
|
17
|
+
// suite above and reproduces the original failure exactly: two surfaces, each
|
|
18
|
+
// internally correct, disagreeing about one thought.
|
|
19
|
+
//
|
|
20
|
+
// SO THE CHAIN IS NEVER RE-TYPED. The trail row this proof reads an hour later
|
|
21
|
+
// is DERIVED from what `captureIdea` actually did at filing time — the task id
|
|
22
|
+
// it minted and the goal id it actually handed `lifecycle.createTask` — rather
|
|
23
|
+
// than written out by hand beside it. A hand-written fixture would agree with
|
|
24
|
+
// the filing because the author made it agree; this one can only agree if the
|
|
25
|
+
// code does. (tests/helpers.mjs's own rule, and the reason
|
|
26
|
+
// tests/hall_thinking_room.mjs shapes every row the way the route shapes one.)
|
|
27
|
+
//
|
|
28
|
+
// THE HOUR IS REAL, not a sleep. What "an hour later" means operationally is
|
|
29
|
+
// that nothing from the filing session is still in hand: the receipt
|
|
30
|
+
// (modules/hall-ui/public/ideas.js) is localStorage, so it is gone on another
|
|
31
|
+
// device, in a private window, or after a storage clear. bootThinkingPage's
|
|
32
|
+
// stub localStorage answers null to every read, which makes that the ONLY
|
|
33
|
+
// condition this file can run under — the room has to answer from the server
|
|
34
|
+
// or it does not answer at all.
|
|
35
|
+
//
|
|
36
|
+
// DB-free: captureIdea, the trail read and all three attachers take an injected
|
|
37
|
+
// client, so the real SQL and the real composition run against a recorded query
|
|
38
|
+
// log. A real-Postgres suite would SELF-SKIP on a machine with no database, and
|
|
39
|
+
// a skipped test prints ok and counts as passed — which is how a green lane
|
|
40
|
+
// hides what never ran.
|
|
41
|
+
//
|
|
42
|
+
// Run: node tests/ideator_traceable_proof.mjs
|
|
43
|
+
|
|
44
|
+
import { strict as assert } from 'node:assert';
|
|
45
|
+
import { createRequire } from 'node:module';
|
|
46
|
+
import { makeRunner, makeSqlAwareClient, bootThinkingPage, settle } from './helpers.mjs';
|
|
47
|
+
|
|
48
|
+
process.env.NODE_ENV = 'test';
|
|
49
|
+
|
|
50
|
+
const require = createRequire(import.meta.url);
|
|
51
|
+
|
|
52
|
+
// The lifecycle port is how the ideas module reaches createTask (never an
|
|
53
|
+
// import — the one-way module rule, ADR 0083). The CALL LOG is load-bearing
|
|
54
|
+
// here and not incidental: `createTaskCalls[0].args.goalId` is the only
|
|
55
|
+
// first-hand record of which goal actually caught the thought, and the whole
|
|
56
|
+
// continuity assertion below is built on it rather than on the constant this
|
|
57
|
+
// file passed in.
|
|
58
|
+
const seams = require('../src/module-seams.js');
|
|
59
|
+
seams._reset();
|
|
60
|
+
let createTaskCalls = [];
|
|
61
|
+
seams.registerProvider('lifecycle', {
|
|
62
|
+
createTask: async (args, deps) => {
|
|
63
|
+
createTaskCalls.push({ args, deps });
|
|
64
|
+
return { id: 9001, status: 'backlog', goal_id: args.goalId, title: args.title };
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const inbox = require('../modules/ideas/inbox.js');
|
|
69
|
+
const { captureIdea } = inbox;
|
|
70
|
+
const trail = require('../modules/ideas/trail.js');
|
|
71
|
+
const { attachDescentToTrail } = require('../modules/ideas/trail-descent.js');
|
|
72
|
+
const { attachSizeToTrail } = require('../modules/ideas/trail-size.js');
|
|
73
|
+
const { attachStateToTrail } = require('../modules/ideas/idea-state.js');
|
|
74
|
+
|
|
75
|
+
const { test, summary } = makeRunner();
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// the world
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const FILER = 42;
|
|
82
|
+
// A routable goal: open, on a version that is still building — the shape
|
|
83
|
+
// routing.js requires before it will let a filing land as work.
|
|
84
|
+
const LIVE_GOAL = { id: 7, status: 'open', version_id: 'BONGOS-V2', version_status: 'building' };
|
|
85
|
+
const THOUGHT = 'the filing receipt does not survive a reload';
|
|
86
|
+
|
|
87
|
+
function fakePool(handler) {
|
|
88
|
+
const c = makeSqlAwareClient(handler);
|
|
89
|
+
return {
|
|
90
|
+
query: (sql, params) => c._client.query(sql, params),
|
|
91
|
+
connect: c.connect,
|
|
92
|
+
queries: c._client.queries,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The idea row the INSERT returns. `id` and `captured_by` are the two fields
|
|
97
|
+
// every later read keys on, so they are the two this proof carries forward.
|
|
98
|
+
function ideaRow(over = {}) {
|
|
99
|
+
return {
|
|
100
|
+
id: 555, title: THOUGHT, body_md: '', kind: 'bug',
|
|
101
|
+
suggested_version: null, suggested_priority: null,
|
|
102
|
+
captured_at: '2026-09-19T09:00:00.000Z', captured_by: FILER,
|
|
103
|
+
status: 'open', promoted_to_task_id: null, reviewed_at: null,
|
|
104
|
+
merged_into_idea_id: null, triage_note: null, suggested_goal_id: null,
|
|
105
|
+
...over,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// THE FILING MOMENT — one line, one gesture
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
// Files `THOUGHT` through the REAL captureIdea and hands back both halves of
|
|
114
|
+
// what the hall learns at that instant: the capture result (which is what
|
|
115
|
+
// ideas.js builds its receipt line from — `routed_task.id` and
|
|
116
|
+
// `routing.goal_id`, see fileQuick) and the goal createTask was actually given.
|
|
117
|
+
async function fileIt({ goalId }) {
|
|
118
|
+
createTaskCalls = [];
|
|
119
|
+
const pool = fakePool((s) => {
|
|
120
|
+
if (/FROM goals g JOIN versions v/.test(s)) return { rows: [LIVE_GOAL] };
|
|
121
|
+
if (/INSERT INTO idea_inbox/.test(s)) {
|
|
122
|
+
return { rows: [ideaRow(goalId ? { status: 'promoted', suggested_goal_id: goalId } : {})] };
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
});
|
|
126
|
+
const captured = await captureIdea({
|
|
127
|
+
title: THOUGHT,
|
|
128
|
+
kind: 'bug',
|
|
129
|
+
capturedBy: FILER,
|
|
130
|
+
filerRank: 'archon',
|
|
131
|
+
...(goalId ? { goalId } : {}),
|
|
132
|
+
}, { pool });
|
|
133
|
+
return { captured, createTaskCalls: createTaskCalls.slice() };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// AN HOUR LATER — the room, from the server alone
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
// Reads the trail the way GET /inbox/by-builder/:id reads it: the real
|
|
141
|
+
// projection, then the same three attachers in the same order the route
|
|
142
|
+
// composes them. The row handed to the SQL layer is BUILT FROM `filed` — what
|
|
143
|
+
// the filing actually did — so nothing in this function can make the two
|
|
144
|
+
// moments agree on its own.
|
|
145
|
+
async function anHourLater(filed) {
|
|
146
|
+
const routed = filed.captured.routing && filed.captured.routing.routed
|
|
147
|
+
? filed.captured
|
|
148
|
+
: null;
|
|
149
|
+
const call = filed.createTaskCalls[0];
|
|
150
|
+
|
|
151
|
+
const row = {
|
|
152
|
+
...ideaRow(),
|
|
153
|
+
status: routed ? 'promoted' : 'open',
|
|
154
|
+
// The three fields the trail projection carries about a promotion, each
|
|
155
|
+
// taken from the filing rather than restated: the task it minted, that
|
|
156
|
+
// task's goal, that task's status.
|
|
157
|
+
promoted_to_task_id: routed ? routed.routed_task.id : null,
|
|
158
|
+
promoted_task_goal_id: routed ? call.args.goalId : null,
|
|
159
|
+
promoted_task_status: routed ? routed.routed_task.status : null,
|
|
160
|
+
promoted_task_title: routed ? routed.routed_task.title : null,
|
|
161
|
+
merged_into_idea_title: null, merged_into_idea_status: null,
|
|
162
|
+
lineage_tasks: routed
|
|
163
|
+
? [{
|
|
164
|
+
task_id: routed.routed_task.id, origin: 'route',
|
|
165
|
+
title: routed.routed_task.title, status: routed.routed_task.status,
|
|
166
|
+
goal_id: call.args.goalId,
|
|
167
|
+
}]
|
|
168
|
+
: [],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const client = makeSqlAwareClient((s) => {
|
|
172
|
+
if (/FROM idea_inbox i/.test(s)) return { rows: [row] };
|
|
173
|
+
return { rows: [] };
|
|
174
|
+
})._client;
|
|
175
|
+
|
|
176
|
+
const ideas = await trail.listIdeaTrailForBuilder(FILER, {}, { pool: client });
|
|
177
|
+
// The route's own order, and its reasons: the walk, then what it paid, then
|
|
178
|
+
// which of the five objects it is.
|
|
179
|
+
await attachDescentToTrail(ideas, { client });
|
|
180
|
+
await attachSizeToTrail(ideas, { client });
|
|
181
|
+
await attachStateToTrail(ideas, { client });
|
|
182
|
+
return { ideas, total: ideas.length, limit: 50, offset: 0 };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Boots the REAL room over a REAL trail response and hands back the HTML a
|
|
186
|
+
// reader is actually shown. No receipt is reachable from here: bootThinkingPage's
|
|
187
|
+
// localStorage answers null, which is the "another device, an hour later"
|
|
188
|
+
// condition this proof is about.
|
|
189
|
+
async function room(page) {
|
|
190
|
+
const p = bootThinkingPage({ pages: [{ ...page, offset: 0 }], viewer: { id: FILER } });
|
|
191
|
+
for (let i = 0; i < 10; i += 1) await settle();
|
|
192
|
+
return p;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// =========================================================================
|
|
196
|
+
// 1. AIMED — the thought that was caught
|
|
197
|
+
// =========================================================================
|
|
198
|
+
|
|
199
|
+
await test('aimed: filing one line lands it as work, and the hall is told so at that instant', async () => {
|
|
200
|
+
const filed = await fileIt({ goalId: LIVE_GOAL.id });
|
|
201
|
+
const r = filed.captured;
|
|
202
|
+
assert.equal(r.routing.routed, true, 'a goal was named, so the filing lands as work');
|
|
203
|
+
assert.equal(filed.createTaskCalls.length, 1, 'exactly one task is minted');
|
|
204
|
+
// The two fields fileQuick (modules/hall-ui/public/ideas.js) reads to write
|
|
205
|
+
// the receipt line "Filed as task N under <goal>." Both must be on the
|
|
206
|
+
// response or the receipt cannot name either.
|
|
207
|
+
assert.ok(r.routed_task && r.routed_task.id, 'the response names the task it became');
|
|
208
|
+
assert.ok(r.routing.goal_id, 'the response names the goal that caught it');
|
|
209
|
+
assert.equal(r.routing.goal_id, filed.createTaskCalls[0].args.goalId,
|
|
210
|
+
'the goal the filer is TOLD about is the goal the task was actually created in');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await test('aimed: AN HOUR LATER the room still names what it became, with no receipt in reach', async () => {
|
|
214
|
+
const filed = await fileIt({ goalId: LIVE_GOAL.id });
|
|
215
|
+
const page = await anHourLater(filed);
|
|
216
|
+
const rows = (await room(page)).rows();
|
|
217
|
+
|
|
218
|
+
assert.match(rows, /the filing receipt does not survive a reload/, 'the thought is on the page');
|
|
219
|
+
// "What became of it" — the task, by NAME and as a link. This is the answer
|
|
220
|
+
// the 2026-09-19 filing could not get.
|
|
221
|
+
assert.match(rows, new RegExp(`/task/${filed.captured.routed_task.id}\\b`),
|
|
222
|
+
'the row links the task the thought became');
|
|
223
|
+
assert.doesNotMatch(rows, /nothing yet/,
|
|
224
|
+
'a thought that became work is never drawn as one that caused none');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await test('aimed: the goal that caught it survives the hour — the room attributes it to the SAME well', async () => {
|
|
228
|
+
// THE CONTINUITY ASSERTION, and the only one here that no other suite makes.
|
|
229
|
+
// Everything else in this file could hold while the two moments still named
|
|
230
|
+
// two different goals.
|
|
231
|
+
const filed = await fileIt({ goalId: LIVE_GOAL.id });
|
|
232
|
+
const page = await anHourLater(filed);
|
|
233
|
+
const st = page.ideas[0].state;
|
|
234
|
+
|
|
235
|
+
assert.equal(st.position, 'well', 'an aimed thought sits in a goal, not in open space');
|
|
236
|
+
assert.equal(String(st.wellGoalId), String(filed.captured.routing.goal_id),
|
|
237
|
+
'the goal the room resolves is the goal the receipt named at filing');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// =========================================================================
|
|
241
|
+
// 2. FLOATED — the thought nothing has claimed
|
|
242
|
+
// =========================================================================
|
|
243
|
+
|
|
244
|
+
await test('floated: letting it float is a recorded outcome, not a failure to route', async () => {
|
|
245
|
+
const filed = await fileIt({ goalId: null });
|
|
246
|
+
assert.equal(filed.captured.routing.routed, false, 'no goal named, so nothing is minted');
|
|
247
|
+
assert.equal(filed.captured.routing.reason, 'no_goal', 'and the reason is recorded');
|
|
248
|
+
assert.equal(filed.createTaskCalls.length, 0, 'no task exists to find later');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
await test('floated: an hour later the room says nothing has claimed it — in those words', async () => {
|
|
252
|
+
const filed = await fileIt({ goalId: null });
|
|
253
|
+
const page = await anHourLater(filed);
|
|
254
|
+
const st = page.ideas[0].state;
|
|
255
|
+
|
|
256
|
+
assert.equal(st.state, 'protostar', 'filed, not yet ignited');
|
|
257
|
+
assert.equal(st.position, 'open-space', 'and nothing caught it');
|
|
258
|
+
|
|
259
|
+
const rows = (await room(page)).rows();
|
|
260
|
+
assert.match(rows, /the filing receipt does not survive a reload/, 'the thought is still on the page');
|
|
261
|
+
assert.match(rows, /nothing has claimed it/,
|
|
262
|
+
'the room says so in the reader\'s words, not by leaving the cell blank');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// =========================================================================
|
|
266
|
+
// 3. THE DISTINCTION — the failure mode this whole scope exists for
|
|
267
|
+
// =========================================================================
|
|
268
|
+
|
|
269
|
+
await test('the two outcomes do not render alike — a caught thought and a floating one are told apart at a glance', async () => {
|
|
270
|
+
// The original failure was not that the answer was WRONG. It was that a
|
|
271
|
+
// thought which had quietly become work looked exactly like one that had gone
|
|
272
|
+
// nowhere. Both rows rendering is not the property; them rendering
|
|
273
|
+
// DIFFERENTLY is.
|
|
274
|
+
const aimedRows = (await room(await anHourLater(await fileIt({ goalId: LIVE_GOAL.id })))).rows();
|
|
275
|
+
const floatRows = (await room(await anHourLater(await fileIt({ goalId: null })))).rows();
|
|
276
|
+
|
|
277
|
+
assert.notEqual(aimedRows, floatRows, 'the two rows are not the same HTML');
|
|
278
|
+
assert.match(aimedRows, /data-object="protostar"/, 'both are protostars — neither has been paid yet');
|
|
279
|
+
assert.match(floatRows, /data-object="protostar"/);
|
|
280
|
+
// …and the object column is what separates them, which is exactly the
|
|
281
|
+
// position axis idea-objects.js says is load-bearing for a protostar alone.
|
|
282
|
+
assert.doesNotMatch(aimedRows, /nothing has claimed it/,
|
|
283
|
+
'the caught thought must never be described as unclaimed');
|
|
284
|
+
assert.match(floatRows, /nothing has claimed it/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// =========================================================================
|
|
288
|
+
// 4. HOW FAR THE CHAIN GETS BEFORE A GOAL IS NAMED — recorded, not assumed
|
|
289
|
+
// =========================================================================
|
|
290
|
+
|
|
291
|
+
await test('the room reaches the goal by LINK, not by name — pinned so the follow-up is visible', async () => {
|
|
292
|
+
// HONEST LIMIT OF THIS PROOF. The criterion asks that a person can answer
|
|
293
|
+
// "what happened to the thing I filed" unaided, and they can: the row names
|
|
294
|
+
// the task and links it, and the task record names its goal. What the room
|
|
295
|
+
// does NOT do is print the goal's own name, even though `state.wellGoalId`
|
|
296
|
+
// is resolved server-side and sits on the row it draws (idea-objects.js
|
|
297
|
+
// positionWord deliberately returns "has a home" / "waiting for a nod" and
|
|
298
|
+
// never the well's identity).
|
|
299
|
+
//
|
|
300
|
+
// That is a rendering gap, not a traceability one, so it is recorded here
|
|
301
|
+
// rather than fixed under a verify task (it is task 1004225) — and pinned, so that when the room
|
|
302
|
+
// does start naming the well this assertion fails and is deleted along with
|
|
303
|
+
// the follow-up it points at.
|
|
304
|
+
const filed = await fileIt({ goalId: LIVE_GOAL.id });
|
|
305
|
+
const page = await anHourLater(filed);
|
|
306
|
+
const rows = (await room(page)).rows();
|
|
307
|
+
|
|
308
|
+
assert.ok(page.ideas[0].state.wellGoalId, 'the well IS resolved and delivered to the page');
|
|
309
|
+
assert.doesNotMatch(rows, new RegExp(`goal ${LIVE_GOAL.id}\\b`),
|
|
310
|
+
'and today the page does not print it — the goal is one link away, on the task');
|
|
311
|
+
assert.match(rows, /has a home|waiting for a nod/,
|
|
312
|
+
'what it prints instead is the position, which says a goal caught it without saying which');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
summary();
|
package/tests/task_lib.mjs
CHANGED
|
@@ -111,5 +111,30 @@ t('rankTagHtml escapes its label and treats xenos as "no floor"', () => {
|
|
|
111
111
|
assert.doesNotMatch(T.rankTagHtml('<img src=x onerror=1>'), /<img/);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
// ---- conflictPeers / conflictChipHtml (task 1001741) ----------------------
|
|
115
|
+
t('conflictPeers names colliding tasks: excludes self, dedupes, sorts numerically', () => {
|
|
116
|
+
const me = { id: 5, touches: ['src/'] };
|
|
117
|
+
const pool = [
|
|
118
|
+
{ id: 5, touches: ['src/'] }, // self
|
|
119
|
+
{ id: 20, touches: ['src/a.js'] },
|
|
120
|
+
{ id: 3, touches: ['src/b.js'] },
|
|
121
|
+
{ id: 20, touches: ['src/a.js'] }, // duplicate id in the pool
|
|
122
|
+
{ id: 9, touches: ['docs/'] }, // disjoint
|
|
123
|
+
{ id: 11, touches: [] }, // no touches never collides
|
|
124
|
+
];
|
|
125
|
+
assert.deepEqual(T.conflictPeers(me, pool), [3, 20]);
|
|
126
|
+
assert.deepEqual(T.conflictPeers({ id: 1, touches: [] }, pool), []);
|
|
127
|
+
assert.deepEqual(T.conflictPeers(me, null), []);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
t('conflictChipHtml: empty for none, count + capped list for many', () => {
|
|
131
|
+
assert.equal(T.conflictChipHtml([]), '');
|
|
132
|
+
assert.match(T.conflictChipHtml([2, 3]), /overlaps 2/);
|
|
133
|
+
const many = T.conflictChipHtml([1, 2, 3, 4, 5, 6, 7]);
|
|
134
|
+
assert.match(many, /overlaps 7/);
|
|
135
|
+
assert.match(many, /#1, #2, #3, #4, #5, …/, 'the tooltip lists five and ellipsises the rest');
|
|
136
|
+
assert.doesNotMatch(many, /#6/);
|
|
137
|
+
});
|
|
138
|
+
|
|
114
139
|
console.log(`\ntask-lib tests: ${passed} passed, ${failed} failed`);
|
|
115
140
|
if (failed) process.exit(1);
|