@bongos/core 1.24.14 → 1.24.15
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 +296 -136
- package/.claude/skills/blocker-review/SKILL.md +2 -0
- package/.claude/skills/session-board/SKILL.md +29 -0
- package/.claude/skills/strand-fix/SKILL.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +14 -0
- package/clients/bongos-client/index.cjs +14 -0
- package/clients/bongos-client/index.d.ts +23 -2
- package/clients/bongos-client/index.mjs +14 -0
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +4 -0
- package/docs/api/openapi.json +369 -6
- package/docs/api-reference.md +19 -4
- package/docs/architecture.md +6 -0
- package/docs/copy-inventory.md +138 -123
- package/docs/copy-registry.json +296 -171
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -2
- package/docs/page-inventory.json +6 -1
- package/docs/page-readings.json +1040 -1021
- package/migrations/core_289_task_short_name.sql +34 -0
- package/modules/ai-studio/db.js +10 -0
- package/modules/ai-studio/gemini.js +73 -5
- package/modules/ai-studio/openrouter.js +17 -2
- package/modules/ai-studio/routes/ai-studio.js +7 -1
- package/modules/ai-studio/studio.js +8 -0
- package/modules/builder-settings/checkout-freshness.js +71 -0
- package/modules/builder-settings/routes/settings.js +26 -0
- package/modules/builder-settings/settings.js +2 -0
- package/modules/economy/migrations/economy_002_project_account.sql +83 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/project-account.js +166 -0
- package/modules/economy/routes/project-account.js +40 -0
- package/modules/hall-ui/public/ai-studio.css +3 -0
- package/modules/hall-ui/public/ai-studio.html +2 -1
- package/modules/hall-ui/public/ai-studio.js +72 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/github-chip.js +85 -0
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +2 -2
- package/modules/hall-ui/public/market-watch-item.js +10 -3
- package/modules/hall-ui/public/market-watch-item.states.json +41 -0
- package/modules/hall-ui/public/market-watch-lib.js +36 -5
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.html +1 -1
- package/modules/hall-ui/public/market-watch.css +5 -0
- package/modules/hall-ui/public/market-watch.html +2 -2
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/my-agents.js +3 -0
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +22 -22
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +28 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/ideas/blockers.js +13 -7
- package/modules/lifecycle/db-claim-reads.js +1 -1
- package/modules/lifecycle/db-ship.js +2 -2
- package/modules/lifecycle/db-tasks.js +4 -4
- package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/pay-type-gates.js +180 -0
- package/modules/lifecycle/publish-reconciler.js +31 -1
- package/modules/lifecycle/rebase-gate.js +3 -2
- package/modules/lifecycle/routes/claims.js +3 -2
- package/modules/lifecycle/routes/pay-type-gates.js +59 -0
- package/modules/lifecycle/routes/project-settings.js +4 -0
- package/modules/lifecycle/routes/task-write-routes.js +43 -0
- package/modules/lifecycle/ship-card.js +4 -1
- package/modules/lifecycle/task-name-db.js +55 -0
- package/modules/lifecycle/task-name.js +140 -0
- package/modules/market-watch/db.js +10 -1
- package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
- package/modules/market-watch/pages.js +20 -1
- package/modules/market-watch/proposals.js +57 -7
- package/modules/market-watch/routes/pages.js +2 -0
- package/modules/market-watch/routes/rules.js +4 -0
- package/modules/platform-identity/terms-acceptance.js +9 -9
- package/modules/public-landing/public/account-google.states.json +14 -0
- package/modules/public-landing/public/assets/cosmos.css +12 -0
- package/modules/public-landing/public/assets/hub-bar.js +44 -1
- package/modules/public-landing/public/privacy.html +5 -1
- package/modules/public-landing/public/projects-google.states.json +14 -0
- package/modules/public-landing/public/projects.html +39 -0
- package/modules/public-landing/public/terms.html +12 -2
- package/modules/ui-design/kit/fixtures/me-google.json +10 -0
- package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
- package/modules/ui-design/kit/serve.js +7 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +49 -1
- package/scripts/bongos/claim.js +4 -2
- package/scripts/bongos/land-watch.js +1 -0
- package/scripts/bongos/report-checkout.js +30 -0
- package/scripts/bongos/run-unit-tests.js +11 -0
- package/scripts/bongos/sessions-board.js +264 -0
- package/scripts/bongos/ship-finish.js +1 -0
- package/scripts/bongos/ship-flow.js +5 -2
- package/scripts/bongos/strand-lease.js +5 -3
- package/scripts/bongos/strand-watch.js +38 -7
- package/scripts/gds/report-checkout.js +7 -0
- package/scripts/gds/sessions-board.js +7 -0
- package/src/bongos/agent-standing-rules.js +13 -3
- package/src/bongos/routes/me.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/agent_standing_rules.mjs +18 -1
- package/tests/ai_studio_billing.mjs +13 -0
- package/tests/ai_studio_gemini_models.mjs +81 -0
- package/tests/ai_studio_requests.mjs +24 -0
- package/tests/blocker_detail_hydration.mjs +5 -3
- package/tests/blocker_tasks_named_db.mjs +74 -0
- package/tests/claim_gate_ci_unblock.mjs +1 -1
- package/tests/github_chip.mjs +217 -0
- package/tests/hall_ai_studio.mjs +129 -5
- package/tests/host_topology_skips.mjs +1 -0
- package/tests/hub_account_page.mjs +3 -2
- package/tests/landing_legal_pages.mjs +3 -2
- package/tests/market_watch_data_db.mjs +6 -0
- package/tests/market_watch_pages.mjs +87 -2
- package/tests/market_watch_rules.mjs +101 -5
- package/tests/market_watch_rules_db.mjs +42 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/pay_terms.mjs +8 -0
- package/tests/pay_type_gates.mjs +234 -0
- package/tests/pay_type_gates_db.mjs +84 -0
- package/tests/project_account.mjs +125 -0
- package/tests/project_account_db.mjs +97 -0
- package/tests/self_healing_start.mjs +162 -0
- package/tests/sessions_board.mjs +153 -0
- package/tests/stop_refcheck.mjs +40 -0
- package/tests/task_name.mjs +237 -0
- package/tests/task_name_db.mjs +79 -0
- package/tests/terms_acceptance.mjs +25 -3
- package/tests/terms_grandfather_db.mjs +11 -4
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// tests/github_chip.mjs — the Link GitHub chip in the top bar (task 1004996, goal
|
|
2
|
+
// 1000095 WA6; owner ask 2026-10-29).
|
|
3
|
+
//
|
|
4
|
+
// Someone with no GitHub linked sees one very small chip with the engineer's icon
|
|
5
|
+
// in the top bar; a press starts the GitHub link (ADR 0396). Anyone with GitHub
|
|
6
|
+
// linked sees nothing, and nothing ever pops up. Three copies draw it — the hall's
|
|
7
|
+
// github-chip.js, the hub's assets/hub-bar.js and projects.html's own bar — so
|
|
8
|
+
// this file RUNS each one under a small DOM stub and holds their words, link and
|
|
9
|
+
// glyph equal, then checks the shell loads the hall's copy and both sheets keep
|
|
10
|
+
// the 24px floor.
|
|
11
|
+
//
|
|
12
|
+
// Run: node --test tests/github_chip.mjs
|
|
13
|
+
|
|
14
|
+
import test from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import vm from 'node:vm';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
|
|
25
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
26
|
+
const read = (p) => fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
|
|
27
|
+
const CHIP_SRC = read(path.join(HALL, 'github-chip.js'));
|
|
28
|
+
const BAR = read(path.join(LANDING, 'assets', 'hub-bar.js'));
|
|
29
|
+
const HUB = read(path.join(LANDING, 'projects.html'));
|
|
30
|
+
const SHELL = read(path.join(HALL, 'shell.js'));
|
|
31
|
+
const chip = require(path.join(HALL, 'github-chip.js'));
|
|
32
|
+
// the engineer's glyph IS the Building craft's (craft-mode.js ICONS.building)
|
|
33
|
+
const BUILDING = /building: '<path d="([^"]+)"\/>'/.exec(read(path.join(HALL, 'craft-mode.js')))[1];
|
|
34
|
+
|
|
35
|
+
// ── a DOM just big enough for the three copies ──────────────────────────────
|
|
36
|
+
class El {
|
|
37
|
+
constructor(tag, id) { this.tagName = tag.toUpperCase(); this.id = id || ''; this.children = []; this.parentNode = null; this.attrs = {}; this.textContent = ''; this.hidden = false; this.className = ''; }
|
|
38
|
+
get firstChild() { return this.children[0] || null; }
|
|
39
|
+
setAttribute(k, v) { this.attrs[k] = String(v); }
|
|
40
|
+
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
|
|
41
|
+
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
|
|
42
|
+
insertBefore(c, ref) { c.parentNode = this; const i = ref ? this.children.indexOf(ref) : -1; if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); return c; }
|
|
43
|
+
remove() { if (this.parentNode) this.parentNode.children.splice(this.parentNode.children.indexOf(this), 1); this.parentNode = null; }
|
|
44
|
+
addEventListener() {}
|
|
45
|
+
querySelector() { return null; }
|
|
46
|
+
querySelectorAll() { return []; }
|
|
47
|
+
contains() { return false; }
|
|
48
|
+
focus() {}
|
|
49
|
+
closest() { return null; }
|
|
50
|
+
}
|
|
51
|
+
function makeDoc(barSelector) {
|
|
52
|
+
const bar = new El('div');
|
|
53
|
+
const byId = new Map();
|
|
54
|
+
const find = (node, id) => { if (node.id === id) return node; for (const c of node.children) { const f = find(c, id); if (f) return f; } return null; };
|
|
55
|
+
const doc = {
|
|
56
|
+
bar,
|
|
57
|
+
cookie: '',
|
|
58
|
+
activeElement: null,
|
|
59
|
+
createElement: (t) => new El(t),
|
|
60
|
+
createElementNS: (ns, t) => new El(t),
|
|
61
|
+
getElementById(id) { return find(bar, id) || (id === 'ghChip' || id === 'gh-chip' ? null : (byId.get(id) || byId.set(id, new El('div', id)).get(id))); },
|
|
62
|
+
querySelector: (sel) => (sel === barSelector ? bar : null),
|
|
63
|
+
querySelectorAll: () => [],
|
|
64
|
+
addEventListener() {},
|
|
65
|
+
};
|
|
66
|
+
return doc;
|
|
67
|
+
}
|
|
68
|
+
const settle = async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r)); };
|
|
69
|
+
const chipOf = (doc, id) => doc.bar.children.find((c) => c.id === id) || null;
|
|
70
|
+
const svgPath = (a) => a.children.find((c) => c.tagName === 'SVG').children[0].attrs.d;
|
|
71
|
+
const labelOf = (a) => a.children.find((c) => c.tagName === 'SPAN').textContent;
|
|
72
|
+
const GOOGLE_ONLY = { methods: [{ provider: 'google', email: 'o@example.com' }], providers: ['github', 'google'] };
|
|
73
|
+
const BOTH = { methods: [{ provider: 'github' }, { provider: 'google' }], providers: ['github', 'google'] };
|
|
74
|
+
|
|
75
|
+
// ── the hall's copy ──────────────────────────────────────────────────────────
|
|
76
|
+
test('the hall: who needs GitHub — only a stand-in login, and the ways in decide where they can be read', () => {
|
|
77
|
+
assert.equal(chip.needsGithub([{ provider: 'google' }], { github_login: '~octo' }), true);
|
|
78
|
+
assert.equal(chip.needsGithub([{ provider: 'github' }, { provider: 'google' }], { github_login: '~octo' }), false, 'a linked GitHub wins over the stand-in login');
|
|
79
|
+
assert.equal(chip.needsGithub(null, { github_login: '~octo' }), true, 'a project hall with no ways-in route: the stand-in says Google-only');
|
|
80
|
+
assert.equal(chip.needsGithub(null, { github_login: 'octo' }), false);
|
|
81
|
+
assert.equal(chip.needsGithub([{ provider: 'google' }], { github_login: 'octo' }), false, 'a real login means GitHub was linked (linking writes it over the stand-in)');
|
|
82
|
+
assert.equal(chip.needsGithub(null, null), false, 'nothing known, nothing drawn');
|
|
83
|
+
assert.equal(chip.chipHref(null), null, 'no hub, no linking, no chip');
|
|
84
|
+
assert.equal(chip.chipHref('https://example.com'), 'https://example.com/api/bongos/auth/link/github/start');
|
|
85
|
+
for (const bad of ['http://example.com', 'https://example.com/x', 'https://evil.example?x=', 'javascript:alert(1)//', '']) assert.equal(chip.chipHref(bad), null, `refused: ${bad}`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
async function mountHall(answer, builder, hub = 'https://example.com') {
|
|
89
|
+
const doc = makeDoc('#app-topbar .topbar-side');
|
|
90
|
+
const calls = [];
|
|
91
|
+
const api = { request: (m, p) => { calls.push(`${m} ${p}`); return typeof answer === 'function' ? answer() : Promise.resolve(answer); } };
|
|
92
|
+
const ctx = { document: doc, window: {}, Array, Promise };
|
|
93
|
+
ctx.window = ctx;
|
|
94
|
+
vm.runInNewContext(CHIP_SRC, ctx);
|
|
95
|
+
const shown = await ctx.OTBGithubChip.mount({ api, builder, hub });
|
|
96
|
+
return { doc, calls, shown, a: chipOf(doc, 'gh-chip') };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('the hall, executed: Google only draws one small link with the engineer glyph, to the hub’s link start', async () => {
|
|
100
|
+
const { a, calls, shown } = await mountHall({ ok: true, data: GOOGLE_ONLY }, { github_login: '~octo' });
|
|
101
|
+
assert.equal(shown, true);
|
|
102
|
+
assert.deepEqual(calls, ['GET /api/bongos/me/sign-in-methods'], 'one read');
|
|
103
|
+
assert.equal(a.tagName, 'A', 'a plain link: no pop-up, no dialog');
|
|
104
|
+
assert.equal(a.href, 'https://example.com/api/bongos/auth/link/github/start');
|
|
105
|
+
assert.equal(a.className, 'gh-chip');
|
|
106
|
+
assert.equal(labelOf(a), 'Link GitHub');
|
|
107
|
+
assert.equal(svgPath(a), BUILDING, 'the engineer craft’s own glyph');
|
|
108
|
+
assert.match(a.attrs['aria-label'], /coding/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the hall, executed: GitHub linked, a failed read for a real login, or no hub draws nothing', async () => {
|
|
112
|
+
assert.equal((await mountHall({ ok: true, data: BOTH }, { github_login: '~octo' })).a, null);
|
|
113
|
+
assert.equal((await mountHall({ ok: false, status: 404 }, { github_login: 'octo' })).a, null);
|
|
114
|
+
assert.equal((await mountHall(() => Promise.reject(new Error('offline')), { github_login: 'octo' })).a, null);
|
|
115
|
+
const noHub = await mountHall({ ok: true, data: GOOGLE_ONLY }, {}, null);
|
|
116
|
+
assert.equal(noHub.a, null);
|
|
117
|
+
assert.deepEqual(noHub.calls, [], 'no hub: not even the read');
|
|
118
|
+
const fallback = await mountHall({ ok: false, status: 404 }, { github_login: '~octo' });
|
|
119
|
+
assert.ok(fallback.a, 'a project hall: the stand-in login draws it');
|
|
120
|
+
const real = await mountHall({ ok: true, data: GOOGLE_ONLY }, { github_login: 'octo' });
|
|
121
|
+
assert.equal(real.a, null);
|
|
122
|
+
assert.deepEqual(real.calls, [], 'a real login costs no read at all');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('the shell loads the hall’s copy once, for a signed-in builder, only where there is a hub', () => {
|
|
126
|
+
assert.match(SHELL, /if \(state\.builder && access\.authed === true\) \{ loadPositions\(state\.builder, state\.positions\); loadGithubChip\(state\.builder\); \}/);
|
|
127
|
+
const fn = /function loadGithubChip\(builder\) \{[\s\S]*?\n {2}\}/.exec(SHELL)[0];
|
|
128
|
+
assert.match(fn, /hubOrigin\(\)/);
|
|
129
|
+
assert.match(fn, /if \(githubChipAsked \|\| !hub\) return;/);
|
|
130
|
+
assert.match(fn, /loadHallScript\('github-chip\.js'/);
|
|
131
|
+
assert.ok(SHELL.split('\n').length <= 1500, 'shell.js stays inside the 1500-line ratchet');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── the hub's two copies ─────────────────────────────────────────────────────
|
|
135
|
+
function bootBar(answers) {
|
|
136
|
+
const doc = makeDoc('.barRight');
|
|
137
|
+
const calls = [];
|
|
138
|
+
const fetch = (url) => {
|
|
139
|
+
const p = url.replace(/^\/api\/bongos/, '');
|
|
140
|
+
calls.push(p);
|
|
141
|
+
const { status, data } = answers(p);
|
|
142
|
+
return Promise.resolve({ status, ok: status >= 200 && status < 300, json: () => Promise.resolve(data) });
|
|
143
|
+
};
|
|
144
|
+
const window = {};
|
|
145
|
+
vm.runInNewContext(BAR, { document: doc, fetch, window, location: { hostname: 'cloudbongos.com', pathname: '/account' }, Array, Promise });
|
|
146
|
+
return { doc, calls, window };
|
|
147
|
+
}
|
|
148
|
+
const ME = { builder: { id: '1', github_login: '~octo' } };
|
|
149
|
+
|
|
150
|
+
test('the hub bar, executed: signed in with Google only, the chip heads the bar’s right side', async () => {
|
|
151
|
+
const { doc, calls } = bootBar((p) => (p === '/me' ? { status: 200, data: ME } : { status: 200, data: GOOGLE_ONLY }));
|
|
152
|
+
await settle();
|
|
153
|
+
assert.deepEqual(calls, ['/me', '/me/sign-in-methods']);
|
|
154
|
+
const a = chipOf(doc, 'ghChip');
|
|
155
|
+
assert.ok(a);
|
|
156
|
+
assert.equal(doc.bar.children[0], a, 'first in the row');
|
|
157
|
+
assert.equal(a.href, '/api/bongos/auth/link/github/start');
|
|
158
|
+
assert.equal(labelOf(a), chip.LABEL);
|
|
159
|
+
assert.equal(svgPath(a), BUILDING);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('the hub bar, executed: linked, signed out, a child (404) or a failed read draws nothing', async () => {
|
|
163
|
+
for (const answers of [
|
|
164
|
+
(p) => (p === '/me' ? { status: 200, data: ME } : { status: 200, data: BOTH }),
|
|
165
|
+
(p) => (p === '/me' ? { status: 200, data: ME } : { status: 404, data: null }),
|
|
166
|
+
(p) => (p === '/me' ? { status: 200, data: ME } : { status: 500, data: null }),
|
|
167
|
+
]) {
|
|
168
|
+
const { doc } = bootBar(answers);
|
|
169
|
+
await settle();
|
|
170
|
+
assert.equal(chipOf(doc, 'ghChip'), null);
|
|
171
|
+
}
|
|
172
|
+
const out = bootBar(() => ({ status: 401, data: null }));
|
|
173
|
+
await settle();
|
|
174
|
+
assert.deepEqual(out.calls, ['/me'], 'signed out: no ways-in read at all');
|
|
175
|
+
assert.equal(chipOf(out.doc, 'ghChip'), null);
|
|
176
|
+
const real = bootBar((p) => (p === '/me' ? { status: 200, data: { builder: { id: '2', github_login: 'octocat' } } } : { status: 200, data: GOOGLE_ONLY }));
|
|
177
|
+
await settle();
|
|
178
|
+
assert.deepEqual(real.calls, ['/me'], 'a real login (GitHub linked): no ways-in read on any page');
|
|
179
|
+
assert.equal(chipOf(real.doc, 'ghChip'), null);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('projects.html keeps its own copy, executed: the same chip from the same read', async () => {
|
|
183
|
+
const fn = /\n {2}var GH_TITLE = [^\n]+\n {2}function githubChip\(\) \{[\s\S]*?\n {2}\}\n/.exec(HUB);
|
|
184
|
+
assert.ok(fn, 'projects.html carries githubChip()');
|
|
185
|
+
assert.match(HUB, /paintShellIdentity\(\);\n\s+githubChip\(\);/, 'drawn after the identity read');
|
|
186
|
+
for (const [answer, who, drawn] of [[GOOGLE_ONLY, ME.builder, true], [BOTH, ME.builder, false], [GOOGLE_ONLY, { github_login: 'octocat' }, false]]) {
|
|
187
|
+
const doc = makeDoc('.barRight');
|
|
188
|
+
let reads = 0;
|
|
189
|
+
const fetch = () => { reads++; return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(answer) }); };
|
|
190
|
+
const ctx = { document: doc, fetch, API: '/api/bongos', me: who, $: (id) => doc.getElementById(id), Array, Promise };
|
|
191
|
+
vm.runInNewContext(fn[0] + '\ngithubChip();', ctx);
|
|
192
|
+
await settle();
|
|
193
|
+
const a = chipOf(doc, 'ghChip');
|
|
194
|
+
assert.equal(!!a, drawn);
|
|
195
|
+
assert.equal(reads, who.github_login.startsWith('~') ? 1 : 0, 'only a stand-in login is asked about');
|
|
196
|
+
if (a) { assert.equal(a.href, '/api/bongos/auth/link/github/start'); assert.equal(labelOf(a), chip.LABEL); assert.equal(svgPath(a), BUILDING); }
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('the three copies say the same words and build no markup from strings', () => {
|
|
201
|
+
const title = /var TITLE = '([^']+)'/.exec(CHIP_SRC)[1];
|
|
202
|
+
assert.match(BAR, new RegExp(`var GH_TITLE = '${title}'`));
|
|
203
|
+
assert.match(HUB, new RegExp(`var GH_TITLE = '${title}'`));
|
|
204
|
+
for (const src of [CHIP_SRC, BAR]) assert.doesNotMatch(src, /innerHTML|insertAdjacentHTML|outerHTML/);
|
|
205
|
+
assert.doesNotMatch(/function githubChip\(\) \{[\s\S]*?\n {2}\}\n/.exec(HUB)[0], /innerHTML/);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── the sheets: very small, but never under the 24px floor ───────────────────
|
|
209
|
+
test('both sheets keep the chip quiet and at least 24px, its words folding away on a phone', () => {
|
|
210
|
+
const cosmos = read(path.join(LANDING, 'assets', 'cosmos.css'));
|
|
211
|
+
const hall = read(path.join(HALL, 'style.css'));
|
|
212
|
+
assert.match(cosmos, /\.ghChip\{[^}]*min-height:24px;min-width:24px;/);
|
|
213
|
+
assert.match(cosmos, /@media \(max-width:900px\)\{[\s\S]*?\.ghChip__label\{display:none;\}/);
|
|
214
|
+
assert.match(hall, /\.gh-chip \{[^}]*min-height: 24px;[^}]*min-width: 24px;/);
|
|
215
|
+
assert.match(hall, /@media \(max-width: 640px\) \{\n {2}\.gh-chip \{[^}]*\}\n {2}\.gh-chip__label \{ display: none; \}/);
|
|
216
|
+
for (const css of [cosmos, hall]) assert.doesNotMatch(/\.gh-?[Cc]hip[^{]*\{[^}]*\}/.exec(css)[0], /#[0-9a-f]{3,8}\b|rgba?\(/i, 'tokens only');
|
|
217
|
+
});
|
package/tests/hall_ai_studio.mjs
CHANGED
|
@@ -131,6 +131,8 @@ function makeEl(id) {
|
|
|
131
131
|
removeAttribute(k) { delete el.attrs[k]; },
|
|
132
132
|
addEventListener(type, fn) { (el.handlers[type] = el.handlers[type] || []).push(fn); },
|
|
133
133
|
focus() {},
|
|
134
|
+
clicks: 0,
|
|
135
|
+
click() { el.clicks += 1; },
|
|
134
136
|
async fire(type, extra = {}) {
|
|
135
137
|
const ev = { preventDefault() {}, currentTarget: el, target: el, ...extra };
|
|
136
138
|
for (const fn of el.handlers[type] || []) await fn(ev);
|
|
@@ -157,8 +159,27 @@ function makeEl(id) {
|
|
|
157
159
|
|
|
158
160
|
const settle = async () => { for (let i = 0; i < 30; i++) await new Promise((r) => setImmediate(r)); };
|
|
159
161
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
+
// A browser File, as much of it as the page reads.
|
|
163
|
+
class FakeFile {
|
|
164
|
+
constructor(parts, name, opts = {}) { this.name = name; this.type = opts.type || ''; this.size = parts.reduce((n, p) => n + (p.size || 3), 0); }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The device the page runs on: a phone (coarse pointer) or a computer, and
|
|
168
|
+
// whether its webcam answers. Each track records whether it was stopped.
|
|
169
|
+
function makeDevice({ touch = false, webcam = 'yes' } = {}) {
|
|
170
|
+
const tracks = [{ stopped: false, stop() { this.stopped = true; } }];
|
|
171
|
+
return {
|
|
172
|
+
tracks,
|
|
173
|
+
matchMedia: (q) => ({ matches: touch && q.includes('coarse') }),
|
|
174
|
+
navigator: { mediaDevices: webcam === 'none' ? undefined : {
|
|
175
|
+
getUserMedia: async () => { if (webcam === 'refused') throw new Error('NotAllowedError'); return { getTracks: () => tracks }; },
|
|
176
|
+
} },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function bootPage(search, { routes = {}, device = makeDevice() } = {}) {
|
|
181
|
+
const els = { 'ais-page': makeEl('ais-page'), 'hall-main': makeEl('hall-main'), 'ais-file': makeEl('ais-file'), 'ais-camera-file': makeEl('ais-camera-file') };
|
|
182
|
+
const winHandlers = {};
|
|
162
183
|
const calls = [];
|
|
163
184
|
const store = new Map();
|
|
164
185
|
const loc = { search, href: `/builders/ai-studio${search}` };
|
|
@@ -201,7 +222,15 @@ function bootPage(search, { routes = {} } = {}) {
|
|
|
201
222
|
clearInterval: () => {},
|
|
202
223
|
Date,
|
|
203
224
|
Promise,
|
|
204
|
-
document: {
|
|
225
|
+
document: {
|
|
226
|
+
getElementById: (id) => els[id] || null,
|
|
227
|
+
body: makeEl('body'),
|
|
228
|
+
// The snapshot canvas: draws nothing, hands back a 3-byte JPEG.
|
|
229
|
+
createElement: () => ({ getContext: () => ({ drawImage() {} }), toBlob: (cb, type) => cb({ size: 3, type }) }),
|
|
230
|
+
},
|
|
231
|
+
File: FakeFile,
|
|
232
|
+
matchMedia: device.matchMedia,
|
|
233
|
+
navigator: device.navigator,
|
|
205
234
|
location: loc,
|
|
206
235
|
history: {
|
|
207
236
|
pushState(_s, _t, url) { loc.href = url; loc.search = url.includes('?') ? url.slice(url.indexOf('?')) : ''; },
|
|
@@ -210,7 +239,7 @@ function bootPage(search, { routes = {} } = {}) {
|
|
|
210
239
|
sessionStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) },
|
|
211
240
|
fetch: async (url) => { calls.push({ method: 'FETCH', url }); return { ok: true, blob: async () => ({ size: 3, type: 'image/png' }) }; },
|
|
212
241
|
FileReader: class { readAsDataURL() { this.result = 'data:image/png;base64,QUJD'; setImmediate(() => this.onload()); } },
|
|
213
|
-
addEventListener() {},
|
|
242
|
+
addEventListener(type, fn) { (winHandlers[type] = winHandlers[type] || []).push(fn); },
|
|
214
243
|
};
|
|
215
244
|
ctx.window = ctx;
|
|
216
245
|
ctx.window.BongosClient = { createClient: () => client };
|
|
@@ -220,7 +249,18 @@ function bootPage(search, { routes = {} } = {}) {
|
|
|
220
249
|
const src = f === 'studio-lib.js' ? 'window.OTBStudioLib = { skyAt: () => "night" };' : fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
221
250
|
vm.runInContext(src, ctx, { filename: f });
|
|
222
251
|
}
|
|
223
|
-
|
|
252
|
+
const fireWindow = async (type, ev) => { for (const fn of winHandlers[type] || []) await fn(ev); };
|
|
253
|
+
return { els, page: els['ais-page'], main: els['hall-main'], calls, loc, store, device, fireWindow };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// A paste event carrying these clipboard files; records whether the page took it.
|
|
257
|
+
function pasteEvent(files) {
|
|
258
|
+
const ev = {
|
|
259
|
+
taken: false,
|
|
260
|
+
preventDefault() { ev.taken = true; },
|
|
261
|
+
clipboardData: { items: files.map((f) => ({ kind: 'file', getAsFile: () => f })) },
|
|
262
|
+
};
|
|
263
|
+
return ev;
|
|
224
264
|
}
|
|
225
265
|
|
|
226
266
|
test('page: step 1 opens on an earlier request\'s files, carried by address, with Next', async () => {
|
|
@@ -234,6 +274,90 @@ test('page: step 1 opens on an earlier request\'s files, carried by address, wit
|
|
|
234
274
|
assert.equal(p.main.getAttribute('data-view'), 'pick');
|
|
235
275
|
});
|
|
236
276
|
|
|
277
|
+
const uploadsOf = (p) => p.calls.filter((c) => c.method === 'POST' && c.url.endsWith('/ai-studio/uploads'));
|
|
278
|
+
|
|
279
|
+
test('page: a pasted picture joins the files on step 1, named so it can be told apart', async () => {
|
|
280
|
+
const p = bootPage('?step=pick');
|
|
281
|
+
await settle();
|
|
282
|
+
assert.match(p.page.innerHTML, /paste a picture/);
|
|
283
|
+
const ev = pasteEvent([new FakeFile([{ size: 3 }], 'image.png', { type: 'image/png' })]);
|
|
284
|
+
await p.fireWindow('paste', ev);
|
|
285
|
+
await settle();
|
|
286
|
+
assert.equal(ev.taken, true);
|
|
287
|
+
const sent = uploadsOf(p);
|
|
288
|
+
assert.equal(sent.length, 1);
|
|
289
|
+
assert.match(sent[0].body.name, /^pasted-\d+-1\.png$/, 'the browser\'s generic image.png is renamed');
|
|
290
|
+
assert.equal(sent[0].body.content_type, 'image/png');
|
|
291
|
+
assert.match(p.page.innerHTML, /1 file ready/);
|
|
292
|
+
// A pasted file that already has a real name keeps it.
|
|
293
|
+
await p.fireWindow('paste', pasteEvent([new FakeFile([{ size: 3 }], 'tower.jpg', { type: 'image/jpeg' })]));
|
|
294
|
+
await settle();
|
|
295
|
+
assert.equal(uploadsOf(p)[1].body.name, 'tower.jpg');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('page: pasting anywhere but step 1, or pasting words, is left to the browser', async () => {
|
|
299
|
+
const p = bootPage('?step=describe&from=1001');
|
|
300
|
+
await settle();
|
|
301
|
+
const pic = pasteEvent([new FakeFile([{ size: 3 }], 'image.png', { type: 'image/png' })]);
|
|
302
|
+
await p.fireWindow('paste', pic);
|
|
303
|
+
assert.equal(pic.taken, false, 'words pasted into the request box still paste');
|
|
304
|
+
const q = bootPage('?step=pick');
|
|
305
|
+
await settle();
|
|
306
|
+
const words = { taken: false, preventDefault() { words.taken = true; }, clipboardData: { items: [{ kind: 'string', getAsFile: () => null }] } };
|
|
307
|
+
await q.fireWindow('paste', words);
|
|
308
|
+
assert.equal(words.taken, false);
|
|
309
|
+
assert.equal(uploadsOf(p).length + uploadsOf(q).length, 0);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('page: on a phone, "take a photo" opens the phone\'s own camera', async () => {
|
|
313
|
+
const p = bootPage('?step=pick', { device: makeDevice({ touch: true }) });
|
|
314
|
+
await settle();
|
|
315
|
+
await p.page.querySelector('#ais-camera').fire('click');
|
|
316
|
+
assert.equal(p.els['ais-camera-file'].clicks, 1);
|
|
317
|
+
assert.equal(p.page.querySelector('#ais-cam').hidden, false, 'no webcam panel on a phone');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('page: on a computer, the webcam shows on the page, a photo is taken and sent, and the camera turns off', async () => {
|
|
321
|
+
const p = bootPage('?step=pick');
|
|
322
|
+
await settle();
|
|
323
|
+
await p.page.querySelector('#ais-camera').fire('click');
|
|
324
|
+
await settle();
|
|
325
|
+
assert.equal(p.els['ais-camera-file'].clicks, 0);
|
|
326
|
+
const view = p.page.querySelector('#ais-cam-view');
|
|
327
|
+
assert.ok(view.srcObject, 'the webcam feeds the page');
|
|
328
|
+
view.videoWidth = 640;
|
|
329
|
+
view.videoHeight = 480;
|
|
330
|
+
await p.page.querySelector('#ais-snap').fire('click');
|
|
331
|
+
await settle();
|
|
332
|
+
assert.equal(p.device.tracks[0].stopped, true, 'taking the photo turns the camera off');
|
|
333
|
+
const sent = uploadsOf(p);
|
|
334
|
+
assert.equal(sent.length, 1);
|
|
335
|
+
assert.match(sent[0].body.name, /^photo-\d+\.jpg$/);
|
|
336
|
+
assert.equal(sent[0].body.content_type, 'image/jpeg');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('page: leaving step 1 with the webcam open turns the camera off', async () => {
|
|
340
|
+
const p = bootPage('?step=pick&from=1001');
|
|
341
|
+
await settle();
|
|
342
|
+
await p.page.querySelector('#ais-camera').fire('click');
|
|
343
|
+
await settle();
|
|
344
|
+
assert.equal(p.device.tracks[0].stopped, false);
|
|
345
|
+
await p.page.querySelector('#ais-next').fire('click');
|
|
346
|
+
await settle();
|
|
347
|
+
assert.equal(p.loc.search, '?step=describe');
|
|
348
|
+
assert.equal(p.device.tracks[0].stopped, true, 'Next must not leave the camera light on');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('page: a refused or missing webcam falls back to the camera picker', async () => {
|
|
352
|
+
for (const webcam of ['refused', 'none']) {
|
|
353
|
+
const p = bootPage('?step=pick', { device: makeDevice({ webcam }) });
|
|
354
|
+
await settle();
|
|
355
|
+
await p.page.querySelector('#ais-camera').fire('click');
|
|
356
|
+
await settle();
|
|
357
|
+
assert.equal(p.els['ais-camera-file'].clicks, 1, webcam);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
237
361
|
test('page: step 3 sends the request on the card pressed, with copies of the carried files', async () => {
|
|
238
362
|
const p = bootPage('?step=make&from=1001');
|
|
239
363
|
await settle();
|
|
@@ -49,6 +49,7 @@ const ALLOWED = {
|
|
|
49
49
|
run_node_reroute: '.claude/hooks/run-node.sh (package-excluded)',
|
|
50
50
|
run_routine_two_root: '.claude/hooks/_core-resolve.js (package-excluded)',
|
|
51
51
|
'session-cards': '.claude/hooks/session-cards.js (package-excluded)',
|
|
52
|
+
self_healing_start: '.claude/hooks/session-start.js (package-excluded)',
|
|
52
53
|
session_start_freshness: '.claude/hooks/session-start.js (package-excluded)',
|
|
53
54
|
shell_hooks: '.claude/hooks/shell-hooks.js (package-excluded)',
|
|
54
55
|
stop_refcheck: '.claude/hooks/stop-refcheck.js (package-excluded; the task-1002234 precedent)',
|
|
@@ -85,9 +85,10 @@ test('the door returns here, and the page calls only its own-scoped routes', ()
|
|
|
85
85
|
assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account&repos=1"/, 'sign-in lands back on /account, asking for repo access once (task 1004549)');
|
|
86
86
|
const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST|DELETE)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
|
|
87
87
|
assert.deepEqual([...new Set(calls)].sort(), ['GET /me/cross-project', 'PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile', 'GET /my-projects', 'POST /my-projects/', 'GET /me/guilds', 'GET /me/sign-in-methods', 'DELETE /me/sign-in-methods/'].sort());
|
|
88
|
-
// the bar's
|
|
88
|
+
// the bar's calls live in the shared asset: the identity read, sign out, and the
|
|
89
|
+
// ways in the Link GitHub chip asks (task 1004996)
|
|
89
90
|
const barCalls = [...BAR.matchAll(/fetch\(API \+ '([^']+)', \{ method: '(GET|POST)'/g)].map((m) => `${m[2]} ${m[1]}`);
|
|
90
|
-
assert.deepEqual(barCalls.sort(), ['GET /me', 'POST /auth/logout']);
|
|
91
|
+
assert.deepEqual(barCalls.sort(), ['GET /me', 'GET /me/sign-in-methods', 'POST /auth/logout']);
|
|
91
92
|
assert.match(BAR, /var API = '\/api\/bongos';/);
|
|
92
93
|
});
|
|
93
94
|
|
|
@@ -239,10 +239,11 @@ test('cosmos.css still carries the bar and the bands the doors lean on', () => {
|
|
|
239
239
|
for (const rule of ['.bar{', '.barMark{', '.trig{', '.signinDoor{', '.menuBand{']) assert.ok(css.includes(rule), `cosmos.css still carries ${rule}`);
|
|
240
240
|
});
|
|
241
241
|
|
|
242
|
-
test('assets/hub-bar.js builds no markup from strings and makes only the bar’s
|
|
242
|
+
test('assets/hub-bar.js builds no markup from strings and makes only the bar’s three calls', () => {
|
|
243
243
|
assert.doesNotMatch(BAR, /innerHTML|insertAdjacentHTML|outerHTML/);
|
|
244
244
|
const calls = [...BAR.matchAll(/fetch\(API \+ '([^']+)', \{ method: '(GET|POST)'/g)].map((m) => `${m[2]} ${m[1]}`);
|
|
245
|
-
|
|
245
|
+
// the identity read, sign out, and the ways in the Link GitHub chip asks (task 1004996)
|
|
246
|
+
assert.deepEqual(calls.sort(), ['GET /me', 'GET /me/sign-in-methods', 'POST /auth/logout']);
|
|
246
247
|
assert.doesNotMatch(BAR, /gtag|plausible|googletagmanager/, 'the privacy page promises no trackers');
|
|
247
248
|
});
|
|
248
249
|
|
|
@@ -29,6 +29,7 @@ const db = require('../modules/market-watch/db.js');
|
|
|
29
29
|
const MIGRATION = readFileSync(new URL('../modules/market-watch/migrations/market_watch_001_tables.sql', import.meta.url), 'utf8');
|
|
30
30
|
const MIGRATION_002 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
|
|
31
31
|
const MIGRATION_003 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_003_ask_pool.sql', import.meta.url), 'utf8');
|
|
32
|
+
const MIGRATION_004 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_004_entry_corrections.sql', import.meta.url), 'utf8');
|
|
32
33
|
const run = require('../modules/market-watch/run.js');
|
|
33
34
|
const store = require('../modules/market-watch/store.js');
|
|
34
35
|
const settings = require('../modules/market-watch/settings.js');
|
|
@@ -86,6 +87,11 @@ try {
|
|
|
86
87
|
// market_watch_003 (task 1004940): the weekly ask's pool-lookup index.
|
|
87
88
|
await q(MIGRATION_003);
|
|
88
89
|
await q(MIGRATION_003);
|
|
90
|
+
// market_watch_004 (task 1005016): the applied-correction lookup index.
|
|
91
|
+
await q(MIGRATION_004);
|
|
92
|
+
await q(MIGRATION_004);
|
|
93
|
+
const { rows: [ix] } = await q(`SELECT count(*)::int AS n FROM pg_indexes WHERE indexname = 'market_watch_proposals_entry_corrections'`);
|
|
94
|
+
assert.equal(ix.n, 1, 'the correction lookup index exists');
|
|
89
95
|
const { rows } = await q(`SELECT count(*)::int AS n FROM information_schema.tables WHERE table_name LIKE 'market\\_watch\\_%'`);
|
|
90
96
|
assert.ok(rows[0].n >= 10, `tables: ${rows[0].n}`);
|
|
91
97
|
});
|
|
@@ -134,7 +134,10 @@ test('each of the four changes, with a one-line reason, becomes a proposal the s
|
|
|
134
134
|
'the direction comes from the item, never from the client');
|
|
135
135
|
assert.deepEqual(norm({ kind: 'move_kind', reason: 'r' }, { id: 9, kind: 'partner' }).value.payload, { from: 'partner', to: 'competitor' });
|
|
136
136
|
assert.deepEqual(norm({ kind: 'add_source', url: 'https://example.org/changelog', reason: 'r' }).value.payload, { source_url: 'https://example.org/changelog' });
|
|
137
|
-
|
|
137
|
+
// task 1005016: the proposal carries the correction itself, or a wrong flag
|
|
138
|
+
assert.deepEqual(norm({ kind: 'correct_entry', log_entry_id: '15', takeaway: ' Raised a seed,\n not a Series B. ', reason: 'r' }).value,
|
|
139
|
+
{ kind: 'correct_entry', item_id: 4, log_entry_id: 15, reason: 'r', payload: { takeaway: 'Raised a seed, not a Series B.', summary: 'Correct an entry to read "Raised a seed, not a Series B."' } });
|
|
140
|
+
assert.deepEqual(norm({ kind: 'correct_entry', log_entry_id: 15, wrong: true, reason: 'r' }).value.payload, { wrong: true, summary: 'Mark an entry as wrong' });
|
|
138
141
|
assert.deepEqual(norm({ kind: 'remove_item', reason: 'shut down' }).value.payload, {}, 'stop tracking');
|
|
139
142
|
assert.deepEqual(pages.normalizeProposal({ kind: 'add_item', scout: 'topic', name: ' agent pay models ', reason: 'r' }).value.payload,
|
|
140
143
|
{ kind: 'topic', name: 'agent pay models', url: null });
|
|
@@ -151,7 +154,11 @@ test('a proposal is refused without a reason, with a bad value, or about somethi
|
|
|
151
154
|
assert.equal(err({ kind: 'rescore', dimension: 'human_gates', value: '', reason: 'r' }), 'invalid_value');
|
|
152
155
|
assert.equal(err({ kind: 'move_kind', reason: 'r' }, { id: 3, kind: 'topic' }), 'cannot_move_topic');
|
|
153
156
|
assert.equal(err({ kind: 'add_source', url: 'javascript:alert(1)', reason: 'r' }), 'invalid_url');
|
|
154
|
-
assert.equal(err({ kind: 'correct_entry', log_entry_id: '0', reason: 'r' }), 'invalid_entry');
|
|
157
|
+
assert.equal(err({ kind: 'correct_entry', log_entry_id: '0', takeaway: 'x', reason: 'r' }), 'invalid_entry');
|
|
158
|
+
assert.equal(err({ kind: 'correct_entry', log_entry_id: '15', reason: 'r' }), 'correction_required', 'the reason alone is no correction');
|
|
159
|
+
assert.equal(err({ kind: 'correct_entry', log_entry_id: '15', takeaway: ' ', reason: 'r' }), 'correction_required');
|
|
160
|
+
assert.equal(err({ kind: 'correct_entry', log_entry_id: '15', takeaway: 'x', wrong: true, reason: 'r' }), 'invalid_correction', 'not both');
|
|
161
|
+
assert.equal(err({ kind: 'correct_entry', log_entry_id: '15', takeaway: 'x'.repeat(501), reason: 'r' }), 'correction_too_long');
|
|
155
162
|
assert.equal(err({ kind: 'rule_change', reason: 'r' }), 'invalid_kind', 'a rules change is the rules page\'s, not this one\'s');
|
|
156
163
|
assert.equal(pages.normalizeProposal({ kind: 'rescore', reason: 'r' }).error, 'item_not_found');
|
|
157
164
|
assert.equal(pages.normalizeProposal({ kind: 'add_item', scout: 'friend', name: 'x', reason: 'r' }).error, 'invalid_scout');
|
|
@@ -166,10 +173,79 @@ test('the propose dialog offers exactly the four changes, and posts what the ser
|
|
|
166
173
|
assert.deepEqual(MW.bodyFor('change', form({ kind: 'rescore', dimension: 'human_gates', value: '8', reason: ' r ' }), { id: 4 }),
|
|
167
174
|
{ kind: 'rescore', item_id: 4, reason: 'r', dimension: 'human_gates', value: '8' });
|
|
168
175
|
assert.deepEqual(MW.bodyFor('stop', form({ reason: 'gone' }), { id: 4 }), { kind: 'remove_item', item_id: 4, reason: 'gone' });
|
|
176
|
+
// correct_entry (task 1005016): the corrected words, or the wrong flag instead
|
|
177
|
+
assert.match(html, /name="takeaway"/);
|
|
178
|
+
assert.match(html, /name="wrong" type="checkbox"/);
|
|
179
|
+
const fix = form({ kind: 'correct_entry', log_entry_id: '15', takeaway: ' seed ', reason: 'r' });
|
|
180
|
+
fix.elements.wrong = { value: 'true', checked: false };
|
|
181
|
+
assert.deepEqual(MW.bodyFor('change', fix, { id: 4 }), { kind: 'correct_entry', item_id: 4, reason: 'r', log_entry_id: '15', takeaway: 'seed' });
|
|
182
|
+
fix.elements.wrong.checked = true;
|
|
183
|
+
assert.deepEqual(MW.bodyFor('change', fix, { id: 4 }), { kind: 'correct_entry', item_id: 4, reason: 'r', log_entry_id: '15', wrong: true });
|
|
184
|
+
for (const b of [MW.bodyFor('change', fix, { id: 4 }), { ...MW.bodyFor('change', fix, { id: 4 }), wrong: undefined, takeaway: 'seed' }]) {
|
|
185
|
+
assert.equal(pages.normalizeProposal(b, { item: COP }).ok, true, 'the dialog posts what the server reads');
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('a corrected entry reads corrected on the item page; one marked wrong is no report on the home', () => {
|
|
190
|
+
const it = byName('GitHub Copilot', 'competitor');
|
|
191
|
+
const cf = rows.caseFileFrom(R, RUNS, it.id);
|
|
192
|
+
cf[0] = { ...cf[0], corrected_at: new Date('2026-10-30T10:00:00Z'), correction_reason: '<b>their post</b>', marked_wrong: false };
|
|
193
|
+
cf[1] = { ...cf[1], corrected_at: new Date('2026-10-30T11:00:00Z'), correction_reason: 'still running', marked_wrong: true };
|
|
194
|
+
const v = pages.shapeItem({ item: it, entries: cf, history: { series: {}, runs: [] }, rules: RULES });
|
|
195
|
+
assert.deepEqual(v.entries[0].corrected, { on: '2026-10-30', reason: '<b>their post</b>', wrong: false });
|
|
196
|
+
assert.deepEqual(v.entries[1].corrected, { on: '2026-10-30', reason: 'still running', wrong: true });
|
|
197
|
+
assert.equal(v.entries[2].corrected, null, 'an entry nobody corrected carries no note');
|
|
198
|
+
// the read behind it: the latest APPLIED correct_entry of this project, per entry
|
|
199
|
+
const caseSql = readFileSync(new URL('../modules/market-watch/db.js', import.meta.url), 'utf8');
|
|
200
|
+
assert.match(caseSql, /p\.project = i\.project AND p\.log_entry_id = e\.id AND p\.kind = 'correct_entry' AND p\.status = 'applied'/);
|
|
201
|
+
const pagesSrc = readFileSync(new URL('../modules/market-watch/pages.js', import.meta.url), 'utf8');
|
|
202
|
+
assert.match(pagesSrc, /AND NOT COALESCE\(\(SELECT p\.payload->>'wrong' = 'true' FROM market_watch_proposals p/, 'the home skips an entry marked wrong');
|
|
203
|
+
// both lookups are served by market_watch_004's partial index, in the order they read
|
|
204
|
+
const m4 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_004_entry_corrections.sql', import.meta.url), 'utf8');
|
|
205
|
+
assert.match(m4, /CREATE INDEX IF NOT EXISTS market_watch_proposals_entry_corrections\s+ON market_watch_proposals \(log_entry_id, decided_at DESC, id DESC\)\s+WHERE kind = 'correct_entry' AND status = 'applied';/);
|
|
206
|
+
assert.match(m4, /^[\s\S]*BEGIN;[\s\S]*COMMIT;\s*$/, 'transactional');
|
|
207
|
+
assert.ok(/^[\x00-\x7F]*$/.test(m4), 'ASCII only');
|
|
208
|
+
for (const src of [caseSql, pagesSrc]) assert.match(src, /ORDER BY p\.decided_at DESC, p\.id DESC/);
|
|
169
209
|
});
|
|
170
210
|
|
|
171
211
|
// ---- untrusted text becomes markup only through esc --------------------------------
|
|
172
212
|
|
|
213
|
+
// task 1005018: the dialog's screen-reader name follows the action that opened it.
|
|
214
|
+
test('the propose dialog is named by its own title: propose a change, stop tracking or send a new scout', () => {
|
|
215
|
+
const titleOf = (html) => {
|
|
216
|
+
const all = [...html.matchAll(/<span[^>]*id="mw-propose-title"[^>]*>([^<]*)<\/span>/g)];
|
|
217
|
+
assert.equal(all.length, 1, 'exactly one title to point at');
|
|
218
|
+
return all[0][1];
|
|
219
|
+
};
|
|
220
|
+
const item = { id: 4, kind: 'competitor', name: 'Copilot' };
|
|
221
|
+
const expected = { change: 'propose a change', stop: 'stop tracking', scout: 'send a new scout' };
|
|
222
|
+
for (const [mode, words] of Object.entries(expected)) {
|
|
223
|
+
// a fake <dialog> that starts with the old fixed label, as a page served before this fix did
|
|
224
|
+
const attrs = new Map([['aria-label', 'Propose a change']]);
|
|
225
|
+
const stub = () => ({ value: '', checked: false, hidden: false, addEventListener() {}, focus() {} });
|
|
226
|
+
const fields = new Proxy({}, { get: (o, k) => (o[k] = o[k] || stub()) });
|
|
227
|
+
const form = { elements: fields, addEventListener() {}, querySelector: () => stub() };
|
|
228
|
+
let html = '';
|
|
229
|
+
const dlg = {
|
|
230
|
+
set innerHTML(v) { html = v; }, get innerHTML() { return html; },
|
|
231
|
+
setAttribute: (k, v) => attrs.set(k, String(v)), removeAttribute: (k) => attrs.delete(k), getAttribute: (k) => attrs.get(k) ?? null,
|
|
232
|
+
querySelector: (s) => (s === 'form' ? form : stub()), querySelectorAll: () => [], showModal() {}, close() {},
|
|
233
|
+
};
|
|
234
|
+
const saved = globalThis.document;
|
|
235
|
+
globalThis.document = { getElementById: (id) => (id === 'mw-propose' ? dlg : null) };
|
|
236
|
+
try { MW.openPropose(mode, { item, dimensions: [], entries: [] }, () => {}); } finally { globalThis.document = saved; }
|
|
237
|
+
assert.equal(attrs.has('aria-label'), false, `${mode}: no fixed label left to override the title`);
|
|
238
|
+
assert.equal(attrs.get('aria-labelledby'), 'mw-propose-title', mode);
|
|
239
|
+
assert.equal(titleOf(html), words, `${mode}: the name a screen reader hears`);
|
|
240
|
+
}
|
|
241
|
+
for (const page of ['market-watch.html', 'market-watch-item.html']) {
|
|
242
|
+
const src = readFileSync(new URL(`../modules/hall-ui/public/${page}`, import.meta.url), 'utf8');
|
|
243
|
+
const tag = src.match(/<dialog[^>]*id="mw-propose"[^>]*>/)[0];
|
|
244
|
+
assert.doesNotMatch(tag, /aria-label=/, `${page}: no fixed name on the dialog`);
|
|
245
|
+
assert.match(tag, /aria-labelledby="mw-propose-title"/, page);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
173
249
|
test('an item name or takeaway with markup in it is escaped on the landscape and in the dialog', () => {
|
|
174
250
|
const evil = '<img src=x onerror=alert(1)>"\'';
|
|
175
251
|
const h = home();
|
|
@@ -307,6 +383,15 @@ test('a member files a proposal: one row, origin member, their id, this project;
|
|
|
307
383
|
assert.equal((await call('POST', '/market-watch/proposals', { kind: 'remove_item', item_id: 4, reason: 'r', status: 'approved' })).status, 400,
|
|
308
384
|
'an unknown field (a member trying to approve their own proposal) is refused');
|
|
309
385
|
assert.equal(inserts.length, 2, 'refused proposals write nothing');
|
|
386
|
+
// task 1005016: the route takes the correction (or the wrong flag) and stores it on the row
|
|
387
|
+
const fix = await call('POST', '/market-watch/proposals', { kind: 'correct_entry', item_id: 4, log_entry_id: 15, takeaway: 'Raised a seed.', reason: 'their post' });
|
|
388
|
+
assert.equal(fix.status, 201);
|
|
389
|
+
assert.deepEqual([inserts[2].params[3], JSON.parse(inserts[2].params[4]).takeaway], [15, 'Raised a seed.']);
|
|
390
|
+
const wrong = await call('POST', '/market-watch/proposals', { kind: 'correct_entry', item_id: 4, log_entry_id: 15, wrong: true, reason: 'still running' });
|
|
391
|
+
assert.equal(wrong.status, 201);
|
|
392
|
+
assert.equal(JSON.parse(inserts[3].params[4]).wrong, true);
|
|
393
|
+
assert.equal((await call('POST', '/market-watch/proposals', { kind: 'correct_entry', item_id: 4, log_entry_id: 15, reason: 'r' })).status, 400, 'no correction, no proposal');
|
|
394
|
+
assert.equal(inserts.length, 4);
|
|
310
395
|
});
|
|
311
396
|
|
|
312
397
|
// ---- the quiet off state -----------------------------------------------------------
|