@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.
- package/.bongos-core.json +34 -29
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +5 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +75 -3
- package/docs/api-reference.md +4 -2
- package/docs/design/project-startup-direction.md +9 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +1 -1
- package/modules/provisioning/routes/callback-page.js +20 -10
- package/modules/provisioning/routes/github-setup.js +110 -2
- package/modules/public-landing/public/projects.html +103 -10
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/github_handback_any_device.mjs +340 -0
- package/tests/projects_hub.mjs +6 -2
- package/tests/provisioning_callback_page.mjs +3 -2
- package/tests/provisioning_github_app_routes.mjs +15 -10
- package/tests/wizard_draft_resume.mjs +4 -0
- package/tests/wizard_github_feedback.mjs +2 -1
|
@@ -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
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -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,
|
|
359
|
-
'the guard is
|
|
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
|
-
|
|
54
|
-
assert.
|
|
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
|
-
|
|
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: '
|
|
123
|
+
const back = { href: '' }, note = { textContent: '' };
|
|
120
124
|
const timers = [], fetched = [];
|
|
121
125
|
const calls = { replaced: null };
|
|
122
126
|
const sandbox = {
|
|
123
|
-
__CB_SETUP__:
|
|
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 === '/
|
|
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 === '/
|
|
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)',
|
|
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: '
|
|
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__:
|
|
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 = '/
|
|
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
|
|
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
|
-
|
|
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
|
|