@bongos/core 1.21.94 → 1.21.95

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,340 @@
1
+ // tests/github_handback_any_device.mjs — coming back from GitHub works from any tab or
2
+ // device, and the page keeps checking while you are away (task 1004650).
3
+ //
4
+ // Before: the GitHub hand-back sent the founder to /projects?view=new#ghstate=…, and the
5
+ // page knew WHICH project only from that tab's sessionStorage (cb-created-instance). Approve
6
+ // on a phone, or in another window, and you landed on a blank create page. And both wizard
7
+ // polls parked themselves while the tab was hidden — exactly while the founder was on GitHub.
8
+ //
9
+ // Now the callback and setup pages send the founder to the return route, which resolves the
10
+ // one-time state to the project the SERVER remembers for it and lands its signed-in owner on
11
+ // /projects?view=new&id=<project>; the page asks the server's resume read for that project
12
+ // and opens part 03 with nothing in browser storage. The polls keep going while hidden, at
13
+ // a slower pace, and a waiting tick runs at once when the tab is seen again.
14
+ //
15
+ // The whole path runs for real at least once: the REAL setup route's page → its REAL hand-
16
+ // back script → the REAL return route (302) → the REAL page code (sliced out of
17
+ // projects.html and run in a vm, the vm-slice idiom of tests/wizard_intent_resume.mjs) with
18
+ // EMPTY sessionStorage, whose fetches go to the REAL resume route. DB-free: the provisioning
19
+ // data-access fns and the session resolver are stubbed on the cached modules.
20
+ //
21
+ // Run: node --test --test-reporter=tap tests/github_handback_any_device.mjs
22
+
23
+ import assert from 'node:assert/strict';
24
+ import { test, before, after } from 'node:test';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import vm from 'node:vm';
28
+ import { createRequire } from 'node:module';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
34
+ // CRLF on a Windows checkout — a vm cannot run a slice with stray \r in it
35
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
36
+ const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
37
+
38
+ // ── the world: two builders, a project each, one attempt in flight ───────────────────
39
+ const STATE = 'ab12'.repeat(8);
40
+ const NOW = new Date().toISOString();
41
+ const MINE = { id: 42, owner_builder_id: 7, slug: 'mercury', domain: 'mercury.example.com', target_ref: 'acme/mercury', status: 'active', onboard_mode: 'greenfield', created_at: NOW };
42
+ const THEIRS = { id: 43, owner_builder_id: 8, slug: 'venus', domain: 'venus.example.com', target_ref: 'zed/venus', status: 'active', onboard_mode: 'greenfield', created_at: NOW };
43
+ const ATTEMPT = { id: 99, instance_id: 42, requested_by: 7, state: STATE, status: 'converting', app_slug: 'mercury-app', created_at: NOW, updated_at: NOW, expires_at: new Date(Date.now() + 600000).toISOString() };
44
+
45
+ const provisioning = require(`${ROOT}/modules/provisioning/provisioning.js`);
46
+ provisioning.getInstanceById = async (_db, id) => [MINE, THEIRS].find((i) => i.id === Number(id)) || null;
47
+ provisioning.getOAuthManifestByState = async (_db, s) => (s === STATE ? ATTEMPT : null);
48
+ provisioning.getLatestOAuthManifestForInstance = async (_db, id) => (Number(id) === 42 ? ATTEMPT : null);
49
+ provisioning.listInstancesForOwner = async (_db, who) => [MINE, THEIRS].filter((i) => i.owner_builder_id === who);
50
+ provisioning.recordEvent = async () => {};
51
+ const githubSetup = require(`${ROOT}/modules/provisioning/github-setup.js`);
52
+ githubSetup.githubSetupFacts = async () => ({}); // nothing verified yet: the install is still to do
53
+
54
+ // The session is a cookie naming the builder; no cookie is nobody. Both auth doors read it.
55
+ const api = require(`${ROOT}/src/module-api.js`);
56
+ const whoOf = (req) => { const m = /(?:^|;\s*)who=(\d+)/.exec(String(req.headers.cookie || '')); return m ? Number(m[1]) : null; };
57
+ api.resolveSession = async (req) => (whoOf(req) ? { builder_id: whoOf(req), status: 'active', source: 'web' } : null);
58
+ api.requireBuilder = (req, res, next) => {
59
+ const who = whoOf(req);
60
+ if (!who) return res.status(401).json({ error: { code: 'unauthenticated' } });
61
+ req.builder = { id: who };
62
+ return next();
63
+ };
64
+
65
+ const express = require('express');
66
+ const githubSetupRoutes = require(`${ROOT}/modules/provisioning/routes/github-setup.js`);
67
+ let server, origin;
68
+ before(async () => {
69
+ const app = express();
70
+ app.use('/api/bongos', githubSetupRoutes());
71
+ server = app.listen(0);
72
+ await new Promise((r) => server.once('listening', r));
73
+ origin = `http://127.0.0.1:${server.address().port}`;
74
+ });
75
+ after(() => server && server.close());
76
+
77
+ const get = (p, who, extra = {}) => fetch(origin + p, { redirect: 'manual', headers: who ? { cookie: `who=${who}` } : {}, ...extra });
78
+
79
+ // ── the page code, sliced (each boundary asserted below) ──────────────────────────────
80
+ function region(startMark, endMark) {
81
+ const s = SCRIPT.indexOf(startMark);
82
+ const e = s < 0 ? -1 : SCRIPT.indexOf(endMark, s);
83
+ return s > 0 && e > s ? SCRIPT.slice(s, e) : null;
84
+ }
85
+ const POLLS = region(" /* ── C8: the wizard's polls run only while the create view is on stage", ' /* ── live build progress (task 2083)');
86
+ const WATCH_VARS = region(' var pollTimer = null;', " /* The done panel's own flow vocabularies");
87
+ const START_PROGRESS = (SCRIPT.match(/ {4}function startProgress\(id\) \{[\s\S]*?\n {4}\}/) || [null])[0];
88
+ const DOORS = region(" /* ── the hub's three doors ── */", ' return { enter: enter, leave: leave, onAuth: onAuth };');
89
+
90
+ test('the page regions are still findable (a rename must fail loudly, not test nothing)', () => {
91
+ assert.ok(POLLS, 'the wizard poll region (C8 → the manifest poll) was not found');
92
+ assert.ok(WATCH_VARS, 'the progress watch vars were not found');
93
+ assert.ok(START_PROGRESS, 'startProgress() was not found');
94
+ assert.ok(DOORS, "the hub's three doors (createdRecord → enter) were not found");
95
+ assert.match(POLLS, /var handbackProject = /, 'the hand-back names its project in the poll region');
96
+ assert.match(DOORS, /function askServerResume\(focus\)/, 'the server resume ask lives with enter()');
97
+ assert.doesNotMatch(POLLS + DOORS, /innerHTML/, 'no live DOM from strings');
98
+ });
99
+
100
+ // A miniature browser for the sliced page: empty storage (or storage that throws), the URL
101
+ // the founder landed on, a document whose `hidden` the test flips, recorded timers, and a
102
+ // fetch that goes to the REAL routes as the given builder.
103
+ function page({ url, who, storage = 'empty', name = '', hidden = false, instanceRead = null }) {
104
+ const u = new URL(url, 'https://hub.example');
105
+ const store = new Map();
106
+ const log = { show: [], progress: [], offers: [], fetched: [], timers: [], cleared: 0, resets: 0, rendered: 0 };
107
+ const listeners = {};
108
+ const inflight = [];
109
+ log.inflight = inflight;
110
+ const sessionStorage = storage === 'throws'
111
+ ? { getItem() { throw new Error('denied'); }, setItem() { throw new Error('denied'); }, removeItem() { throw new Error('denied'); } }
112
+ : { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) };
113
+ const el = () => ({ addEventListener() {}, focus() {}, hidden: true, textContent: '' });
114
+ const sandbox = {
115
+ API: '/api/bongos', DRAFT_KEY: 'cb-create-draft-v10', CREATED_KEY: 'cb-created-instance', DECLINED_KEY: 'cb-resume-declined',
116
+ location: { search: u.search, hash: u.hash },
117
+ URLSearchParams, JSON, String, Number, Math, Object, Date, encodeURIComponent, Promise,
118
+ document: { hidden, addEventListener: (ev, fn) => { (listeners[ev] = listeners[ev] || []).push(fn); } },
119
+ sessionStorage,
120
+ setTimeout: (fn, ms) => { log.timers.push({ fn, ms }); return log.timers.length; },
121
+ clearTimeout: () => { log.cleared += 1; },
122
+ fetch: (p, opts) => {
123
+ log.fetched.push(p);
124
+ const answer = (instanceRead && /\/provisioning\/instances\/\d+$/.test(p))
125
+ ? Promise.resolve({ ok: true, status: 200, json: async () => instanceRead() })
126
+ : get(p, who, { method: (opts && opts.method) || 'GET' });
127
+ inflight.push(answer.then((r) => r.clone().text()).catch(() => null));
128
+ return answer;
129
+ },
130
+ $: el,
131
+ currentView: 'new', authKnown: true, me: { id: who, github_login: 'founder' },
132
+ state: { step: 0, name, slug: '', domain: '', mode: 'greenfield', owner: 'founder', repo: '', noAddress: false },
133
+ live: { instStatus: null, addrReady: false, manifest: null, manifestFailure: null, appSlug: null, setup: null, installUrl: null },
134
+ instanceId: null, watchPending: false,
135
+ show(step) { sandbox.state.step = step; log.show.push(step); },
136
+ renderDone() { log.rendered += 1; }, renderSteps() { log.rendered += 1; },
137
+ resetForNewProject() { log.resets += 1; },
138
+ saveDraft() {},
139
+ setProgress() {}, progressMarkup: () => '', setMoment() {}, renderProgress: () => false, addressReady: () => true,
140
+ };
141
+ sandbox.globalThis = sandbox;
142
+ vm.createContext(sandbox);
143
+ vm.runInContext([POLLS, WATCH_VARS, START_PROGRESS, DOORS].join('\n'), sandbox);
144
+ // the real startProgress runs too; record which project it watches
145
+ const realStart = sandbox.startProgress;
146
+ // the resume line is real markup (#wizResume); record what it is told to name
147
+ sandbox.paintResumeOffer = (rec) => { log.offers.push(rec ? rec.id : null); };
148
+ sandbox.startProgress = (id) => { log.progress.push(id); return realStart(id); };
149
+ return { sandbox, log, store, listeners, fire: (ev) => (listeners[ev] || []).forEach((fn) => fn()) };
150
+ }
151
+ // wait until every request the page made has answered and its handlers have run
152
+ async function settle(log) {
153
+ for (let round = 0; round < 20; round++) {
154
+ const seen = log.inflight.length;
155
+ await Promise.all(log.inflight.slice());
156
+ for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r));
157
+ if (log.inflight.length === seen) return;
158
+ }
159
+ }
160
+
161
+ // The served hand-back page, run for real: its own config and script, no opener (a phone
162
+ // has no wizard tab behind it), and where it sends the browser.
163
+ async function handbackFromSetupRoute() {
164
+ const res = await get(`/api/bongos/provisioning/github-app/setup?installation_id=5&setup_action=install&state=${STATE}`);
165
+ const html = await res.text();
166
+ const cfg = JSON.parse((html.match(/window\.__CB_SETUP__=(\{[^<]*?\});/) || [])[1] || 'null');
167
+ const script = (html.match(/<script>\s*(\(function\(\)\{[\s\S]*?)<\/script>\s*<\/body>/) || [])[1];
168
+ assert.ok(cfg && script, 'the setup route serves the hand-back page with its config and script');
169
+ let replaced = null;
170
+ const timers = [];
171
+ const back = { href: '' };
172
+ const sb = { __CB_SETUP__: cfg, opener: null, close() {}, document: { getElementById: (id) => (id === 'back' ? back : null) }, location: { replace: (u) => { replaced = u; } }, setTimeout: (fn) => timers.push(fn), encodeURIComponent };
173
+ sb.window = sb;
174
+ vm.runInNewContext(script, sb);
175
+ while (timers.length) timers.shift()();
176
+ return { replaced, html };
177
+ }
178
+
179
+ test('approved on a phone, empty storage: the real hand-back lands on the right project at part 03', async () => {
180
+ const { replaced, html } = await handbackFromSetupRoute();
181
+ assert.equal(replaced, `/api/bongos/provisioning/github-app/return?state=${STATE}`, 'the hand-back goes to the return route, not a bare create page');
182
+ assert.ok(html.includes(`href="/api/bongos/provisioning/github-app/return?state=${STATE}"`), 'the no-script link goes there too');
183
+
184
+ const ret = await get(replaced, 7);
185
+ assert.equal(ret.status, 302, 'the owner is redirected');
186
+ const landing = ret.headers.get('location');
187
+ assert.equal(landing, `/projects?view=new&id=42#ghstate=${STATE}`, 'to the project the SERVER remembers for the state');
188
+ assert.equal(ret.headers.get('cache-control'), 'no-store');
189
+
190
+ // the page, with NOTHING in this browser's storage
191
+ const { sandbox, log, store } = page({ url: landing, who: 7 });
192
+ assert.equal(store.size, 0, 'the browser starts with empty storage');
193
+ sandbox.enter(false); // what onAuth() does once /me answers
194
+ assert.deepEqual(log.show, [], 'a named hand-back waits for the server instead of painting another project');
195
+ await settle(log);
196
+ assert.ok(log.fetched.includes('/api/bongos/provisioning/github-app/resume?id=42'), JSON.stringify(log.fetched));
197
+ assert.deepEqual(log.show, [2], 'part 03 — the created page — is shown');
198
+ assert.equal(log.progress[0], 42, 'and it watches project 42');
199
+ assert.equal(sandbox.state.slug, 'mercury');
200
+ assert.equal(sandbox.state.repo, 'mercury', 'the install step names the repo the server knows');
201
+ assert.equal(sandbox.manifestState, STATE, 'the attempt from the fragment keeps being watched');
202
+ assert.ok(log.fetched.some((p) => p === `/api/bongos/provisioning/github-app/status?state=${STATE}`), 'the state-keyed checklist poll runs');
203
+ assert.equal(JSON.parse(store.get('cb-created-instance')).id, 42, 'a reload of this tab keeps the project');
204
+ });
205
+
206
+ test('the same hand-back works where the browser refuses storage outright', async () => {
207
+ const { sandbox, log } = page({ url: `/projects?view=new&id=42#ghstate=${STATE}`, who: 7, storage: 'throws' });
208
+ sandbox.enter(false);
209
+ await settle(log);
210
+ assert.deepEqual(log.show, [2]);
211
+ assert.equal(log.progress[0], 42);
212
+ });
213
+
214
+ test('a different signed-in builder is refused — never shown or landed on the project', async () => {
215
+ const ret = await get(`/api/bongos/provisioning/github-app/return?state=${STATE}`, 8);
216
+ const html = await ret.text();
217
+ assert.equal(ret.status, 404, 'a refusal, not a redirect');
218
+ assert.equal(ret.headers.get('location'), null);
219
+ assert.match(html, /This isn’t your project/);
220
+ assert.doesNotMatch(html, /mercury|view=new&id=|42/, 'the page names no project');
221
+ const unknown = await (await get(`/api/bongos/provisioning/github-app/return?state=${'cd34'.repeat(8)}`, 8)).text();
222
+ assert.equal(unknown, html, 'an unknown state reads the same (no oracle about which)');
223
+ const malformed = await get('/api/bongos/provisioning/github-app/return?state=../../etc', 7);
224
+ assert.equal(malformed.status, 404);
225
+
226
+ // and the resume read gives them nothing either, even for a crafted ?id=42
227
+ const j = await (await get('/api/bongos/provisioning/github-app/resume?id=42', 8)).json();
228
+ assert.equal(j.project, null);
229
+ const { sandbox, log } = page({ url: '/projects?view=new&id=42', who: 8 });
230
+ sandbox.enter(false);
231
+ await settle(log);
232
+ assert.ok(!log.progress.includes(42), 'the page never watches a project that is not theirs');
233
+ assert.ok(!log.show.includes(2), 'and never opens its part 03');
234
+ });
235
+
236
+ test('not signed in → through sign-in, then back to the same return', async () => {
237
+ const ret = await get(`/api/bongos/provisioning/github-app/return?state=${STATE}`, null);
238
+ assert.equal(ret.status, 302);
239
+ const loc = ret.headers.get('location');
240
+ const u = new URL(loc, 'https://hub.example');
241
+ assert.equal(u.pathname, '/api/bongos/auth/web/start');
242
+ assert.equal(u.searchParams.get('return'), `/api/bongos/provisioning/github-app/return?state=${STATE}`, 'the return target is the return route itself');
243
+ assert.equal(u.searchParams.get('repos'), '1', 'with the hub sign-in door’s repo scope');
244
+ });
245
+
246
+ test('the state stays single-use where it is used: the return route changes nothing', async () => {
247
+ let writes = 0;
248
+ const prev = provisioning.attachManifestCode;
249
+ provisioning.attachManifestCode = async () => { writes += 1; return null; };
250
+ const a = await get(`/api/bongos/provisioning/github-app/return?state=${STATE}`, 7);
251
+ const b = await get(`/api/bongos/provisioning/github-app/return?state=${STATE}`, 7);
252
+ provisioning.attachManifestCode = prev;
253
+ assert.equal(writes, 0, 'no code exchange, no status change');
254
+ assert.equal(a.headers.get('location'), b.headers.get('location'), 'the owner can come back again; nobody else can at all');
255
+ });
256
+
257
+ test('opening the create page later, from any device, resumes part 03 from the server’s view', async () => {
258
+ const later = page({ url: '/projects?view=new', who: 7 });
259
+ later.sandbox.enter(false);
260
+ await settle(later.log);
261
+ assert.ok(later.log.fetched.includes('/api/bongos/provisioning/github-app/resume'));
262
+ assert.deepEqual(later.log.show.slice(-1), [2], 'the unfinished project comes back');
263
+ assert.equal(later.log.progress[0], 42);
264
+
265
+ // someone typing a new project keeps their page; the project waits on the resume line
266
+ const typing = page({ url: '/projects?view=new', who: 7, name: 'Second idea' });
267
+ typing.sandbox.enter(false);
268
+ await settle(typing.log);
269
+ assert.ok(!typing.log.progress.includes(42));
270
+ assert.deepEqual(typing.log.offers.filter(Boolean), [42]);
271
+
272
+ // a deliberate "New project" only offers it
273
+ const nav = page({ url: '/projects?view=new', who: 7 });
274
+ nav.sandbox.enter(true);
275
+ await settle(nav.log);
276
+ assert.ok(!nav.log.progress.includes(42));
277
+ assert.deepEqual(nav.log.offers.filter(Boolean), [42]);
278
+
279
+ // "Start another project" said no to it in this tab
280
+ const declined = page({ url: '/projects?view=new', who: 7 });
281
+ declined.store.set('cb-resume-declined', '42');
282
+ declined.sandbox.enter(false);
283
+ await settle(declined.log);
284
+ assert.ok(!declined.log.progress.includes(42));
285
+
286
+ // a connected project is not "in progress"; nor is anything for a builder with none
287
+ githubSetup.githubSetupFacts = async () => ({ installedAt: NOW });
288
+ const prevAttempt = { ...ATTEMPT };
289
+ Object.assign(ATTEMPT, { status: 'done' });
290
+ const done = await (await get('/api/bongos/provisioning/github-app/resume', 7)).json();
291
+ Object.assign(ATTEMPT, prevAttempt);
292
+ githubSetup.githubSetupFacts = async () => ({});
293
+ assert.equal(done.project, null, 'a project whose GitHub is connected is not resumed');
294
+ const none = await (await get('/api/bongos/provisioning/github-app/resume', 9)).json();
295
+ assert.equal(none.project, null);
296
+ const anon = await get('/api/bongos/provisioning/github-app/resume', null);
297
+ assert.equal(anon.status, 401, 'signed-in only');
298
+ });
299
+
300
+ test('a hidden tab keeps checking at the slow pace, and checks at once when seen again', async () => {
301
+ const { sandbox, log, fire } = page({ url: `/projects?view=new&id=42#ghstate=${STATE}`, who: 7, hidden: true });
302
+ sandbox.pollManifest(STATE);
303
+ await settle(log);
304
+ assert.equal(log.fetched.filter((p) => /github-app\/status/.test(p)).length, 1, 'the hidden tab still asks — it does not park');
305
+ assert.equal(log.timers.length, 1);
306
+ assert.equal(log.timers[0].ms, 20000, 'and asks again at the slow pace');
307
+ assert.equal(sandbox.manifestPending, false, 'nothing is parked');
308
+
309
+ // the founder comes back to the laptop: the waiting tick runs now, not in 20s
310
+ sandbox.document.hidden = false;
311
+ fire('visibilitychange');
312
+ await settle(log);
313
+ assert.equal(log.cleared, 1, 'the slow timer is dropped');
314
+ assert.equal(log.fetched.filter((p) => /github-app\/status/.test(p)).length, 2, 'and the check runs immediately');
315
+ assert.equal(log.timers[log.timers.length - 1].ms, 4000, 'then the normal pace');
316
+
317
+ // off the create view the poll still parks (C8)
318
+ sandbox.currentView = 'map';
319
+ log.timers[log.timers.length - 1].fn();
320
+ assert.equal(sandbox.manifestPending, true);
321
+ });
322
+
323
+ test('the laptop left waiting ticks on its own while the phone approves (the owner read, hidden tab)', async () => {
324
+ let installed = false;
325
+ const instanceRead = () => ({
326
+ instance: { ...MINE },
327
+ github_app: { status: 'done', app_slug: 'mercury-app' },
328
+ github_setup: { checklist: { approve: 'done', install: installed ? 'done' : 'now', sign_in: 'done', device_flow: 'later', connected: installed }, install_url: null },
329
+ });
330
+ const { sandbox, log } = page({ url: '/projects?view=new', who: 7, hidden: true, instanceRead });
331
+ sandbox.startProgress(42);
332
+ await settle(log);
333
+ assert.equal(sandbox.live.setup.install, 'now');
334
+ assert.equal(log.timers.length, 1, 'still watching while hidden');
335
+ assert.equal(log.timers[0].ms, 20000, 'at the slow pace');
336
+ installed = true; // the phone installed the app; the runner verified it
337
+ log.timers[0].fn();
338
+ await settle(log);
339
+ assert.equal(sandbox.live.setup.install, 'done', 'the checklist ticked without anyone touching the laptop');
340
+ });
@@ -355,8 +355,12 @@ test('C8 — the wizard polls stop dead off the new view, like the sky off the m
355
355
  // guard — merged unguarded they would poll a hidden view for up to 33
356
356
  // minutes. Both ticks must consult the same gate the sky consults.
357
357
  assert.match(SCRIPT, /function wizPollActive\(\)/, 'the wizard needs its own stage guard');
358
- assert.match(SCRIPT, /!document\.hidden && currentView === 'new'/,
359
- 'the guard is hidden-tab aware and view-scoped');
358
+ assert.match(SCRIPT, /function wizPollActive\(\) \{\s*return currentView === 'new';\s*\}/,
359
+ 'the guard is view-scoped');
360
+ // a HIDDEN tab slows the wizard's polls instead of stopping them (task 1004650): that
361
+ // is exactly when the founder is on GitHub. tests/github_handback_any_device.mjs runs it.
362
+ assert.match(SCRIPT, /return document\.hidden \? Math\.max\(ms, HIDDEN_POLL_MS\) : ms;/,
363
+ 'a hidden tab keeps checking at the slow pace');
360
364
  const gatedTicks = (SCRIPT.match(/if \(!wizPollActive\(\)\)/g) || []).length;
361
365
  assert.ok(gatedTicks >= 2, `both wizard ticks (progress + manifest) must check the guard, found ${gatedTicks}`);
362
366
  assert.match(SCRIPT, /if \(v === 'new'\) wizard\.enter\(focus\); else wizard\.leave\(\);/,
@@ -50,8 +50,9 @@ test('the hand-back variant escapes the title + domain it interpolates', () => {
50
50
  assert.doesNotMatch(html, /Setting up <your> instance/, 'no raw title anywhere on the page');
51
51
  assert.doesNotMatch(html, /https:\/\/evil"\.example\.com/, 'no unescaped domain in an href');
52
52
  // it IS the hand-back variant (task 1004548): the way back + the config block the client script reads
53
- assert.match(html, /id="back" href="\/projects\?view=new"/);
54
- assert.match(html, /__CB_SETUP__=\{"state":"GOODSTATE","domain":"evil\\"\.example\.com","back":"\/projects\?view=new"\}/);
53
+ // the way back is the return route, which names the project on the server (task 1004650)
54
+ assert.ok(html.includes('id="back" href="/api/bongos/provisioning/github-app/return?state=GOODSTATE"'));
55
+ assert.match(html, /__CB_SETUP__=\{"state":"GOODSTATE","domain":"evil\\"\.example\.com","back":"\/api\/bongos\/provisioning\/github-app\/return\?state=GOODSTATE"\}/);
55
56
  });
56
57
 
57
58
  test('the message variant escapes the title but passes server-authored body HTML through', () => {
@@ -108,7 +108,11 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
108
108
  t('good state → 200 text/html one-trip page', res.status === 200 && /text\/html/.test(res.headers.get('content-type') || '') && /GitHub app created/.test(html) && /__CB_SETUP__/.test(html) && html.includes('GOODSTATE'), `status=${res.status}`);
109
109
  // task 1004649: GitHub's half of the approval is done — the SAME tab goes on to the install
110
110
  t('says the next step is installing it on the repo', /Next, install it on your repo/.test(html));
111
- t('carries a plain way back to the project (works without script)', /<a class="btn" id="back" href="\/projects\?view=new">Back to my project/.test(html));
111
+ // task 1004650: the way back is the return route, which names the project on the server
112
+ t('carries a plain way back to the project (works without script)', html.includes('<a class="btn" id="back" href="/api/bongos/provisioning/github-app/return?state=GOODSTATE">Back to my project'));
113
+ // the script's config is the one the page SERVED, not a copy (task 1004650)
114
+ const servedCfg = JSON.parse((html.match(/window\.__CB_SETUP__=(\{[^<]*?\});/) || [])[1] || '{}');
115
+ t('the served config sends the founder to the return route, never to a bare create page', servedCfg.back === '/api/bongos/provisioning/github-app/return?state=GOODSTATE', JSON.stringify(servedCfg));
112
116
  t('no progress stepper or countdown left on the page', !/id="steps"/.test(html) && !/Placing credentials/.test(html) && !/location\.href=target/.test(html));
113
117
 
114
118
  // Run the served INSTALL script (not a copy): it polls the real status shape and goes on.
@@ -116,11 +120,11 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
116
120
  t('the one-trip page ships its client script', typeof installScript === 'string' && /installations|install_url/.test(installScript));
117
121
  async function runInstall(statuses, repoAnswer) {
118
122
  const vm = await import('node:vm');
119
- const back = { href: '/projects?view=new' }, note = { textContent: '' };
123
+ const back = { href: '' }, note = { textContent: '' };
120
124
  const timers = [], fetched = [];
121
125
  const calls = { replaced: null };
122
126
  const sandbox = {
123
- __CB_SETUP__: { state: 'GOODSTATE', domain: 'mercury.example.com', back: '/projects?view=new' },
127
+ __CB_SETUP__: servedCfg,
124
128
  document: { getElementById: (id) => (id === 'back' ? back : id === 'note' ? note : null) },
125
129
  location: { replace: (u) => { calls.replaced = u; } },
126
130
  setTimeout: (fn) => { timers.push(fn); return timers.length; },
@@ -154,9 +158,9 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
154
158
  const plain = await runInstall([named], null);
155
159
  t('a repo GitHub will not describe (private) falls back to the plain install page', plain.replaced === 'https://github.com/apps/mercury-app/installations/new?state=GOODSTATE', JSON.stringify(plain));
156
160
  const doneAlready = await runInstall([{ ...named, checklist: { ...named.checklist, install: 'done' } }], null);
157
- t('already installed → straight back to the checklist', doneAlready.replaced === '/projects?view=new#ghstate=GOODSTATE', JSON.stringify(doneAlready));
161
+ t('already installed → straight back to the checklist', doneAlready.replaced === '/api/bongos/provisioning/github-app/return?state=GOODSTATE', JSON.stringify(doneAlready));
158
162
  const stopped = await runInstall([{ ...named, status: 'error' }], null);
159
- t('a stopped attempt → back to the checklist, which says what stopped', stopped.replaced === '/projects?view=new#ghstate=GOODSTATE', JSON.stringify(stopped));
163
+ t('a stopped attempt → back to the checklist, which says what stopped', stopped.replaced === '/api/bongos/provisioning/github-app/return?state=GOODSTATE', JSON.stringify(stopped));
160
164
  }
161
165
 
162
166
  console.log('\nGET /provisioning/github-app/setup (the App\'s setup_url — task 1004649):');
@@ -181,16 +185,17 @@ console.log('\nGET /provisioning/github-app/setup (the App\'s setup_url — task
181
185
  // the plain hand-back page (now the setup_url's) still behaves as before (task 1004548)
182
186
  const callbackPage = require(`${ROOT}/modules/provisioning/routes/callback-page.js`).callbackPage;
183
187
  const html = callbackPage({ ok: true, title: 'Installed on GitHub', state: 'GOODSTATE', domain: 'mercury.example.com' });
184
- t('carries a plain "back to my checklist" link (works without script)', /<a class="btn" id="back" href="\/projects\?view=new">Back to my checklist/.test(html));
188
+ t('carries a plain "back to my checklist" link (works without script)', html.includes('<a class="btn" id="back" href="/api/bongos/provisioning/github-app/return?state=GOODSTATE">Back to my checklist'));
189
+ const servedCfg = JSON.parse((html.match(/window\.__CB_SETUP__=(\{[^<]*?\});/) || [])[1] || '{}');
185
190
  const script = (html.match(/<script>\s*(\(function\(\)\{[\s\S]*?)<\/script>\s*<\/body>/) || [])[1];
186
191
  t('the hand-back page ships the client script', typeof script === 'string' && script.length > 100);
187
192
  async function runCallback({ opener }) {
188
193
  const vm = await import('node:vm');
189
- const back = { href: '/projects?view=new' };
194
+ const back = { href: '' };
190
195
  const timers = [];
191
196
  const calls = { close: 0, focusOpener: 0, replaced: null };
192
197
  const sandbox = {
193
- __CB_SETUP__: { state: 'GOODSTATE', domain: 'mercury.example.com', back: '/projects?view=new' },
198
+ __CB_SETUP__: servedCfg,
194
199
  opener: opener ? { closed: false, focus: () => { calls.focusOpener++; } } : null,
195
200
  close: () => { calls.close++; },
196
201
  document: { getElementById: (id) => (id === 'back' ? back : null) },
@@ -204,12 +209,12 @@ console.log('\nGET /provisioning/github-app/setup (the App\'s setup_url — task
204
209
  while (timers.length) timers.shift()();
205
210
  return { ...calls, replacedBeforeTimer, backHref: back.href };
206
211
  }
207
- const want = '/projects?view=new#ghstate=GOODSTATE';
212
+ const want = '/api/bongos/provisioning/github-app/return?state=GOODSTATE';
208
213
  const popup = await runCallback({ opener: true });
209
214
  t('as the wizard’s pop-up it focuses the checklist tab and closes itself', popup.focusOpener === 1 && popup.close === 1 && popup.replacedBeforeTimer === null, JSON.stringify(popup));
210
215
  t('…and if the browser refuses the close, the pop-up becomes the checklist', popup.replaced === want, JSON.stringify(popup));
211
216
  const sameTab = await runCallback({ opener: false });
212
- t('as a same-tab return it goes straight back to the checklist, carrying the attempt in the fragment', sameTab.close === 0 && sameTab.replaced === want && sameTab.backHref === want, JSON.stringify(sameTab));
217
+ t('as a same-tab return it goes straight back through the return route, carrying the attempt', sameTab.close === 0 && sameTab.replaced === want && sameTab.backHref === want, JSON.stringify(sameTab));
213
218
  }
214
219
  {
215
220
  const res = await fetch(`${base}/provisioning/github-app/callback?code=X&state=NOPE`);
@@ -247,6 +247,10 @@ function runEnter({ draftStep, focus = false, resumeIntent = false, created = {
247
247
  const log = { shown: [], created: [], offer: undefined };
248
248
  const sandbox = {
249
249
  authKnown: true, me: { github_login: 'probe' }, resumeIntent,
250
+ // the server's resume read (task 1004650) already answered for this page load —
251
+ // this slice is about the record the tab stored (tests/github_handback_any_device.mjs
252
+ // holds the server's half)
253
+ resumeAsked: true, handbackProject: null, askServerResume() { throw new Error('not asked twice'); },
250
254
  state: { step: draftStep },
251
255
  createdRecord: () => created,
252
256
  enterCreated: (rec, f) => log.created.push({ id: rec.id, focus: f }),
@@ -164,7 +164,8 @@ const SEED = SCRIPT.slice(SS, SE);
164
164
  test('the reload regions are still findable (a rename must fail loudly)', () => {
165
165
  assert.ok(WS > 0 && WE > WS, 'wait-timer boundaries found');
166
166
  assert.ok(SS > 0 && SE > SS, 'instance-read seed boundaries found');
167
- assert.match(SCRIPT, /if \(!terminal \|\| manifestInFlight\(\) \|\| setupOpen\(\)\) pollTimer = setTimeout\(tick, 5000\);/,
167
+ // the pace slows while the tab is hidden, never stops (task 1004650)
168
+ assert.match(SCRIPT, /if \(!terminal \|\| manifestInFlight\(\) \|\| setupOpen\(\)\) pollTimer = setTimeout\(tick, wizPollDelay\(5000\)\);/,
168
169
  'a GitHub leg in flight — or an install not yet verified (task 1004649) — keeps the instance read alive once the project is up');
169
170
  });
170
171