@bongos/core 1.20.10 → 1.20.12

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.
@@ -13,6 +13,16 @@
13
13
  // feed. Latent when found (every blocker was open), but eleven tasks were one
14
14
  // resolve away from vanishing.
15
15
  //
16
+ // TASK 1004190 WIDENED THIS FILE TO THE SECOND CLAUSE. core_239's header deferred
17
+ // the OTHER gap in the same view by name — it never consulted `dependencies`, so a
18
+ // 'ready' task whose predecessor had not shipped was advertised by /builder-start
19
+ // and then refused by POST /claims with DEPS_NOT_SHIPPED. core_264 closes it. The
20
+ // "LAST definition" test below is what forced the blocker clause to be carried
21
+ // forward into that rewrite rather than lost the way 177 lost migration 176's;
22
+ // it now pins core_264, and every clause assertion here reads the LIVE definition,
23
+ // because a correct older migration still sitting in the tree proves nothing about
24
+ // what Postgres runs.
25
+ //
16
26
  // Run: node tests/claimable_blocker_status.mjs
17
27
 
18
28
  import { strict as assert } from 'node:assert';
@@ -25,7 +35,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
25
35
  const repoRoot = join(__dirname, '..');
26
36
  const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
27
37
 
28
- const MIG = 'core_239_claimable_respects_blocker_status.sql';
38
+ const MIG = 'core_264_claimable_respects_dependencies.sql';
29
39
  const mig = read('migrations', MIG);
30
40
  // Comments carry the rationale; strip them when asserting on the executable body
31
41
  // so a claim in prose can never stand in for the real SQL.
@@ -33,7 +43,7 @@ const body = mig.replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
33
43
 
34
44
  const { test, summary } = makeRunner();
35
45
 
36
- await test('239 replaces claimable_tasks, in core_231-s drop-and-create shape', () => {
46
+ await test('the live migration replaces claimable_tasks, in core_231-s drop-and-create shape', () => {
37
47
  assert.match(body, /DROP VIEW IF EXISTS claimable_tasks;/);
38
48
  assert.match(body, /CREATE VIEW claimable_tasks AS/);
39
49
  });
@@ -74,10 +84,15 @@ await test('the column list stays the t.* wildcard (core_227-s silent-column les
74
84
  assert.match(body, /pms_calibrated_minutes\(t\.est_minutes, t\.kind\) AS est_minutes_calibrated/);
75
85
  });
76
86
 
77
- await test('239 is the LAST definition of claimable_tasks in migrations/', () => {
87
+ await test('the pinned migration is the LAST definition of claimable_tasks in migrations/', () => {
78
88
  // If a later migration redefines the view, it inherits whatever that one says —
79
89
  // which is exactly how 177 silently dropped migration 176's blocker clause from
80
90
  // task_is_fully_unblocked. Catch the next one at the source.
91
+ //
92
+ // This pin is not theoretical: it is what made core_264 (task 1004190) carry
93
+ // core_239's blocker clause forward instead of replacing it. Moving MIG to the
94
+ // new file is HALF the job — every clause assertion above then runs against the
95
+ // new definition, and a rewrite that dropped one reds this file.
81
96
  const defs = readdirSync(join(repoRoot, 'migrations'))
82
97
  .filter((f) => f.endsWith('.sql'))
83
98
  .filter((f) => /CREATE VIEW claimable_tasks/.test(read('migrations', f)))
@@ -201,4 +216,146 @@ await test('the promotion sweep keeps its own, deliberately different, guard', (
201
216
  'the sweep resolves the blocker too — it just answers the legacy-link case differently');
202
217
  });
203
218
 
219
+ /* ───────────────────────────────────────────────────────────────────────────
220
+ Task 1004190 / finding #60 — THE SECOND CLAUSE.
221
+
222
+ core_239 deferred the other half of the same defect by name: the view filtered
223
+ on status, smoke titles, blockers and touches-overlap and never once on
224
+ `dependencies`, so a 'ready' task whose predecessor had not shipped was
225
+ advertised by /builder-start and then refused by POST /claims. core_264 adds
226
+ the clause, and it has the same three copies the blocker guard does — so it
227
+ gets the same three-file treatment, because a migration alone did not move the
228
+ feed last time either.
229
+ ─────────────────────────────────────────────────────────────────────────── */
230
+
231
+ // The dependency predicate, lifted out of the LIVE migration. Everything below
232
+ // compares the JS copies against what Postgres actually runs rather than against a
233
+ // second hand-written spelling of the same idea — the same reason GUARDED above is
234
+ // derived from the migration and not typed out here.
235
+ const DEPS_START = 'NOT EXISTS ( SELECT 1 FROM dependencies d';
236
+ const DEPS_END = "x.status <> 'achieved'))) )";
237
+ function depsClause(src, what) {
238
+ const i = src.indexOf(DEPS_START);
239
+ const j = i < 0 ? -1 : src.indexOf(DEPS_END, i);
240
+ assert.ok(i >= 0 && j > i, `${what} carries the dependency clause`);
241
+ return src.slice(i, j + DEPS_END.length);
242
+ }
243
+
244
+ await test('the view resolves the same three gating sources as the claim gate', () => {
245
+ const clause = depsClause(body, MIG);
246
+ assert.match(clause, /d\.from_kind = 'task' AND d\.from_id = t\.id/, "the task's own edges");
247
+ assert.match(clause, /d\.from_kind = 'goal' AND d\.from_id = t\.goal_id/, 'plus what it inherits from its goal');
248
+ assert.match(clause, /d\.from_kind = 'criterion' AND d\.from_id IN \( SELECT tc\.criterion_id FROM task_criteria tc WHERE tc\.task_id = t\.id\)/,
249
+ 'and from every criterion it fulfils');
250
+ assert.match(clause, /d\.to_kind = 'task' AND EXISTS \(SELECT 1 FROM tasks x WHERE x\.id = d\.to_id AND x\.status <> 'shipped'\)/);
251
+ assert.match(clause, /d\.to_kind = 'criterion' AND EXISTS \(SELECT 1 FROM done_when_criteria x WHERE x\.id = d\.to_id AND x\.satisfied = false\)/);
252
+ assert.match(clause, /d\.to_kind = 'goal' AND EXISTS \(SELECT 1 FROM goals x WHERE x\.id = d\.to_id AND x\.status <> 'achieved'\)/);
253
+ });
254
+
255
+ await test('the view mirrors unsatisfiedDeps (the CLAIM path), not task_is_fully_unblocked (promotion)', () => {
256
+ // The two dependency predicates in this codebase disagree on exactly one case,
257
+ // and both sides of the disagreement are deliberate: an edge whose target row no
258
+ // longer exists gates PROMOTION (core_245's header calls that fail-closed) and
259
+ // does NOT gate a claim (every unsatisfiedDeps arm JOINs the target, so an orphan
260
+ // yields no row). Calling task_is_fully_unblocked from the view is the shorter
261
+ // line and it would hide a task POST /claims accepts — core_239's bug inverted,
262
+ // with no blocker row to explain it. The done-when of 1004190 is that the feed
263
+ // and the gate AGREE, so this asserts WHICH one the view copied.
264
+ assert.doesNotMatch(body, /task_is_fully_unblocked/,
265
+ 'the feed must not borrow the promotion predicate — it answers the orphaned-edge case the other way');
266
+ const deps = code('modules', 'lifecycle', 'db-deps-criteria.js');
267
+ const clause = depsClause(body, MIG);
268
+ for (const frag of [
269
+ "d.from_kind = 'task' AND d.from_id = t.id",
270
+ "d.from_kind = 'goal' AND d.from_id = t.goal_id",
271
+ "d.from_kind = 'criterion' AND d.from_id IN ( SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id)",
272
+ "x.status <> 'shipped'",
273
+ 'x.satisfied = false',
274
+ "x.status <> 'achieved'",
275
+ ]) {
276
+ assert.ok(deps.includes(frag),
277
+ `unsatisfiedDeps no longer spells it "${frag}" — the view copied that spelling and must be re-checked against it`);
278
+ assert.ok(clause.includes(frag), `the view must carry "${frag}" exactly as the claim gate does`);
279
+ }
280
+ });
281
+
282
+ await test('each runtime copy declares the dependency guard ONCE, and the claimable query uses it', () => {
283
+ for (const f of RUNTIME) {
284
+ const src = code(...f);
285
+ const decls = (src.match(/const UNSHIPPED_DEPS_GUARD = /g) || []).length;
286
+ const uses = (src.match(/\$\{UNSHIPPED_DEPS_GUARD\}/g) || []).length;
287
+ assert.equal(decls, 1, `${f.join('/')} must declare the dependency guard exactly once`);
288
+ // ONE, where the blocker guard has two — and that asymmetry is the decision,
289
+ // not an omission. The session-fit body is only ever JOINed onto the claimable
290
+ // body (here and in listClaimableTasks), so every row it can reach is already
291
+ // guarded; putting the clause there would change what the tasks_session_fit
292
+ // VIEW means with no migration saying so.
293
+ assert.equal(uses, 1, `${f.join('/')}: the CLAIMABLE body interpolates it; the session-fit body deliberately does not`);
294
+ }
295
+ });
296
+
297
+ await test('each runtime copy-s dependency guard is the live migration-s predicate, verbatim', () => {
298
+ const fromMigration = depsClause(body, MIG);
299
+ for (const f of RUNTIME) {
300
+ const decl = (code(...f).match(/const UNSHIPPED_DEPS_GUARD = `[^`]*`/) || [])[0];
301
+ assert.ok(decl, `${f.join('/')} declares the dependency guard as a template literal`);
302
+ assert.ok(decl.includes(fromMigration),
303
+ `${f.join('/')}'s dependency guard must be the migration's predicate verbatim — a paraphrase is how the next one drifts`);
304
+ }
305
+ });
306
+
307
+ await test('the dependency guard actually reaches the composed claimable statement', () => {
308
+ const fromMigration = depsClause(body, MIG);
309
+ for (const f of RUNTIME) {
310
+ const raw = read(...f);
311
+ const gm = raw.match(/const UNSHIPPED_DEPS_GUARD = `([\s\S]*?)`;/);
312
+ assert.ok(gm, `${f.join('/')} declares the dependency guard`);
313
+ const expanded = raw.split('${UNSHIPPED_DEPS_GUARD}').join(gm[1]).replace(/\s+/g, ' ');
314
+ const hits = expanded.split(fromMigration).length - 1;
315
+ assert.equal(hits, 2, `${f.join('/')}: one declaration plus one expanded use`);
316
+ }
317
+ });
318
+
319
+ await test('every lookup the dependency clause makes is index-covered', () => {
320
+ // This runs on the /builder-start hot path, so "a correlated NOT EXISTS with no
321
+ // index migration beside it" is the right thing to ask about the clause. The
322
+ // answer is that nothing new was needed — but that answer lived in two OTHER
323
+ // migrations, which is to say it was unverifiable from here and therefore not
324
+ // really an answer. Pin it, so deleting the cover reds this file instead of
325
+ // quietly turning the queue into a seq scan.
326
+ const allSql = readdirSync(join(repoRoot, 'migrations'))
327
+ .filter((f) => f.endsWith('.sql'))
328
+ .map((f) => read('migrations', f))
329
+ .join('\n');
330
+ assert.match(allSql, /CREATE INDEX IF NOT EXISTS idx_dependencies_from ON dependencies \(from_kind, from_id\)/,
331
+ 'all three from_kind arms probe (from_kind, from_id) — migration 163');
332
+ assert.match(allSql, /CREATE INDEX IF NOT EXISTS idx_task_criteria_task\s+ON task_criteria \(task_id\)/,
333
+ "the criterion arm's IN probes task_criteria by task_id — migration 055");
334
+ assert.doesNotMatch(allSql, /DROP INDEX[^;]*idx_dependencies_from/,
335
+ 'a later migration drops the index the claimable feed now depends on');
336
+ assert.doesNotMatch(allSql, /DROP INDEX[^;]*idx_task_criteria_task/,
337
+ 'a later migration drops the index the criterion arm now depends on');
338
+ // The three doneness EXISTS lookups are by primary key (tasks.id,
339
+ // done_when_criteria.id, goals.id) and need nothing of their own.
340
+ });
341
+
342
+ await test('the dependency clause closes every paren it opens, in all three copies', () => {
343
+ // Nothing in this repo can reach a Postgres, so a stray paren in a clause this
344
+ // nested ships green and takes the ENTIRE claimable feed down with a syntax error
345
+ // on the first query after deploy — the feed, the optimizer and /builder-start at
346
+ // once. Counting is the cheapest real check available, and it is the failure mode
347
+ // hand-written inline SQL actually has.
348
+ const sources = [[body, MIG], ...RUNTIME.map((f) => [code(...f), f.join('/')])];
349
+ for (const [src, what] of sources) {
350
+ const clause = depsClause(src, what);
351
+ let depth = 0;
352
+ for (const ch of clause) {
353
+ if (ch === '(') depth++;
354
+ else if (ch === ')') depth--;
355
+ assert.ok(depth >= 0, `${what}: a closing paren with nothing open`);
356
+ }
357
+ assert.equal(depth, 0, `${what}: the clause closes every group it opens`);
358
+ }
359
+ });
360
+
204
361
  summary();
@@ -0,0 +1,185 @@
1
+ // tests/speciality_offer_ui.mjs — a speciality's OWNER chooses what it offers,
2
+ // from the hall (task 1004404; ADR 0310 §1).
3
+ //
4
+ // Before this only the API could set a speciality's offered skills, so nothing
5
+ // made in the hall offered any and the adoption walkthrough never appeared. These
6
+ // pin that the owner can choose on create AND afterwards, that each choice shows
7
+ // the skill's plain description, that only installed skills are choices, and that
8
+ // a skill removed since is named rather than silently kept.
9
+ //
10
+ // Like tests/speciality_walkthrough_ui.mjs this EXECUTES the real
11
+ // modules/hall-ui/public/settings-specialities.js in a vm under a minimal DOM
12
+ // stub against a fake API, and drives the delegated handlers the page wires.
13
+ //
14
+ // Run: node tests/speciality_offer_ui.mjs
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import vm from 'node:vm';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { makeRunner } from './helpers.mjs';
21
+
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings-specialities.js'), 'utf8');
24
+ const HTML = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
25
+ const { test, summary } = makeRunner();
26
+
27
+ const INSTALLED = [
28
+ { name: 'builder-cost', does: 'Records money spent on the project.', cost: 'Free. It only adds an entry.' },
29
+ { name: 'recall', does: 'Searches everything the project already knows.', cost: 'Free, and it changes nothing.' },
30
+ { name: 'status', does: 'Shows how far along a version is.', cost: 'Free, and it changes nothing.' },
31
+ ];
32
+
33
+ const mine = (over = {}) => ({ id: 12, name: 'Pricing', discipline: 'engineer', summary: '', contract_md: '', visibility: 'private', adoptable: true, mine: true, skills: ['recall', 'retired-skill'], ...over });
34
+ const theirs = (over = {}) => ({ id: 9, name: 'Economics', discipline: 'ideator', summary: '', contract_md: '', visibility: 'public', adoptable: true, mine: false, skills: ['recall'], ...over });
35
+
36
+ // The checked boxes a form would submit, read off the rendered markup.
37
+ const checkedIn = (html) => [...html.matchAll(/<input type="checkbox" name="skill" value="([^"]+)" checked/g)].map((m) => m[1]);
38
+ const fakeForm = (dataset, values) => ({
39
+ dataset,
40
+ querySelectorAll: (sel) => (sel === 'input[name="skill"]:checked' ? values.map((value) => ({ value })) : []),
41
+ });
42
+
43
+ async function boot({ catalog = [mine(), theirs()] } = {}) {
44
+ const requests = [];
45
+ const toasts = [];
46
+ const els = new Map();
47
+ const handlers = new Map();
48
+ const makeEl = (id) => ({
49
+ id, hidden: id === 'specialities-scroll', innerHTML: '', textContent: '', elements: {}, dataset: {}, open: false,
50
+ addEventListener(type, fn) { handlers.set(`${id}:${type}`, fn); },
51
+ });
52
+ for (const id of ['specialities-scroll', 'specialities-rows', 'speciality-suggestions', 'specialities-status', 'speciality-create', 'speciality-create-offer', 'speciality-create-skills']) els.set(id, makeEl(id));
53
+ // The create form's fields, as settings.html declares them.
54
+ const create = els.get('speciality-create');
55
+ const field = (value) => ({ value, addEventListener() {} });
56
+ create.elements = { name: field(''), discipline: field(''), summary: field(''), contract_md: field(''), visibility: field('private'), sellable: { checked: false, disabled: false, addEventListener() {} } };
57
+ create.reset = () => { create.elements.name.value = ''; };
58
+ create.querySelectorAll = () => [];
59
+
60
+ const fetch = async (url, opts = {}) => {
61
+ const method = opts.method || 'GET';
62
+ const p = url.replace('/api/bongos', '');
63
+ requests.push({ method, path: p, body: opts.body ? JSON.parse(opts.body) : undefined });
64
+ let body = {};
65
+ if (method === 'GET' && p === '/specialities') body = { specialities: catalog };
66
+ else if (method === 'GET' && p === '/me/specialities') body = { adoptions: [] };
67
+ else if (method === 'GET' && p === '/specialities/offered') body = { suggestions: [] };
68
+ else if (method === 'GET' && p === '/specialities/installed-skills') body = { skills: INSTALLED };
69
+ else if (method === 'PATCH') body = { speciality: { id: 12 } };
70
+ else if (method === 'POST' && p === '/specialities') body = { speciality: { id: 13 } };
71
+ return { ok: true, status: 200, json: async () => body };
72
+ };
73
+
74
+ const document = { readyState: 'complete', getElementById: (id) => els.get(id) || null, addEventListener() {}, querySelector: () => null };
75
+ const window = {
76
+ OTB: {
77
+ escapeHtml: (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
78
+ $: () => null, toast: (m) => toasts.push(m), roleAxisLabel: () => 'Role',
79
+ },
80
+ location: { assign() {} },
81
+ };
82
+ vm.runInNewContext(SRC, { window, document, fetch, JSON, Promise, Map, Set, String, Number, Array, Object, Math, RegExp, console });
83
+ const settle = async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0)); };
84
+ await settle();
85
+
86
+ const rows = els.get('specialities-rows');
87
+ async function click(act, id) {
88
+ const btn = { dataset: { act, id: String(id) } };
89
+ handlers.get('specialities-rows:click')({ target: { closest: (sel) => (sel === 'button[data-act]' ? btn : null) } });
90
+ await settle();
91
+ }
92
+ async function submitOffer(id, values) {
93
+ const form = fakeForm({ id: String(id) }, values);
94
+ handlers.get('specialities-rows:submit')({ target: { closest: (sel) => (sel === 'form.speciality-offer' ? form : null) }, preventDefault() {} });
95
+ await settle();
96
+ }
97
+ return { els, rows, requests, toasts, click, submitOffer, handlers, create, settle };
98
+ }
99
+
100
+ const writes = (h) => h.requests.filter((r) => r.method !== 'GET');
101
+ const installedReads = (h) => h.requests.filter((r) => r.path === '/specialities/installed-skills').length;
102
+
103
+ await test('only a speciality you own has an "Offered skills" control', async () => {
104
+ const h = await boot();
105
+ const buttons = [...h.rows.innerHTML.matchAll(/data-act="offer" data-id="(\d+)"/g)].map((m) => m[1]);
106
+ assert.deepEqual(buttons, ['12']);
107
+ });
108
+
109
+ await test('opening it lists every installed skill with its plain description and cost, the current offer ticked', async () => {
110
+ const h = await boot();
111
+ assert.equal(installedReads(h), 0, 'nothing is fetched until the owner asks');
112
+ await h.click('offer', 12);
113
+ assert.equal(installedReads(h), 1);
114
+ const html = h.rows.innerHTML;
115
+ for (const k of INSTALLED) {
116
+ assert.ok(html.includes(`value="${k.name}"`), `${k.name} is a choice`);
117
+ assert.ok(html.includes(k.does), `${k.name} shows what it does`);
118
+ assert.ok(html.includes(k.cost), `${k.name} shows what it costs`);
119
+ }
120
+ assert.deepEqual(checkedIn(html), ['recall'], 'what it offers today is ticked');
121
+ assert.match(html, /Offering a skill turns nothing on/);
122
+ });
123
+
124
+ await test('a skill it offers that the instance no longer has is named, not offered as a choice', async () => {
125
+ const h = await boot();
126
+ await h.click('offer', 12);
127
+ assert.ok(!h.rows.innerHTML.includes('value="retired-skill"'), 'not a checkbox');
128
+ assert.match(h.rows.innerHTML, /No longer on this project, so saving drops it from the offer: <code>retired-skill<\/code>/);
129
+ });
130
+
131
+ await test('saving sends exactly the ticked skills as one PATCH, and nothing is written before', async () => {
132
+ const h = await boot();
133
+ await h.click('offer', 12);
134
+ assert.equal(writes(h).length, 0);
135
+ await h.submitOffer(12, ['recall', 'status']);
136
+ const w = writes(h);
137
+ assert.equal(w.length, 1);
138
+ assert.equal(w[0].method, 'PATCH');
139
+ assert.equal(w[0].path, '/specialities/12');
140
+ assert.deepEqual(w[0].body, { skills: ['recall', 'status'] });
141
+ assert.ok(h.toasts.some((m) => /offers 2 skills/.test(m)));
142
+ assert.ok(!/class="speciality-offer"/.test(h.rows.innerHTML), 'the editor closes on save');
143
+ });
144
+
145
+ await test('offering nothing is a choice too', async () => {
146
+ const h = await boot();
147
+ await h.click('offer', 12);
148
+ await h.submitOffer(12, []);
149
+ assert.deepEqual(writes(h)[0].body, { skills: [] });
150
+ });
151
+
152
+ await test('the installed list is read once per page, however often the editor opens', async () => {
153
+ const h = await boot();
154
+ await h.click('offer', 12);
155
+ await h.click('offer', 12); // close
156
+ await h.click('offer', 12); // open again
157
+ assert.equal(installedReads(h), 1);
158
+ });
159
+
160
+ await test('the create form offers the same choices, filled on first open, and sends them with the new speciality', async () => {
161
+ const h = await boot();
162
+ const box = h.els.get('speciality-create-offer');
163
+ const host = h.els.get('speciality-create-skills');
164
+ box.open = true;
165
+ h.handlers.get('speciality-create-offer:toggle')();
166
+ await h.settle();
167
+ for (const k of INSTALLED) assert.ok(host.innerHTML.includes(`value="${k.name}"`) && host.innerHTML.includes(k.does));
168
+ assert.deepEqual(checkedIn(host.innerHTML), [], 'a new speciality starts offering nothing');
169
+ h.create.elements.name.value = 'New one';
170
+ h.create.elements.discipline.value = 'engineer';
171
+ h.create.querySelectorAll = (sel) => (sel === 'input[name="skill"]:checked' ? [{ value: 'status' }] : []);
172
+ h.handlers.get('speciality-create:submit')({ preventDefault() {} });
173
+ await h.settle();
174
+ const post = writes(h).find((r) => r.method === 'POST' && r.path === '/specialities');
175
+ assert.ok(post, 'created');
176
+ assert.deepEqual(post.body.skills, ['status']);
177
+ });
178
+
179
+ await test('settings.html carries the create form\'s picker inside the form it submits with', () => {
180
+ const form = HTML.slice(HTML.indexOf('<form id="speciality-create"'), HTML.indexOf('</form>', HTML.indexOf('<form id="speciality-create"')));
181
+ assert.match(form, /<details id="speciality-create-offer"/);
182
+ assert.match(form, /id="speciality-create-skills"/);
183
+ });
184
+
185
+ summary();
@@ -304,6 +304,15 @@ try {
304
304
  store.speciality = saved;
305
305
  });
306
306
 
307
+ await test('GET /specialities/installed-skills lists every installed skill, explained, for the owner\'s picker (task 1004404)', async () => {
308
+ const r = await call('GET', '/specialities/installed-skills');
309
+ assert.equal(r.status, 200, `not captured by /specialities/:id: ${JSON.stringify(r.body)}`);
310
+ assert.deepEqual(r.body.skills.map((s) => s.name), ['costing', 'recall', 'status'], 'every installed skill, by name — the choices are what the instance has');
311
+ const recall = r.body.skills.find((s) => s.name === 'recall');
312
+ assert.equal(recall.does, 'Search what the project knows. Read-only.', 'the same explanation the walkthrough shows');
313
+ assert.match(recall.cost, /^Read-only/);
314
+ });
315
+
307
316
  await test('PUT /specialities/:id/skills needs an active adoption', async () => {
308
317
  store.adoption = null;
309
318
  const r = await call('PUT', '/specialities/9/skills', { enabled_skills: ['recall'] });