@bongos/core 1.21.94 → 1.21.96
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 +45 -35
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -3
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +181 -7
- package/docs/api-reference.md +9 -5
- package/docs/copy-inventory.md +367 -358
- package/docs/copy-registry.json +536 -425
- package/docs/design/project-startup-direction.md +17 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +351 -348
- package/modules/provisioning/github-setup.js +27 -5
- package/modules/provisioning/routes/callback-page.js +20 -10
- package/modules/provisioning/routes/github-setup.js +111 -2
- package/modules/provisioning/routes/provisioning.js +29 -4
- package/modules/public-landing/public/projects.html +367 -24
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/github-repos.js +129 -0
- package/src/bongos/routes/github.js +71 -5
- package/src/module-api.js +1 -1
- package/tests/github_handback_any_device.mjs +340 -0
- package/tests/github_org_account.mjs +378 -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
|
+
});
|