@bongos/core 1.19.806 → 1.19.808

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.
@@ -274,8 +274,15 @@ const MD_SRC = read('md-reader.js');
274
274
  // (default) echoes the task back at 200 with goal_id applied from the request
275
275
  // body (so a reload's GET reflects the move); { status, data } refuses it with
276
276
  // that shape instead, for the refusal-surfacing tests.
277
- function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsList = null, patch = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims } = {}) {
277
+ // task 1002858 — `write` refuses the shaping routes the way `patch` refuses the
278
+ // goal move: { status, data }, applied to POST /dependencies, DELETE
279
+ // /dependencies/:id and POST /abandon. `writes` records method + path + body for
280
+ // all of them, because what these tests are actually about is the REQUEST the
281
+ // page issues — `requests` carries paths only and is left alone so the older
282
+ // assertions on it keep meaning what they meant.
283
+ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsList = null, patch = null, write = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims } = {}) {
278
284
  const requests = [];
285
+ const writes = [];
279
286
  const toasts = [];
280
287
  const els = new Map();
281
288
  const makeEl = () => ({
@@ -302,6 +309,15 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
302
309
  const client = {
303
310
  request: async (method, p, opts) => {
304
311
  requests.push(p);
312
+ if (method !== 'GET') writes.push({ method, path: p, body: (opts && opts.body) || null });
313
+ // task 1002858 — the shaping routes. Checked before the generic /tasks/\d+
314
+ // read below, which ignores method and would otherwise swallow them.
315
+ if (/\/tasks\/\d+\/dependencies/.test(p) || /\/tasks\/\d+\/abandon$/.test(p)) {
316
+ if (write && write.status && write.status !== 200) {
317
+ return { status: write.status, ok: false, data: write.data || null };
318
+ }
319
+ return { status: 200, ok: true, data: { ok: true } };
320
+ }
305
321
  // PATCH must be checked before the generic /tasks/\d+ read below — both
306
322
  // match the same path shape, and the read branch ignores method.
307
323
  if (method === 'PATCH' && /\/tasks\/\d+$/.test(p)) {
@@ -356,6 +372,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
356
372
  sub: () => documentObj.getElementById('task-sub').textContent,
357
373
  el: (id) => documentObj.getElementById(id),
358
374
  requests,
375
+ writes,
359
376
  toasts,
360
377
  };
361
378
  }
@@ -821,6 +838,171 @@ test('a successful move-to-goal PATCH re-renders the goal line against the new g
821
838
  'the goal line reflects the new goal after the PATCH triggers a reload');
822
839
  });
823
840
 
841
+ // ---- 11b. the task-SHAPING verbs (task 1002858) -----------------------------
842
+ //
843
+ // priority, dependency add/remove and abandon all had routes and no hall control,
844
+ // so a goal lead shaping their goal's work dropped to the CLI to retire a
845
+ // duplicate, re-rank a task or fix a dependency chain. Same posture as every
846
+ // other write on this page (ADR 0016): the server owns the gate, this only
847
+ // decides what to draw and prints the refusal it gets back.
848
+ //
849
+ // WHAT THESE TESTS ARE FOR is the REQUEST each control issues — the method, the
850
+ // path and the body — because a control that renders correctly and sends the
851
+ // wrong thing looks identical from the markup. That is the defect class the
852
+ // invite button in task 1002886 carried for months.
853
+
854
+ test('the shaping controls render for Metic/Archon and not for Xenos/Thetes', async () => {
855
+ // BASE_TASK is shipped, and a shipped task deliberately loses the retire
856
+ // control (see the guard test below) — so this one asks about a LIVE task.
857
+ const OPEN_TASK = { ...BASE_TASK, status: 'ready' };
858
+ for (const rank of ['metic', 'archon']) {
859
+ const r = renderTask(OPEN_TASK, { viewer: { rank } });
860
+ await r.boot(); await settle();
861
+ assert.match(r.html(), /id="priority-select"/, `${rank} sees the priority control`);
862
+ assert.match(r.html(), /id="dep-add-input"/, `${rank} sees the add-dependency control`);
863
+ assert.match(r.html(), /id="abandon-open-btn"/, `${rank} sees the retire control`);
864
+ }
865
+ for (const rank of ['xenos', 'thetes']) {
866
+ const r = renderTask(OPEN_TASK, { viewer: { rank } });
867
+ await r.boot(); await settle();
868
+ for (const id of ['priority-select', 'dep-add-input', 'abandon-open-btn']) {
869
+ assert.doesNotMatch(r.html(), new RegExp(`id="${id}"`), `${rank} must not see ${id}`);
870
+ }
871
+ }
872
+ });
873
+
874
+ test('setting a priority PATCHes the priority field, and unset is sent as null', async () => {
875
+ const r = renderTask({ ...BASE_TASK, priority: 5 }, { viewer: { rank: 'metic' } });
876
+ await r.boot(); await settle();
877
+ r.el('priority-select').value = '2';
878
+ await r.el('priority-btn')._handlers.click();
879
+ await settle();
880
+ const patchCall = r.writes.find((w) => w.method === 'PATCH');
881
+ assert.ok(patchCall, 'a PATCH was issued');
882
+ assert.equal(patchCall.body.priority, 2);
883
+
884
+ // '' is the UNSET option and must travel as null: "no priority" is a real state
885
+ // the optimizer sorts last (NULLS LAST), not a synonym for P5. Sending nothing
886
+ // would 400 as no_supported_fields.
887
+ const r2 = renderTask({ ...BASE_TASK, priority: 3 }, { viewer: { rank: 'metic' } });
888
+ await r2.boot(); await settle();
889
+ r2.el('priority-select').value = '';
890
+ await r2.el('priority-btn')._handlers.click();
891
+ await settle();
892
+ const clear = r2.writes.find((w) => w.method === 'PATCH');
893
+ assert.ok(clear && Object.prototype.hasOwnProperty.call(clear.body, 'priority'), 'priority is present');
894
+ assert.equal(clear.body.priority, null, 'and it is null, not "" and not 5');
895
+ });
896
+
897
+ test('re-setting the priority it already has sends nothing', async () => {
898
+ const r = renderTask({ ...BASE_TASK, priority: 3 }, { viewer: { rank: 'metic' } });
899
+ await r.boot(); await settle();
900
+ r.el('priority-select').value = '3';
901
+ await r.el('priority-btn')._handlers.click();
902
+ await settle();
903
+ assert.equal(r.writes.filter((w) => w.method === 'PATCH').length, 0, 'no write for a no-op');
904
+ assert.match(r.toasts.join(' '), /already P3/);
905
+ });
906
+
907
+ test('adding a dependency POSTs the id, and refuses a self-dependency before the network', async () => {
908
+ const r = renderTask(BASE_TASK, { viewer: { rank: 'metic' } });
909
+ await r.boot(); await settle();
910
+ r.el('dep-add-input').value = '4242';
911
+ await r.el('dep-add-btn')._handlers.click();
912
+ await settle();
913
+ const post = r.writes.find((w) => w.method === 'POST' && /\/dependencies$/.test(w.path));
914
+ assert.ok(post, 'a POST to /dependencies was issued');
915
+ assert.equal(post.body.depends_on_task_id, 4242);
916
+
917
+ const r2 = renderTask(BASE_TASK, { viewer: { rank: 'metic' } });
918
+ await r2.boot(); await settle();
919
+ r2.el('dep-add-input').value = String(BASE_TASK.id);
920
+ await r2.el('dep-add-btn')._handlers.click();
921
+ await settle();
922
+ assert.equal(r2.writes.length, 0, 'a self-dependency is caught before the request');
923
+ assert.match(r2.toasts.join(' '), /cannot wait for itself/i);
924
+ });
925
+
926
+ test('the × cuts an edge with DELETE, and only ever from "Blocked by"', async () => {
927
+ // The asymmetry is the point. DELETE /tasks/:id/dependencies/:depId removes the
928
+ // edge where THIS task waits on another — its own business. The "Blocks" list is
929
+ // the same edges from the far end: a × there would edit a DIFFERENT task's
930
+ // dependency set from a page not showing it.
931
+ const task = {
932
+ ...BASE_TASK,
933
+ dependencies: [{ id: 900, title: 'upstream', status: 'ready' }],
934
+ dependents: [{ id: 901, title: 'downstream', status: 'ready' }],
935
+ };
936
+ const r = renderTask(task, { viewer: { rank: 'metic' } });
937
+ await r.boot(); await settle();
938
+ const html = r.html();
939
+ assert.match(html, /data-cut-dep="900"/, 'the blocked-by edge is cuttable');
940
+ assert.doesNotMatch(html, /data-cut-dep="901"/, 'the blocks edge is NOT cuttable from here');
941
+
942
+ const xenos = renderTask(task, { viewer: { rank: 'xenos' } });
943
+ await xenos.boot(); await settle();
944
+ assert.doesNotMatch(xenos.html(), /data-cut-dep=/, 'and it is Metic+ only');
945
+ });
946
+
947
+ test('retire requires a reason, then POSTs it to /abandon', async () => {
948
+ const r = renderTask({ ...BASE_TASK, status: 'ready' }, { viewer: { rank: 'metic' } });
949
+ await r.boot(); await settle();
950
+ await r.el('abandon-open-btn')._handlers.click();
951
+ await settle();
952
+ assert.match(r.html(), /id="abandon-reason"/, 'the disclosure opens onto a reason field');
953
+
954
+ // A REASON IS REQUIRED though the server accepts an empty one: the value of the
955
+ // audit_log row is the sentence in it.
956
+ r.el('abandon-reason').value = ' ';
957
+ await r.el('abandon-btn')._handlers.click();
958
+ await settle();
959
+ assert.equal(r.writes.length, 0, 'a blank reason never reaches the server');
960
+ assert.match(r.toasts.join(' '), /Say why/i);
961
+
962
+ r.el('abandon-reason').value = 'superseded by the consolidated task';
963
+ await r.el('abandon-btn')._handlers.click();
964
+ await settle();
965
+ const post = r.writes.find((w) => /\/abandon$/.test(w.path));
966
+ assert.ok(post, 'a POST to /abandon was issued');
967
+ assert.equal(post.method, 'POST');
968
+ assert.equal(post.body.reason, 'superseded by the consolidated task');
969
+ assert.match(r.toasts.join(' '), /Promote restores it/i,
970
+ 'and the toast names the way back, so a misclick is visibly recoverable');
971
+ });
972
+
973
+ test('retire is replaced by a sentence where the server would only refuse it', async () => {
974
+ // Both of these are 409s the server raises anyway AND are knowable from this
975
+ // record. Offering a button that cannot work teaches the reader the page guesses.
976
+ const shipped = renderTask({ ...BASE_TASK, status: 'shipped' }, { viewer: { rank: 'metic' } });
977
+ await shipped.boot(); await settle();
978
+ assert.doesNotMatch(shipped.html(), /id="abandon-open-btn"/, 'no retire control on shipped');
979
+ assert.match(shipped.html(), /immutable history/i, 'it says why instead');
980
+
981
+ const held = renderTask({ ...BASE_TASK, status: 'active' }, {
982
+ viewer: { rank: 'metic' },
983
+ lastClaim: { github_login: 'someone', claimed_at: '2026-09-01T00:00:00Z', released_at: null },
984
+ });
985
+ await held.boot(); await settle();
986
+ assert.doesNotMatch(held.html(), /id="abandon-open-btn"/, 'no retire control while a claim is live');
987
+ assert.match(held.html(), /released/i, 'it names the step that unblocks it');
988
+ });
989
+
990
+ test("a shaping refusal surfaces the server's own sentence, verbatim", async () => {
991
+ const serverMsg = 'release the active claim before abandoning';
992
+ const r = renderTask({ ...BASE_TASK, status: 'ready' }, {
993
+ viewer: { rank: 'metic' },
994
+ write: { status: 409, data: { error: { code: 'task_has_active_claim', message: serverMsg } } },
995
+ });
996
+ await r.boot(); await settle();
997
+ await r.el('abandon-open-btn')._handlers.click();
998
+ await settle();
999
+ r.el('abandon-reason').value = 'no longer relevant';
1000
+ await r.el('abandon-btn')._handlers.click();
1001
+ await settle();
1002
+ assert.ok(r.toasts.some((m) => m.includes(serverMsg)),
1003
+ `the server's sentence is printed as-is — got ${JSON.stringify(r.toasts)}`);
1004
+ });
1005
+
824
1006
  test("a move-to-goal refusal (403) surfaces the server's own sentence, verbatim", async () => {
825
1007
  const task = { ...BASE_TASK, goal_id: 1000071 };
826
1008
  const goalsList = [{ id: 1000071, title: 'Old goal', status: 'open' }, { id: 555, title: 'New goal', status: 'open' }];
@@ -50,13 +50,73 @@ test('cronCadence refuses what its vocabulary cannot describe, rather than guess
50
50
  // Unclassifiable must mean "never matches a declared cadence" — the safe
51
51
  // direction. A clever cron that silently counted as `weekly` would reopen
52
52
  // exactly the drift this check exists to catch.
53
- for (const expr of ['*/5 * * * *', '0 6 * * 1-5', '0 6 * * 1,4', '0 0 1 1 *', '17 6 * *', '', 'nonsense']) {
53
+ for (const expr of ['0 6 * * 1-5', '0 6 * * 1,4', '0 0 1 1 *', '17 6 * *', '', 'nonsense']) {
54
54
  assert.equal(rs.cronCadence(expr), null, `${expr} should not classify`);
55
55
  }
56
56
  // A day-of-month AND day-of-week together is an OR in cron — neither period.
57
57
  assert.equal(rs.cronCadence('17 6 3 * 1'), null);
58
58
  });
59
59
 
60
+ test('cronCadence classifies a uniform sub-hourly step (task 1003674)', () => {
61
+ // The vocabulary used to stop at `hourly`, so a routine meant to fire every
62
+ // 15 minutes had no way to declare it: the cron that realizes it read as
63
+ // unclassifiable and hard-failed, leaving "declare nothing, stay dormant" as
64
+ // the only green option — the state this check exists to refuse.
65
+ assert.equal(rs.cronCadence('*/15 * * * *'), '15min');
66
+ assert.equal(rs.cronCadence('*/5 * * * *'), '5min');
67
+ assert.equal(rs.cronCadence('*/30 * * * *'), '30min');
68
+ assert.equal(rs.cronCadence('0/15 * * * *'), '15min', 'the 0/N spelling is the same schedule');
69
+ });
70
+
71
+ test('cronCadence refuses a step that is not a true period (task 1003674)', () => {
72
+ // A cron step restarts every hour, so only a divisor of 60 is uniform:
73
+ // */7 fires at :00…:56 then waits 4. Calling that "7min" would be the same
74
+ // silent drift between declared and realized period the check is here for.
75
+ for (const expr of ['*/7 * * * *', '*/8 * * * *', '*/45 * * * *', '*/60 * * * *', '*/0 * * * *']) {
76
+ assert.equal(rs.cronCadence(expr), null, `${expr} is not a uniform period`);
77
+ }
78
+ // A step narrowed to one hour or one weekday is not the sub-hourly period it
79
+ // resembles — it only fires inside that window.
80
+ assert.equal(rs.cronCadence('*/15 6 * * *'), null);
81
+ assert.equal(rs.cronCadence('*/15 * * * 1'), null);
82
+ });
83
+
84
+ test('isCadence accepts the named periods and the divisor-of-60 Nmin form (task 1003674)', () => {
85
+ for (const ok of ['hourly', 'daily', 'weekly', 'monthly', '15min', '5min', '30min', '1min']) {
86
+ assert.equal(rs.isCadence(ok), true, `${ok} should be declarable`);
87
+ }
88
+ for (const no of ['7min', '60min', '90min', 'min', '0min', 'fortnightly', '', null, undefined]) {
89
+ assert.equal(rs.isCadence(no), false, `${no} should not be declarable`);
90
+ }
91
+ });
92
+
93
+ test('a routine declaring a sub-hourly cadence matches a step cron that fires it (task 1003674)', () => {
94
+ // End-to-end through evaluate(): declaring 15min is only green when a
95
+ // committed scheduler actually fires it at 15 minutes.
96
+ const wf = (cron) => [{
97
+ file: 'sweep.yml',
98
+ text: `on:\n schedule:\n - cron: '${cron}'\njobs:\n go:\n steps:\n - run: node scripts/gds/run-routine.js sweepy\n`,
99
+ }];
100
+ const declared = routines([{ name: 'sweepy', cadence: '15min', steps: [] }]);
101
+ assert.deepEqual(rs.evaluate(declared, wf('*/15 * * * *')).violations, []);
102
+
103
+ // Wrong period, and the check names both sides rather than shrugging.
104
+ const drifted = rs.evaluate(declared, wf('17 6 * * *')).violations;
105
+ assert.equal(drifted.length, 1);
106
+ assert.match(drifted[0], /declares cadence: 15min/);
107
+ assert.match(drifted[0], /-> daily|→ daily/);
108
+
109
+ // A non-divisor step cannot satisfy it either — it classifies as nothing.
110
+ assert.match(rs.evaluate(declared, wf('*/7 * * * *')).violations[0], /unclassifiable/);
111
+ });
112
+
113
+ test('an undeclarable cadence is refused by name (task 1003674)', () => {
114
+ const v = rs.evaluate(routines([{ name: 'sweepy', cadence: '7min', steps: [] }]), []).violations;
115
+ assert.equal(v.length, 1);
116
+ assert.match(v[0], /unknown cadence "7min"/);
117
+ assert.match(v[0], /N divides 60/);
118
+ });
119
+
60
120
  test('workflowCrons reads every cron a workflow declares, quoted or bare, ignoring trailing comments', () => {
61
121
  const y = [" schedule:", " - cron: '17 6 * * 1'", ' - cron: "0 3 * * *"', ' - cron: 5 * * * * # bare + comment'].join('\n');
62
122
  assert.deepEqual(rs.workflowCrons(y), ['17 6 * * 1', '0 3 * * *', '5 * * * *']);