@bongos/core 1.19.1005 → 1.19.1006

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.
@@ -0,0 +1,382 @@
1
+ // tests/collab_self_settling.mjs — Collab stops waiting to be told, and settled
2
+ // asks get a two-sided archive (task 1004224, goal 1000095; owner direction
3
+ // 2026-09-24).
4
+ //
5
+ // Two properties are worth more than the rest here, and both are tested by
6
+ // RUNNING the real functions against a recording pool rather than by grepping
7
+ // their source:
8
+ //
9
+ // 1. THE SETTLE TAKES NOTHING FROM A CALLER. The PATCH path's WHERE clause is
10
+ // the entire permission model for these tables; a second writer of `status`
11
+ // that accepted an id would be a way to close somebody else's ask. So the
12
+ // test asserts on the ARITY and on the bound parameters — a settle that
13
+ // grew a parameter would still pass a source scan for "no req.body here".
14
+ //
15
+ // 2. THE ARCHIVE CANNOT BE WIDENED BY A QUERY PARAM. C01's grader found this
16
+ // surface's one real hole (a ?craft= validated against the enum and not
17
+ // against the caller), and an archive is the larger prize because it is the
18
+ // whole history rather than today's board.
19
+ import { strict as assert } from 'node:assert';
20
+ import { test } from 'node:test';
21
+ import { createRequire } from 'node:module';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const help = require(path.join(ROOT, 'modules', 'lifecycle', 'help-requests.js'));
29
+ const recs = require(path.join(ROOT, 'modules', 'lifecycle', 'task-recommendations.js'));
30
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
31
+
32
+ // Records every query the function under test issues, so a test can assert on
33
+ // what actually reached Postgres rather than on what the source appears to say.
34
+ function recorder(rows = []) {
35
+ const calls = [];
36
+ return { calls, pool: { query: async (sql, params) => { calls.push({ sql, params }); return { rows }; } } };
37
+ }
38
+
39
+ // ---- 1. the ledger settles the ask ----------------------------------------
40
+
41
+ test('a help request naming a SHIPPED task settles itself', async () => {
42
+ const r = recorder([{ id: 4, task_id: 900 }]);
43
+ const settled = await help.settleFromLedger({ pool: r.pool });
44
+ const { sql } = r.calls[0];
45
+ assert.match(sql, /UPDATE lifecycle_help_requests/);
46
+ assert.match(sql, /status = 'answered'/, 'the ask is answered, not deleted');
47
+ assert.match(sql, /settled_reason = 'task_shipped'/, 'and the record says the ledger did it');
48
+ assert.match(sql, /FROM tasks t WHERE t\.id = h\.task_id AND t\.status = 'shipped'/);
49
+ assert.deepEqual(settled, [{ id: 4, task_id: 900 }], 'it reports what it settled');
50
+ });
51
+
52
+ test('an ask with NO task is never settled automatically', async () => {
53
+ // There is no ledger fact that answers it. Inventing one — treating a reply as
54
+ // an answer, say — would close the clarifying question that was asking for
55
+ // more detail.
56
+ //
57
+ // ASSERTED AS A PURE CONJUNCTION, not as a substring. The first version of this
58
+ // matched /h\.task_id IS NOT NULL/ and stayed GREEN when the guard was
59
+ // neutered to `(h.task_id IS NOT NULL OR TRUE)` — the substring survives its
60
+ // own defeat, which makes it a test of the spelling rather than of the rule.
61
+ // Every clause in this WHERE is load-bearing and ANDed; a disjunction anywhere
62
+ // in it is a widening, so the absence of OR is the property worth pinning.
63
+ const r = recorder();
64
+ await help.settleFromLedger({ pool: r.pool });
65
+ const where = r.calls[0].sql.slice(r.calls[0].sql.indexOf('WHERE'), r.calls[0].sql.indexOf('RETURNING'));
66
+ assert.match(where, /h\.task_id IS NOT NULL/);
67
+ assert.ok(!/\bOR\b/.test(where), `the settle predicate is a pure conjunction, so no clause can be bypassed: ${where}`);
68
+ });
69
+
70
+ test('only a LANDED task settles an ask — not completed, confirmed or abandoned', async () => {
71
+ // completed and confirmed are stations on the way to landing, and a question
72
+ // closed at confirmed would reopen as a lie if the ship then stranded. An
73
+ // abandoned task is the case where the asker most needs the question standing.
74
+ const r = recorder();
75
+ await help.settleFromLedger({ pool: r.pool });
76
+ const { sql } = r.calls[0];
77
+ for (const status of ['completed', 'confirmed', 'abandoned']) {
78
+ assert.ok(!sql.includes(`'${status}'`), `${status} must not settle an ask`);
79
+ }
80
+ });
81
+
82
+ test('an already-settled ask is not touched again', async () => {
83
+ const r = recorder();
84
+ await help.settleFromLedger({ pool: r.pool });
85
+ assert.match(r.calls[0].sql, /h\.status = 'open'/, 'idempotent, so it is safe on every read');
86
+ });
87
+
88
+ test('THE SECURITY PROPERTY: the settle accepts nothing a caller could supply', async () => {
89
+ // The whole reason this is safe to run on an unauthenticated-shaped read path.
90
+ // Arity FIRST: a settle that grew an `id` or `taskId` parameter would still
91
+ // pass a source scan for "no req.body in here", and would be a way to close
92
+ // any row by naming it.
93
+ // 0, because `deps = {}` is a DEFAULTED parameter and does not count toward
94
+ // arity. A settle that grew a required `id` or `taskId` would read 1 here.
95
+ assert.equal(help.settleFromLedger.length, 0, 'no required parameter — no id, no task, no builder');
96
+ assert.equal(recs.settleFromLedger.length, 0, 'and the same on the recommendations half');
97
+ const r = recorder();
98
+ await help.settleFromLedger({ pool: r.pool });
99
+ assert.equal(r.calls[0].params, undefined, 'and it binds no parameters at all');
100
+ });
101
+
102
+ // ---- 2. the recommendation settles too ------------------------------------
103
+
104
+ test('a recommendation settles when its RECIPIENT takes the task up', async () => {
105
+ const r = recorder([{ id: 2, task_id: 900, recommended_to: 7 }]);
106
+ await recs.settleFromLedger({ pool: r.pool });
107
+ const { sql } = r.calls[0];
108
+ assert.match(sql, /FROM claims c/, 'a claim by the recipient is the signal');
109
+ assert.match(sql, /c\.builder_id = r\.recommended_to/);
110
+ assert.match(sql, /t\.shipped_by = r\.recommended_to/, 'or they shipped it');
111
+ });
112
+
113
+ test('a RELEASED claim does not settle it — handing a task back is not taking it up', async () => {
114
+ // A builder who claimed and gave the task back has arrived where a decline
115
+ // arrives, and recording that as 'taken_up' would put a success in the
116
+ // sender's ledger that did not happen. It is also the indexable shape:
117
+ // uniq_active_claim_per_task is partial on `released_at IS NULL`, and this
118
+ // module may not add an index to `claims`, which is a core table it only reads.
119
+ const r = recorder();
120
+ await recs.settleFromLedger({ pool: r.pool });
121
+ const claimBranch = r.calls[0].sql.slice(r.calls[0].sql.indexOf('FROM claims c'),
122
+ r.calls[0].sql.indexOf('OR EXISTS'));
123
+ assert.match(claimBranch, /c\.released_at IS NULL/,
124
+ 'only a LIVE claim — a released one is covered by shipped_by if it shipped');
125
+ });
126
+
127
+ test('a task somebody ELSE shipped does not settle the recommendation', async () => {
128
+ // That is the case where the recommendation was never taken up at all, and
129
+ // closing it as taken_up would put a success in the sender's ledger that did
130
+ // not happen. Both branches are bound to recommended_to; neither is a bare
131
+ // "the task shipped".
132
+ const r = recorder();
133
+ await recs.settleFromLedger({ pool: r.pool });
134
+ const { sql } = r.calls[0];
135
+ const shipBranch = sql.slice(sql.indexOf('FROM tasks t'));
136
+ assert.match(shipBranch, /shipped_by = r\.recommended_to/,
137
+ 'the ship branch names the recipient, so an unrelated ship cannot close it');
138
+ });
139
+
140
+ test("'taken_up' is NOT an 'accepted' in disguise, and no route can write it", () => {
141
+ // lifecycle_005's header argues at length that this table must have no
142
+ // 'accepted': accepting a recommendation IS claiming the task, and a status
143
+ // here saying so would be a second, weaker record of holding work.
144
+ const sql = read('modules', 'lifecycle', 'migrations', 'lifecycle_012_collab_self_settling.sql');
145
+ assert.match(sql, /status IN \('open', 'declined', 'withdrawn', 'taken_up'\)/);
146
+ // Scoped to the STATEMENTS, not the file: the migration's own header argues at
147
+ // length about why the word is rejected, and a whole-file scan flags that.
148
+ const statements = sql.split(/\r?\n/).filter((l) => !l.trim().startsWith('--')).join('\n');
149
+ assert.ok(!/'accepted'/.test(statements), 'the rejected word stays rejected');
150
+
151
+ const routes = read('modules', 'lifecycle', 'routes', 'recommendations.js');
152
+ assert.ok(!/taken_up/.test(routes), 'no route writes it — it is derived, never sent');
153
+ const lib = read('modules', 'lifecycle', 'task-recommendations.js');
154
+ const writes = lib.match(/SET status = '[a-z_]+'/g) || [];
155
+ assert.deepEqual(writes.sort(), ["SET status = 'declined'", "SET status = 'taken_up'", "SET status = 'withdrawn'"],
156
+ 'exactly three status writes, each with its own guard');
157
+ });
158
+
159
+ // ---- 2b. one settle per burst, not one per route --------------------------
160
+
161
+ const settle = require(path.join(ROOT, 'modules', 'lifecycle', 'collab-settle.js'));
162
+
163
+ test('a page load that hits four list routes settles ONCE', async () => {
164
+ // The defect: settling is idempotent and bounded, so calling it on every read
165
+ // is correct — but one Collab load fans out across four routes and three of
166
+ // those UPDATEs wrote nothing. Wasted write volume on a read path, and
167
+ // avoidable row-lock contention when several builders open Collab at once.
168
+ settle._reset();
169
+ let runs = 0;
170
+ const run = async () => { runs += 1; };
171
+ await Promise.all([1, 2, 3, 4].map(() => settle.throttled('k', run)));
172
+ assert.equal(runs, 1, 'four routes, one UPDATE');
173
+ });
174
+
175
+ test('SERIALLY too — four reads that finish one after another still settle once', async () => {
176
+ // The concurrent case above is caught by the in-flight promise, not by the
177
+ // window, so on its own it leaves the window untested: neutering the window
178
+ // check left it green. A page's reads can also land one after another, and
179
+ // that is the case only the window covers.
180
+ settle._reset();
181
+ let runs = 0;
182
+ const run = async () => { runs += 1; };
183
+ for (let i = 0; i < 4; i += 1) await settle.throttled('k', run);
184
+ assert.equal(runs, 1, 'the window collapses a serial burst too');
185
+ });
186
+
187
+ test('a concurrent caller AWAITS the in-flight settle rather than skipping it', async () => {
188
+ // Returning early would hand the second request the PRE-settle rows while
189
+ // claiming the settle had happened — the two reads of one page load would
190
+ // then disagree, which is the double-count this whole ordering exists to stop.
191
+ settle._reset();
192
+ let resolveRun;
193
+ let finished = false;
194
+ const run = () => new Promise((res) => { resolveRun = () => { finished = true; res(); }; });
195
+ const first = settle.throttled('k', run);
196
+ // A tick, because the throttle starts `run` in a microtask — without this,
197
+ // `resolveRun` is still undefined and the test would be measuring its own
198
+ // scheduling rather than the throttle's.
199
+ await Promise.resolve();
200
+ const second = settle.throttled('k', run);
201
+ resolveRun();
202
+ await Promise.all([first, second]);
203
+ assert.ok(finished, 'the second caller did not return before the settle completed');
204
+ });
205
+
206
+ test('the two tables do not share a window', async () => {
207
+ // A Collab page load settles help requests; a surface reading recommendations
208
+ // must not be told the work is done because somebody opened Collab a moment ago.
209
+ settle._reset();
210
+ const ran = [];
211
+ await settle.throttled('help_requests', async () => ran.push('help'));
212
+ await settle.throttled('task_recommendations', async () => ran.push('recs'));
213
+ assert.deepEqual(ran, ['help', 'recs']);
214
+ });
215
+
216
+ test('a FAILED settle does not hold the window shut', async () => {
217
+ // Marking the window on entry and never clearing would turn one transient
218
+ // database error into a silent stall for the rest of the window.
219
+ settle._reset();
220
+ let runs = 0;
221
+ await assert.rejects(() => settle.throttled('k', async () => { runs += 1; throw new Error('boom'); }));
222
+ await settle.throttled('k', async () => { runs += 1; });
223
+ assert.equal(runs, 2, 'the next read retried instead of waiting the window out');
224
+ });
225
+
226
+ test('the window is short — a burst collapser, never a staleness budget', () => {
227
+ // The latency this can add is bounded by the window, so a task that ships now
228
+ // is reflected on the next load a second later. Minutes would quietly turn a
229
+ // correctness property into a staleness budget nobody asked for.
230
+ assert.ok(settle.WINDOW_MS <= 5000, `window is ${settle.WINDOW_MS}ms`);
231
+ });
232
+
233
+ test('a settle failure is LOGGED, not silently swallowed', () => {
234
+ // A surface that quietly stopped settling looks exactly like one with nothing
235
+ // to settle, which is the harder of the two to notice.
236
+ for (const file of ['help-requests.js', 'recommendations.js']) {
237
+ const src = read('modules', 'lifecycle', 'routes', file);
238
+ const fn = src.slice(src.indexOf('async function settleFirst'), src.indexOf('module.exports'));
239
+ assert.match(fn, /console\.error/, `${file} says so when a settle fails`);
240
+ assert.match(fn, /settle\.throttled\(/, `${file} settles through the throttle`);
241
+ }
242
+ });
243
+
244
+ // ---- 3. the archive, and the hole it must not reopen ----------------------
245
+
246
+ test('the archive addressee read uses the SAME predicate as the open read', async () => {
247
+ const r = recorder();
248
+ await help.listArchivedForBuilder({ builderId: 7, crafts: ['artist'] }, { pool: r.pool });
249
+ const { sql, params } = r.calls[0];
250
+ assert.match(sql, /h\.status <> 'open'/, 'settled, the mirror of the open read');
251
+ assert.match(sql, /h\.requested_by <> \$1/, 'your own settled asks are the OTHER list');
252
+ assert.match(sql, /h\.needs_builder_id = \$1 OR h\.needs_craft = ANY\(\$2::text\[\]\)/);
253
+ assert.deepEqual(params[1], ['artist'], 'crafts are bound, never interpolated');
254
+ });
255
+
256
+ test('a craft the caller does not hold is dropped before it binds', async () => {
257
+ // The C01 hole, one surface over: a craft value that did not come from the
258
+ // caller's own profile must never widen a scope.
259
+ const r = recorder();
260
+ await help.listArchivedForBuilder({ builderId: 7, crafts: ['wizard', 'ideator'] }, { pool: r.pool });
261
+ assert.deepEqual(r.calls[0].params[1], ['ideator']);
262
+ });
263
+
264
+ test('the archive ROUTE reads crafts from the profile and takes nothing from the query', () => {
265
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
266
+ // From the router.get line, not the handler name — requireBuilder sits between
267
+ // the two, and slicing from the name would silently skip the gate.
268
+ const idx = routes.indexOf("router.get('/help-requests/archive'");
269
+ assert.ok(idx > 0, 'the archive route exists');
270
+ const body = routes.slice(idx, routes.indexOf('errorCode', idx));
271
+ assert.match(body, /craftsForBuilder\(req\.builder\.id\)/);
272
+ assert.ok(!/req\.query|req\.body/.test(body), 'no caller-supplied narrowing OR widening');
273
+ assert.match(body, /api\.requireBuilder/ , 'and it is behind a sign-in like its siblings');
274
+ });
275
+
276
+ test('both archive reads are BOUNDED like every other read here', async () => {
277
+ const r = recorder();
278
+ await help.listArchivedForBuilder({ builderId: 7, crafts: [], limit: 99999 }, { pool: r.pool });
279
+ assert.equal(r.calls[0].params[2], help.MAX_ROWS, 'an absurd limit is capped, not honoured');
280
+ const r2 = recorder();
281
+ await help.listArchivedMine({ builderId: 7, limit: -3 }, { pool: r2.pool });
282
+ assert.equal(r2.calls[0].params[1], help.MAX_ROWS);
283
+ });
284
+
285
+ test('the asker-side archive is everything NOT open, not one named status', async () => {
286
+ // An exact-status filter would need the caller to enumerate the vocabulary,
287
+ // and the two would drift the next time a status is added.
288
+ const r = recorder();
289
+ await help.listArchivedMine({ builderId: 7 }, { pool: r.pool });
290
+ assert.match(r.calls[0].sql, /h\.requested_by = \$1 AND h\.status <> 'open'/);
291
+ });
292
+
293
+ // ---- 4. the open blocks and the archive must agree ------------------------
294
+
295
+ test('every list read settles BEFORE it lists, so a row is never in both at once', () => {
296
+ // The failure this prevents: a row that settles between two reads of one page
297
+ // load leaves one block and appears in another in the same render — shown
298
+ // twice, counted twice, and the figure strip lying about both.
299
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
300
+ for (const name of ['GET /help-requests/for-me', 'GET /help-requests/mine',
301
+ 'GET /help-requests/archive', 'GET /help-requests']) {
302
+ const idx = routes.indexOf(`'${name}'`);
303
+ assert.ok(idx > 0, `${name} exists`);
304
+ const body = routes.slice(idx, routes.indexOf('errorCode', idx));
305
+ assert.match(body, /await settleFirst\(\)/, `${name} settles before it lists`);
306
+ const settleAt = body.indexOf('settleFirst()');
307
+ const listAt = body.search(/help\.list(Open|Mine|Archived)/);
308
+ assert.ok(settleAt < listAt, `${name} settles BEFORE the read, not after it`);
309
+ }
310
+ });
311
+
312
+ test('the open reads carry the settled facts too, so one projection feeds both', () => {
313
+ // A hand-built archive-only projection between the query and the page is how a
314
+ // new field silently stops reaching the renderer that needs it.
315
+ const lib = read('modules', 'lifecycle', 'help-requests.js');
316
+ const listCols = lib.slice(lib.indexOf('const LIST_COLS'), lib.indexOf('const LIST_FROM'));
317
+ assert.match(listCols, /settled_by_login/);
318
+ assert.match(listCols, /settled_by_name/);
319
+ assert.match(listCols, /task_title/, 'a task id with no title cannot be rendered as a fact');
320
+ assert.match(lib, /const COLS = .*settled_reason/, 'and the write path returns the reason it wrote');
321
+ });
322
+
323
+ // ---- 5. how a row says it ended (pure) ------------------------------------
324
+
325
+ const L = (() => {
326
+ const src = read('modules', 'hall-ui', 'public', 'collab-lib.js');
327
+ const win = {};
328
+ // eslint-disable-next-line no-new-func
329
+ new Function('window', src)(win);
330
+ return win.OTBCollabLib;
331
+ })();
332
+
333
+ test('an OPEN row has no outcome sentence', () => {
334
+ assert.equal(L.outcomeLine({ status: 'open' }), '');
335
+ });
336
+
337
+ test('an automatic close says the LEDGER did it, and names the task', () => {
338
+ // Rendering it as a bare "Closed" would read as an anonymous person; rendering
339
+ // it as the asker's act would attribute something nobody did.
340
+ const line = L.outcomeLine({ status: 'answered', settled_reason: 'task_shipped', task_id: 1004224 });
341
+ assert.match(line, /by itself/);
342
+ assert.match(line, /1004224/);
343
+ });
344
+
345
+ test('a human close names the human', () => {
346
+ assert.equal(L.outcomeLine({ status: 'answered', settled_by: 9, settled_by_name: 'Lars' }),
347
+ 'Closed by Lars.');
348
+ // A GENERIC login, not a real one: audit-authorship.sh scans tests too, and a
349
+ // hardcoded reference to the original Archon fails it (the hit is invisible to
350
+ // ship-check and surfaces only in CI).
351
+ assert.equal(L.outcomeLine({ status: 'withdrawn', settled_by: 9, settled_by_login: 'amelia-w' }),
352
+ 'Withdrawn by amelia-w.');
353
+ });
354
+
355
+ test('a settled row with no settler and no reason still says something true', () => {
356
+ // Real state: settled before ADR 0330 added settled_by. "Closed." is honest;
357
+ // a blank right column is not.
358
+ assert.equal(L.outcomeLine({ status: 'answered' }), 'Closed.');
359
+ assert.equal(L.outcomeLine({ status: 'withdrawn' }), 'Withdrawn.');
360
+ });
361
+
362
+ // ---- 6. the migration stays inside the module's own wall ------------------
363
+
364
+ test('the migration alters only lifecycle-owned tables', () => {
365
+ // tasks and claims are core tables this module only READS. Fitness check 11
366
+ // permits a module migration on its own lifecycle_-prefixed tables and nothing
367
+ // else (ADR 0083 §Decision #5).
368
+ const sql = read('modules', 'lifecycle', 'migrations', 'lifecycle_012_collab_self_settling.sql');
369
+ const altered = [...sql.matchAll(/ALTER TABLE\s+(\w+)/g)].map((m) => m[1]);
370
+ assert.ok(altered.length > 0, 'it does alter something');
371
+ for (const t of altered) {
372
+ assert.ok(t.startsWith('lifecycle_'), `${t} is not this module's to alter`);
373
+ }
374
+ });
375
+
376
+ test('the settle predicates are indexed, since they run on the page load path', () => {
377
+ const sql = read('modules', 'lifecycle', 'migrations', 'lifecycle_012_collab_self_settling.sql');
378
+ assert.match(sql, /lifecycle_help_requests_open_task_idx/);
379
+ assert.match(sql, /lifecycle_task_recommendations_open_task_idx/);
380
+ assert.match(sql, /lifecycle_help_requests_settled_person_idx/);
381
+ assert.match(sql, /lifecycle_help_requests_settled_craft_idx/);
382
+ });
@@ -230,11 +230,26 @@ test('the door and the copy control are on the row, and the copy control is reac
230
230
  // its id, its asker, its task and the instruction — none of which is on the
231
231
  // face however short the ask is. So the shortest asks, the ones a session can
232
232
  // act on fastest, stopped being the only ones you could not take away.
233
+ //
234
+ // AND SINCE TASK 1004224 the control is built into its own `copy` binding
235
+ // rather than written inline in the bar, because an ARCHIVED row drops it —
236
+ // copying an ask for a session start is the affordance for going and doing it,
237
+ // and nobody is waiting on a settled ask. So this reads the binding and the
238
+ // bar separately. The invariant is unchanged and is what is asserted: the
239
+ // copy control is gated on `spent` and on nothing else — never on `s.more`,
240
+ // which would make the shortest asks the only ones you could not take away.
241
+ const copy = js.slice(js.indexOf('const copy = spent'), js.indexOf(';', js.indexOf('const copy = spent')));
242
+ assert.match(copy, /Copy for Session Start/, 'the copy control, in the owner\'s words');
243
+ assert.ok(!/s\.more/.test(copy), 'and it is not gated on there being more to read');
244
+ assert.ok(!/hidden/.test(copy), 'nor hidden');
245
+
233
246
  const bar = js.slice(js.indexOf('<div class="cb-ask">'), js.indexOf('</div>', js.indexOf('<div class="cb-ask">')));
234
- assert.match(bar, /\$\{door\}/, 'the door is in the bar');
235
- assert.match(bar, /Copy for Session Start/, 'and so is the copy control, in the owner\'s words');
247
+ assert.match(bar, /\$\{door\}\$\{copy\}/, 'the door and the copy control share one bar');
236
248
  assert.ok(!/hidden/.test(bar), 'the bar itself is never hidden');
237
- assert.ok(!/s\.more/.test(bar), 'and the copy control is not gated on there being more to read');
249
+ // An archived row asks for the words without the affordance, and gets a bar
250
+ // with neither rather than an empty one.
251
+ assert.match(js, /if \(!door && !copy\) return html \+ full;/, 'no empty bar when both are absent');
252
+ assert.match(js, /askHtml\(req, key, true\)/, 'and the archive is the caller that spends it');
238
253
  assert.match(js, /<p class="cb-ask__full" id="\$\{fid\}" hidden>/, 'only the ask is hidden');
239
254
 
240
255
  // What lands on the clipboard is the SESSION START — the skill, the id, the