@bongos/core 1.19.582 → 1.19.584
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 +31 -16
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +34 -33
- package/docs/copy-registry.json +52 -43
- package/docs/module-api-changelog.md +4 -0
- package/modules/provisioning/paid-shape-gate.js +57 -0
- package/modules/provisioning/routes/provisioning.js +8 -2
- package/modules/public-landing/public/projects.html +18 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/provisioning_paid_shape_gate.mjs +172 -0
- package/tests/wizard_draft_resume.mjs +194 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// tests/provisioning_paid_shape_gate.mjs
|
|
2
|
+
//
|
|
3
|
+
// The paid-shape gate on POST /provisioning/instances (task 1003682).
|
|
4
|
+
//
|
|
5
|
+
// Before this, the create route's only gate was requireBuilder, and `hosting_shape` was
|
|
6
|
+
// caller-supplied and validated only against the allowed set. `dedicated` is the ONE shape
|
|
7
|
+
// whose runner leg creates a real DigitalOcean droplet (scripts/gds/provision.js — billed
|
|
8
|
+
// monthly, defaulting to s-2vcpu-4gb), so any authenticated builder — a Xenos included —
|
|
9
|
+
// could queue recurring infrastructure spend the owner never approved. The route that
|
|
10
|
+
// SPENDS was less gated than force-teardown, which stops spending and sits behind
|
|
11
|
+
// provisioning.fleet.manage.
|
|
12
|
+
//
|
|
13
|
+
// The fix gates the SHAPE, not the route: every $0 shape (standalone, co-tenant) stays open
|
|
14
|
+
// to any authenticated builder, because ordinary project creation must not require a rank.
|
|
15
|
+
// Only `dedicated` demands provisioning.fleet.manage — deliberately the same atom that
|
|
16
|
+
// already guards the fleet cost-ledger and force-teardown, since spending on the fleet and
|
|
17
|
+
// managing the fleet are one authority.
|
|
18
|
+
//
|
|
19
|
+
// DB-free: the pool is never reached (createInstance is stubbed), auth is faked. No network
|
|
20
|
+
// beyond a loopback listener.
|
|
21
|
+
//
|
|
22
|
+
// Run: node tests/provisioning_paid_shape_gate.mjs
|
|
23
|
+
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
import http from 'node:http';
|
|
27
|
+
|
|
28
|
+
const require = createRequire(import.meta.url);
|
|
29
|
+
|
|
30
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
31
|
+
|
|
32
|
+
let passed = 0, failed = 0;
|
|
33
|
+
async function ta(name, fn) {
|
|
34
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
35
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── stubs: the route's data access + auth ────────────────────────────────────────
|
|
39
|
+
let lastCreateArgs = null;
|
|
40
|
+
provisioning.createInstance = async (_db, args) => {
|
|
41
|
+
lastCreateArgs = args;
|
|
42
|
+
return { instance: { id: 42, owner_builder_id: 7, slug: args.slug, hosting_shape: args.hostingShape }, created: true };
|
|
43
|
+
};
|
|
44
|
+
provisioning.enqueueIntent = async () => ({ intent: { id: 1, state: 'pending' }, created: true });
|
|
45
|
+
provisioning.recordEvent = async () => {};
|
|
46
|
+
provisioning.getInstanceBySlug = async () => null;
|
|
47
|
+
|
|
48
|
+
const api = require('../src/module-api.js');
|
|
49
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
50
|
+
|
|
51
|
+
// The permission gate, faked at the same seam the real one uses. `held` is flipped per
|
|
52
|
+
// case below so one mounted app covers both the denied and the admitted builder.
|
|
53
|
+
let held = [];
|
|
54
|
+
api.requirePermission = (...perms) => async (req, res, next) => {
|
|
55
|
+
if (perms.every((p) => held.includes(p))) return next();
|
|
56
|
+
return res.fail('permission_forbidden', {
|
|
57
|
+
status: 403,
|
|
58
|
+
message: `missing permission: ${perms.filter((p) => !held.includes(p)).join(', ')}`,
|
|
59
|
+
details: { required: perms, held: perms.filter((p) => held.includes(p)) },
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const express = require('express');
|
|
64
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
65
|
+
const app = express();
|
|
66
|
+
app.use(express.json());
|
|
67
|
+
app.use((_req, res, next) => {
|
|
68
|
+
res.fail = (code, opts, details) => {
|
|
69
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
70
|
+
res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
|
|
71
|
+
};
|
|
72
|
+
next();
|
|
73
|
+
});
|
|
74
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
75
|
+
|
|
76
|
+
const server = app.listen(0);
|
|
77
|
+
await new Promise((r) => server.once('listening', r));
|
|
78
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
79
|
+
|
|
80
|
+
function post(path, body) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const payload = JSON.stringify(body);
|
|
83
|
+
const req = http.request(`${base}${path}`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
86
|
+
}, (res) => {
|
|
87
|
+
let d = '';
|
|
88
|
+
res.on('data', (c) => { d += c; });
|
|
89
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
90
|
+
});
|
|
91
|
+
req.on('error', reject);
|
|
92
|
+
req.end(payload);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── the $0 shapes stay open — this is the half a careless fix would break ─────────
|
|
97
|
+
|
|
98
|
+
await ta('standalone: a builder with NO fleet permission still creates (the live wizard path)', async () => {
|
|
99
|
+
held = [];
|
|
100
|
+
lastCreateArgs = null;
|
|
101
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone', no_address: true });
|
|
102
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
103
|
+
assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'standalone');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await ta('co-tenant (and an absent shape, which defaults to it) stays open with no permission', async () => {
|
|
107
|
+
for (const body of [
|
|
108
|
+
{ slug: 'demo', hosting_shape: 'co-tenant', no_address: true },
|
|
109
|
+
{ slug: 'demo', no_address: true },
|
|
110
|
+
]) {
|
|
111
|
+
held = [];
|
|
112
|
+
const res = await post('/provisioning/instances', body);
|
|
113
|
+
assert.equal(res.status, 201, `expected 201 for ${JSON.stringify(body)}, got ${res.status} ${res.raw}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── dedicated is the metered one ──────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
await ta('dedicated: REFUSED for a builder without provisioning.fleet.manage', async () => {
|
|
120
|
+
held = [];
|
|
121
|
+
lastCreateArgs = null;
|
|
122
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'dedicated', no_address: true });
|
|
123
|
+
assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
|
|
124
|
+
assert.equal(res.body.error.code, 'permission_forbidden');
|
|
125
|
+
// requirePermission reports the gate's literal list under `details` (the same envelope
|
|
126
|
+
// requireRank uses), so the denial names the atom a caller has to be granted.
|
|
127
|
+
assert.deepEqual(res.body.error.details.required, ['provisioning.fleet.manage']);
|
|
128
|
+
assert.equal(lastCreateArgs, null, 'no row is created when the shape is refused');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await ta('dedicated: ADMITTED for a builder holding provisioning.fleet.manage', async () => {
|
|
132
|
+
held = ['provisioning.fleet.manage'];
|
|
133
|
+
lastCreateArgs = null;
|
|
134
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'dedicated', no_address: true });
|
|
135
|
+
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
136
|
+
assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'dedicated');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await ta('the gate refuses BEFORE the row exists — a denied dedicated create leaves nothing behind', async () => {
|
|
140
|
+
// The denial must land ahead of createInstance, not after it: a refused-but-inserted row
|
|
141
|
+
// would still be drained by the runner, which reads the column, not the HTTP status.
|
|
142
|
+
held = [];
|
|
143
|
+
lastCreateArgs = null;
|
|
144
|
+
await post('/provisioning/instances', { slug: 'spendy', hosting_shape: 'dedicated', tier: 's-4vcpu-8gb' });
|
|
145
|
+
assert.equal(lastCreateArgs, null);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── the gate cannot be walked around ──────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
await ta('a case-variant shape does not slip past the gate INTO a dedicated row', async () => {
|
|
151
|
+
// The gate matches the exact wire value, and so does the shape validator — so a variant
|
|
152
|
+
// is refused as a bad shape rather than admitted as a cheap one and stored as dedicated.
|
|
153
|
+
// The property that matters is not WHICH error comes back, it is that no dedicated row
|
|
154
|
+
// is ever created without the permission.
|
|
155
|
+
for (const shape of ['DEDICATED', 'Dedicated', ' dedicated', 'dedicated ']) {
|
|
156
|
+
held = [];
|
|
157
|
+
lastCreateArgs = null;
|
|
158
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: shape, no_address: true });
|
|
159
|
+
assert.ok(res.status === 400 || res.status === 403, `${JSON.stringify(shape)} → expected refusal, got ${res.status} ${res.raw}`);
|
|
160
|
+
assert.equal(lastCreateArgs, null, `${JSON.stringify(shape)} must not create a row`);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await ta('HOSTING_SHAPES still carries exactly the three shapes the gate reasons about', () => {
|
|
165
|
+
// If a fourth, also-metered shape is ever added, this test is the tripwire that says the
|
|
166
|
+
// gate's single-shape assumption needs revisiting.
|
|
167
|
+
assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(), ['co-tenant', 'dedicated', 'standalone']);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
server.close();
|
|
171
|
+
console.log(`\nprovisioning_paid_shape_gate: ${passed} passed, ${failed} failed`);
|
|
172
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// tests/wizard_draft_resume.mjs — the create wizard survives a round-trip through GitHub
|
|
2
|
+
// (task 1003683).
|
|
3
|
+
//
|
|
4
|
+
// Two defects on the Home step, both found while walking the R24 proof on live:
|
|
5
|
+
//
|
|
6
|
+
// 1. EVERY boot reset the draft to step 1. enter() deliberately renders the name step
|
|
7
|
+
// before /me settles ("deeper steps carry authenticated side effects"), and its own
|
|
8
|
+
// comment promises "onAuth() re-enters to the real draft step the moment /me answers".
|
|
9
|
+
// But show() PERSISTS whatever step it renders, so the placeholder overwrote the
|
|
10
|
+
// draft's real position with 1 — leaving onAuth() nothing to return to. Granting repo
|
|
11
|
+
// access from step 5 therefore dumped the visitor back at the name step, every time.
|
|
12
|
+
// Reproduced live on core 1.19.580: planting {step:5} and reloading read back step 1.
|
|
13
|
+
//
|
|
14
|
+
// 2. "or enter it manually" was a ONE-WAY door. #ghConnect renders only while repoAccess
|
|
15
|
+
// is 'need-connect', the toggle set repoAccess='manual', and nothing ever set it back
|
|
16
|
+
// — so the "Allow repo access" button vanished until a full page reload. Navigating
|
|
17
|
+
// back to step 4 and forward again did not restore it.
|
|
18
|
+
//
|
|
19
|
+
// Harness: there is no jsdom in this repo, so this follows tests/project_door_ui.mjs —
|
|
20
|
+
// slice the real region out of the page's INLINE script and run it in a vm under a minimal
|
|
21
|
+
// DOM stub. The slice boundaries are asserted, so a rename can never leave this file
|
|
22
|
+
// quietly testing nothing.
|
|
23
|
+
//
|
|
24
|
+
// NOT covered here, deliberately: the team shape. The first draft of this task claimed the
|
|
25
|
+
// OAuth hop dropped it; that was wrong — teamShape, projType and modules all survive a
|
|
26
|
+
// reload (verified live). What looked like a drop was the documented "clicking the chosen
|
|
27
|
+
// pill again UNSAYS it" toggle being re-clicked by hand.
|
|
28
|
+
//
|
|
29
|
+
// Run: node --test --test-reporter=tap tests/wizard_draft_resume.mjs
|
|
30
|
+
|
|
31
|
+
import assert from 'node:assert/strict';
|
|
32
|
+
import { test } from 'node:test';
|
|
33
|
+
import fs from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import vm from 'node:vm';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
|
|
38
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
39
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8');
|
|
40
|
+
|
|
41
|
+
// ── slice 1 · the step-resolution block inside enter() ────────────────────────────
|
|
42
|
+
const STEP_RE = /var step = authKnown &&[\s\S]*?show\(step, !!focus\);\r?\n\s*\}/;
|
|
43
|
+
const stepSlice = (HUB.match(STEP_RE) || [null])[0];
|
|
44
|
+
|
|
45
|
+
test('the step-resolution slice is still findable (a rename must fail loudly, not silently pass)', () => {
|
|
46
|
+
assert.ok(stepSlice, 'enter()\'s step-resolution block was not found in projects.html');
|
|
47
|
+
assert.match(stepSlice, /show\(1, false\)/, 'the pre-auth placeholder render is part of the slice');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// A faithful miniature of the real show(): the ONE behaviour that matters here is that it
|
|
51
|
+
// writes state.step and then persists the whole state. If show() ever stops persisting,
|
|
52
|
+
// this test still passes for the right reason — the draft keeps the real step either way.
|
|
53
|
+
function runStep({ authKnown, draftStep }) {
|
|
54
|
+
const store = new Map();
|
|
55
|
+
const state = { step: draftStep, name: 'probe', teamShape: 'small-team' };
|
|
56
|
+
store.set('draft', JSON.stringify(state));
|
|
57
|
+
const rendered = [];
|
|
58
|
+
const sandbox = {
|
|
59
|
+
authKnown,
|
|
60
|
+
state,
|
|
61
|
+
focus: false,
|
|
62
|
+
saveDraft() { store.set('draft', JSON.stringify(state)); },
|
|
63
|
+
show(step) { state.step = step; rendered.push(step); sandbox.saveDraft(); },
|
|
64
|
+
$: () => ({ focus() {} }),
|
|
65
|
+
};
|
|
66
|
+
sandbox.globalThis = sandbox;
|
|
67
|
+
vm.createContext(sandbox);
|
|
68
|
+
vm.runInContext(stepSlice, sandbox);
|
|
69
|
+
return { rendered, persisted: JSON.parse(store.get('draft')), state };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('pre-auth: the placeholder renders step 1 but the draft KEEPS the real step', () => {
|
|
73
|
+
const { rendered, persisted } = runStep({ authKnown: false, draftStep: 5 });
|
|
74
|
+
assert.deepEqual(rendered, [1], 'still renders the name step before identity settles');
|
|
75
|
+
assert.equal(persisted.step, 5, 'the draft must not be overwritten with the placeholder');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('pre-auth: the in-memory step is restored too, so onAuth() can re-enter to it', () => {
|
|
79
|
+
// onAuth() calls enter() again and reads state.step from MEMORY, not from storage — so
|
|
80
|
+
// restoring only the stored copy would fix nothing.
|
|
81
|
+
const { state } = runStep({ authKnown: false, draftStep: 6 });
|
|
82
|
+
assert.equal(state.step, 6);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('post-auth: the real step is rendered', () => {
|
|
86
|
+
const { rendered, persisted } = runStep({ authKnown: true, draftStep: 5 });
|
|
87
|
+
assert.deepEqual(rendered, [5]);
|
|
88
|
+
assert.equal(persisted.step, 5);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('a draft already at step 1 is unchanged, and an out-of-range step is not resurrected', () => {
|
|
92
|
+
assert.equal(runStep({ authKnown: false, draftStep: 1 }).persisted.step, 1);
|
|
93
|
+
// 8 is the done panel, reached by its own branch above this slice; 0 is the gate. Neither
|
|
94
|
+
// is a resumable wizard position, so the placeholder is allowed to stand.
|
|
95
|
+
for (const bad of [0, 8, 99]) {
|
|
96
|
+
assert.equal(runStep({ authKnown: false, draftStep: bad }).persisted.step, 1, `step ${bad} is not resumed`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// ── slice 2 · syncHome's control over the way back ────────────────────────────────
|
|
101
|
+
const SYNC_RE = /function syncHome\(\) \{[\s\S]*?\n {4}\}/;
|
|
102
|
+
const syncSlice = (HUB.match(SYNC_RE) || [null])[0];
|
|
103
|
+
|
|
104
|
+
test('the syncHome slice is still findable', () => {
|
|
105
|
+
assert.ok(syncSlice, 'syncHome() was not found in projects.html');
|
|
106
|
+
assert.match(syncSlice, /ghManualBack/, 'syncHome governs the way-back control');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
function runSync(repoAccess) {
|
|
110
|
+
const els = {};
|
|
111
|
+
// Elements start HIDDEN so that "shown" is only ever the result of syncHome setting it.
|
|
112
|
+
// With a `hidden: false` default, a control syncHome never mentions reads as visible and
|
|
113
|
+
// the assertion below would pass against the unfixed page.
|
|
114
|
+
const el = (id) => (els[id] ||= {
|
|
115
|
+
id, hidden: true, textContent: '', _on: false,
|
|
116
|
+
classList: { toggle(_c, v) { els[id]._on = !!v; } },
|
|
117
|
+
});
|
|
118
|
+
const sandbox = {
|
|
119
|
+
repoAccess,
|
|
120
|
+
state: { mode: 'greenfield' },
|
|
121
|
+
$: el,
|
|
122
|
+
isManualHome: () => repoAccess === 'manual' || repoAccess === 'unavailable',
|
|
123
|
+
initAdoptPicker() {}, syncFresh() {},
|
|
124
|
+
};
|
|
125
|
+
sandbox.globalThis = sandbox;
|
|
126
|
+
vm.createContext(sandbox);
|
|
127
|
+
vm.runInContext(syncSlice + '\nsyncHome();', sandbox);
|
|
128
|
+
return { connectShown: el('ghConnect')._on, manualShown: !el('manualHome').hidden, backShown: !el('ghManualBack').hidden };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
test('choosing manual leaves a way back to the connect offer', () => {
|
|
132
|
+
const manual = runSync('manual');
|
|
133
|
+
assert.equal(manual.manualShown, true, 'the manual fields are up');
|
|
134
|
+
assert.equal(manual.connectShown, false, 'the connect box is down — that is what made it a trap');
|
|
135
|
+
assert.equal(manual.backShown, true, 'and the way back is offered');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a FORCED fallback offers no way back, because connecting cannot help', () => {
|
|
139
|
+
// 'unavailable' means the server could not store a token. Offering "use my GitHub
|
|
140
|
+
// repositories instead" there would send the visitor through a round-trip that lands
|
|
141
|
+
// them right back here — an offer that cannot be honoured is worse than none.
|
|
142
|
+
const forced = runSync('unavailable');
|
|
143
|
+
assert.equal(forced.manualShown, true);
|
|
144
|
+
assert.equal(forced.backShown, false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('while the connect offer is up, the way back is not', () => {
|
|
148
|
+
const need = runSync('need-connect');
|
|
149
|
+
assert.equal(need.connectShown, true);
|
|
150
|
+
assert.equal(need.backShown, false, 'no second, redundant control beside the button itself');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── slice 3 · why this task's original team-shape claim was wrong ─────────────────
|
|
154
|
+
// The bug was filed claiming a GitHub round-trip drops the team shape. It does not: a
|
|
155
|
+
// planted draft reads teamShape back intact after a reload (verified live on 1.19.580).
|
|
156
|
+
// The real explanation is this handler — the pill is an UNSAY toggle, so re-clicking the
|
|
157
|
+
// already-chosen shape clears it. During the R24 walk that second click was mine. Pinning
|
|
158
|
+
// it here so the claim rests on the handler's actual behaviour rather than on prose.
|
|
159
|
+
// Anchored on addEventListener: the same selector also appears inside paintTeam(), which
|
|
160
|
+
// only writes aria-pressed. Matching that one instead yields a slice that will not parse.
|
|
161
|
+
const TEAM_RE = /root\.querySelectorAll\('#teamCards \.tpill'\)\.forEach\(function \(b\) \{\s*b\.addEventListener[\s\S]*?\n {4}\}\);/;
|
|
162
|
+
const teamSlice = (HUB.match(TEAM_RE) || [null])[0];
|
|
163
|
+
|
|
164
|
+
test('the team-pill slice is still findable', () => {
|
|
165
|
+
assert.ok(teamSlice, 'the #teamCards pill handler was not found in projects.html');
|
|
166
|
+
assert.match(teamSlice, /state\.teamShape/, 'the handler owns the team-shape answer');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('the team pill UNSAYS on a second click — the answer is not dropped, it is toggled off', () => {
|
|
170
|
+
const state = { teamShape: null };
|
|
171
|
+
const handlers = [];
|
|
172
|
+
const pill = (team) => ({
|
|
173
|
+
getAttribute: (a) => (a === 'data-team' ? team : null),
|
|
174
|
+
addEventListener: (_e, fn) => handlers.push({ team, fn }),
|
|
175
|
+
});
|
|
176
|
+
const sandbox = {
|
|
177
|
+
state,
|
|
178
|
+
root: { querySelectorAll: () => [pill('solo'), pill('small-team'), pill('community')] },
|
|
179
|
+
paintTeam() {}, paintDetail() {}, saveDraft() {},
|
|
180
|
+
};
|
|
181
|
+
sandbox.globalThis = sandbox;
|
|
182
|
+
vm.createContext(sandbox);
|
|
183
|
+
vm.runInContext(teamSlice, sandbox);
|
|
184
|
+
|
|
185
|
+
const click = (team) => handlers.find((h) => h.team === team).fn();
|
|
186
|
+
click('small-team');
|
|
187
|
+
assert.equal(state.teamShape, 'small-team', 'first click answers');
|
|
188
|
+
click('small-team');
|
|
189
|
+
assert.equal(state.teamShape, null, 'the SAME pill again unsays it — this is the mis-read bug');
|
|
190
|
+
click('small-team');
|
|
191
|
+
assert.equal(state.teamShape, 'small-team', 'and a third click answers again');
|
|
192
|
+
click('community');
|
|
193
|
+
assert.equal(state.teamShape, 'community', 'a DIFFERENT pill replaces rather than clears');
|
|
194
|
+
});
|