@bongos/core 1.21.104 → 1.21.106
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 +57 -47
- package/docs/copy-inventory.md +368 -366
- package/docs/copy-registry.json +475 -457
- package/docs/design/project-startup-direction.md +14 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +10 -3
- package/docs/page-readings.json +1023 -977
- package/modules/lifecycle/check-rollup.js +11 -1
- package/modules/lifecycle/github-push.js +3 -7
- package/modules/public-landing/public/projects-hall-live.states.json +3 -2
- package/modules/public-landing/public/projects-setup.states.json +91 -0
- package/modules/public-landing/public/projects.html +535 -804
- package/modules/public-landing/public/projects.probes.json +3 -3
- package/modules/public-landing/public/projects.states.json +1 -1
- package/modules/ui-design/kit/fixtures/provisioning-instances-hall-live.json +22 -2
- package/modules/ui-design/kit/fixtures/provisioning-instances-setup.json +195 -0
- package/modules/ui-design/kit/serve.js +22 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/ship-land.js +2 -2
- package/src/module-api.js +1 -1
- package/tests/cosmos_token_contrast.mjs +10 -18
- package/tests/github_handback_any_device.mjs +4 -3
- package/tests/github_one_trip.mjs +45 -47
- package/tests/github_org_account.mjs +2 -2
- package/tests/github_pick_up.mjs +40 -29
- package/tests/github_push_merge_driver.mjs +46 -0
- package/tests/onboard.mjs +13 -7
- package/tests/project_invite_ui.mjs +6 -3
- package/tests/project_modules_ui.mjs +3 -3
- package/tests/projects_hub.mjs +8 -9
- package/tests/projects_hub_cloud_hop.mjs +77 -125
- package/tests/projects_hub_dns_ready.mjs +42 -51
- package/tests/projects_hub_pre_uat.mjs +68 -69
- package/tests/public_landing_projects.mjs +45 -102
- package/tests/setup_status_facts.mjs +400 -0
- package/tests/wizard_github_feedback.mjs +90 -87
- package/tests/wizard_one_page.mjs +11 -9
- package/tests/wizard_auto_open.mjs +0 -94
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// tests/setup_status_facts.mjs — part 03 after Create is ONE honest list of checked facts
|
|
2
|
+
// (task 1004683; the design of record docs/design/mocks/day-night-rooms/setup-status.html,
|
|
3
|
+
// approved by the owner 2026-10-09: "change the status bar mechanisms for the GitHub related
|
|
4
|
+
// steps and the text, it seems antiquated and not real").
|
|
5
|
+
//
|
|
6
|
+
// The whole path is run, not read: each scenario's rows go through the REAL owner read
|
|
7
|
+
// (GET /provisioning/instances/:id, modules/provisioning/routes/provisioning.js, with its
|
|
8
|
+
// real projections — ownerInstance, publicGithubApp, githubSetupRead — over a fake pool),
|
|
9
|
+
// and the JSON it answers is fetched by the REAL page watch (startProgress, sliced out of
|
|
10
|
+
// projects.html by the vm-slice idiom) into the REAL painter (paintFacts) under a small DOM.
|
|
11
|
+
// What the founder would see is then read back off that DOM. Pinned:
|
|
12
|
+
// * the five scenarios of the mockup — the count, each fact's state, the one sentence;
|
|
13
|
+
// * every fact's state comes from a server field: moving one field moves its fact, and
|
|
14
|
+
// moving the clock moves nothing but the right-hand "when";
|
|
15
|
+
// * no fact is said twice, none of the outdated words is anywhere, nothing is markup;
|
|
16
|
+
// * Enter Genesis (and "is alive") appear when, and only when, every required fact is done;
|
|
17
|
+
// * one live region, written only when its sentence changes.
|
|
18
|
+
//
|
|
19
|
+
// Run: node --test tests/setup_status_facts.mjs
|
|
20
|
+
|
|
21
|
+
import { test, after } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import vm from 'node:vm';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
31
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
32
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
33
|
+
const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
|
|
34
|
+
|
|
35
|
+
// ── the page regions (each boundary asserted, so a rename fails loudly) ─────────────────
|
|
36
|
+
function region(start, end) {
|
|
37
|
+
const a = SCRIPT.indexOf(start);
|
|
38
|
+
const b = a < 0 ? -1 : SCRIPT.indexOf(end, a + 1);
|
|
39
|
+
assert.ok(a > 0 && b > a, `region not found: ${start} … ${end}`);
|
|
40
|
+
return SCRIPT.slice(a, b);
|
|
41
|
+
}
|
|
42
|
+
const FACTS = region(' /* ── part 03 after Create (task 1004683', " /* ── C8: the wizard's polls run only");
|
|
43
|
+
const NOTE_ORG = region(" /* the organisation the server says this attempt's app is on: change-only */", ' /* ── Pick up where it stopped');
|
|
44
|
+
const MANIFEST = region(' function manifestInFlight() {', ' function pollManifest(stateToken) {');
|
|
45
|
+
const WATCH = region(" /* ── the owner's instance read (task 2083)", ' /* Everything the wizard must FORGET');
|
|
46
|
+
const ADDRESS_READY = (SCRIPT.match(/\n {2}function addressReady\(inst\) \{[^\n]*\}/) || [''])[0];
|
|
47
|
+
assert.ok(ADDRESS_READY, 'addressReady() must be findable');
|
|
48
|
+
const PANEL = (HUB.match(/<div class="part waiting" id="panel-done"[\s\S]*?\n {10}<\/section>/) || [''])[0];
|
|
49
|
+
assert.ok(PANEL, 'part 03 ships as real markup');
|
|
50
|
+
|
|
51
|
+
// ── the server: the REAL owner read over a fake pool ───────────────────────────────────
|
|
52
|
+
const provisioning = require(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'));
|
|
53
|
+
const catalogBridge = require(path.join(ROOT, 'modules', 'provisioning', 'catalog-bridge.js'));
|
|
54
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
55
|
+
let db = null; // { inst, intent, manifest, events } — the rows the read finds
|
|
56
|
+
provisioning.getInstanceById = async () => db.inst;
|
|
57
|
+
provisioning.getOpenIntent = async () => db.intent;
|
|
58
|
+
provisioning.getLatestOAuthManifestForInstance = async () => db.manifest;
|
|
59
|
+
catalogBridge.healInstanceCatalog = async () => {};
|
|
60
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, rank: 'metic', github_login: 'you' }; next(); };
|
|
61
|
+
api.pool.query = async (sql) => (/FROM provisioning_events/.test(sql) ? { rows: db.events } : { rows: [] });
|
|
62
|
+
const express = require('express');
|
|
63
|
+
const app = express();
|
|
64
|
+
app.use((_req, res, next) => {
|
|
65
|
+
res.fail = (code, status) => res.status(typeof status === 'number' ? status : 400).json({ error: { code } });
|
|
66
|
+
next();
|
|
67
|
+
});
|
|
68
|
+
app.use('/api/bongos', require(path.join(ROOT, 'modules', 'provisioning', 'routes', 'provisioning.js'))());
|
|
69
|
+
const server = app.listen(0);
|
|
70
|
+
await new Promise((r) => server.once('listening', r));
|
|
71
|
+
const ORIGIN = `http://127.0.0.1:${server.address().port}`;
|
|
72
|
+
after(() => server.close());
|
|
73
|
+
|
|
74
|
+
// ── a small DOM: nodes, text, attributes, and the few selectors the painter asks ────────
|
|
75
|
+
class Node {
|
|
76
|
+
constructor(tag, doc) { this.tagName = String(tag).toUpperCase(); this.doc = doc; this.childNodes = []; this.parentNode = null; this.attrs = {}; this.className = ''; this.hidden = false; this._text = ''; this.writes = 0; this.id = ''; }
|
|
77
|
+
get children() { return this.childNodes.filter((n) => n instanceof Node); }
|
|
78
|
+
get lastChild() { return this.childNodes[this.childNodes.length - 1] || null; }
|
|
79
|
+
get textContent() { return this._text + this.childNodes.map((n) => (n instanceof Node ? n.textContent : n.data)).join(''); }
|
|
80
|
+
set textContent(v) { this.writes += 1; this.childNodes.forEach((n) => { n.parentNode = null; }); this.childNodes = []; this._text = String(v); }
|
|
81
|
+
set innerHTML(_v) { throw new Error('innerHTML written on #' + (this.id || this.tagName)); }
|
|
82
|
+
_adopt(n) { if (n.parentNode) n.parentNode.removeChild(n); n.parentNode = this; }
|
|
83
|
+
appendChild(n) { this._adopt(n); this.childNodes.push(n); this.writes += 1; return n; }
|
|
84
|
+
insertBefore(n, ref) { this._adopt(n); const i = this.childNodes.indexOf(ref); if (i < 0) this.childNodes.push(n); else this.childNodes.splice(i, 0, n); return n; }
|
|
85
|
+
removeChild(n) { this.childNodes = this.childNodes.filter((x) => x !== n); n.parentNode = null; return n; }
|
|
86
|
+
replaceChild(n, old) { this._adopt(n); const i = this.childNodes.indexOf(old); this.childNodes[i] = n; old.parentNode = null; this.writes += 1; return old; }
|
|
87
|
+
contains(n) { for (let x = n; x; x = x.parentNode) if (x === this) return true; return false; }
|
|
88
|
+
setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'id') this.id = String(v); }
|
|
89
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
|
90
|
+
removeAttribute(k) { delete this.attrs[k]; }
|
|
91
|
+
addEventListener() {}
|
|
92
|
+
matches(part) {
|
|
93
|
+
if (part.startsWith('#')) return this.id === part.slice(1);
|
|
94
|
+
if (part.startsWith('.')) return (' ' + this.className + ' ').includes(' ' + part.slice(1) + ' ');
|
|
95
|
+
if (/^[a-z]+$/.test(part)) return this.tagName === part.toUpperCase();
|
|
96
|
+
const m = part.match(/^\[([\w-]+)(?:="([^"]*)")?\]$/);
|
|
97
|
+
assert.ok(m, `the DOM stub does not know the selector "${part}"`);
|
|
98
|
+
return m[2] === undefined ? this.getAttribute(m[1]) !== null : this.getAttribute(m[1]) === m[2];
|
|
99
|
+
}
|
|
100
|
+
descendants() { return this.children.flatMap((c) => [c, ...c.descendants()]); }
|
|
101
|
+
querySelectorAll(sel) {
|
|
102
|
+
let found = [this];
|
|
103
|
+
for (const part of sel.trim().split(/\s+/)) found = [...new Set(found.flatMap((n) => n.descendants().filter((d) => d.matches(part))))];
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
|
|
107
|
+
}
|
|
108
|
+
function makeDom() {
|
|
109
|
+
const doc = { hidden: false };
|
|
110
|
+
const body = new Node('body', doc);
|
|
111
|
+
const byId = {};
|
|
112
|
+
const el = (id, tag = 'div', parent = body) => { const n = new Node(tag, doc); n.setAttribute('id', id); parent.appendChild(n); byId[id] = n; return n; };
|
|
113
|
+
// part 03's own markup, as projects.html bakes it
|
|
114
|
+
const panel = el('panel-done');
|
|
115
|
+
el('donePartT', 'span', panel); el('ghState', 'span', panel);
|
|
116
|
+
const bodyEl = el('doneBody', 'div', panel);
|
|
117
|
+
el('setupNow', 'p', bodyEl);
|
|
118
|
+
el('homeFacts', 'div', bodyEl); el('ghFacts', 'div', bodyEl);
|
|
119
|
+
const pick = el('aaPick', 'div', bodyEl); pick.hidden = true;
|
|
120
|
+
el('hopLink', 'button', bodyEl);
|
|
121
|
+
const alive = el('doneAlive', 'div', bodyEl); alive.hidden = true;
|
|
122
|
+
el('done-h', 'h2', alive); el('enterGenesis', 'a', alive);
|
|
123
|
+
Object.assign(doc, {
|
|
124
|
+
createElement: (tag) => new Node(tag, doc),
|
|
125
|
+
createTextNode: (data) => ({ data: String(data) }),
|
|
126
|
+
querySelector: (sel) => body.querySelector(sel),
|
|
127
|
+
});
|
|
128
|
+
return { doc, $: (id) => byId[id] || null, body };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── one page, booted on one project, reading the real route ─────────────────────────────
|
|
132
|
+
async function page({ clockShift = 0 } = {}) {
|
|
133
|
+
const dom = makeDom();
|
|
134
|
+
const calls = { invite: [], arrive: 0, pick: [] };
|
|
135
|
+
const inflight = [];
|
|
136
|
+
const sandbox = {
|
|
137
|
+
API: '/api/bongos', $: dom.$, document: dom.doc, root: { addEventListener() {}, contains: () => true },
|
|
138
|
+
currentView: 'new', instanceId: null, manifestPollTimer: null, manifestStartedAt: 0, progressKick: null, manifestKick: null,
|
|
139
|
+
state: { step: 2, name: 'Kestrel', slug: 'kestrel', owner: 'you', repo: 'kestrel', domain: 'kestrel.cloudbongos.com', noAddress: false },
|
|
140
|
+
resolvedDomain: () => 'kestrel.cloudbongos.com', apexBase: () => 'cloudbongos.com',
|
|
141
|
+
wizPollActive: () => true, wizPollDelay: (ms) => ms,
|
|
142
|
+
setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {},
|
|
143
|
+
rememberManifest() {}, syncAppPick: (on) => calls.pick.push(on), paintInviteGate: (on) => calls.invite.push(on), hopArrive: () => { calls.arrive += 1; },
|
|
144
|
+
fetch: (p, init) => { const r = fetch(ORIGIN + p, init); inflight.push(r.then((x) => x.clone().text()).catch(() => null)); return r; },
|
|
145
|
+
Date: class extends Date { static now() { return Date.now() + clockShift; } },
|
|
146
|
+
JSON, Math, Object, Array, String, Number, Promise, encodeURIComponent, isNaN, Error,
|
|
147
|
+
};
|
|
148
|
+
sandbox.globalThis = sandbox;
|
|
149
|
+
vm.createContext(sandbox);
|
|
150
|
+
vm.runInContext([ADDRESS_READY, FACTS, NOTE_ORG, MANIFEST, WATCH].join('\n'), sandbox);
|
|
151
|
+
const settle = async () => { for (let i = 0; i < 6; i++) { await Promise.all(inflight.slice()); await new Promise((r) => setImmediate(r)); } };
|
|
152
|
+
vm.runInContext('startProgress(42)', sandbox);
|
|
153
|
+
await settle();
|
|
154
|
+
const read = () => {
|
|
155
|
+
const facts = dom.body.querySelectorAll('[data-fact]').map((f) => ({
|
|
156
|
+
key: f.getAttribute('data-fact'),
|
|
157
|
+
state: f.className.replace(/^setFact /, ''),
|
|
158
|
+
title: f.querySelector('.t')._text,
|
|
159
|
+
sub: (f.querySelector('small') || { textContent: '' }).textContent,
|
|
160
|
+
right: f.querySelector('.r').textContent,
|
|
161
|
+
acts: f.querySelectorAll('[data-action]').map((b) => b.getAttribute('data-action')).concat(f.querySelectorAll('.btn').filter((b) => !b.getAttribute('data-action')).map((b) => 'link:' + b.textContent)),
|
|
162
|
+
labels: (f.querySelector('.acts') || { descendants: () => [] }).descendants().filter((n) => /^(BUTTON|A|SUMMARY)$/.test(n.tagName)).map((n) => n.textContent),
|
|
163
|
+
}));
|
|
164
|
+
return {
|
|
165
|
+
facts, byKey: Object.fromEntries(facts.map((f) => [f.key, f.state])),
|
|
166
|
+
title: dom.$('donePartT').textContent, count: dom.$('ghState').textContent, said: dom.$('setupNow').textContent,
|
|
167
|
+
aliveHidden: dom.$('doneAlive').hidden, aliveH: dom.$('done-h').textContent, genesis: dom.$('enterGenesis').getAttribute('href'),
|
|
168
|
+
hopLinkHidden: dom.$('hopLink').hidden, pickInApprove: !!dom.body.querySelector('[data-fact="approve"] .acts') && dom.body.querySelector('[data-fact="approve"] .acts').contains(dom.$('aaPick')),
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
const again = async () => { vm.runInContext('progressKick()', sandbox); await settle(); };
|
|
172
|
+
return { sandbox, dom, calls, read, again };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── the rows: one project per scenario of the mockup ───────────────────────────────────
|
|
176
|
+
const NOW = Date.now();
|
|
177
|
+
const ago = (min) => new Date(NOW - min * 60000).toISOString();
|
|
178
|
+
const soon = (min) => new Date(NOW + min * 60000).toISOString();
|
|
179
|
+
const INST = { id: 42, owner_builder_id: 7, slug: 'kestrel', domain: 'kestrel.cloudbongos.com', target_ref: 'you/kestrel', hosting_shape: 'cloud-host', onboard_mode: 'greenfield', torn_down_at: null };
|
|
180
|
+
const inst = (over) => ({ ...INST, status: 'active', created_at: ago(30), updated_at: ago(20), provisioned_at: ago(27), last_verified_at: ago(0.1), last_verified_up: true, dns_resolved_at: ago(25), ...over });
|
|
181
|
+
const attempt = (over) => ({ id: 9, instance_id: 42, state: 'S9', requested_by: 7, attempts: 1, created_at: ago(15), expires_at: ago(1), ...over });
|
|
182
|
+
const installed = { event: 'github-app-installed', at: ago(10), detail: 'you/kestrel' };
|
|
183
|
+
const SCENARIOS = {
|
|
184
|
+
'Just created': {
|
|
185
|
+
rows: { inst: inst({ status: 'provisioning', created_at: ago(1), provisioned_at: null, last_verified_at: null, last_verified_up: null, dns_resolved_at: null }), intent: { action: 'provision', state: 'running' }, manifest: null, events: [] },
|
|
186
|
+
count: '1 of 6 done',
|
|
187
|
+
states: { address: 'done', server: 'now', answers: 'todo', approve: 'you', install: 'todo', sign_in: 'todo', device_flow: 'todo' },
|
|
188
|
+
said: 'Setting up its home. You can connect GitHub now; nothing waits on you otherwise.',
|
|
189
|
+
},
|
|
190
|
+
'Waiting on GitHub': {
|
|
191
|
+
rows: { inst: inst({ last_verified_at: null, last_verified_up: null, dns_resolved_at: null }), intent: null, manifest: attempt({ status: 'pending', attempts: 0, created_at: ago(1), expires_at: soon(29) }), events: [] },
|
|
192
|
+
count: '2 of 6 done',
|
|
193
|
+
states: { address: 'done', server: 'done', answers: 'now', approve: 'you', install: 'todo', sign_in: 'todo', device_flow: 'todo' },
|
|
194
|
+
said: 'Your turn on GitHub: approve the app in the tab that opened. This page ticks on its own when you do, on any device.',
|
|
195
|
+
},
|
|
196
|
+
Installing: {
|
|
197
|
+
rows: { inst: inst(), intent: null, manifest: attempt({ status: 'converting', app_id: 5, app_slug: 'kestrel-cloudbongos-com', updated_at: ago(1) }), events: [] },
|
|
198
|
+
count: '4 of 6 done',
|
|
199
|
+
states: { address: 'done', server: 'done', answers: 'done', approve: 'done', install: 'you', sign_in: 'now', device_flow: 'todo' },
|
|
200
|
+
said: 'Your turn on GitHub: confirm the install on you/kestrel. The repo is already picked.',
|
|
201
|
+
},
|
|
202
|
+
Stopped: {
|
|
203
|
+
rows: { inst: inst(), intent: null, manifest: attempt({ status: 'done', app_id: 5, app_slug: 'kestrel-cloudbongos-com', created_at: ago(300), resolved_at: ago(290), updated_at: ago(290) }), events: [] },
|
|
204
|
+
count: '5 of 6 done',
|
|
205
|
+
states: { address: 'done', server: 'done', answers: 'done', approve: 'done', install: 'bad', sign_in: 'done', device_flow: 'todo' },
|
|
206
|
+
said: 'One step stopped. Nothing is lost, and no second app will be made.',
|
|
207
|
+
},
|
|
208
|
+
'All done': {
|
|
209
|
+
rows: { inst: inst(), intent: null, manifest: attempt({ status: 'done', app_id: 5, app_slug: 'kestrel-cloudbongos-com', resolved_at: ago(12), updated_at: ago(12) }), events: [installed] },
|
|
210
|
+
count: 'ready ✓',
|
|
211
|
+
states: { address: 'done', server: 'done', answers: 'done', approve: 'done', install: 'done', sign_in: 'done', device_flow: 'todo' },
|
|
212
|
+
said: 'Kestrel is ready. Command-line sign-in is optional; it ticks itself whenever you turn it on.',
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
const ORDER = ['address', 'server', 'answers', 'approve', 'install', 'sign_in', 'device_flow'];
|
|
216
|
+
async function scenario(name, opts) { db = JSON.parse(JSON.stringify(SCENARIOS[name].rows)); return page(opts); }
|
|
217
|
+
|
|
218
|
+
// every word part 03 says, split into sentences (the right-hand "when" is not a sentence)
|
|
219
|
+
function sentences(v) {
|
|
220
|
+
const parts = [v.said, v.title, v.aliveHidden ? '' : v.aliveH, ...v.facts.flatMap((f) => [f.title, f.sub, ...f.labels])];
|
|
221
|
+
return parts.join('\n').split(/(?<=[.!?])\s+|\n/).map((s) => s.trim()).filter(Boolean);
|
|
222
|
+
}
|
|
223
|
+
const OUTDATED = ['Request filed', 'Standing it up', 'Revive queued', 'Placing credentials', 'Ready to sign in',
|
|
224
|
+
'Filed. Watching for the standup', 'A standup is queued', 'The standup stopped partway', 'These steps are the same however',
|
|
225
|
+
'Setting up Kestrel', 'Setting up your project', 'Watch it here', 'Watch the rest here', 'Prefer to create an OAuth app by hand',
|
|
226
|
+
'Create the empty GitHub repo', 'Enter your builders hall', 'bongos dev', 'Placing the app’s keys', 'placing the app’s keys'];
|
|
227
|
+
|
|
228
|
+
// ── 1. the five scenarios, from the real read ──────────────────────────────────────────
|
|
229
|
+
for (const name of Object.keys(SCENARIOS)) {
|
|
230
|
+
test(`${name}: the real owner read draws the mockup's count, facts and one sentence`, async () => {
|
|
231
|
+
const want = SCENARIOS[name];
|
|
232
|
+
const p = await scenario(name);
|
|
233
|
+
const v = p.read();
|
|
234
|
+
assert.equal(v.title, 'Getting Kestrel ready');
|
|
235
|
+
assert.equal(v.count, want.count, 'optional facts are not counted');
|
|
236
|
+
assert.deepEqual(v.facts.map((f) => f.key), ORDER, 'two groups, each fact once, in the mockup\'s order');
|
|
237
|
+
assert.deepEqual(v.byKey, want.states);
|
|
238
|
+
assert.equal(v.said, want.said, 'one sentence: what is happening now, and whether it is the founder\'s turn');
|
|
239
|
+
// Connect GitHub and the account choice sit inside the Approve fact while it is offered
|
|
240
|
+
const approve = v.facts.find((f) => f.key === 'approve');
|
|
241
|
+
assert.equal(approve.acts.includes('manifest-start'), name === 'Just created');
|
|
242
|
+
assert.equal(v.pickInApprove, name === 'Just created', 'the account select is inside the Approve fact only while Connect is offered');
|
|
243
|
+
// a stopped fact carries the one fix
|
|
244
|
+
for (const f of v.facts.filter((x) => x.state === 'bad')) assert.equal(f.acts.filter((a) => a === 'pick-up').length, 1, `${f.key}: one "Pick up where it stopped"`);
|
|
245
|
+
// "is alive", the invite and Enter Genesis only when every required fact is done
|
|
246
|
+
const ready = name === 'All done';
|
|
247
|
+
assert.equal(v.aliveHidden, !ready);
|
|
248
|
+
assert.equal(v.genesis, ready ? 'https://kestrel.cloudbongos.com' : null, 'Enter Genesis opens the project\'s own address, and only then');
|
|
249
|
+
if (ready) { assert.equal(v.aliveH, 'Kestrel is alive.'); assert.deepEqual(p.calls.invite, [true]); assert.equal(p.calls.arrive, 1, 'the game, if open, puts itself away'); }
|
|
250
|
+
else assert.deepEqual(p.calls.invite, [], 'the invite box stays shut');
|
|
251
|
+
assert.equal(v.hopLinkHidden, ready, '"Play cloud hop while you wait" while there is a wait');
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── 2. every fact's state is a server field ────────────────────────────────────────────
|
|
256
|
+
test('moving the clock moves nothing but the right-hand "when": no fact\'s state is a timer', async () => {
|
|
257
|
+
for (const name of Object.keys(SCENARIOS)) {
|
|
258
|
+
const a = (await scenario(name)).read();
|
|
259
|
+
const b = (await scenario(name, { clockShift: 3 * 3600 * 1000 })).read();
|
|
260
|
+
assert.deepEqual(b.byKey, a.byKey, `${name}: three hours later the same read draws the same facts`);
|
|
261
|
+
assert.equal(b.count, a.count);
|
|
262
|
+
assert.equal(b.said, a.said);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('moving one server field moves its fact — each of the seven, from the real read', async () => {
|
|
267
|
+
const base = SCENARIOS.Installing.rows;
|
|
268
|
+
const flips = [
|
|
269
|
+
['address', 'instance.domain', { inst: { ...base.inst, domain: null } }, 'todo'],
|
|
270
|
+
['server', 'instance.status', { inst: { ...base.inst, status: 'provisioning' } }, 'now'],
|
|
271
|
+
['server', 'open_intent', { inst: { ...base.inst, status: 'torn_down', torn_down_at: ago(5) }, intent: { action: 'provision', state: 'queued' } }, 'now'],
|
|
272
|
+
['server', 'instance.status (error)', { inst: { ...base.inst, status: 'error' } }, 'bad'],
|
|
273
|
+
['answers', 'dns_resolves', { inst: { ...base.inst, dns_resolved_at: null, last_verified_up: null } }, 'now'],
|
|
274
|
+
['answers', 'last_verified_up', { inst: { ...base.inst, last_verified_up: false } }, 'now'],
|
|
275
|
+
['approve', 'the attempt (none yet)', { manifest: null }, 'you'],
|
|
276
|
+
['install', 'github-setup checklist.install', { events: [installed] }, 'done'],
|
|
277
|
+
['sign_in', 'github-setup checklist.sign_in', { manifest: { ...base.manifest, status: 'done', resolved_at: ago(1) } }, 'done'],
|
|
278
|
+
['device_flow', 'github-setup checklist.device_flow', { manifest: { ...base.manifest, status: 'done', resolved_at: ago(1) }, events: [installed, { event: 'github-app-device-flow', at: ago(2) }] }, 'done'],
|
|
279
|
+
];
|
|
280
|
+
const before = (await scenario('Installing')).read().byKey;
|
|
281
|
+
for (const [key, field, rows, want] of flips) {
|
|
282
|
+
db = { ...JSON.parse(JSON.stringify(base)), ...JSON.parse(JSON.stringify(rows)) };
|
|
283
|
+
const after = (await page()).read().byKey;
|
|
284
|
+
assert.notEqual(before[key], want, `${key}: the flip must actually move it (${field})`);
|
|
285
|
+
assert.equal(after[key], want, `${key} follows ${field}`);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('the right-hand side says when the server saw it, or what it found', async () => {
|
|
290
|
+
const v = (await scenario('Installing')).read();
|
|
291
|
+
const right = Object.fromEntries(v.facts.map((f) => [f.key, f.right]));
|
|
292
|
+
assert.match(right.address, /^(just now|\d{1,2}:\d\d [ap]m)$/, 'when the address was kept (instance.created_at)');
|
|
293
|
+
assert.match(right.server, /^(just now|\d{1,2}:\d\d [ap]m)$/, 'when it started running (instance.provisioned_at)');
|
|
294
|
+
assert.match(right.answers, /^checked \d+s ago$/, 'when it last answered (instance.last_verified_at)');
|
|
295
|
+
assert.equal(right.install, 'not yet seen', 'what GitHub has told the runner so far');
|
|
296
|
+
// what it found, under the fact: the address once it resolves, the repo, the app's account
|
|
297
|
+
const sub = Object.fromEntries(v.facts.map((f) => [f.key, f.sub]));
|
|
298
|
+
assert.equal(sub.address, 'kestrel.cloudbongos.com');
|
|
299
|
+
assert.equal(sub.approve, 'Kestrel app on your account');
|
|
300
|
+
assert.equal(sub.install, 'you/kestrel');
|
|
301
|
+
const stopped = (await scenario('Stopped')).read();
|
|
302
|
+
assert.match(stopped.facts.find((f) => f.key === 'install').right, /^checked \d+s ago$/, 'a stop says when it was last checked');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// ── 3. nothing said twice, nothing outdated, nothing markup ────────────────────────────
|
|
306
|
+
test('no fact is said twice, and none of the outdated words is anywhere', async () => {
|
|
307
|
+
for (const name of Object.keys(SCENARIOS)) {
|
|
308
|
+
const v = (await scenario(name)).read();
|
|
309
|
+
const said = sentences(v);
|
|
310
|
+
const twice = said.filter((s, i) => said.indexOf(s) !== i);
|
|
311
|
+
assert.deepEqual(twice, [], `${name}: nothing is said twice`);
|
|
312
|
+
for (const old of OUTDATED) assert.ok(!said.join('\n').includes(old), `${name}: "${old}" is gone`);
|
|
313
|
+
}
|
|
314
|
+
// …nor anywhere in part 03's markup or the code that draws it
|
|
315
|
+
for (const old of OUTDATED) {
|
|
316
|
+
assert.ok(!PANEL.includes(old), `part 03's markup no longer says "${old}"`);
|
|
317
|
+
assert.ok(!(FACTS + WATCH).includes(old), `part 03's code no longer says "${old}"`);
|
|
318
|
+
}
|
|
319
|
+
for (const id of ['liveProgress', 'hopBar', 'hopBarFill', 'hopStatus', 'humanSteps', 'doneLede']) assert.ok(!HUB.includes(`id="${id}"`), `#${id} is gone`);
|
|
320
|
+
assert.doesNotMatch(SCRIPT, /PROG_FLOWS|progressMarkup|momentFor|setMoment|ticks > 400/, 'no stage strip, no moment, no tick counter');
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('part 03 is drawn from nodes: the DOM stub refuses innerHTML, and a typed name stays text', async () => {
|
|
324
|
+
db = JSON.parse(JSON.stringify(SCENARIOS['Just created'].rows));
|
|
325
|
+
const p = await page();
|
|
326
|
+
p.sandbox.state.name = 'Kes<b>trel</b>';
|
|
327
|
+
vm.runInContext('repaintFacts()', p.sandbox);
|
|
328
|
+
const v = p.read();
|
|
329
|
+
assert.equal(v.title, 'Getting Kes<b>trel</b> ready', 'the name is text');
|
|
330
|
+
assert.equal(v.facts.find((f) => f.key === 'approve').title, 'Approve the Kes<b>trel</b> app on GitHub');
|
|
331
|
+
assert.doesNotMatch(FACTS + WATCH, /\.innerHTML\s*=/, 'no innerHTML anywhere in part 03\'s code');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('a part 03 link goes only to GitHub, this page or its sign-in; anything else is text', async () => {
|
|
335
|
+
db = JSON.parse(JSON.stringify(SCENARIOS.Installing.rows));
|
|
336
|
+
const p = await page();
|
|
337
|
+
const href = (h) => vm.runInContext(`factHref(${JSON.stringify(h)})`, p.sandbox);
|
|
338
|
+
for (const ok of ['https://github.com/apps/kestrel-app/installations/new?state=S1', 'https://github.com/settings/apps/kestrel-app', '?view=manage&id=42', '?view=mine', '/api/bongos/auth/web/start?return=/projects%3Fview%3Dnew&repos=1']) {
|
|
339
|
+
assert.equal(href(ok), ok, ok);
|
|
340
|
+
}
|
|
341
|
+
for (const bad of ['javascript:alert(1)', 'https://evil.example/x', 'https://github.com.evil.example/x', '//evil.example', 'data:text/html,x', '?view=manage&id=1"><b>']) {
|
|
342
|
+
assert.equal(href(bad), '', bad);
|
|
343
|
+
}
|
|
344
|
+
// a hostile install link that somehow reached the fact never becomes an href
|
|
345
|
+
const box = p.dom.doc.createElement('div');
|
|
346
|
+
vm.runInContext('(function (box) { box.appendChild(factAct("install-link", { href: "javascript:alert(1)" })); })', p.sandbox)(box);
|
|
347
|
+
assert.equal(box.children[0].getAttribute('href'), 'https://github.com/settings/installations');
|
|
348
|
+
const line = p.dom.doc.createElement('p');
|
|
349
|
+
vm.runInContext('partsInto', p.sandbox)(line, [{ t: 'click', href: 'https://evil.example/' }]);
|
|
350
|
+
assert.equal(line.children.length, 0, 'drawn as text, not an anchor');
|
|
351
|
+
assert.equal(line.textContent, 'click');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ── 4. one live region, written only on a real change ──────────────────────────────────
|
|
355
|
+
test('the sentence is the one live region, written only when its words change', async () => {
|
|
356
|
+
// the sentence (role=status + aria-live) is the one region that speaks for the setup; the
|
|
357
|
+
// others answer an action the founder just took: the Render key and the look sends
|
|
358
|
+
// (role=status), the organisation check, and an invite (role=status + aria-live)
|
|
359
|
+
assert.equal((PANEL.match(/aria-live=|role="status"/g) || []).length, 7, 'no other live region in part 03');
|
|
360
|
+
assert.match(PANEL, /<p class="setNow" id="setupNow" role="status" aria-live="polite"><\/p>/);
|
|
361
|
+
assert.doesNotMatch(PANEL, /id="(homeFacts|ghFacts|hopScore|ghState)"[^>]*aria-live/, 'the facts, the score and the count are not live regions');
|
|
362
|
+
const p = await scenario('Waiting on GitHub');
|
|
363
|
+
const line = p.dom.$('setupNow');
|
|
364
|
+
const writes = line.writes;
|
|
365
|
+
await p.again();
|
|
366
|
+
assert.equal(line.writes, writes, 'an identical read writes nothing to the live region');
|
|
367
|
+
db.inst.dns_resolved_at = ago(0.5);
|
|
368
|
+
db.inst.last_verified_up = true;
|
|
369
|
+
await p.again();
|
|
370
|
+
assert.equal(p.read().byKey.answers, 'done');
|
|
371
|
+
assert.equal(line.writes, writes, 'a fact ticking under the same sentence is not announced twice');
|
|
372
|
+
db.manifest = attempt({ status: 'converting', app_id: 5, app_slug: 'kestrel-cloudbongos-com', updated_at: ago(0.2) });
|
|
373
|
+
await p.again();
|
|
374
|
+
assert.ok(line.writes > writes, 'the founder\'s turn moving on is announced');
|
|
375
|
+
assert.equal(p.read().said, 'Your turn on GitHub: confirm the install on you/kestrel. The repo is already picked.');
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('a fact\'s buttons are rebuilt only when the fact changes, so a poll never steals the focus', async () => {
|
|
379
|
+
const p = await scenario('Just created');
|
|
380
|
+
const approve = p.dom.body.querySelector('[data-fact="approve"]');
|
|
381
|
+
await p.again();
|
|
382
|
+
assert.equal(p.dom.body.querySelector('[data-fact="approve"]'), approve, 'the same node: an unchanged fact is left alone');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// ── 5. the page's own guards ───────────────────────────────────────────────────────────
|
|
386
|
+
test('no wizard function is declared twice (a second paintSetup once silently replaced part 02\'s)', () => {
|
|
387
|
+
const names = [...SCRIPT.matchAll(/\n {4}function (\w+)\(/g)].map((m) => m[1]);
|
|
388
|
+
const twice = [...new Set(names.filter((n, i) => names.indexOf(n) !== i))];
|
|
389
|
+
// tick() is the one name two separate closures declare on purpose (the manifest poll and
|
|
390
|
+
// the hub's own watch); everything else is one function per name
|
|
391
|
+
assert.deepEqual(twice, ['tick']);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test('part 03 names its own classes: the map table already owns .facts / .fact', () => {
|
|
395
|
+
// the first cut drew part 03 as .fact and restyled the projects table's .facts > .fact spans
|
|
396
|
+
const style = HUB.slice(HUB.indexOf('<style>'), HUB.indexOf('</style>'));
|
|
397
|
+
assert.doesNotMatch(style, /(^|[\s,}])\.facts?[\s{.:,]/m, 'no page rule for the table\'s .facts / .fact');
|
|
398
|
+
assert.match(PANEL, /<div class="setFacts" id="homeFacts"/);
|
|
399
|
+
assert.match(FACTS, /textNode\('div', 'setFact ' \+ r\.state\)/);
|
|
400
|
+
});
|