@bongos/core 1.20.67 → 1.20.69
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 +19 -19
- package/docs/copy-inventory.md +61 -52
- package/docs/copy-registry.json +258 -166
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +3 -1
- package/docs/page-readings.json +82 -72
- package/modules/public-landing/public/account-private.states.json +1 -1
- package/modules/public-landing/public/account.html +143 -26
- package/modules/public-landing/public/account.probes.json +30 -0
- package/modules/public-landing/public/account.states.json +7 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/module-api.js +1 -1
- package/tests/hub_account_page.mjs +235 -12
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_": "The account page's states for modules/ui-design/kit/render.js (task 1004259, ADR 0337 D4.3): signed out it is the sign-in door and nothing else; signed in it is the four sections, painted from the stub's one account (fixtures/me__cross-project.json — public, recruiters following the profile setting, a bio and two links). Dark only, like every apex page (ADR 0204). The private, hidden, not-yet-published account renders from account-private.states.json.",
|
|
2
|
+
"_": "The account page's states for modules/ui-design/kit/render.js (task 1004259, ADR 0337 D4.3): signed out it is the sign-in door and nothing else; signed in it is the hub's bar (task 1004455: the identity chip from GET /me, the Projects band and the profile band with Account current) over the four sections, painted from the stub's one account (fixtures/me__cross-project.json — public, recruiters following the profile setting, a bio and two links). Dark only, like every apex page (ADR 0204). The private, hidden, not-yet-published account renders from account-private.states.json.",
|
|
3
3
|
"page": "/account",
|
|
4
4
|
"surface": "public-landing",
|
|
5
5
|
"modes": ["dark"],
|
|
6
6
|
"modeQuery": false,
|
|
7
|
-
"ignoreRequests": ["/api/(gds|bongos)/me/cross-project$"],
|
|
8
|
-
"ignoreConsole": ["/api/(gds|bongos)/me
|
|
7
|
+
"ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/cross-project$"],
|
|
8
|
+
"ignoreConsole": ["/api/(gds|bongos)/me", "\\b401\\b", "Unauthorized"],
|
|
9
9
|
"ignoreControls": [".vh"],
|
|
10
10
|
"states": {
|
|
11
|
-
"out": { "auth": false, "actions": [["wait", 300]], "expect": { "visible": ["#door", "#signin"], "hidden": ["#acctBody", "#
|
|
12
|
-
"in": { "auth": true, "actions": [["wait", 400]], "expect": { "visible": ["#acctBody", "#fName", "#fBio", "#fHandle", "#recWhat", "#recordLine", "#
|
|
11
|
+
"out": { "auth": false, "actions": [["wait", 300]], "expect": { "visible": ["#door", "#signin", "#signinTop", "#projTrig"], "hidden": ["#acctBody", "#who", "#loading"] } },
|
|
12
|
+
"in": { "auth": true, "actions": [["wait", 400]], "expect": { "visible": ["#acctBody", "#fName", "#fBio", "#fHandle", "#recWhat", "#recordLine", "#who"], "text": [["#whoLogin", "builder"], ["#recordLine", "Your record: 3 projects · 1000 credits · 19 works shipped · 10 karma."]], "hidden": ["#door", "#loading", "#signinTop", "#menuProjects", "#menuProfile"], "value": [["#fHandle", "builder"], ["#fName", "A Builder"], ["#fLinkX", "https://x.com/builder"]] } },
|
|
13
|
+
"in-projects-band": { "auth": true, "actions": [["wait", 400], ["click", "#projTrig"], ["wait", 300]], "expect": { "visible": ["#menuProjects", "#mineCell"], "hidden": ["#menuProfile"] } },
|
|
14
|
+
"in-profile-band": { "auth": true, "actions": [["wait", 400], ["click", "#who"], ["wait", 300]], "expect": { "visible": ["#menuProfile"], "hidden": ["#menuProjects"], "text": [["#bandWhoLogin", "builder"], ["#menuProfile [aria-current=\"page\"]", "Account"]] } },
|
|
13
15
|
"in-privacy": { "auth": true, "actions": [["wait", 400], ["click", "#aPrivacy .aOpt:nth-child(2)"], ["wait", 150]], "expect": { "checked": "input[name=visibility][value=private]", "text": [["#recWhat", "They will see your name, your picture, your roles, and whether you have been active in the last 30 days — never your credits, works shipped, karma, or the projects you build on. This is the one thing a recruiter can see while your profile page is private. Turning this off removes you from both recruiting surfaces — the recruiting directory, and a recruiter’s look-up of one builder — straight away. · For now this follows your profile page setting."]] } }
|
|
14
16
|
},
|
|
15
17
|
"reducedMotion": { "auth": true, "actions": [["wait", 300]] }
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.69",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.69",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.69",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8531,5 +8531,17 @@
|
|
|
8531
8531
|
"id": "1004487",
|
|
8532
8532
|
"text": "The hall no longer offers a Claim button on work the system will refuse. If a task gets a new prerequisite after it was opened up for claiming, its page and its goal card now say it is waiting on that other task — and name whi"
|
|
8533
8533
|
}
|
|
8534
|
+
],
|
|
8535
|
+
"1.20.68": [
|
|
8536
|
+
{
|
|
8537
|
+
"id": "1004455",
|
|
8538
|
+
"text": "The account page on cloudbongos.com now has the same top bar as the projects page — the Projects menu and your profile menu (My projects, Account, Sign out) — so moving between the two no longer swaps the whole header."
|
|
8539
|
+
}
|
|
8540
|
+
],
|
|
8541
|
+
"1.20.69": [
|
|
8542
|
+
{
|
|
8543
|
+
"id": "1004490",
|
|
8544
|
+
"text": "Session notes for today's account-page work are on file, so the next session can pick up without re-discovering what was done."
|
|
8545
|
+
}
|
|
8534
8546
|
]
|
|
8535
8547
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.69'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
// 4. the route: served on the apex only where platform-identity runs, never on
|
|
14
14
|
// the builders/status hosts, and never on a child instance;
|
|
15
15
|
// 5. the hall's side: the two privacy sections are gone, one link-out row
|
|
16
|
-
// remains, and its hub origin is a bare https origin or nothing
|
|
16
|
+
// remains, and its hub origin is a bare https origin or nothing;
|
|
17
|
+
// 6. the bar (task 1004455): the hub's own bar and two bands rather than a
|
|
18
|
+
// header of its own — the same markup as projects.html, cosmos.css's rules,
|
|
19
|
+
// no map search, links into /projects, Account as the current place — and
|
|
20
|
+
// the bands EXECUTED: the chip from GET /me, open/close, Escape, the arrows,
|
|
21
|
+
// a click outside, and sign out as the POST.
|
|
17
22
|
//
|
|
18
23
|
// DB-free. Run: node --test tests/hub_account_page.mjs
|
|
19
24
|
|
|
@@ -32,6 +37,8 @@ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
|
32
37
|
const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
33
38
|
const PAGE = fs.readFileSync(path.join(LANDING, 'account.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
34
39
|
const SCRIPT = PAGE.split('<script>').pop().split('</script>')[0];
|
|
40
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
41
|
+
const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8');
|
|
35
42
|
|
|
36
43
|
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
37
44
|
const { applyBrandTokens } = require('../src/bongos/serve-internal.js');
|
|
@@ -69,10 +76,64 @@ test('the script builds no markup from strings, and the controls are real markup
|
|
|
69
76
|
test('the door returns here, and the page calls only its own-scoped routes', () => {
|
|
70
77
|
assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account"/, 'sign-in lands back on /account');
|
|
71
78
|
const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
|
|
72
|
-
assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project',
|
|
79
|
+
assert.deepEqual([...new Set(calls)].sort(), ['GET /me', 'GET /me/cross-project','PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile'].sort());
|
|
73
80
|
assert.match(SCRIPT, /fetch\(API \+ '\/auth\/logout', \{ method: 'POST'/, 'sign out is the hub logout POST');
|
|
74
81
|
});
|
|
75
82
|
|
|
83
|
+
// ── 1b. the bar is the hub's (task 1004455) ──────────────────────────────────
|
|
84
|
+
|
|
85
|
+
const bandOf = (html, id) => {
|
|
86
|
+
const m = html.match(new RegExp(`<div class="menuBand" id="${id}"[\\s\\S]*?\\n</div>`));
|
|
87
|
+
assert.ok(m, `#${id} is a whole band`);
|
|
88
|
+
return m[0];
|
|
89
|
+
};
|
|
90
|
+
const bandLinks = (band) => [...band.matchAll(/<a class="bandT[^"]*" href="([^"]+)"[^>]*>([^<]+)<\/a>/g)].map((m) => [m[2], m[1]]);
|
|
91
|
+
|
|
92
|
+
test('the bar is the hub’s own: the same markup as /projects, no header of its own, no map search', () => {
|
|
93
|
+
assert.match(PAGE, /<header class="shell bar">/);
|
|
94
|
+
assert.doesNotMatch(PAGE, /<header class="top">|class="btn-space"|id="aWho"|id="signOut"/, 'the simple header and its in-page sign-out line are gone');
|
|
95
|
+
assert.doesNotMatch(PAGE, /id="(mapQ|qWrap|qToggle|onlyMine)"/, 'the search only filters the map, so it stays on the map');
|
|
96
|
+
// byte-equal pieces of projects.html: a rename on either page fails here
|
|
97
|
+
for (const piece of [
|
|
98
|
+
'<a class="barMark" id="barMark" href="/">',
|
|
99
|
+
'<ellipse cx="8.4" cy="5.6" rx="6.6" ry="2.9"/>',
|
|
100
|
+
'<button class="trig" type="button" id="projTrig" data-menu="projects" aria-expanded="false" aria-controls="menuProjects">Projects<svg class="cv" viewBox="0 0 20 20" aria-hidden="true"><path d="m5.6 8 4.4 4.4L14.4 8"/></svg></button>',
|
|
101
|
+
'<button class="trig who" type="button" id="who" data-menu="profile" aria-expanded="false" aria-controls="menuProfile" hidden>',
|
|
102
|
+
'<div class="menuBand" id="menuProjects" data-menu="projects" hidden>',
|
|
103
|
+
'<div class="menuBand" id="menuProfile" data-menu="profile" hidden>',
|
|
104
|
+
'<div class="bandIn bandProfile">',
|
|
105
|
+
'<li class="bandCell"><a class="bandT signout" href="/api/bongos/auth/logout">Sign out</a></li>',
|
|
106
|
+
]) {
|
|
107
|
+
assert.ok(HUB.includes(piece), `projects.html no longer carries: ${piece}`);
|
|
108
|
+
assert.ok(PAGE.includes(piece), `account.html does not carry the hub's: ${piece}`);
|
|
109
|
+
}
|
|
110
|
+
assert.match(PAGE, /<a class="signinDoor" id="signinTop" hidden href="\/api\/bongos\/auth\/web\/start\?return=\/account">Sign in<\/a>/, 'signed out, the door in the chip’s slot returns here');
|
|
111
|
+
assert.match(PAGE, /<a class="skip vh" href="#acct">/);
|
|
112
|
+
assert.match(PAGE, /<main class="acct" id="acct">/, 'the skip link has its landmark');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('the bands say what the hub’s say and lead into /projects; Account is the one current place', () => {
|
|
116
|
+
const clauses = (band) => [...band.matchAll(/>([^<]+)<\/a><span class="bandC">([^<]+)<\/span>/g)].map((m) => [m[1], m[2]]);
|
|
117
|
+
assert.deepEqual(clauses(bandOf(PAGE, 'menuProjects')), clauses(bandOf(HUB, 'menuProjects')), 'the Projects band’s items and clauses are the hub’s');
|
|
118
|
+
assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProjects')), [
|
|
119
|
+
['Sky', '/projects?view=map'], ['Table', '/projects?view=map&sub=table'],
|
|
120
|
+
['Your projects', '/projects?view=mine'], ['New project', '/projects?view=new'],
|
|
121
|
+
], 'this page has no views of its own, so every item names /projects in full');
|
|
122
|
+
assert.deepEqual(bandLinks(bandOf(PAGE, 'menuProfile')), [
|
|
123
|
+
['My projects', '/projects?view=mine'], ['Account', '/account'], ['Sign out', '/api/bongos/auth/logout'],
|
|
124
|
+
]);
|
|
125
|
+
assert.deepEqual(PAGE.match(/<a [^>]*aria-current="page"[^>]*>[^<]+/g), ['<a class="bandT" href="/account" aria-current="page">Account']);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('the bar is not restyled here: the page takes cosmos.css’s rules', () => {
|
|
129
|
+
const styles = [...PAGE.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
|
|
130
|
+
assert.doesNotMatch(styles, /\.(shell|bar|barMark|barLeft|barRight|trig|who|signinDoor|menuBand|band[A-Z]\w*)\b[^{}]*\{/, 'no bar or band rule in the page');
|
|
131
|
+
for (const rule of ['.bar{', '.barMark{', '.trig{', '.signinDoor{', '.menuBand{', '.bandCell:has([aria-current="page"])::before{']) {
|
|
132
|
+
assert.ok(COSMOS.includes(rule), `cosmos.css still carries ${rule}`);
|
|
133
|
+
}
|
|
134
|
+
assert.match(PAGE, /<link rel="stylesheet" href="\/assets\/cosmos\.css">/);
|
|
135
|
+
});
|
|
136
|
+
|
|
76
137
|
// ── 2. the mirrors hold ──────────────────────────────────────────────────────
|
|
77
138
|
|
|
78
139
|
test('the page’s limits are the module’s own (boundary-forced copies)', () => {
|
|
@@ -105,15 +166,42 @@ const ACCOUNT = () => ({
|
|
|
105
166
|
function boot(answer) {
|
|
106
167
|
const els = new Map();
|
|
107
168
|
const listeners = (o) => { o._on = {}; o.addEventListener = (t, f) => { (o._on[t] ||= []).push(f); }; return o; };
|
|
108
|
-
const el = (id, hidden) => listeners({
|
|
109
|
-
id, hidden, value: '', textContent: '', className: '', disabled: false, style: {},
|
|
169
|
+
const el = (id, hidden, attrs) => listeners({
|
|
170
|
+
id, hidden, attrs, value: '', textContent: '', className: '', disabled: false, style: {},
|
|
110
171
|
appendChild(c) { this.textContent += c.textContent; return c; },
|
|
111
|
-
getAttribute() { return null; },
|
|
112
|
-
|
|
172
|
+
getAttribute(n) { return n in attrs ? attrs[n] : null; },
|
|
173
|
+
setAttribute(n, v) { attrs[n] = v; },
|
|
174
|
+
closest(sel) { return sel === '.trig' && /\btrig\b/.test(attrs.class || '') ? this : null; },
|
|
175
|
+
focus() { dom.focused = id; document.activeElement = this; },
|
|
113
176
|
});
|
|
114
177
|
for (const m of PAGE.matchAll(/<(\w+)([^>]*?)\sid="([\w-]+)"([^>]*)>/g)) {
|
|
115
|
-
|
|
178
|
+
const attrs = Object.fromEntries([...(m[2] + m[4]).matchAll(/\s([\w-]+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
|
|
179
|
+
els.set(m[3], el(m[3], /\shidden\b/.test(m[2] + m[4]), attrs));
|
|
116
180
|
}
|
|
181
|
+
// The bar: the two triggers, and each band with its links in markup order. An
|
|
182
|
+
// item's cell is its <li>; only #mineCell carries an id, so only it can hide.
|
|
183
|
+
const trigs = [...els.values()].filter((e) => /\btrig\b/.test(e.attrs.class || ''));
|
|
184
|
+
const bands = [...PAGE.matchAll(/<div class="menuBand" id="(\w+)"[\s\S]*?\n<\/div>/g)].map((m) => {
|
|
185
|
+
const band = els.get(m[1]);
|
|
186
|
+
band.items = [...m[0].matchAll(/<li class="bandCell"(?: id="(\w+)")?><a class="([^"]+)" href="([^"]+)"([^>]*)>([^<]+)<\/a>/g)].map((a) => ({
|
|
187
|
+
cls: a[2], href: a[3], text: a[5], cell: els.get(a[1]) || { hidden: false },
|
|
188
|
+
focus() { document.activeElement = this; },
|
|
189
|
+
closest(sel) {
|
|
190
|
+
if (sel === 'a') return this;
|
|
191
|
+
if (sel === '[aria-current="page"]') return a[4].includes(' aria-current="page"') ? this : null;
|
|
192
|
+
if (sel === 'a.signout') return /\bsignout\b/.test(this.cls) ? this : null;
|
|
193
|
+
if (sel === '.menuBand') return band;
|
|
194
|
+
if (sel === '[hidden]') return this.cell.hidden ? this.cell : null;
|
|
195
|
+
return null;
|
|
196
|
+
},
|
|
197
|
+
}));
|
|
198
|
+
band.contains = (x) => band.items.includes(x);
|
|
199
|
+
band.querySelector = () => band.items.find((i) => !i.cell.hidden) || null;
|
|
200
|
+
band.querySelectorAll = () => band.items;
|
|
201
|
+
return band;
|
|
202
|
+
});
|
|
203
|
+
const docOn = {};
|
|
204
|
+
const event = (extra) => ({ prevented: false, preventDefault() { this.prevented = true; }, ...extra });
|
|
117
205
|
const radios = [...PAGE.matchAll(/<input type="radio" name="(\w+)" value="(\w+)">/g)]
|
|
118
206
|
.map((m) => listeners({ name: m[1], value: m[2], checked: false }));
|
|
119
207
|
const links = [...PAGE.matchAll(/id="(fLink\w+)" data-link="(\w+)"/g)].map((m) => {
|
|
@@ -123,7 +211,17 @@ function boot(answer) {
|
|
|
123
211
|
});
|
|
124
212
|
const calls = [];
|
|
125
213
|
const dom = {
|
|
126
|
-
els, radios, calls, focused: null,
|
|
214
|
+
els, radios, calls, focused: null, assigned: null,
|
|
215
|
+
active: () => document.activeElement,
|
|
216
|
+
// a click bubbles: the target's own listeners, its band's, then the document's
|
|
217
|
+
click(target, detail = 1) {
|
|
218
|
+
const band = bands.find((b) => b.items.includes(target));
|
|
219
|
+
const ev = event({ target, detail });
|
|
220
|
+
for (const f of [...(target._on ? target._on.click || [] : []), ...(band ? band._on.click || [] : []), ...(docOn.click || [])]) f(ev);
|
|
221
|
+
return ev;
|
|
222
|
+
},
|
|
223
|
+
key(key) { const ev = event({ key }); for (const f of docOn.keydown || []) f(ev); return ev; },
|
|
224
|
+
focusout(band, relatedTarget) { for (const f of band._on.focusout || []) f({ relatedTarget }); },
|
|
127
225
|
pick(name, value) {
|
|
128
226
|
for (const r of radios.filter((x) => x.name === name)) r.checked = r.value === value;
|
|
129
227
|
for (const f of radios.find((x) => x.name === name && x.value === value)._on.change || []) f({});
|
|
@@ -132,15 +230,20 @@ function boot(answer) {
|
|
|
132
230
|
text: (id) => els.get(id).textContent,
|
|
133
231
|
};
|
|
134
232
|
const document = {
|
|
135
|
-
cookie: '',
|
|
233
|
+
cookie: '', activeElement: null,
|
|
234
|
+
addEventListener: (t, f) => { (docOn[t] ||= []).push(f); },
|
|
136
235
|
getElementById: (id) => els.get(id) || null,
|
|
137
236
|
querySelector(sel) {
|
|
237
|
+
if (sel === '.menuBand:not([hidden])') return bands.find((b) => !b.hidden) || null;
|
|
238
|
+
if (sel === '.trig[aria-expanded="true"]') return trigs.find((t) => t.attrs['aria-expanded'] === 'true') || null;
|
|
138
239
|
const m = sel.match(/^input\[name="(\w+)"\]:checked$/);
|
|
139
240
|
if (!m) throw new Error(`unstubbed selector: ${sel}`);
|
|
140
241
|
return radios.find((r) => r.name === m[1] && r.checked) || null;
|
|
141
242
|
},
|
|
142
243
|
querySelectorAll(sel) {
|
|
143
244
|
if (sel === '#profileForm [data-link]') return links;
|
|
245
|
+
if (sel === '.trig') return trigs;
|
|
246
|
+
if (sel === '.menuBand') return bands;
|
|
144
247
|
const names = [...sel.matchAll(/input\[name="(\w+)"\]/g)].map((m) => m[1]);
|
|
145
248
|
if (!names.length) throw new Error(`unstubbed selector: ${sel}`);
|
|
146
249
|
return radios.filter((r) => names.includes(r.name));
|
|
@@ -156,7 +259,7 @@ function boot(answer) {
|
|
|
156
259
|
return Promise.resolve({ status, ok: status >= 200 && status < 300, json: () => Promise.resolve(data) });
|
|
157
260
|
};
|
|
158
261
|
const location = { hostname: 'cloudbongos.com', pathname: '/account' };
|
|
159
|
-
vm.runInNewContext(SCRIPT, { document, fetch, location, window: { location: { assign() {} } }, URL, Array, Object, JSON, Promise });
|
|
262
|
+
vm.runInNewContext(SCRIPT, { document, fetch, location, window: { location: { assign(p) { dom.assigned = p; } } }, URL, Array, Object, JSON, Promise });
|
|
160
263
|
return dom;
|
|
161
264
|
}
|
|
162
265
|
const settle = async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r)); };
|
|
@@ -166,8 +269,10 @@ test('signed out: a 401 opens the door and shows nothing else', async () => {
|
|
|
166
269
|
await settle();
|
|
167
270
|
assert.equal(dom.els.get('door').hidden, false);
|
|
168
271
|
assert.equal(dom.els.get('acctBody').hidden, true);
|
|
169
|
-
assert.equal(dom.els.get('aWho').hidden, true);
|
|
170
272
|
assert.equal(dom.els.get('loading').hidden, true);
|
|
273
|
+
assert.equal(dom.els.get('who').hidden, true, 'no chip without a session');
|
|
274
|
+
assert.equal(dom.els.get('signinTop').hidden, false, 'the sign-in door takes its slot');
|
|
275
|
+
assert.equal(dom.els.get('mineCell').hidden, true, 'and the Projects band offers no Your projects');
|
|
171
276
|
});
|
|
172
277
|
|
|
173
278
|
test('signed in: every control is painted from the server’s answer', async () => {
|
|
@@ -175,7 +280,6 @@ test('signed in: every control is painted from the server’s answer', async ()
|
|
|
175
280
|
await settle();
|
|
176
281
|
assert.equal(dom.els.get('acctBody').hidden, false);
|
|
177
282
|
assert.equal(dom.els.get('door').hidden, true);
|
|
178
|
-
assert.equal(dom.text('whoName'), 'octo');
|
|
179
283
|
assert.equal(dom.els.get('fName').value, 'Octo');
|
|
180
284
|
assert.equal(dom.els.get('fBio').value, 'Old bio.');
|
|
181
285
|
assert.equal(dom.els.get('fLinkWebsite').value, 'https://octo.example/');
|
|
@@ -315,6 +419,125 @@ test('a session that expired mid-edit reopens the door instead of failing silent
|
|
|
315
419
|
await settle();
|
|
316
420
|
assert.equal(dom.text('profileMsg'), 'Your session expired — sign in again to save.');
|
|
317
421
|
assert.equal(dom.els.get('door').hidden, false);
|
|
422
|
+
assert.equal(dom.els.get('who').hidden, true, 'the bar stops naming a session that is gone');
|
|
423
|
+
assert.equal(dom.els.get('signinTop').hidden, false);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// ── 3b. the bar, executed (task 1004455) ─────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
const ME = { builder: { id: '1', github_login: 'octocat', avatar_url: 'https://avatars.example/u/1' } };
|
|
429
|
+
const signedIn = (m, p) => (m !== 'GET' ? { status: 200, data: {} } : { status: 200, data: p === '/me' ? ME : ACCOUNT() });
|
|
430
|
+
const item = (band, text) => band.items.find((i) => i.text === text);
|
|
431
|
+
|
|
432
|
+
test('the chip is the hub’s identity read: GET /me fills the trigger and the profile band', async () => {
|
|
433
|
+
const dom = boot(signedIn);
|
|
434
|
+
await settle();
|
|
435
|
+
assert.ok(dom.calls.some((c) => c.method === 'GET' && c.path === '/me'));
|
|
436
|
+
const who = dom.els.get('who');
|
|
437
|
+
assert.equal(who.hidden, false);
|
|
438
|
+
assert.equal(dom.els.get('signinTop').hidden, true);
|
|
439
|
+
assert.equal(dom.text('whoLogin'), 'octocat', 'the GitHub login, as on /projects — not the hub handle');
|
|
440
|
+
assert.equal(dom.text('bandWhoLogin'), 'octocat');
|
|
441
|
+
assert.equal(who.getAttribute('aria-label'), 'Account, octocat');
|
|
442
|
+
for (const id of ['whoImg', 'bandWhoImg']) {
|
|
443
|
+
assert.equal(dom.els.get(id).src, 'https://avatars.example/u/1');
|
|
444
|
+
assert.equal(dom.els.get(id).hidden, false);
|
|
445
|
+
}
|
|
446
|
+
assert.equal(dom.els.get('mineCell').hidden, false);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('an account with no avatar gets a chip with no picture rather than a broken one', async () => {
|
|
450
|
+
const dom = boot((m, p) => (p === '/me' ? { status: 200, data: { builder: { github_login: 'octocat' } } } : signedIn(m, p)));
|
|
451
|
+
await settle();
|
|
452
|
+
assert.equal(dom.els.get('whoImg').hidden, true);
|
|
453
|
+
assert.equal(dom.els.get('whoImg').src, undefined);
|
|
454
|
+
assert.equal(dom.text('whoLogin'), 'octocat');
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('a failed identity read is not a sign-out: the chip falls back to the account’s own handle', async () => {
|
|
458
|
+
const dom = boot((m, p) => (p === '/me' ? { status: 500, data: null } : signedIn(m, p)));
|
|
459
|
+
await settle();
|
|
460
|
+
assert.equal(dom.els.get('acctBody').hidden, false);
|
|
461
|
+
assert.equal(dom.els.get('who').hidden, false, 'the profile band — and Sign out in it — stays reachable');
|
|
462
|
+
assert.equal(dom.els.get('signinTop').hidden, true, 'no Sign in door over a page that is signed in');
|
|
463
|
+
assert.equal(dom.text('whoLogin'), 'octo');
|
|
464
|
+
assert.equal(dom.els.get('whoImg').hidden, true);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test('the bands open and close the way the hub’s do: toggle, one at a time, a click outside, Tab out', async () => {
|
|
468
|
+
const dom = boot(signedIn);
|
|
469
|
+
await settle();
|
|
470
|
+
const [projTrig, who] = [dom.els.get('projTrig'), dom.els.get('who')];
|
|
471
|
+
const [projects, profile] = [dom.els.get('menuProjects'), dom.els.get('menuProfile')];
|
|
472
|
+
const expanded = (t) => t.getAttribute('aria-expanded');
|
|
473
|
+
|
|
474
|
+
dom.click(projTrig);
|
|
475
|
+
assert.deepEqual([projects.hidden, expanded(projTrig)], [false, 'true']);
|
|
476
|
+
assert.equal(dom.active(), null, 'a pointer open leaves focus where it was');
|
|
477
|
+
dom.click(projTrig);
|
|
478
|
+
assert.deepEqual([projects.hidden, expanded(projTrig)], [true, 'false'], 'the trigger toggles');
|
|
479
|
+
|
|
480
|
+
dom.click(projTrig);
|
|
481
|
+
dom.click(who);
|
|
482
|
+
assert.deepEqual([projects.hidden, expanded(projTrig), profile.hidden, expanded(who)], [true, 'false', false, 'true'], 'opening one closes the other');
|
|
483
|
+
|
|
484
|
+
dom.click({ closest: () => null });
|
|
485
|
+
assert.deepEqual([profile.hidden, expanded(who)], [true, 'false'], 'a click outside closes');
|
|
486
|
+
|
|
487
|
+
dom.click(who);
|
|
488
|
+
const here = dom.click(item(profile, 'Account'));
|
|
489
|
+
assert.equal(profile.hidden, true, 'an item activated closes its band');
|
|
490
|
+
assert.equal(here.prevented, true, 'Account is this page — no reload, so nothing typed is thrown away');
|
|
491
|
+
assert.equal(dom.calls.some((c) => c.method === 'POST'), false, 'and it is not the sign-out');
|
|
492
|
+
|
|
493
|
+
dom.click(projTrig);
|
|
494
|
+
dom.focusout(projects, null);
|
|
495
|
+
assert.equal(projects.hidden, false, 'a null relatedTarget is left to the document click');
|
|
496
|
+
dom.focusout(projects, dom.els.get('fName'));
|
|
497
|
+
assert.equal(projects.hidden, true, 'Tab out of the band closes it');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('the bands by keyboard: Enter lands on the first item, the arrows walk and wrap, Escape hands focus back', async () => {
|
|
501
|
+
const dom = boot(signedIn);
|
|
502
|
+
await settle();
|
|
503
|
+
const projTrig = dom.els.get('projTrig');
|
|
504
|
+
const projects = dom.els.get('menuProjects');
|
|
505
|
+
assert.equal(dom.key('ArrowDown').prevented, false, 'with nothing open the keys are the page’s');
|
|
506
|
+
|
|
507
|
+
dom.click(projTrig, 0);
|
|
508
|
+
assert.equal(dom.active().text, 'Sky', 'detail 0 is a keyboard activation');
|
|
509
|
+
assert.equal(dom.key('ArrowDown').prevented, true);
|
|
510
|
+
assert.equal(dom.active().text, 'Table');
|
|
511
|
+
dom.key('ArrowUp');
|
|
512
|
+
dom.key('ArrowUp');
|
|
513
|
+
assert.equal(dom.active().text, 'New project', 'ArrowUp wraps past the first item');
|
|
514
|
+
dom.key('ArrowDown');
|
|
515
|
+
assert.equal(dom.active().text, 'Sky');
|
|
516
|
+
dom.key('Escape');
|
|
517
|
+
assert.equal(projects.hidden, true);
|
|
518
|
+
assert.equal(dom.active(), projTrig, 'focus was inside the band, so it returns to the trigger');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('signed out, the arrows skip the hidden Your projects item', async () => {
|
|
522
|
+
const dom = boot(() => ({ status: 401, data: null }));
|
|
523
|
+
await settle();
|
|
524
|
+
dom.click(dom.els.get('projTrig'), 0);
|
|
525
|
+
dom.key('ArrowDown');
|
|
526
|
+
dom.key('ArrowDown');
|
|
527
|
+
assert.equal(dom.active().text, 'New project', 'Sky → Table → New project');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test('sign out is the profile band’s plain anchor upgraded to the POST, then a reload of this page', async () => {
|
|
531
|
+
const dom = boot((m, p) => (p === '/auth/logout' ? { status: 204, data: null } : signedIn(m, p)));
|
|
532
|
+
await settle();
|
|
533
|
+
const profile = dom.els.get('menuProfile');
|
|
534
|
+
assert.equal(dom.click(item(profile, 'My projects')).prevented, false, 'every other item is a real navigation');
|
|
535
|
+
dom.click(dom.els.get('who'));
|
|
536
|
+
const ev = dom.click(item(profile, 'Sign out'));
|
|
537
|
+
assert.equal(ev.prevented, true, 'the GET the href names would 404 and leave the session live');
|
|
538
|
+
await settle();
|
|
539
|
+
assert.deepEqual(dom.calls.at(-1), { method: 'POST', path: '/auth/logout', body: undefined });
|
|
540
|
+
assert.equal(dom.assigned, '/account');
|
|
318
541
|
});
|
|
319
542
|
|
|
320
543
|
// ── 4. the route ─────────────────────────────────────────────────────────────
|