@bongos/core 1.21.61 → 1.21.63
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 +62 -42
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0366-hall-planning-is-forms-and-a-goal-made-there-carries-its-own-spec-task.md +85 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +7 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +301 -301
- package/docs/copy-registry.json +342 -342
- package/docs/module-api-changelog.md +4 -0
- package/docs/onboarding/diagrams/assertions.json +1 -1
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +687 -670
- package/modules/lifecycle/merge-lock.js +53 -9
- package/modules/lifecycle/ship-preflight.js +40 -22
- package/modules/platform-identity/guilds.js +32 -0
- package/modules/platform-identity/routes/guilds.js +10 -3
- package/modules/public-landing/public/assets/cosmos.css +29 -3
- package/modules/public-landing/public/projects.html +294 -4
- package/modules/public-landing/public/projects.probes.json +18 -0
- package/modules/public-landing/public/projects.states.json +3 -0
- package/modules/ui-design/kit/fixtures/guilds.json +7 -0
- package/modules/ui-design/kit/fixtures/me__guild-requests.json +9 -0
- package/modules/ui-design/kit/serve.js +41 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/ship-grade.js +13 -6
- package/scripts/gds/ship-merge-gate.js +26 -0
- package/scripts/gds/ship-merge.js +12 -3
- package/scripts/gds/ship-state.js +42 -5
- package/src/module-api.js +1 -1
- package/tests/guilds_api.mjs +31 -0
- package/tests/guilds_db.mjs +7 -0
- package/tests/hub_engagement_cards.mjs +9 -6
- package/tests/hub_guild_cards.mjs +392 -0
- package/tests/merge_lock.mjs +58 -0
- package/tests/ship_merge_gate.mjs +63 -0
- package/tests/ship_preflight.mjs +46 -1
- package/tests/ship_vendored_instance.mjs +259 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// tests/hub_guild_cards.mjs — a guild's invite to you and a join request to a guild
|
|
2
|
+
// you own, in /projects?view=mine's Invitations, EXECUTED (task 1004583; ADR 0336
|
|
3
|
+
// D3/D4 §3, ADR 0337 D4.4).
|
|
4
|
+
//
|
|
5
|
+
// R24 (task 1002300) shipped the routes — GET /me/guild-requests, POST
|
|
6
|
+
// /guild-requests/:id/accept|decline, GET /guilds/:slug/requests — and nothing in
|
|
7
|
+
// any UI called them. These cards do. What this pins:
|
|
8
|
+
// • an invite card tells the member, BEFORE they accept, that their numbers count
|
|
9
|
+
// toward the guild's rounded totals, in guild.html's own words (the two pages
|
|
10
|
+
// are held to one sentence here), and offers the counted switch right there;
|
|
11
|
+
// picking "Leave them out" repaints the notice and sends nothing
|
|
12
|
+
// • it says what joining shows (a public guild lists you; an unlisted one has no
|
|
13
|
+
// page) and links a public guild's page only
|
|
14
|
+
// • Accept sends exactly { visibility (the card's), counted_in_totals } on the
|
|
15
|
+
// cookie session; a 409 guild_visibility_changed re-draws the card for the
|
|
16
|
+
// guild as it is now, and the next accept sends that
|
|
17
|
+
// • an ended invite or request leaves nothing pressable; an expired session and a
|
|
18
|
+
// dead network are said as such, and the card stays re-tryable
|
|
19
|
+
// • Decline is a body-less POST whose 204 drops the row (the section hides with
|
|
20
|
+
// its last row); a failure keeps the row and says so
|
|
21
|
+
// • the owner's card names who asked, with the handle once, and accepts with no
|
|
22
|
+
// body — the requester's switch rode their request
|
|
23
|
+
// • ONE read feeds both lists — GET /me/guild-requests's incoming and
|
|
24
|
+
// owner_incoming — never one request per guild you own; a failed read is silent
|
|
25
|
+
// • a row the page cannot answer is dropped, never drawn with a button that fails
|
|
26
|
+
// • textContent / createElement only
|
|
27
|
+
//
|
|
28
|
+
// The idiom is tests/hub_engagement_cards.mjs: lift the region out of
|
|
29
|
+
// projects.html, run it in a node:vm under a DOM stub, drive real clicks, and
|
|
30
|
+
// assert the slice boundaries so a rename can never leave this executing nothing.
|
|
31
|
+
//
|
|
32
|
+
// DB-free. Run: node --test tests/hub_guild_cards.mjs
|
|
33
|
+
|
|
34
|
+
import test from 'node:test';
|
|
35
|
+
import assert from 'node:assert/strict';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import vm from 'node:vm';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
|
|
41
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
43
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
44
|
+
const GUILD_PAGE = fs.readFileSync(path.join(LANDING, 'guild.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
45
|
+
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
46
|
+
|
|
47
|
+
// the rows region, mineEl through the guild cards' loaders
|
|
48
|
+
const START = SCRIPT.indexOf(' function mineEl(tag, cls)');
|
|
49
|
+
const END = SCRIPT.indexOf('\n }\n', SCRIPT.indexOf(' function loadGuildQueue() {')) + 5;
|
|
50
|
+
assert.ok(START > 0, 'the region starts at mineEl');
|
|
51
|
+
assert.ok(END > START, 'and ends with loadGuildQueue');
|
|
52
|
+
const REGION = SCRIPT.slice(START, END);
|
|
53
|
+
for (const fn of ['function paintInvitesSection(', 'function sessionExpired(', 'function guildPost(', 'function guildDecline(',
|
|
54
|
+
'function guildInviteRow(', 'function renderGuildInvites(', 'function guildRequestRow(', 'function renderGuildRequests(',
|
|
55
|
+
'function loadGuildQueue(']) {
|
|
56
|
+
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
57
|
+
}
|
|
58
|
+
const CARDS = REGION.slice(REGION.indexOf('var GUILD_COUNTED_ON'));
|
|
59
|
+
assert.ok(CARDS.length > 1000, 'the guild cards are findable');
|
|
60
|
+
|
|
61
|
+
// ── the DOM stub ─────────────────────────────────────────────────────────────
|
|
62
|
+
function el(tag) {
|
|
63
|
+
const n = { tag, children: [], _text: '', className: '', hidden: false, disabled: false, checked: false, value: '', attrs: {}, _on: {}, parent: null };
|
|
64
|
+
Object.defineProperty(n, 'textContent', {
|
|
65
|
+
get() { return n._text + n.children.map((c) => c.textContent).join(''); },
|
|
66
|
+
set(v) { n._text = String(v); n.children = []; },
|
|
67
|
+
});
|
|
68
|
+
n.appendChild = (c) => { c.parent = n; n.children.push(c); return c; };
|
|
69
|
+
n.remove = () => { if (n.parent) n.parent.children = n.parent.children.filter((c) => c !== n); };
|
|
70
|
+
n.setAttribute = (k, v) => { n.attrs[k] = String(v); };
|
|
71
|
+
n.getAttribute = (k) => (k in n.attrs ? n.attrs[k] : null);
|
|
72
|
+
n.addEventListener = (t, f) => { (n._on[t] ||= []).push(f); };
|
|
73
|
+
n.querySelector = (sel) => {
|
|
74
|
+
const cls = sel.replace(/^\./, '');
|
|
75
|
+
const walk = (x) => { for (const c of x.children) { if (String(c.className).split(' ').includes(cls)) return c; const d = walk(c); if (d) return d; } return null; };
|
|
76
|
+
return walk(n);
|
|
77
|
+
};
|
|
78
|
+
return n;
|
|
79
|
+
}
|
|
80
|
+
const all = (n, pred, out = []) => { for (const c of n.children || []) { if (pred(c)) out.push(c); all(c, pred, out); } return out; };
|
|
81
|
+
|
|
82
|
+
const OPEN_HANDS = { id: '501', created_at: '2026-10-04T15:10:00.000Z', seen_at: null, slug: 'open-hands', name: 'Open Hands', description: 'A newer guild for first-time builders.', visibility: 'public' };
|
|
83
|
+
const QUIET_ROOM = { id: '502', created_at: '2026-10-03T08:45:00.000Z', seen_at: null, slug: 'quiet-room', name: 'Quiet Room', description: '', visibility: 'unlisted' };
|
|
84
|
+
const TOMAS = { id: '601', created_at: '2026-10-04T19:20:00.000Z', seen_at: null, handle: 'tomas-ek', display_name: 'Tomás Ek', avatar_url: null, guild: { slug: 'night-shift', name: 'Night Shift' } };
|
|
85
|
+
const KAI = { id: '602', created_at: '2026-10-02T11:05:00.000Z', seen_at: null, handle: 'kai-lowe', display_name: null, avatar_url: null, guild: { slug: 'night-shift', name: 'Night Shift' } };
|
|
86
|
+
const OK = { ok: true, status: 200, body: { status: 'accepted' } };
|
|
87
|
+
const GONE = { ok: false, status: 204, body: undefined };
|
|
88
|
+
|
|
89
|
+
function boot({ answers = [] } = {}) {
|
|
90
|
+
const ids = new Map();
|
|
91
|
+
for (const id of ['mineInvites', 'invitesList', 'guildInviteList', 'engageList', 'guildRequestList']) ids.set(id, el(id === 'mineInvites' ? 'section' : 'ul'));
|
|
92
|
+
ids.get('mineInvites').hidden = true;
|
|
93
|
+
const requests = [];
|
|
94
|
+
const queued = answers.slice();
|
|
95
|
+
const fetch = (url, init = {}) => {
|
|
96
|
+
requests.push({ url, method: init.method || 'GET', body: init.body, headers: init.headers, credentials: init.credentials });
|
|
97
|
+
const a = typeof queued[0] === 'function' ? queued.shift()(url) : (queued.shift() || OK);
|
|
98
|
+
if (a.reject) return Promise.reject(new Error('offline'));
|
|
99
|
+
const status = a === GONE ? 204 : a.status;
|
|
100
|
+
return Promise.resolve({ ok: status >= 200 && status < 300, status, json: () => (a.body === undefined ? Promise.reject(new SyntaxError('no body')) : Promise.resolve(a.body)) });
|
|
101
|
+
};
|
|
102
|
+
const document = { createElement: el, createTextNode: (t) => ({ tag: '#text', textContent: String(t), children: [], className: '' }) };
|
|
103
|
+
const sandbox = {
|
|
104
|
+
document, fetch, console, Promise, JSON, String, Object, Array, URL,
|
|
105
|
+
$: (id) => ids.get(id) || null,
|
|
106
|
+
API: '/api/bongos',
|
|
107
|
+
hostOf: (o) => String(o || '').replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, '').toLowerCase(),
|
|
108
|
+
safeHttpUrl: (raw) => { try { const u = new URL(String(raw)); return (u.protocol === 'https:' || u.protocol === 'http:') ? u.href : ''; } catch (e) { return ''; } },
|
|
109
|
+
jsonOrEmpty: (res) => res.json().catch(() => ({})),
|
|
110
|
+
apiError: (data, status) => { const e = (data && data.error) || {}; return { code: e.code || ('http_' + status), message: e.message || '', details: e.details || e || {} }; },
|
|
111
|
+
};
|
|
112
|
+
vm.createContext(sandbox);
|
|
113
|
+
vm.runInContext(REGION, sandbox, { filename: 'projects.html#mine-rows' });
|
|
114
|
+
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
|
|
115
|
+
const invites = () => ids.get('guildInviteList').children;
|
|
116
|
+
const asks = () => ids.get('guildRequestList').children;
|
|
117
|
+
const part = (card, cls) => card.querySelector('.' + cls);
|
|
118
|
+
const actions = (card) => part(card, 'mineRow__actions').children;
|
|
119
|
+
const button = (card, label) => actions(card).find((b) => b.tag === 'button' && b.textContent === label);
|
|
120
|
+
const radios = (card) => all(card, (n) => n.tag === 'input' && n.type === 'radio');
|
|
121
|
+
const msg = (card) => part(card, 'projMsg');
|
|
122
|
+
const click = async (b) => { for (const f of b._on.click || []) f({}); await settle(); };
|
|
123
|
+
const pick = async (card, value) => {
|
|
124
|
+
for (const r of radios(card)) r.checked = r.value === value;
|
|
125
|
+
for (const f of radios(card).find((r) => r.value === value)._on.change || []) f({});
|
|
126
|
+
await settle();
|
|
127
|
+
};
|
|
128
|
+
return { sandbox, ids, requests, settle, invites, asks, part, actions, button, radios, msg, click, pick, section: () => ids.get('mineInvites') };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── the D4.4 notice: one sentence on two pages ───────────────────────────────
|
|
132
|
+
|
|
133
|
+
test('the notice is guild.html’s own words, both ways, byte for byte', () => {
|
|
134
|
+
const said = (src, re) => { const m = src.match(re); assert.ok(m, `found ${re}`); return vm.runInNewContext(m[1]); };
|
|
135
|
+
const onGuild = said(GUILD_PAGE, /\? 'Your numbers are left out[^']*'\s*:\s*('Your numbers count[\s\S]*?share them with\.');/);
|
|
136
|
+
const offGuild = said(GUILD_PAGE, /picked\('counted'\) === 'off'\s*\?\s*('[^']*')/);
|
|
137
|
+
const onHub = said(HUB, /var GUILD_COUNTED_ON = ('[\s\S]*?share them with\.');/);
|
|
138
|
+
const offHub = said(HUB, /var GUILD_COUNTED_OFF = ('[^']*');/);
|
|
139
|
+
assert.equal(onHub, onGuild);
|
|
140
|
+
assert.equal(offHub, offGuild);
|
|
141
|
+
assert.match(onHub, /count toward this guild’s rounded totals/, 'ADR 0337 D4.4’s words');
|
|
142
|
+
// the switch's own labels are the guild page's too
|
|
143
|
+
for (const words of ['Count my numbers', 'They add to this guild’s totals.', 'Leave them out', 'The totals add up everyone else.', 'In this guild’s totals']) {
|
|
144
|
+
assert.ok(GUILD_PAGE.includes(words) && CARDS.includes(words), `"${words}" on both pages`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ── the invite card ──────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
test('an invite card says what joining shows, offers the switch set to count, and the notice BEFORE any accept', () => {
|
|
151
|
+
const ui = boot();
|
|
152
|
+
ui.sandbox.renderGuildInvites([OPEN_HANDS, QUIET_ROOM]);
|
|
153
|
+
assert.equal(ui.requests.length, 0, 'drawing sends nothing');
|
|
154
|
+
assert.equal(ui.section().hidden, false);
|
|
155
|
+
const [open, quiet] = ui.invites();
|
|
156
|
+
assert.equal(ui.part(open, 'mineRow__name').textContent, 'You’re invited to join Open Hands');
|
|
157
|
+
assert.equal(ui.part(open, 'guildRow__desc').textContent, 'A newer guild for first-time builders.');
|
|
158
|
+
assert.equal(ui.part(quiet, 'guildRow__desc'), null, 'an empty description draws nothing');
|
|
159
|
+
assert.match(ui.part(open, 'mineRow__hint').textContent, /^It’s public: joining lists your name and picture on its page/);
|
|
160
|
+
assert.match(ui.part(quiet, 'mineRow__hint').textContent, /^It’s unlisted: it has no public page/);
|
|
161
|
+
const link = ui.actions(open).find((a) => a.tag === 'a');
|
|
162
|
+
assert.equal(link.href, '/g/open-hands');
|
|
163
|
+
assert.equal(link.hidden, false);
|
|
164
|
+
assert.equal(ui.actions(quiet).find((a) => a.tag === 'a').hidden, true, 'an unlisted guild has no page to show an invitee');
|
|
165
|
+
for (const card of [open, quiet]) {
|
|
166
|
+
const r = ui.radios(card);
|
|
167
|
+
assert.deepEqual(r.map((x) => [x.value, x.checked]), [['on', true], ['off', false]], 'counted by default, as the membership is');
|
|
168
|
+
assert.equal(new Set(r.map((x) => x.name)).size, 1, 'one group per card');
|
|
169
|
+
const group = ui.part(card, 'guildCount__opts');
|
|
170
|
+
assert.equal(group.getAttribute('role'), 'radiogroup');
|
|
171
|
+
assert.equal(group.getAttribute('aria-labelledby'), ui.part(card, 'guildCount__label').id);
|
|
172
|
+
assert.match(ui.part(card, 'guildCount__what').textContent, /^Your numbers count toward this guild’s rounded totals/);
|
|
173
|
+
assert.deepEqual(ui.actions(card).filter((b) => b.tag === 'button').map((b) => b.textContent), ['Accept', 'Decline']);
|
|
174
|
+
}
|
|
175
|
+
assert.notEqual(ui.radios(open)[0].name, ui.radios(quiet)[0].name, 'two cards never share a group');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('picking Leave them out says what that means before anything is sent; Count brings the notice back', async () => {
|
|
179
|
+
const ui = boot();
|
|
180
|
+
ui.sandbox.renderGuildInvites([OPEN_HANDS]);
|
|
181
|
+
const card = ui.invites()[0];
|
|
182
|
+
await ui.pick(card, 'off');
|
|
183
|
+
assert.equal(ui.part(card, 'guildCount__what').textContent, 'Your numbers are left out of this guild’s totals, from its next weekly count.');
|
|
184
|
+
await ui.pick(card, 'on');
|
|
185
|
+
assert.match(ui.part(card, 'guildCount__what').textContent, /^Your numbers count toward/);
|
|
186
|
+
assert.equal(ui.requests.length, 0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('Accept sends exactly the card’s visibility and the switch, on the cookie session', async () => {
|
|
190
|
+
const ui = boot();
|
|
191
|
+
ui.sandbox.renderGuildInvites([OPEN_HANDS, QUIET_ROOM]);
|
|
192
|
+
const [open, quiet] = ui.invites();
|
|
193
|
+
await ui.click(ui.button(open, 'Accept'));
|
|
194
|
+
await ui.pick(quiet, 'off');
|
|
195
|
+
await ui.click(ui.button(quiet, 'Accept'));
|
|
196
|
+
assert.equal(ui.requests.length, 2);
|
|
197
|
+
const [a, b] = ui.requests;
|
|
198
|
+
assert.equal(a.url, '/api/bongos/guild-requests/501/accept');
|
|
199
|
+
assert.equal(a.method, 'POST');
|
|
200
|
+
assert.equal(a.credentials, 'same-origin');
|
|
201
|
+
assert.equal(a.headers['Content-Type'], 'application/json');
|
|
202
|
+
assert.deepEqual(JSON.parse(a.body), { visibility: 'public', counted_in_totals: true });
|
|
203
|
+
assert.equal(b.url, '/api/bongos/guild-requests/502/accept');
|
|
204
|
+
assert.deepEqual(JSON.parse(b.body), { visibility: 'unlisted', counted_in_totals: false });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('accepted: Joined and no longer the accent, nothing left to press, and the line says where your numbers went', async () => {
|
|
208
|
+
const ui = boot();
|
|
209
|
+
ui.sandbox.renderGuildInvites([QUIET_ROOM]);
|
|
210
|
+
const card = ui.invites()[0];
|
|
211
|
+
await ui.pick(card, 'off');
|
|
212
|
+
await ui.click(ui.button(card, 'Accept'));
|
|
213
|
+
const joined = ui.button(card, 'Joined');
|
|
214
|
+
assert.ok(joined, 'the button says so');
|
|
215
|
+
assert.equal(joined.disabled, true);
|
|
216
|
+
assert.equal(joined.className, 'btn small ghost');
|
|
217
|
+
assert.equal(ui.button(card, 'Decline').hidden, true);
|
|
218
|
+
assert.ok(ui.radios(card).every((r) => r.disabled), 'the switch is set; it moves on the guild page now');
|
|
219
|
+
assert.equal(ui.actions(card).find((a) => a.tag === 'a').hidden, false, 'a member reaches even an unlisted guild’s page');
|
|
220
|
+
assert.equal(ui.msg(card).className, 'projMsg ok');
|
|
221
|
+
assert.equal(ui.msg(card).textContent, 'You’ve joined Quiet Room, and your numbers are left out of its totals.');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('409 guild_visibility_changed: the card re-draws for the guild as it is now, and the next accept sends that', async () => {
|
|
225
|
+
const ui = boot({ answers: [
|
|
226
|
+
{ ok: false, status: 409, body: { error: { code: 'guild_visibility_changed', message: 'guild_visibility_changed', details: { visibility: 'public' } } } },
|
|
227
|
+
OK,
|
|
228
|
+
] });
|
|
229
|
+
ui.sandbox.renderGuildInvites([QUIET_ROOM]);
|
|
230
|
+
const card = ui.invites()[0];
|
|
231
|
+
await ui.click(ui.button(card, 'Accept'));
|
|
232
|
+
assert.match(ui.part(card, 'mineRow__hint').textContent, /^It’s public:/, 'the card now says what joining shows');
|
|
233
|
+
assert.equal(ui.actions(card).find((a) => a.tag === 'a').hidden, false);
|
|
234
|
+
assert.equal(ui.msg(card).textContent, 'This guild changed since the card was drawn: it’s public now. Read it again, then accept.');
|
|
235
|
+
assert.equal(ui.button(card, 'Accept').disabled, false, 'a fresh decision, not a dead end');
|
|
236
|
+
await ui.click(ui.button(card, 'Accept'));
|
|
237
|
+
assert.deepEqual(JSON.parse(ui.requests[1].body), { visibility: 'public', counted_in_totals: true });
|
|
238
|
+
assert.ok(ui.button(card, 'Joined'));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('an ended invite leaves nothing pressable; an expired session offers a way back; a dead network is said, and both stay re-tryable', async () => {
|
|
242
|
+
const gone = boot({ answers: [{ ok: false, status: 404, body: { error: { code: 'guild_request_not_found' } } }] });
|
|
243
|
+
gone.sandbox.renderGuildInvites([OPEN_HANDS]);
|
|
244
|
+
let card = gone.invites()[0];
|
|
245
|
+
await gone.click(gone.button(card, 'Accept'));
|
|
246
|
+
assert.ok(gone.actions(card).filter((b) => b.tag === 'button').every((b) => b.hidden));
|
|
247
|
+
assert.ok(gone.radios(card).every((r) => r.disabled));
|
|
248
|
+
assert.equal(gone.msg(card).textContent, 'That invite has ended — it was withdrawn, or it ran out.');
|
|
249
|
+
|
|
250
|
+
const out = boot({ answers: [{ ok: false, status: 401, body: { error: { code: 'unauthenticated' } } }, { reject: true }] });
|
|
251
|
+
out.sandbox.renderGuildInvites([OPEN_HANDS]);
|
|
252
|
+
card = out.invites()[0];
|
|
253
|
+
await out.click(out.button(card, 'Accept'));
|
|
254
|
+
const a = out.msg(card).children.find((c) => c.tag === 'a');
|
|
255
|
+
assert.ok(a, 'a sign-in link, built as an element');
|
|
256
|
+
assert.equal(a.href, '/api/bongos/auth/web/start?return=/projects%3Fview%3Dmine&repos=1');
|
|
257
|
+
assert.equal(out.button(card, 'Accept').disabled, false);
|
|
258
|
+
await out.click(out.button(card, 'Accept'));
|
|
259
|
+
assert.equal(out.msg(card).textContent, 'Couldn’t reach the platform — check your connection and try again.');
|
|
260
|
+
assert.equal(out.button(card, 'Accept').disabled, false);
|
|
261
|
+
assert.equal(out.button(card, 'Decline').disabled, false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('Decline is a body-less POST whose 204 drops the row, and the last one hides the section; a failure keeps it', async () => {
|
|
265
|
+
const ui = boot({ answers: [GONE, { ok: false, status: 500, body: {} }] });
|
|
266
|
+
ui.sandbox.renderGuildInvites([OPEN_HANDS]);
|
|
267
|
+
ui.sandbox.renderGuildRequests([TOMAS]);
|
|
268
|
+
await ui.click(ui.button(ui.invites()[0], 'Decline'));
|
|
269
|
+
assert.equal(ui.requests[0].url, '/api/bongos/guild-requests/501/decline');
|
|
270
|
+
assert.equal(ui.requests[0].method, 'POST');
|
|
271
|
+
assert.equal(ui.requests[0].body, undefined, 'nothing to send');
|
|
272
|
+
assert.equal(ui.invites().length, 0);
|
|
273
|
+
assert.equal(ui.section().hidden, false, 'the owner’s card still stands');
|
|
274
|
+
const ask = ui.asks()[0];
|
|
275
|
+
await ui.click(ui.button(ask, 'Decline'));
|
|
276
|
+
assert.equal(ui.asks().length, 1, 'kept on a failure');
|
|
277
|
+
assert.equal(ui.msg(ask).textContent, 'Couldn’t decline just now — try again in a moment.');
|
|
278
|
+
assert.equal(ui.button(ask, 'Decline').disabled, false);
|
|
279
|
+
ui.sandbox.renderGuildRequests([]);
|
|
280
|
+
assert.equal(ui.section().hidden, true, 'no row in any list hides the section');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// ── the owner's card ─────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
test('a join request names who asked, with the handle once, and accepts with no body', async () => {
|
|
286
|
+
const ui = boot();
|
|
287
|
+
ui.sandbox.renderGuildRequests([TOMAS, KAI]);
|
|
288
|
+
const [tomas, kai] = ui.asks();
|
|
289
|
+
assert.equal(ui.part(tomas, 'mineRow__name').textContent, 'Tomás Ek asked to join Night Shift');
|
|
290
|
+
assert.equal(ui.part(tomas, 'mineRow__host').textContent, '@tomas-ek');
|
|
291
|
+
assert.equal(ui.part(kai, 'mineRow__name').textContent, '@kai-lowe asked to join Night Shift');
|
|
292
|
+
assert.equal(ui.part(kai, 'mineRow__host'), null, 'never the handle twice');
|
|
293
|
+
assert.equal(ui.part(tomas, 'guildCount'), null, 'the requester’s switch rode their request: none here');
|
|
294
|
+
assert.equal(ui.part(tomas, 'mineRow__hint').textContent, 'Accepting makes them a member. A decline is never told to them.');
|
|
295
|
+
await ui.click(ui.button(tomas, 'Accept'));
|
|
296
|
+
assert.equal(ui.requests[0].url, '/api/bongos/guild-requests/601/accept');
|
|
297
|
+
assert.equal(ui.requests[0].body, undefined, 'an owner cannot set the requester’s switch');
|
|
298
|
+
assert.equal(ui.requests[0].credentials, 'same-origin');
|
|
299
|
+
assert.equal(ui.button(tomas, 'Accepted').className, 'btn small ghost');
|
|
300
|
+
assert.equal(ui.button(tomas, 'Decline').hidden, true);
|
|
301
|
+
assert.equal(ui.msg(tomas).textContent, 'Tomás Ek is in Night Shift now.');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('an ended request leaves nothing pressable; an expired session and a dead network keep the card re-tryable', async () => {
|
|
305
|
+
const ui = boot({ answers: [{ ok: false, status: 404, body: {} }, { ok: false, status: 401, body: {} }, { reject: true }] });
|
|
306
|
+
ui.sandbox.renderGuildRequests([TOMAS, KAI]);
|
|
307
|
+
const [tomas, kai] = ui.asks();
|
|
308
|
+
await ui.click(ui.button(tomas, 'Accept'));
|
|
309
|
+
assert.ok(ui.actions(tomas).every((b) => b.hidden));
|
|
310
|
+
assert.equal(ui.msg(tomas).textContent, 'That request has ended — it was withdrawn, it ran out, or their account is closed.');
|
|
311
|
+
await ui.click(ui.button(kai, 'Accept'));
|
|
312
|
+
assert.ok(ui.msg(kai).children.find((c) => c.tag === 'a'), 'sign in again');
|
|
313
|
+
assert.equal(ui.button(kai, 'Accept').disabled, false);
|
|
314
|
+
await ui.click(ui.button(kai, 'Accept'));
|
|
315
|
+
assert.equal(ui.msg(kai).textContent, 'Couldn’t reach the platform — check your connection and try again.');
|
|
316
|
+
assert.equal(ui.button(kai, 'Accept').disabled, false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ── the reads ────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
test('ONE read feeds both lists — the invites to you and the requests to every guild you own — never one per guild', async () => {
|
|
322
|
+
const ui = boot({ answers: [{ ok: true, status: 200, body: {
|
|
323
|
+
incoming: [OPEN_HANDS], outgoing: [{ id: '9', slug: 'x' }], owner_incoming: [TOMAS, KAI],
|
|
324
|
+
incoming_count: 1, outgoing_count: 1, owner_incoming_count: 2,
|
|
325
|
+
} }] });
|
|
326
|
+
await ui.sandbox.loadGuildQueue();
|
|
327
|
+
await ui.settle();
|
|
328
|
+
assert.deepEqual(ui.requests.map((r) => `${r.method} ${r.url}`), ['GET /api/bongos/me/guild-requests']);
|
|
329
|
+
assert.equal(ui.requests[0].credentials, 'same-origin');
|
|
330
|
+
assert.equal(ui.invites().length, 1, 'incoming only: the requests YOU sent are not cards here');
|
|
331
|
+
assert.equal(ui.asks().length, 2);
|
|
332
|
+
assert.equal(ui.part(ui.asks()[0], 'mineRow__name').textContent, 'Tomás Ek asked to join Night Shift', 'each row names its own guild');
|
|
333
|
+
assert.doesNotMatch(CARDS, /\/me\/guilds'|\/requests'/, 'no per-guild queue read anywhere in the cards');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('a failed or empty read is silent: no cards, and the section stays hidden', async () => {
|
|
337
|
+
for (const answer of [{ ok: false, status: 500, body: {} }, { reject: true }, { ok: true, status: 200, body: {} }]) {
|
|
338
|
+
const ui = boot({ answers: [answer] });
|
|
339
|
+
await ui.sandbox.loadGuildQueue();
|
|
340
|
+
await ui.settle();
|
|
341
|
+
assert.equal(ui.invites().length + ui.asks().length, 0);
|
|
342
|
+
assert.equal(ui.section().hidden, true);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('a row the page cannot answer is dropped, never drawn with a button that fails', () => {
|
|
347
|
+
const ui = boot();
|
|
348
|
+
ui.sandbox.renderGuildInvites([
|
|
349
|
+
{ ...OPEN_HANDS, id: 'abc' }, { ...OPEN_HANDS, id: undefined }, { ...OPEN_HANDS, slug: '' },
|
|
350
|
+
{ ...OPEN_HANDS, visibility: 'secret' }, null, QUIET_ROOM,
|
|
351
|
+
]);
|
|
352
|
+
assert.deepEqual(ui.invites().map((c) => ui.part(c, 'mineRow__name').textContent), ['You’re invited to join Quiet Room']);
|
|
353
|
+
ui.sandbox.renderGuildRequests([{ ...TOMAS, id: '1x' }, { ...TOMAS, handle: '' }, { ...TOMAS, guild: null }, KAI]);
|
|
354
|
+
assert.equal(ui.asks().length, 1);
|
|
355
|
+
// within the bigint range the route checks (routes/guilds.js requestId), and no further
|
|
356
|
+
ui.sandbox.renderGuildRequests([{ ...TOMAS, id: '9223372036854775808' }, { ...KAI, id: '9223372036854775807' }]);
|
|
357
|
+
assert.deepEqual(ui.asks().map((c) => ui.part(c, 'mineRow__name').textContent), ['@kai-lowe asked to join Night Shift']);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ── the markup, the region's rules, the kit ──────────────────────────────────
|
|
361
|
+
|
|
362
|
+
test('the cards are real lists in the Invitations section, built with createElement / textContent only', () => {
|
|
363
|
+
const section = (HUB.match(/<section id="mineInvites" hidden aria-labelledby="mineInvitesH">[\s\S]*?<\/section>/) || [''])[0];
|
|
364
|
+
const order = ['invitesList', 'guildInviteList', 'engageList', 'guildRequestList'].map((id) => section.indexOf(`<ul class="mineRows" id="${id}"></ul>`));
|
|
365
|
+
assert.ok(order.every((i) => i > 0), 'each list ships as real markup the script only fills');
|
|
366
|
+
assert.deepEqual([...order].sort((a, b) => a - b), order, 'invites to you first, the requests to your guilds last');
|
|
367
|
+
assert.doesNotMatch(CARDS, /innerHTML|insertAdjacentHTML|outerHTML/);
|
|
368
|
+
assert.match(REGION, /function loadInvites\(\) \{[\s\S]*?loadEngagements\(\),\s*loadGuildQueue\(\)\s*\]\);/, 'the mine view reads them with the other invitations');
|
|
369
|
+
const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
|
|
370
|
+
for (const rule of ['.guildCount__opts{', '.guildCount__opt:has(input:checked) b::before{', '.guildCount__opt:focus-within{', '.guildRow--invite .mineRow__actions{align-self:flex-end;}']) {
|
|
371
|
+
assert.ok(COSMOS.includes(rule), `cosmos.css carries ${rule}`);
|
|
372
|
+
}
|
|
373
|
+
assert.doesNotMatch(HUB.slice(0, HUB.indexOf('</head>')), /guildCount|guildRow/, 'styled in cosmos.css, not in the page head');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('the kit pictures the cards, and the stub answers the four calls as the routes do', () => {
|
|
377
|
+
const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
|
|
378
|
+
for (const s of ['in-mine-guild-cards', 'in-mine-guild-count-off', 'in-mine-guild-joined']) assert.ok(states[s], `kit state ${s}`);
|
|
379
|
+
const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes.map((p) => p.name);
|
|
380
|
+
assert.ok(probes.some((n) => /guild invite/.test(n)) && probes.some((n) => /join request/.test(n)), 'probes drive both cards');
|
|
381
|
+
const serve = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
|
|
382
|
+
assert.match(serve, /rest === '\/me\/guild-requests' && req\.method === 'GET'\) return AUTH \? myGuildRequests\(res\)/);
|
|
383
|
+
assert.match(serve, /owner_incoming: owner, owner_incoming_count: owner\.length/, 'the route’s one-read shape, owner rows included');
|
|
384
|
+
assert.match(serve, /'guild_card_visibility_required'/, 'an invitee accept with no visibility is refused, as the route does');
|
|
385
|
+
assert.match(serve, /code: 'guild_visibility_changed'[^\n]*details: \{ visibility: now \}/, 'and one that differs is the 409 with the guild as it is now');
|
|
386
|
+
assert.match(serve, /res\.writeHead\(204/, 'a decline is a silent 204');
|
|
387
|
+
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'me__guild-requests.json'), 'utf8'));
|
|
388
|
+
for (const r of fixture.incoming) {
|
|
389
|
+
assert.deepEqual(Object.keys(r).sort(), ['created_at', 'description', 'id', 'name', 'seen_at', 'slug', 'visibility'], 'listMyRequests’s row shape');
|
|
390
|
+
}
|
|
391
|
+
assert.deepEqual(fixture.incoming.map((r) => r.visibility).sort(), ['public', 'unlisted']);
|
|
392
|
+
});
|
package/tests/merge_lock.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import assert from 'node:assert/strict';
|
|
11
11
|
import { createRequire } from 'node:module';
|
|
12
|
+
import { execSync } from 'node:child_process';
|
|
12
13
|
import fs from 'node:fs';
|
|
13
14
|
import os from 'node:os';
|
|
14
15
|
import path from 'node:path';
|
|
@@ -292,6 +293,63 @@ test('recoverWorktree: clean reclaim → no churn (no abort, no reset)', () => {
|
|
|
292
293
|
}
|
|
293
294
|
});
|
|
294
295
|
|
|
296
|
+
// ---- linked worktrees (task 1004584) ----
|
|
297
|
+
// When `main` is checked out in a LINKED worktree, <root>/.git is a FILE, so the
|
|
298
|
+
// old <root>/.git/otb-automerge.lock was ENOENT on every acquire. The lock now
|
|
299
|
+
// lives in the common git dir — the same file from every worktree.
|
|
300
|
+
|
|
301
|
+
function realRepoWithLinkedWorktree() {
|
|
302
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-mergelock-linked-'));
|
|
303
|
+
const run = (cmd) => execSync(cmd, { cwd: dir, stdio: 'ignore' });
|
|
304
|
+
run('git init -q -b trunk');
|
|
305
|
+
run('git config user.email t@t.t');
|
|
306
|
+
run('git config user.name t');
|
|
307
|
+
fs.writeFileSync(path.join(dir, 'a.txt'), 'x\n');
|
|
308
|
+
run('git add a.txt');
|
|
309
|
+
run('git commit -q -m init');
|
|
310
|
+
const linked = path.join(dir, 'wt-main');
|
|
311
|
+
run(`git worktree add -q -b main "${linked}"`);
|
|
312
|
+
return { dir, linked };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
test('lockPath: from a linked worktree, resolves to the COMMON git dir (same file as the primary)', () => {
|
|
316
|
+
const { dir, linked } = realRepoWithLinkedWorktree();
|
|
317
|
+
try {
|
|
318
|
+
assert.ok(fs.statSync(path.join(linked, '.git')).isFile(), 'precondition: .git is a gitdir pointer file');
|
|
319
|
+
assert.equal(
|
|
320
|
+
fs.realpathSync.native(path.dirname(lock.lockPath(linked))),
|
|
321
|
+
fs.realpathSync.native(path.join(dir, '.git')),
|
|
322
|
+
);
|
|
323
|
+
assert.equal(path.basename(lock.lockPath(linked)), 'otb-automerge.lock');
|
|
324
|
+
} finally {
|
|
325
|
+
cleanup(dir);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('acquire/release: work from a linked main worktree, and the primary sees the hold', () => {
|
|
330
|
+
const { dir, linked } = realRepoWithLinkedWorktree();
|
|
331
|
+
try {
|
|
332
|
+
assert.equal(lock.acquire(linked, { waitMs: 0 }), true);
|
|
333
|
+
assert.equal(lock.readLock(dir).pid, process.pid, 'the primary must see the hold the linked worktree took');
|
|
334
|
+
assert.equal(lock.acquire(dir, { waitMs: 0, sleep: () => {} }), false, 'a second holder from the primary must be refused');
|
|
335
|
+
lock.release(linked);
|
|
336
|
+
assert.equal(lock.readLock(dir), null);
|
|
337
|
+
} finally {
|
|
338
|
+
cleanup(dir);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('indexLockPath: a linked worktree keeps its index.lock in its OWN git dir', () => {
|
|
343
|
+
const { dir, linked } = realRepoWithLinkedWorktree();
|
|
344
|
+
try {
|
|
345
|
+
const p = lock.indexLockPath(linked);
|
|
346
|
+
assert.equal(path.basename(p), 'index.lock');
|
|
347
|
+
assert.match(p.replace(/\\/g, '/'), /\.git\/worktrees\/wt-main\/index\.lock$/);
|
|
348
|
+
} finally {
|
|
349
|
+
cleanup(dir);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
295
353
|
// ---- runner ----
|
|
296
354
|
|
|
297
355
|
let pass = 0;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import assert from 'node:assert/strict';
|
|
18
18
|
import { test } from 'node:test';
|
|
19
19
|
import { createRequire } from 'node:module';
|
|
20
|
+
import { execSync } from 'node:child_process';
|
|
20
21
|
import fs from 'node:fs';
|
|
21
22
|
import os from 'node:os';
|
|
22
23
|
import path from 'node:path';
|
|
@@ -170,3 +171,65 @@ test('an unmeasurable base blocks rather than waving the ship through', () => {
|
|
|
170
171
|
// it could not measure, and null blocks.
|
|
171
172
|
assert.equal(gate.mergeGateVerdict({ mergeClean: false, baseClean: null }).block, true);
|
|
172
173
|
});
|
|
174
|
+
|
|
175
|
+
// ---- gateApplies (task 1004584) ----
|
|
176
|
+
// A VENDORED instance (core under node_modules/@bongos/core) carries no
|
|
177
|
+
// scripts/gds/fitness.js of its own, so both sides read 'unmeasurable' and every
|
|
178
|
+
// vendored land was blocked. The gate stands down ONLY when the base lacked the
|
|
179
|
+
// file too — a branch that deletes fitness.js from the core is still measured.
|
|
180
|
+
function gitRepo(withFitness) {
|
|
181
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-gate-applies-'));
|
|
182
|
+
const run = (cmd) => execSync(cmd, { cwd: dir, stdio: 'ignore' });
|
|
183
|
+
run('git init -q -b main');
|
|
184
|
+
run('git config user.email t@t.t');
|
|
185
|
+
run('git config user.name t');
|
|
186
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'x\n');
|
|
187
|
+
if (withFitness) {
|
|
188
|
+
fs.mkdirSync(path.join(dir, 'scripts', 'gds'), { recursive: true });
|
|
189
|
+
fs.writeFileSync(path.join(dir, 'scripts', 'gds', 'fitness.js'), 'process.exit(0);\n');
|
|
190
|
+
}
|
|
191
|
+
run('git add -A');
|
|
192
|
+
run('git commit -q -m base');
|
|
193
|
+
const baseSha = execSync('git rev-parse HEAD', { cwd: dir, encoding: 'utf8' }).trim();
|
|
194
|
+
return { dir, baseSha };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
test('gateApplies: a repo that carries fitness.js is always gated', () => {
|
|
198
|
+
const { dir, baseSha } = gitRepo(true);
|
|
199
|
+
try {
|
|
200
|
+
assert.equal(gate.gateApplies(dir, baseSha), true);
|
|
201
|
+
} finally {
|
|
202
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('gateApplies: a vendored instance (no fitness.js on either side) stands the gate down', () => {
|
|
207
|
+
const { dir, baseSha } = gitRepo(false);
|
|
208
|
+
try {
|
|
209
|
+
assert.equal(gate.gateApplies(dir, baseSha), false);
|
|
210
|
+
const verdict = gate.gateMergeResult({ repoRoot: dir, baseSha, log: () => {} });
|
|
211
|
+
assert.deepEqual(verdict, { block: false, reason: 'not-applicable' });
|
|
212
|
+
} finally {
|
|
213
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('gateApplies: a merge that DELETES fitness.js from a gated base is still measured (and blocked)', () => {
|
|
218
|
+
const { dir, baseSha } = gitRepo(true);
|
|
219
|
+
try {
|
|
220
|
+
fs.rmSync(path.join(dir, 'scripts', 'gds', 'fitness.js'));
|
|
221
|
+
execSync('git commit -q -am "drop the gate"', { cwd: dir, stdio: 'ignore' });
|
|
222
|
+
assert.equal(gate.gateApplies(dir, baseSha), true, 'the base had the gate, so its removal must not wave the merge through');
|
|
223
|
+
} finally {
|
|
224
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('gateApplies: with no base sha it cannot prove the gate was never there → gated', () => {
|
|
229
|
+
const { dir } = gitRepo(false);
|
|
230
|
+
try {
|
|
231
|
+
assert.equal(gate.gateApplies(dir, null), true);
|
|
232
|
+
} finally {
|
|
233
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
});
|
package/tests/ship_preflight.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
|
|
14
14
|
const require = createRequire(import.meta.url);
|
|
15
15
|
const {
|
|
16
|
-
parsePorcelain, trackedDirty, classifyEmptyShip, cleanupMacFinderDupRefs, findMainWorktree, closeoutPlan,
|
|
16
|
+
parsePorcelain, trackedDirty, classifyEmptyShip, cleanupMacFinderDupRefs, findMainWorktree, findPrimaryWorktree, closeoutPlan,
|
|
17
17
|
untrackedPaths, untrackedLeftBehind, untrackedWarningLines,
|
|
18
18
|
} = require('../modules/lifecycle/ship-preflight.js');
|
|
19
19
|
// task 1001436: the preflight step itself + the ship-input resolver it is handed.
|
|
@@ -318,6 +318,51 @@ test('findMainWorktree: returns null outside any git repo', () => {
|
|
|
318
318
|
}
|
|
319
319
|
});
|
|
320
320
|
|
|
321
|
+
// ---- findPrimaryWorktree (task 1004584) ----
|
|
322
|
+
// The merge target when NO worktree has main: the primary checkout, whatever it
|
|
323
|
+
// is on — so ship can say "check out main there" instead of falling back to the
|
|
324
|
+
// core package dir (a vendored instance's node_modules/@bongos/core).
|
|
325
|
+
|
|
326
|
+
test('findPrimaryWorktree: from a linked worktree, returns the primary checkout even when it is off main', () => {
|
|
327
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-primarywt-'));
|
|
328
|
+
try {
|
|
329
|
+
execSync('git init -q -b trunk', { cwd: tmp });
|
|
330
|
+
execSync('git config user.email t@t.t && git config user.name t', { cwd: tmp });
|
|
331
|
+
fs.writeFileSync(path.join(tmp, 'a.txt'), 'x');
|
|
332
|
+
execSync('git add a.txt && git commit -q -m init', { cwd: tmp });
|
|
333
|
+
execSync('git branch feature', { cwd: tmp });
|
|
334
|
+
const wt = path.join(tmp, 'linked');
|
|
335
|
+
execSync(`git worktree add -q "${wt}" feature`, { cwd: tmp });
|
|
336
|
+
|
|
337
|
+
assert.equal(findMainWorktree(wt), null, 'precondition: no worktree is on main');
|
|
338
|
+
const found = findPrimaryWorktree(wt);
|
|
339
|
+
assert.ok(found, 'expected a path');
|
|
340
|
+
assert.equal(fs.realpathSync.native(found), fs.realpathSync.native(tmp));
|
|
341
|
+
} finally {
|
|
342
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('findPrimaryWorktree: a BARE primary is not a checkout to merge in → null', () => {
|
|
347
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-primarywt-bare-'));
|
|
348
|
+
try {
|
|
349
|
+
const bare = path.join(tmp, 'repo.git');
|
|
350
|
+
execSync(`git init -q --bare -b main "${bare}"`, { cwd: tmp });
|
|
351
|
+
assert.equal(findPrimaryWorktree(bare), null);
|
|
352
|
+
} finally {
|
|
353
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('findPrimaryWorktree: returns null outside any git repo', () => {
|
|
358
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'otb-primarywt-nogit-'));
|
|
359
|
+
try {
|
|
360
|
+
assert.equal(findPrimaryWorktree(tmp), null);
|
|
361
|
+
} finally {
|
|
362
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
321
366
|
// ---- closeoutPlan (task 932) ----
|
|
322
367
|
|
|
323
368
|
test('closeoutPlan: clean tree → clean:true, no leftover', () => {
|