@bongos/core 1.21.39 → 1.21.41
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 +144 -84
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +16 -0
- package/clients/bongos-client/index.cjs +16 -0
- package/clients/bongos-client/index.d.ts +25 -0
- package/clients/bongos-client/index.mjs +16 -0
- package/docs/api/openapi.json +492 -3
- package/docs/api-reference.md +13 -1
- package/docs/copy-inventory.md +29 -13
- package/docs/copy-registry.json +177 -23
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +14 -2
- package/docs/page-readings.json +601 -554
- package/modules/government/docket.js +14 -0
- package/modules/hall-ui/public/atlas.html +3 -3
- package/modules/hall-ui/public/blockers.html +3 -3
- package/modules/hall-ui/public/board-room.html +3 -3
- package/modules/hall-ui/public/city.html +3 -3
- package/modules/hall-ui/public/collab.html +3 -3
- package/modules/hall-ui/public/commands.html +3 -3
- package/modules/hall-ui/public/copy-desk.html +3 -3
- package/modules/hall-ui/public/craft-mode.js +62 -0
- package/modules/hall-ui/public/deploy.html +3 -3
- package/modules/hall-ui/public/diagrams.html +3 -3
- package/modules/hall-ui/public/drachmae.html +3 -3
- package/modules/hall-ui/public/fleet.html +3 -3
- package/modules/hall-ui/public/gate.html +3 -3
- package/modules/hall-ui/public/goal-map.html +3 -3
- package/modules/hall-ui/public/goals.html +3 -3
- package/modules/hall-ui/public/government.html +3 -3
- package/modules/hall-ui/public/idea.html +3 -3
- package/modules/hall-ui/public/ideas.html +3 -3
- package/modules/hall-ui/public/index.html +3 -3
- package/modules/hall-ui/public/modules.html +3 -3
- package/modules/hall-ui/public/page-notes.js +320 -0
- package/modules/hall-ui/public/primer.html +3 -3
- package/modules/hall-ui/public/profile.html +3 -3
- package/modules/hall-ui/public/project-settings.html +3 -3
- package/modules/hall-ui/public/ranks.html +3 -3
- package/modules/hall-ui/public/roadmap.html +3 -3
- package/modules/hall-ui/public/roster.html +3 -3
- package/modules/hall-ui/public/sessions.html +3 -3
- package/modules/hall-ui/public/settings.html +3 -3
- package/modules/hall-ui/public/street-card.css +21 -2
- package/modules/hall-ui/public/street-card.js +198 -64
- package/modules/hall-ui/public/street-card.states.json +495 -7
- package/modules/hall-ui/public/street-desk.js +47 -2
- package/modules/hall-ui/public/street.css +1 -3
- package/modules/hall-ui/public/street.html +3 -4
- package/modules/hall-ui/public/street.js +42 -15
- package/modules/hall-ui/public/studio.html +3 -3
- package/modules/hall-ui/public/style.css +144 -0
- package/modules/hall-ui/public/task.html +3 -3
- package/modules/hall-ui/public/thinking.html +3 -3
- package/modules/hall-ui/public/tweak-editor.html +3 -3
- package/modules/hall-ui/public/watch.html +3 -3
- package/modules/hall-ui/public/work.html +3 -3
- package/modules/hall-ui/records/page-notes.md +16 -0
- package/modules/ideas/governor-docket.js +2 -0
- package/modules/lifecycle/governor-docket.js +4 -0
- package/modules/onboarding/routes/access-requests.js +2 -0
- package/modules/page-notes/CLAUDE.md +15 -0
- package/modules/page-notes/db.js +147 -0
- package/modules/page-notes/migrations/page_notes_001_notes.sql +68 -0
- package/modules/page-notes/module.json +13 -0
- package/modules/page-notes/notes.js +174 -0
- package/modules/page-notes/routes/page-notes.js +172 -0
- package/modules/page-notes/split.js +68 -0
- package/modules/provisioning/starter-bundles.js +1 -0
- package/modules/security/routes/reports.js +3 -0
- package/modules/ui-design/kit/serve.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/publish-manifest.js +1 -0
- package/src/bongos/module-rechecks.js +12 -1
- package/src/bongos/module-scope-map.js +4 -0
- package/src/bongos/routes.js +4 -1
- package/src/module-api.js +1 -1
- package/tests/governor_docket.mjs +20 -0
- package/tests/hall_matter_card.mjs +143 -18
- package/tests/hall_street.mjs +1 -1
- package/tests/hall_tweak_editor.mjs +1 -1
- package/tests/module_loader.mjs +1 -1
- package/tests/page_notes.mjs +289 -0
- package/tests/page_notes_db.mjs +140 -0
- package/tests/page_notes_panel.mjs +145 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// tests/page_notes_db.mjs — the page feedback doc's tables hold their rules in
|
|
2
|
+
// Postgres, not just in JS (task 1004496). Runs the REAL migration and the REAL
|
|
3
|
+
// db.js against a database; SKIPS (exit 0) with no reachable Postgres, like
|
|
4
|
+
// every *_db test (the copy_desk_flags_db.mjs pattern).
|
|
5
|
+
//
|
|
6
|
+
// Run: DATABASE_URL=postgres://... node tests/page_notes_db.mjs
|
|
7
|
+
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import url from 'node:url';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const { Pool } = require('pg');
|
|
16
|
+
|
|
17
|
+
const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
await pool.query('SELECT 1');
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.log(`page_notes_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}). Set DATABASE_URL to run.`);
|
|
24
|
+
await pool.end().catch(() => {});
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const db = require('../modules/page-notes/db.js');
|
|
29
|
+
const q = (text, params) => pool.query(text, params);
|
|
30
|
+
|
|
31
|
+
let passed = 0;
|
|
32
|
+
let failed = 0;
|
|
33
|
+
async function test(name, fn) {
|
|
34
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
35
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
36
|
+
}
|
|
37
|
+
async function refuses(sql, params, constraint) {
|
|
38
|
+
try { await pool.query(sql, params); } catch (e) {
|
|
39
|
+
if (constraint && e.constraint !== constraint) throw new Error(`refused by ${e.constraint || e.code}, expected ${constraint}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
throw new Error(`expected ${constraint || 'a refusal'}, but the write landed`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await pool.query(`
|
|
46
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY);
|
|
47
|
+
CREATE TABLE IF NOT EXISTS builders (id bigserial PRIMARY KEY, github_login text, display_name text);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS idea_inbox (id bigserial PRIMARY KEY, title text, status text);
|
|
49
|
+
`);
|
|
50
|
+
const A = 910000001;
|
|
51
|
+
const B = 910000002;
|
|
52
|
+
await pool.query('INSERT INTO builders (id, github_login, display_name) VALUES ($1,$2,$3),($4,$5,$6) ON CONFLICT (id) DO NOTHING',
|
|
53
|
+
[A, 'pntest-a', 'Page Notes Test A', B, 'pntest-b', '']);
|
|
54
|
+
|
|
55
|
+
const MIGRATION = fs.readFileSync(path.join(ROOT, 'modules/page-notes/migrations/page_notes_001_notes.sql'), 'utf8');
|
|
56
|
+
await pool.query(MIGRATION);
|
|
57
|
+
const PAGE = 'builders:pn-test-page';
|
|
58
|
+
const clean = () => pool.query('DELETE FROM page_notes_docs WHERE page_id LIKE $1', ['builders:pn-test-%']);
|
|
59
|
+
await clean();
|
|
60
|
+
|
|
61
|
+
const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex');
|
|
62
|
+
|
|
63
|
+
console.log('page_notes — the constraints hold in Postgres, not just in JS');
|
|
64
|
+
|
|
65
|
+
await test('the migration is idempotent (re-running is clean)', async () => {
|
|
66
|
+
await pool.query(MIGRATION);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await test('ONE DRAFT per builder per page: opening twice resumes the same row', async () => {
|
|
70
|
+
const one = await db.openDraft({ authorId: A, pageId: PAGE, pagePath: '/x', pageTitle: 'X' }, q);
|
|
71
|
+
const two = await db.openDraft({ authorId: A, pageId: PAGE, pagePath: '/x', pageTitle: 'X' }, q);
|
|
72
|
+
assert.equal(String(one.id), String(two.id));
|
|
73
|
+
assert.equal(one.status, 'draft');
|
|
74
|
+
assert.equal(one.author_name, 'Page Notes Test A');
|
|
75
|
+
const other = await db.openDraft({ authorId: B, pageId: PAGE, pagePath: '/x', pageTitle: 'X' }, q);
|
|
76
|
+
assert.notEqual(String(other.id), String(one.id), 'another builder gets their own draft');
|
|
77
|
+
assert.equal(other.author_name, 'pntest-b', 'an empty display name falls back to the login');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('the page id, path, status and body are checked by the schema', async () => {
|
|
81
|
+
await refuses("INSERT INTO page_notes_docs (page_id, author_id) VALUES ('nope', $1)", [A], 'page_notes_page_id_chk');
|
|
82
|
+
await refuses("INSERT INTO page_notes_docs (page_id, page_path, author_id) VALUES ($1, 'javascript:x', $2)", [PAGE, A], 'page_notes_page_path_chk');
|
|
83
|
+
await refuses("INSERT INTO page_notes_docs (page_id, status, author_id) VALUES ($1, 'published', $2)", [PAGE, A], 'page_notes_status_chk');
|
|
84
|
+
await refuses('INSERT INTO page_notes_docs (page_id, body_md, author_id) VALUES ($1, $2, $3)', [PAGE, 'x'.repeat(40001), A], 'page_notes_body_chk');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await test('IMAGES ONLY: the schema refuses SVG and anything over 4 MB', async () => {
|
|
88
|
+
const note = await db.openDraft({ authorId: A, pageId: 'builders:pn-test-img', pagePath: '', pageTitle: '' }, q);
|
|
89
|
+
const row = await db.addImage(note.id, { content_type: 'image/png', bytes: PNG, name: 'a.png' }, q);
|
|
90
|
+
assert.equal(Number(row.size), PNG.length);
|
|
91
|
+
await refuses("INSERT INTO page_notes_images (note_id, content_type, bytes) VALUES ($1, 'image/svg+xml', $2)", [note.id, Buffer.from('<svg/>')], 'page_notes_images_type_chk');
|
|
92
|
+
await refuses("INSERT INTO page_notes_images (note_id, content_type, bytes) VALUES ($1, 'image/png', $2)", [note.id, Buffer.alloc(4 * 1024 * 1024 + 1)], 'page_notes_images_size_chk');
|
|
93
|
+
const img = await db.getImage(row.id, q);
|
|
94
|
+
assert.ok(Buffer.from(img.bytes).equals(PNG), 'the bytes come back as they went in');
|
|
95
|
+
assert.equal(img.status, 'draft');
|
|
96
|
+
assert.equal(String(img.author_id), String(A));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await test('SUBMIT: once only, dropping the images the text no longer shows', async () => {
|
|
100
|
+
const note = await db.openDraft({ authorId: A, pageId: 'builders:pn-test-submit', pagePath: '/s', pageTitle: 'S' }, q);
|
|
101
|
+
const keep = await db.addImage(note.id, { content_type: 'image/png', bytes: PNG, name: 'keep' }, q);
|
|
102
|
+
await db.addImage(note.id, { content_type: 'image/png', bytes: PNG, name: 'drop' }, q);
|
|
103
|
+
assert.equal(await db.countImages(note.id, q), 2);
|
|
104
|
+
assert.ok(await db.saveBody(note.id, A, 'words', q));
|
|
105
|
+
assert.equal(await db.saveBody(note.id, B, 'not yours', q), null, 'only the author saves');
|
|
106
|
+
assert.equal(await db.markSplitting(note.id, B, [String(keep.id)], q), false, 'only the author submits');
|
|
107
|
+
assert.equal(await db.markSplitting(note.id, A, [String(keep.id)], q), true);
|
|
108
|
+
assert.equal(await db.markSplitting(note.id, A, [String(keep.id)], q), false, 'a second submit is a no-op');
|
|
109
|
+
assert.deepEqual((await db.listImages(note.id, q)).map((i) => String(i.id)), [String(keep.id)]);
|
|
110
|
+
assert.equal(await db.saveBody(note.id, A, 'too late', q), null, 'a submitted doc is no longer a draft');
|
|
111
|
+
const idea = await pool.query("INSERT INTO idea_inbox (title, status) VALUES ('pn test idea', 'open') RETURNING id");
|
|
112
|
+
assert.equal(await db.markFiled(note.id, { ideaIds: [String(idea.rows[0].id)], splitBy: 'ai' }, q), true);
|
|
113
|
+
assert.equal(await db.markFiled(note.id, { ideaIds: [], splitBy: 'ai' }, q), false, 'only a splitting doc is filed');
|
|
114
|
+
const filed = await db.getNote(note.id, q);
|
|
115
|
+
assert.equal(filed.status, 'filed');
|
|
116
|
+
assert.equal(filed.split_by, 'ai');
|
|
117
|
+
assert.deepEqual((await db.ideaTitles(filed.idea_ids, q)).map((i) => i.title), ['pn test idea']);
|
|
118
|
+
const list = await db.listForPage('builders:pn-test-submit', {}, q);
|
|
119
|
+
assert.deepEqual(list.map((r) => String(r.id)), [String(note.id)], 'the page lists its submitted docs');
|
|
120
|
+
await pool.query('DELETE FROM idea_inbox WHERE id = $1', [idea.rows[0].id]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await test('a submitted doc frees the page for a new draft', async () => {
|
|
124
|
+
const fresh = await db.openDraft({ authorId: A, pageId: 'builders:pn-test-submit', pagePath: '/s', pageTitle: 'S' }, q);
|
|
125
|
+
assert.equal(fresh.status, 'draft');
|
|
126
|
+
assert.equal((await db.listForPage('builders:pn-test-submit', {}, q)).length, 1, 'drafts are not listed');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await test('deleting a doc takes its images with it', async () => {
|
|
130
|
+
const note = await db.openDraft({ authorId: B, pageId: 'builders:pn-test-cascade', pagePath: '', pageTitle: '' }, q);
|
|
131
|
+
await db.addImage(note.id, { content_type: 'image/png', bytes: PNG, name: '' }, q);
|
|
132
|
+
await pool.query('DELETE FROM page_notes_docs WHERE id = $1', [note.id]);
|
|
133
|
+
assert.equal(await db.countImages(note.id, q), 0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await clean();
|
|
137
|
+
await pool.query('DELETE FROM builders WHERE id = ANY($1::bigint[])', [[A, B]]).catch(() => {});
|
|
138
|
+
await pool.end();
|
|
139
|
+
console.log(`page_notes_db: ${passed} passed, ${failed} failed`);
|
|
140
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// tests/page_notes_panel.mjs — the hall side of the artist's page feedback doc
|
|
2
|
+
// (task 1004496): the palette tool craft-mode.js puts in the top bar, and the
|
|
3
|
+
// reader in page-notes.js. The server half is tests/page_notes.mjs.
|
|
4
|
+
//
|
|
5
|
+
// Runs the REAL craft-mode.js over a small DOM: the palette shows in Artistry
|
|
6
|
+
// alone and leaves when the mode changes, pressing it loads page-notes.js once
|
|
7
|
+
// (same ?v as craft-mode.js) and a ?note= link opens the doc in any mode. Then
|
|
8
|
+
// the REAL page-notes.js reader: it escapes the doc and shows only this module's
|
|
9
|
+
// own images, which is what keeps a doc from pulling a picture off another site.
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import vm from 'node:vm';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const HALL = path.join(ROOT, 'modules/hall-ui/public');
|
|
19
|
+
const CRAFT = fs.readFileSync(path.join(HALL, 'craft-mode.js'), 'utf8');
|
|
20
|
+
const NOTES = fs.readFileSync(path.join(HALL, 'page-notes.js'), 'utf8');
|
|
21
|
+
|
|
22
|
+
function node(tag, id, cls) {
|
|
23
|
+
const attrs = {};
|
|
24
|
+
const listeners = {};
|
|
25
|
+
const el = {
|
|
26
|
+
tagName: tag, id: id || '', className: cls || '', dataset: {}, children: [], parentNode: null, innerHTML: '', title: '', type: '', src: '',
|
|
27
|
+
setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
|
|
28
|
+
hasAttribute(k) { return k in attrs; }, removeAttribute(k) { delete attrs[k]; },
|
|
29
|
+
get nextSibling() { const s = this.parentNode && this.parentNode.children; return s ? s[s.indexOf(this) + 1] || null : null; },
|
|
30
|
+
appendChild(c) { c.parentNode = this; this.children.push(c); if (this.onAppend) this.onAppend(c); return c; },
|
|
31
|
+
insertBefore(c, ref) { c.parentNode = this; const at = ref ? this.children.indexOf(ref) : -1; if (at === -1) this.children.push(c); else this.children.splice(at, 0, c); return c; },
|
|
32
|
+
removeChild(c) { this.children.splice(this.children.indexOf(c), 1); c.parentNode = null; return c; },
|
|
33
|
+
querySelector(sel) { return all(this).find((n) => sel === '.' + n.className) || null; },
|
|
34
|
+
querySelectorAll() { return []; },
|
|
35
|
+
addEventListener(t, fn) { (listeners[t] ||= []).push(fn); },
|
|
36
|
+
click() { (listeners.click || []).forEach((fn) => fn({ target: el })); },
|
|
37
|
+
classList: { toggle() {} },
|
|
38
|
+
};
|
|
39
|
+
return el;
|
|
40
|
+
}
|
|
41
|
+
function all(root) { return root.children.flatMap((c) => [c, ...all(c)]); }
|
|
42
|
+
|
|
43
|
+
function boot({ session = {}, search = '' } = {}) {
|
|
44
|
+
const sidebar = node('NAV', 'app-sidebar');
|
|
45
|
+
const topbar = node('DIV', 'app-topbar');
|
|
46
|
+
const side = topbar.appendChild(node('DIV', '', 'topbar-side'));
|
|
47
|
+
const sw = side.appendChild(node('DIV', 'craft-switch'));
|
|
48
|
+
const head = node('HEAD');
|
|
49
|
+
const loaded = [];
|
|
50
|
+
const root = node('HTML');
|
|
51
|
+
const everything = () => [sidebar, topbar, ...all(sidebar), ...all(topbar)];
|
|
52
|
+
const document = {
|
|
53
|
+
documentElement: root, body: node('BODY'), head, activeElement: null,
|
|
54
|
+
currentScript: { src: 'https://h/builders/craft-mode.js?v=2026-10-04-test' },
|
|
55
|
+
getElementById: (id) => everything().find((n) => n.id === id) || null,
|
|
56
|
+
querySelectorAll: (sel) => (sel === '[data-craft-tool]' ? everything().filter((n) => n.hasAttribute('data-craft-tool')) : []),
|
|
57
|
+
createElement: (t) => node(t.toUpperCase()),
|
|
58
|
+
addEventListener() {}, removeEventListener() {}, dispatchEvent() { return true; },
|
|
59
|
+
};
|
|
60
|
+
const mk = (obj) => ({ getItem: (k) => (k in obj ? obj[k] : null), setItem: (k, v) => { obj[k] = String(v); }, removeItem: (k) => { delete obj[k]; } });
|
|
61
|
+
const opened = [];
|
|
62
|
+
const window = { localStorage: mk({}), sessionStorage: mk(session), matchMedia: () => ({ matches: false }), location: { search } };
|
|
63
|
+
// "Loading" page-notes.js: record the tag, then define the API it would define.
|
|
64
|
+
head.onAppend = (tag) => {
|
|
65
|
+
loaded.push(tag.src);
|
|
66
|
+
window.OTBPageNotes = { open: () => opened.push('open'), openNote: (id) => opened.push('note ' + id) };
|
|
67
|
+
tag.onload();
|
|
68
|
+
};
|
|
69
|
+
const sandbox = { window, document, Promise, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
|
|
70
|
+
vm.createContext(sandbox);
|
|
71
|
+
vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
|
|
72
|
+
window.OTBCraftMode.mount();
|
|
73
|
+
return { craft: window.OTBCraftMode, side, sw, loaded, opened, tools: () => all(side).filter((n) => n.hasAttribute('data-craft-tool')) };
|
|
74
|
+
}
|
|
75
|
+
const flush = () => new Promise((r) => setTimeout(r, 0));
|
|
76
|
+
|
|
77
|
+
test('THE PALETTE: in the top bar in Artistry alone, right before the switch, and gone when the mode changes', () => {
|
|
78
|
+
const p = boot({ session: { 'hall-craft': 'artistry' } });
|
|
79
|
+
assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-palette']);
|
|
80
|
+
assert.equal(p.tools()[0].nextSibling, p.sw, 'it sits right before the mode switch');
|
|
81
|
+
assert.equal(p.tools()[0].getAttribute('aria-label'), 'Write feedback on this page');
|
|
82
|
+
for (const m of ['building', 'ideating', 'governing', 'legacy']) {
|
|
83
|
+
p.craft.choose(m);
|
|
84
|
+
assert.equal(p.tools().length, 0, `no palette in ${m}`);
|
|
85
|
+
}
|
|
86
|
+
p.craft.choose('artistry');
|
|
87
|
+
p.craft.choose('artistry');
|
|
88
|
+
assert.equal(p.tools().length, 1, 'choosing Artistry twice draws one palette');
|
|
89
|
+
assert.equal(boot().tools().length, 0, 'and none with no mode at all');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('pressing it loads page-notes.js once, at the same version as craft-mode.js, and opens it', async () => {
|
|
93
|
+
const p = boot({ session: { 'hall-craft': 'artistry' } });
|
|
94
|
+
assert.deepEqual(p.loaded, [], 'nothing loads until the palette is pressed');
|
|
95
|
+
p.tools()[0].click();
|
|
96
|
+
await flush();
|
|
97
|
+
p.tools()[0].click();
|
|
98
|
+
await flush();
|
|
99
|
+
assert.deepEqual(p.loaded, ['/builders/page-notes.js?v=2026-10-04-test']);
|
|
100
|
+
assert.deepEqual(p.opened, ['open', 'open']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a ?note= link opens that doc on arrival, in any mode', async () => {
|
|
104
|
+
const p = boot({ search: '?note=42' });
|
|
105
|
+
await flush();
|
|
106
|
+
assert.deepEqual(p.opened, ['note 42']);
|
|
107
|
+
const q = boot({ session: { 'hall-craft': 'governing' }, search: '?x=1¬e=7' });
|
|
108
|
+
await flush();
|
|
109
|
+
assert.deepEqual(q.opened, ['note 7']);
|
|
110
|
+
const bad = boot({ search: '?note=7x' });
|
|
111
|
+
await flush();
|
|
112
|
+
assert.deepEqual(bad.opened, [], 'a malformed id opens nothing');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---------- the reader ----------
|
|
116
|
+
function reader() {
|
|
117
|
+
const window = { location: { pathname: '/studio' } };
|
|
118
|
+
const document = { body: { dataset: { page: 'studio', pageTitle: 'Studio' } }, title: 'Studio — hall' };
|
|
119
|
+
const sandbox = { window, document };
|
|
120
|
+
vm.createContext(sandbox);
|
|
121
|
+
vm.runInContext(NOTES, sandbox, { filename: 'page-notes.js' });
|
|
122
|
+
return window.OTBPageNotes;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
test('the reader escapes the doc and shows ONLY this module\'s own images', () => {
|
|
126
|
+
const R = reader();
|
|
127
|
+
const html = R.renderBody('# Header <b>\n- one\n- two\n\nSee  and \n<script>alert(1)</script>');
|
|
128
|
+
assert.match(html, /<h3 class="pn-doc__h">Header <b><\/h3>/);
|
|
129
|
+
assert.match(html, /<ul><li>one<\/li><li>two<\/li><\/ul>/);
|
|
130
|
+
assert.match(html, /<img class="pn-doc__img" src="\/api\/bongos\/page-notes\/images\/12" alt="shot" loading="lazy">/);
|
|
131
|
+
assert.doesNotMatch(html, /<img[^>]+evil\.example/, 'another site\'s image is left as text');
|
|
132
|
+
assert.doesNotMatch(html, /<script/);
|
|
133
|
+
assert.equal((html.match(/<img/g) || []).length, 1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the doc names its page the way the page inventory does', () => {
|
|
137
|
+
const R = reader();
|
|
138
|
+
assert.deepEqual(JSON.parse(JSON.stringify(R.page())), { id: 'builders:studio', path: '/studio', title: 'Studio' });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('colours live in style.css, and the panel and tool are styled there', () => {
|
|
142
|
+
assert.doesNotMatch(NOTES, /#[0-9a-f]{3,6}\b|rgba?\(/i, 'no colour literal in the panel script');
|
|
143
|
+
const css = fs.readFileSync(path.join(HALL, 'style.css'), 'utf8');
|
|
144
|
+
for (const sel of ['.craft-tool {', '.pn {', '.pn__panel {', '.pn__text {', '.pn-doc__img {']) assert.ok(css.includes(sel), sel);
|
|
145
|
+
});
|