@bongos/core 1.19.120 → 1.19.122

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,331 @@
1
+ // tests/provisioning_default_domain.mjs
2
+ //
3
+ // Auto-assign at creation (task 1002704, the owner's 2026-08-12 decision): every
4
+ // new project gets <slug>.<instanceBase> at creation so its hall is reachable day
5
+ // one; `no_address: true` is the explicit opt-out; an explicit body.domain
6
+ // (bring-your-own) wins. Also closes the create path's validation gap — the
7
+ // attach route normalized + uniqueness-checked its domain (task 1002692) while
8
+ // create inserted body.domain raw — and adds the reserved-name guard that keeps
9
+ // a project slug ('www', 'get', …) from UPSERTing over the platform's own DNS
10
+ // once every create carries an address.
11
+ //
12
+ // Covers:
13
+ // • defaultDomainForSlug / isReservedHostname — the pure helpers
14
+ // • POST /provisioning/instances — assign-by-default, opt-out, BYO-wins,
15
+ // bad_domain, domain_taken, domain_reserved, blank-apex degradation,
16
+ // re-request honesty (no phantom domain_ignored from the derived default;
17
+ // no_address_ignored when the opt-out can't apply)
18
+ // • POST /provisioning/instances/:id/domain — the same reserved-name refusal
19
+ //
20
+ // DB-free: data-access fns are stubbed on the cached module object (the
21
+ // provisioning_domain_attach.mjs harness). No network.
22
+ //
23
+ // Run: node tests/provisioning_default_domain.mjs
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { createRequire } from 'node:module';
27
+ import http from 'node:http';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const provisioning = require('../modules/provisioning/provisioning.js');
31
+
32
+ let passed = 0, failed = 0;
33
+ function t(name, fn) {
34
+ try { fn(); passed++; console.log(` PASS ${name}`); }
35
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
36
+ }
37
+ async function ta(name, fn) {
38
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
39
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
40
+ }
41
+
42
+ console.log('pure helpers — defaultDomainForSlug / isReservedHostname:');
43
+
44
+ t('defaultDomainForSlug: <slug>.<apex>, normalized', () => {
45
+ assert.equal(provisioning.defaultDomainForSlug('spike', 'cloudbongos.com'), 'spike.cloudbongos.com');
46
+ assert.equal(provisioning.defaultDomainForSlug('spike', 'CloudBongos.com.'), 'spike.cloudbongos.com');
47
+ });
48
+
49
+ t('defaultDomainForSlug: blank/absent apex → null (self-host degrades to domainless)', () => {
50
+ assert.equal(provisioning.defaultDomainForSlug('spike', ''), null);
51
+ assert.equal(provisioning.defaultDomainForSlug('spike', null), null);
52
+ assert.equal(provisioning.defaultDomainForSlug('spike', undefined), null);
53
+ });
54
+
55
+ t('defaultDomainForSlug: invalid slug or unparseable apex → null', () => {
56
+ assert.equal(provisioning.defaultDomainForSlug('', 'cloudbongos.com'), null);
57
+ assert.equal(provisioning.defaultDomainForSlug('-bad-', 'cloudbongos.com'), null);
58
+ assert.equal(provisioning.defaultDomainForSlug('spike', '???'), null);
59
+ });
60
+
61
+ t('isReservedHostname: platform labels + box prefixes under the apex are reserved', () => {
62
+ for (const h of ['www.cloudbongos.com', 'get.cloudbongos.com', 'status.cloudbongos.com',
63
+ 'term-alice.cloudbongos.com', 'sandbox-bob.cloudbongos.com', 'cloudbongos.com']) {
64
+ assert.equal(provisioning.isReservedHostname(h, 'cloudbongos.com'), true, `${h} should be reserved`);
65
+ }
66
+ });
67
+
68
+ t('isReservedHostname: ordinary one-label projects and foreign zones are not reserved', () => {
69
+ assert.equal(provisioning.isReservedHostname('spike.cloudbongos.com', 'cloudbongos.com'), false);
70
+ assert.equal(provisioning.isReservedHostname('www.example.com', 'cloudbongos.com'), false, 'BYO on a foreign zone is fine');
71
+ assert.equal(provisioning.isReservedHostname('spike.cloudbongos.com', ''), false, 'no apex configured → nothing to protect');
72
+ });
73
+
74
+ t('isReservedHostname: deeper than one label under the apex is reserved (breaks the wildcard cert; nothing legitimate creates it)', () => {
75
+ assert.equal(provisioning.isReservedHostname('a.b.cloudbongos.com', 'cloudbongos.com'), true);
76
+ });
77
+
78
+ // ── the create route, over a real mounted router (stubbed data access) ───────────
79
+ let domainHolder = null; // what getInstanceByDomain returns
80
+ let existingRow = null; // what getInstanceBySlug returns (re-request tests)
81
+ let createCalls = []; // what createInstance was asked to insert
82
+ let createResult = null; // what createInstance reports
83
+ let enqueueCalls = [];
84
+ provisioning.getInstanceByDomain = async (_db, _d) => domainHolder;
85
+ provisioning.getInstanceBySlug = async (_db, _s) => existingRow;
86
+ provisioning.getInstanceById = async () => null;
87
+ provisioning.updateInstanceDomain = async (_db, id, domain) => ({ id, domain });
88
+ provisioning.createInstance = async (_db, args) => { createCalls.push(args); return createResult; };
89
+ provisioning.enqueueIntent = async (_db, id, action, requestedBy) => {
90
+ enqueueCalls.push({ id, action, requestedBy });
91
+ return { intent: { id: 9, action, state: 'pending' }, created: true };
92
+ };
93
+ provisioning.getOpenIntent = async () => null;
94
+ provisioning.recordEvent = async () => {};
95
+
96
+ const api = require('../src/module-api.js');
97
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
98
+ api.requirePermission = () => (_req, _res, next) => next();
99
+ let brandingDomains = { publicOrigin: 'https://cb.test', instanceBase: 'cloudbongos.com' };
100
+ api.branding = () => ({ domains: brandingDomains, identity: { productName: 'Cloud Bongos' } });
101
+
102
+ const express = require('express');
103
+ const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
104
+ const app = express();
105
+ app.use(express.json());
106
+ app.use((_req, res, next) => {
107
+ res.fail = (code, opts, details) => {
108
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
109
+ res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
110
+ };
111
+ next();
112
+ });
113
+ app.use('/api/bongos', provisioningRoutes());
114
+
115
+ const server = app.listen(0);
116
+ await new Promise((r) => server.once('listening', r));
117
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
118
+
119
+ function post(path, body = {}) {
120
+ return new Promise((resolve, reject) => {
121
+ const payload = JSON.stringify(body);
122
+ const req = http.request(`${base}${path}`, {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
125
+ }, (res) => {
126
+ let d = '';
127
+ res.on('data', (c) => { d += c; });
128
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
129
+ });
130
+ req.on('error', reject);
131
+ req.end(payload);
132
+ });
133
+ }
134
+
135
+ const freshRow = (over = {}) => ({
136
+ id: 41, owner_builder_id: 7, slug: 'spike', status: 'requested', hosting_shape: 'standalone',
137
+ domain: 'spike.cloudbongos.com', onboard_mode: 'greenfield', type: null, created_at: '', updated_at: '', ...over,
138
+ });
139
+
140
+ function reset() {
141
+ domainHolder = null;
142
+ existingRow = null;
143
+ createCalls = [];
144
+ enqueueCalls = [];
145
+ createResult = { instance: freshRow(), created: true };
146
+ }
147
+
148
+ console.log('\nPOST /provisioning/instances — auto-assign, opt-out, BYO wins:');
149
+
150
+ await ta('no domain in the body → the assigned <slug>.<instanceBase> is created (the API-first gap, N2)', async () => {
151
+ reset();
152
+ const res = await post('/provisioning/instances', { slug: 'spike' });
153
+ assert.equal(res.status, 201, res.raw);
154
+ assert.equal(createCalls[0].domain, 'spike.cloudbongos.com');
155
+ });
156
+
157
+ await ta('no_address: true → deliberately domainless (the opt-out survives the server default)', async () => {
158
+ reset();
159
+ createResult = { instance: freshRow({ domain: null }), created: true };
160
+ const res = await post('/provisioning/instances', { slug: 'spike', no_address: true });
161
+ assert.equal(res.status, 201, res.raw);
162
+ assert.equal(createCalls[0].domain == null, true, 'no domain may be assigned');
163
+ });
164
+
165
+ await ta('an explicit domain (bring-your-own) wins over the default, normalized', async () => {
166
+ reset();
167
+ createResult = { instance: freshRow({ domain: 'spike.example.com' }), created: true };
168
+ const res = await post('/provisioning/instances', { slug: 'spike', domain: 'Spike.Example.com.' });
169
+ assert.equal(res.status, 201, res.raw);
170
+ assert.equal(createCalls[0].domain, 'spike.example.com');
171
+ });
172
+
173
+ await ta('a malformed explicit domain → 400 bad_domain, nothing created (create now matches attach)', async () => {
174
+ reset();
175
+ const res = await post('/provisioning/instances', { slug: 'spike', domain: 'not a hostname' });
176
+ assert.equal(res.status, 400, res.raw);
177
+ assert.equal(res.body.error.code, 'bad_domain');
178
+ assert.equal(createCalls.length, 0);
179
+ });
180
+
181
+ await ta("an address held by ANOTHER instance → 409 domain_taken (create had no uniqueness check)", async () => {
182
+ reset();
183
+ domainHolder = freshRow({ id: 99, slug: 'other' });
184
+ const res = await post('/provisioning/instances', { slug: 'spike' });
185
+ assert.equal(res.status, 409, res.raw);
186
+ assert.equal(res.body.error.code, 'domain_taken');
187
+ assert.equal(createCalls.length, 0);
188
+ });
189
+
190
+ await ta('the holder being this slug’s own row (a re-request) is not a collision', async () => {
191
+ reset();
192
+ domainHolder = freshRow();
193
+ createResult = { instance: freshRow(), created: false };
194
+ const res = await post('/provisioning/instances', { slug: 'spike' });
195
+ assert.equal(res.status, 200, res.raw);
196
+ });
197
+
198
+ await ta('a BYO domain under the apex that is not the slug’s own address → 400 (squat on another handle)', async () => {
199
+ reset();
200
+ const res = await post('/provisioning/instances', { slug: 'spike', domain: 'victim.cloudbongos.com' });
201
+ assert.equal(res.status, 400, res.raw);
202
+ assert.equal(res.body.error.code, 'domain_reserved');
203
+ assert.equal(createCalls.length, 0);
204
+ });
205
+
206
+ await ta('an explicit domain equal to the slug’s own assigned address is fine (the wizard sends exactly this)', async () => {
207
+ reset();
208
+ const res = await post('/provisioning/instances', { slug: 'spike', domain: 'spike.cloudbongos.com' });
209
+ assert.equal(res.status, 201, res.raw);
210
+ assert.equal(createCalls[0].domain, 'spike.cloudbongos.com');
211
+ });
212
+
213
+ await ta('a reserved label under the apex → 400 domain_reserved (auto-assign path)', async () => {
214
+ reset();
215
+ const res = await post('/provisioning/instances', { slug: 'www' });
216
+ assert.equal(res.status, 400, res.raw);
217
+ assert.equal(res.body.error.code, 'domain_reserved');
218
+ assert.equal(createCalls.length, 0);
219
+ });
220
+
221
+ await ta('a reserved hostname as BYO → 400 domain_reserved (same guard, explicit path)', async () => {
222
+ reset();
223
+ const res = await post('/provisioning/instances', { slug: 'spike', domain: 'GET.cloudbongos.com' });
224
+ assert.equal(res.status, 400, res.raw);
225
+ assert.equal(res.body.error.code, 'domain_reserved');
226
+ });
227
+
228
+ await ta('a reserved slug WITH no_address is fine — no hostname is being claimed', async () => {
229
+ reset();
230
+ createResult = { instance: freshRow({ slug: 'www', domain: null }), created: true };
231
+ const res = await post('/provisioning/instances', { slug: 'www', no_address: true });
232
+ assert.equal(res.status, 201, res.raw);
233
+ });
234
+
235
+ await ta('blank instanceBase (self-host / unconfigured) → domainless create, no guessing', async () => {
236
+ reset();
237
+ brandingDomains = { publicOrigin: 'http://localhost:3000' };
238
+ createResult = { instance: freshRow({ domain: null }), created: true };
239
+ const res = await post('/provisioning/instances', { slug: 'spike' });
240
+ assert.equal(res.status, 201, res.raw);
241
+ assert.equal(createCalls[0].domain == null, true, 'no apex → nothing to assign');
242
+ brandingDomains = { publicOrigin: 'https://cb.test', instanceBase: 'cloudbongos.com' };
243
+ });
244
+
245
+ console.log('\nre-requests skip the birth-time guards (Try again / Bring it back must keep working):');
246
+
247
+ await ta('a re-request of an EXISTING reserved-label row → 200 with its provision intent (not domain_reserved)', async () => {
248
+ reset();
249
+ existingRow = freshRow({ slug: 'demo', domain: 'demo.cloudbongos.com', status: 'error' });
250
+ createResult = { instance: existingRow, created: false };
251
+ const res = await post('/provisioning/instances', { slug: 'demo', domain: 'demo.cloudbongos.com' });
252
+ assert.equal(res.status, 200, res.raw);
253
+ assert.deepEqual(enqueueCalls.map((c) => c.action), ['provision'], "the 'error' row re-enqueues its provision intent");
254
+ });
255
+
256
+ await ta('a re-request is never 409ed against its own (or anyone’s) address — createInstance won’t write one', async () => {
257
+ reset();
258
+ existingRow = freshRow();
259
+ domainHolder = freshRow({ id: 99, slug: 'other' });
260
+ createResult = { instance: freshRow(), created: false };
261
+ const res = await post('/provisioning/instances', { slug: 'spike' });
262
+ assert.equal(res.status, 200, res.raw);
263
+ });
264
+
265
+ console.log('\nre-request honesty:');
266
+
267
+ await ta('a re-request with NO explicit domain never reports domain_ignored (the derived default is not an ask)', async () => {
268
+ reset();
269
+ createResult = { instance: freshRow({ domain: 'custom.example.com' }), created: false };
270
+ const res = await post('/provisioning/instances', { slug: 'spike' });
271
+ assert.equal(res.status, 200, res.raw);
272
+ assert.equal(res.body.domain_ignored, undefined);
273
+ });
274
+
275
+ await ta('a re-request with an explicit different domain still reports domain_ignored', async () => {
276
+ reset();
277
+ createResult = { instance: freshRow({ domain: 'custom.example.com' }), created: false };
278
+ const res = await post('/provisioning/instances', { slug: 'spike', domain: 'spike.other.org' });
279
+ assert.equal(res.status, 200, res.raw);
280
+ assert.equal(res.body.domain_ignored.requested, 'spike.other.org');
281
+ });
282
+
283
+ await ta("a re-request with no_address on an addressed instance says so (no_address_ignored)", async () => {
284
+ reset();
285
+ domainHolder = freshRow();
286
+ createResult = { instance: freshRow(), created: false };
287
+ const res = await post('/provisioning/instances', { slug: 'spike', no_address: true });
288
+ assert.equal(res.status, 200, res.raw);
289
+ assert.equal(res.body.no_address_ignored.current, 'spike.cloudbongos.com');
290
+ });
291
+
292
+ console.log('\nPOST /provisioning/instances/:id/domain — the reserved guard applies to attach too:');
293
+
294
+ await ta('attaching a reserved hostname under the apex → 400 domain_reserved', async () => {
295
+ reset();
296
+ provisioning.getInstanceById = async (_db, id) => freshRow({ id, domain: null, status: 'active' });
297
+ const res = await post('/provisioning/instances/41/domain', { domain: 'www.cloudbongos.com' });
298
+ assert.equal(res.status, 400, res.raw);
299
+ assert.equal(res.body.error.code, 'domain_reserved');
300
+ });
301
+
302
+ await ta('attaching another handle’s address under the apex → 400 (squat, same rule as create)', async () => {
303
+ reset();
304
+ provisioning.getInstanceById = async (_db, id) => freshRow({ id, domain: null, status: 'active' });
305
+ const res = await post('/provisioning/instances/41/domain', { domain: 'victim.cloudbongos.com' });
306
+ assert.equal(res.status, 400, res.raw);
307
+ assert.equal(res.body.error.code, 'domain_reserved');
308
+ });
309
+
310
+ console.log('\nthe assigned default may be upgraded to a bring-your-own domain (the decision’s upgrade path):');
311
+
312
+ await ta('re-pointing AWAY from the platform-assigned address → recorded + domain-attach intent', async () => {
313
+ reset();
314
+ provisioning.getInstanceById = async (_db, id) => freshRow({ id, domain: 'spike.cloudbongos.com', status: 'active' });
315
+ const res = await post('/provisioning/instances/41/domain', { domain: 'spike.example.com' });
316
+ assert.equal(res.status, 200, res.raw);
317
+ assert.equal(res.body.domain, 'spike.example.com');
318
+ assert.deepEqual(enqueueCalls.map((c) => c.action), ['domain-attach']);
319
+ });
320
+
321
+ await ta('a genuine bring-your-own address still refuses to re-point (unchanged)', async () => {
322
+ reset();
323
+ provisioning.getInstanceById = async (_db, id) => freshRow({ id, domain: 'spike.example.com', status: 'active' });
324
+ const res = await post('/provisioning/instances/41/domain', { domain: 'another.example.com' });
325
+ assert.equal(res.status, 409, res.raw);
326
+ assert.equal(res.body.error.code, 'domain_already_set');
327
+ });
328
+
329
+ server.close();
330
+ console.log(`\n${passed} passed, ${failed} failed`);
331
+ process.exit(failed ? 1 : 0);
@@ -91,6 +91,7 @@ provisioning.enqueueIntent = async (_db, id, action, requestedBy) => {
91
91
  };
92
92
  provisioning.getOpenIntent = async () => null;
93
93
  provisioning.createInstance = async () => createResult;
94
+ provisioning.getInstanceBySlug = async () => null; // the task-1002704 birth-time pre-check
94
95
  provisioning.recordEvent = async () => {};
95
96
  provisioning.createOAuthManifest = async (_db, row) => { manifestRows.push(row); return { id: 1, ...row }; };
96
97
  provisioning.getLatestOAuthManifestForInstance = async () => null;
@@ -148,6 +148,7 @@ provisioning.enqueueIntent = async (_db, id, action, requestedBy) => {
148
148
  };
149
149
  provisioning.getOpenIntent = async () => null;
150
150
  provisioning.createInstance = async () => createResult;
151
+ provisioning.getInstanceBySlug = async () => null; // the task-1002704 birth-time pre-check
151
152
  provisioning.recordEvent = async () => {};
152
153
 
153
154
  const api = require('../src/module-api.js');
@@ -0,0 +1,114 @@
1
+ // tests/ship_post_land_verify.mjs
2
+ //
3
+ // Unit tests for the post-land ship-call outcome (task 1002809). After a
4
+ // successful merge, ship.js POSTs /tasks/<id>/ship; the server may have landed
5
+ // the task first (reconcile-on-read, or the ~5-min reconciler racing this call),
6
+ // in which case it refuses with task_not_confirmed. The old code compared
7
+ // `ship.data.error === 'task_not_confirmed'` — the legacy FLAT shape — but the
8
+ // live server emits the ADR 0116 (R01) envelope `{ error: { code, … } }`, so the
9
+ // string test never matched and an already-landed SUCCESS rendered as
10
+ // "merged, but the ship call failed — [object Object]" (the HANDOFF-apex-ui
11
+ // '400 task_not_confirmed AFTER a successful land' gotcha). Same bug class as
12
+ // claim.js task 1476 / cli-lib isBoxScope task 1002162.
13
+ //
14
+ // What we prove here (pure helpers only — the live GET verify is exercised by
15
+ // every real ship):
16
+ // 1. normalizeApiError reads BOTH body shapes (envelope + flat) and survives
17
+ // missing/odd bodies.
18
+ // 2. postLandShipOutcome: a live status of 'shipped' is SUCCESS whatever the
19
+ // error code or HTTP status was (the GET verify is authoritative).
20
+ // 3. task_not_confirmed with the verify GET unavailable keeps the task-1049
21
+ // benefit-of-the-doubt success (the server reconciled it).
22
+ // 4. A known live status that is NOT shipped stays a loud failure — never
23
+ // paper over a genuinely stranded task.
24
+ //
25
+ // Run: node tests/ship_post_land_verify.mjs
26
+
27
+ import { createRequire } from 'node:module';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const ship = require('../scripts/gds/ship.js');
31
+ const { normalizeApiError, postLandShipOutcome } = ship;
32
+
33
+ let passed = 0;
34
+ let failed = 0;
35
+ function test(name, fn) {
36
+ try {
37
+ fn();
38
+ passed++;
39
+ console.log(` ok ${name}`);
40
+ } catch (err) {
41
+ failed++;
42
+ console.log(` FAIL ${name}`);
43
+ console.log(` ${err.message}`);
44
+ }
45
+ }
46
+ function eq(actual, expected, msg) {
47
+ if (actual !== expected) throw new Error(`${msg || 'mismatch'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
48
+ }
49
+
50
+ // ---- 1. normalizeApiError: both wire shapes ----
51
+
52
+ test('normalizeApiError reads the R01 envelope', () => {
53
+ const { code, message } = normalizeApiError({ error: { code: 'task_not_confirmed', message: 'not at confirmed' } });
54
+ eq(code, 'task_not_confirmed');
55
+ eq(message, 'not at confirmed');
56
+ });
57
+
58
+ test('normalizeApiError reads the legacy flat shape', () => {
59
+ const { code, message } = normalizeApiError({ error: 'task_not_confirmed' });
60
+ eq(code, 'task_not_confirmed');
61
+ eq(message, undefined);
62
+ });
63
+
64
+ test('normalizeApiError survives missing/odd bodies', () => {
65
+ eq(normalizeApiError(null).code, undefined);
66
+ eq(normalizeApiError({}).code, undefined);
67
+ eq(normalizeApiError({ error: { message: 'no code' } }).message, 'no code');
68
+ });
69
+
70
+ test('normalizeApiError survives { error: null } (typeof null is object)', () => {
71
+ eq(normalizeApiError({ error: null }).code, null);
72
+ eq(normalizeApiError({ error: null }).message, undefined);
73
+ });
74
+
75
+ // ---- 2. live status 'shipped' is success regardless of code/status ----
76
+
77
+ test('verified shipped ⇒ success even on an unexpected code', () => {
78
+ const out = postLandShipOutcome({ code: 'internal_error', liveStatus: 'shipped' });
79
+ eq(out.alreadyShipped, true);
80
+ if (!out.why || typeof out.why !== 'string') throw new Error('expected a human why-line');
81
+ });
82
+
83
+ test('verified shipped ⇒ success on task_not_confirmed (409 or 400 alike)', () => {
84
+ eq(postLandShipOutcome({ code: 'task_not_confirmed', liveStatus: 'shipped' }).alreadyShipped, true);
85
+ });
86
+
87
+ // ---- 3. verify GET unavailable: keep the task-1049 reconcile success ----
88
+
89
+ test('task_not_confirmed with no live status keeps the reconciled success', () => {
90
+ const out = postLandShipOutcome({ code: 'task_not_confirmed', liveStatus: null });
91
+ eq(out.alreadyShipped, true);
92
+ // The why feeds `shipped (${why})` directly — an absent why prints
93
+ // "shipped (undefined)", the same garbage-output class this task fixes.
94
+ if (!out.why || typeof out.why !== 'string') throw new Error('expected a human why-line');
95
+ });
96
+
97
+ test('an unknown code with no live status is a failure (no blind success)', () => {
98
+ eq(postLandShipOutcome({ code: 'internal_error', liveStatus: null }).alreadyShipped, false);
99
+ });
100
+
101
+ // ---- 4. a known non-shipped status stays a loud failure ----
102
+
103
+ test('task_not_confirmed but live status confirmed ⇒ failure, not success', () => {
104
+ const out = postLandShipOutcome({ code: 'task_not_confirmed', liveStatus: 'confirmed' });
105
+ eq(out.alreadyShipped, false);
106
+ });
107
+
108
+ test('task_not_confirmed but live status completed ⇒ failure', () => {
109
+ eq(postLandShipOutcome({ code: 'task_not_confirmed', liveStatus: 'completed' }).alreadyShipped, false);
110
+ });
111
+
112
+ console.log('');
113
+ console.log(`ship_post_land_verify: ${passed} passed, ${failed} failed`);
114
+ process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,145 @@
1
+ // tests/worktree_helper.mjs
2
+ //
3
+ // Unit tests for the pure decision helpers behind scripts/gds/worktree.js
4
+ // (task 1002809) — the junction-safe worktree lifecycle for Windows builders.
5
+ //
6
+ // The trap this tooling kills: a worktree OUTSIDE the repo root (C:/tmp/wt-N,
7
+ // used to dodge OneDrive locks) needs a node_modules junction back to the main
8
+ // checkout; `cmd //c mklink` from Git Bash mangles quoted targets with spaces
9
+ // (dead junction), and `git worktree remove` FOLLOWS the junction and deletes
10
+ // the SHARED node_modules. The helper creates junctions with
11
+ // fs.symlinkSync(..., 'junction') (no shell involved) and always removes the
12
+ // junction BEFORE git worktree remove.
13
+ //
14
+ // What we prove here:
15
+ // 1. worktreeNameFor: a numeric task id becomes task-<id>; an explicit name
16
+ // passes through; junk is rejected.
17
+ // 2. needsJunction: only win32 AND a path outside the repo root.
18
+ // 3. removalPlan: a junction is unlinked BEFORE git worktree remove; a real
19
+ // node_modules dir is never touched by us (git refuses; we surface that);
20
+ // absent node_modules goes straight to git worktree remove.
21
+ //
22
+ // Run: node tests/worktree_helper.mjs
23
+
24
+ import { createRequire } from 'node:module';
25
+ import fs from 'node:fs';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const { worktreeNameFor, needsJunction, removalPlan, nodeModulesState } = require('../scripts/gds/worktree.js');
31
+
32
+ let passed = 0;
33
+ let failed = 0;
34
+ function test(name, fn) {
35
+ try {
36
+ fn();
37
+ passed++;
38
+ console.log(` ok ${name}`);
39
+ } catch (err) {
40
+ failed++;
41
+ console.log(` FAIL ${name}`);
42
+ console.log(` ${err.message}`);
43
+ }
44
+ }
45
+ function eq(actual, expected, msg) {
46
+ if (actual !== expected) throw new Error(`${msg || 'mismatch'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
47
+ }
48
+ function eqArr(actual, expected, msg) {
49
+ eq(JSON.stringify(actual), JSON.stringify(expected), msg);
50
+ }
51
+
52
+ // ---- 1. worktreeNameFor ----
53
+
54
+ test('numeric task id becomes task-<id>', () => {
55
+ eq(worktreeNameFor('1002809'), 'task-1002809');
56
+ });
57
+
58
+ test('an explicit task-N name passes through', () => {
59
+ eq(worktreeNameFor('task-77'), 'task-77');
60
+ });
61
+
62
+ test('a plain name passes through', () => {
63
+ eq(worktreeNameFor('spike-tiles'), 'spike-tiles');
64
+ });
65
+
66
+ test('junk (path separators, empty) is rejected with null', () => {
67
+ eq(worktreeNameFor('a/b'), null);
68
+ eq(worktreeNameFor('..'), null);
69
+ eq(worktreeNameFor(''), null);
70
+ });
71
+
72
+ // ---- 2. needsJunction ----
73
+
74
+ const REPO = 'C:\\Users\\rocke\\OneDrive\\Desktop\\Cloud Bongos\\cloud-bongos';
75
+
76
+ test('outside-repo worktree on win32 needs a junction', () => {
77
+ eq(needsJunction({ wtPath: 'C:\\tmp\\task-9', repoRoot: REPO, platform: 'win32' }), true);
78
+ });
79
+
80
+ test('inside-repo worktree resolves node_modules by walking up — no junction', () => {
81
+ eq(needsJunction({ wtPath: REPO + '\\.claude\\worktrees\\task-9', repoRoot: REPO, platform: 'win32' }), false);
82
+ });
83
+
84
+ test('non-win32 never gets a junction (symlink semantics differ; not the trap)', () => {
85
+ eq(needsJunction({ wtPath: '/tmp/task-9', repoRoot: '/home/u/repo', platform: 'linux' }), false);
86
+ });
87
+
88
+ test('a sibling dir that merely shares the repo prefix is still outside', () => {
89
+ eq(needsJunction({ wtPath: REPO + '-other\\task-9', repoRoot: REPO, platform: 'win32' }), true);
90
+ });
91
+
92
+ test('path compare is case-insensitive (Windows filesystems are)', () => {
93
+ eq(needsJunction({ wtPath: REPO.toUpperCase() + '\\.CLAUDE\\WORKTREES\\TASK-9', repoRoot: REPO, platform: 'win32' }), false);
94
+ eq(needsJunction({ wtPath: 'c:\\TMP\\task-9', repoRoot: REPO, platform: 'win32' }), true);
95
+ });
96
+
97
+ // ---- 2b. nodeModulesState — the classifier that picks the removal order ----
98
+ // Behavioural, against a real scratch dir: a dir-symlink stands in for the
99
+ // junction (node's lstat reports both as symbolic links, on every platform).
100
+
101
+ test('nodeModulesState: absent / real-dir / junction classified from disk', () => {
102
+ const base = fs.mkdtempSync(path.join(os.tmpdir(), 'wt-helper-test-'));
103
+ try {
104
+ eq(nodeModulesState(path.join(base, 'wt-absent')), 'absent');
105
+
106
+ const wtReal = path.join(base, 'wt-real');
107
+ fs.mkdirSync(path.join(wtReal, 'node_modules'), { recursive: true });
108
+ eq(nodeModulesState(wtReal), 'real-dir');
109
+
110
+ const target = path.join(base, 'shared-nm');
111
+ fs.mkdirSync(target);
112
+ fs.writeFileSync(path.join(target, 'canary.txt'), 'x');
113
+ const wtJct = path.join(base, 'wt-junction');
114
+ fs.mkdirSync(wtJct);
115
+ const link = path.join(wtJct, 'node_modules');
116
+ fs.symlinkSync(target, link, 'junction');
117
+ eq(nodeModulesState(wtJct), 'junction');
118
+
119
+ // Removing the link (the helper's unlink step) must not touch the target.
120
+ // win32 junctions need rmdir; a posix dir-symlink needs unlink — cover both
121
+ // so this file passes on Linux CI and the real Windows box alike.
122
+ try { fs.rmdirSync(link); } catch (_) { fs.unlinkSync(link); }
123
+ eq(fs.existsSync(path.join(target, 'canary.txt')), true, 'target survives link removal');
124
+ } finally {
125
+ fs.rmSync(base, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ // ---- 3. removalPlan — junction-first ordering is the whole point ----
130
+
131
+ test('junction node_modules: unlink FIRST, then git worktree remove', () => {
132
+ eqArr(removalPlan({ nodeModulesState: 'junction' }), ['unlink-junction', 'git-worktree-remove']);
133
+ });
134
+
135
+ test('absent node_modules: straight to git worktree remove', () => {
136
+ eqArr(removalPlan({ nodeModulesState: 'absent' }), ['git-worktree-remove']);
137
+ });
138
+
139
+ test('a REAL node_modules dir is left for git to refuse — we never rm it', () => {
140
+ eqArr(removalPlan({ nodeModulesState: 'real-dir' }), ['git-worktree-remove']);
141
+ });
142
+
143
+ console.log('');
144
+ console.log(`worktree_helper: ${passed} passed, ${failed} failed`);
145
+ process.exit(failed > 0 ? 1 : 0);