@bongos/core 1.25.27 → 1.25.29

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.
@@ -0,0 +1,34 @@
1
+ // tests/home_camera.mjs — the camera over Home's city in game mode (task 1005305).
2
+ // Runs home-camera.js's own maths: the world is always larger than the view, the camera
3
+ // never shows past an edge, and the cursor pushes only when it rests near a side.
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { createRequire } from 'node:module';
7
+
8
+ const require = createRequire(import.meta.url);
9
+ const Cam = require('../modules/hall-ui/public/home-camera.js');
10
+
11
+ test('the world keeps the drawing\'s shape and is larger than the view on both axes', () => {
12
+ for (const [vw, vh] of [[1280, 800], [390, 844], [2560, 1080]]) {
13
+ const s = Cam.worldSize(vw, vh, 1000, 793);
14
+ assert.ok(s.w >= vw * Cam.OVER - 1 && s.h >= vh * Cam.OVER - 1, `${vw}x${vh} -> ${s.w}x${s.h}`);
15
+ assert.ok(Math.abs(s.w / s.h - 1000 / 793) < 0.01, 'the drawing is not stretched');
16
+ }
17
+ assert.deepEqual(Cam.worldSize(0, 800, 1000, 793), { w: 0, h: 0 }, 'no view, no world');
18
+ });
19
+
20
+ test('the camera never shows past an edge of the world', () => {
21
+ assert.equal(Cam.clamp(50, 1280, 2000), 0, 'not past the left edge');
22
+ assert.equal(Cam.clamp(-5000, 1280, 2000), -720, 'not past the right edge');
23
+ assert.equal(Cam.clamp(-300, 1280, 2000), -300, 'anywhere between is kept');
24
+ });
25
+
26
+ test('the cursor pushes only near a side, harder the closer it is, and toward that side', () => {
27
+ assert.equal(Cam.edgePush(640, 1280), 0, 'the middle of the view does not move the city');
28
+ assert.equal(Cam.edgePush(Cam.EDGE + 1, 1280), 0);
29
+ assert.ok(Cam.edgePush(10, 1280) > Cam.edgePush(40, 1280) && Cam.edgePush(40, 1280) > 0, 'left side: the view goes left');
30
+ assert.ok(Cam.edgePush(1275, 1280) < Cam.edgePush(1240, 1280) && Cam.edgePush(1240, 1280) < 0, 'right side: the view goes right');
31
+ assert.equal(Cam.edgePush(0, 1280), 1);
32
+ assert.equal(Cam.edgePush(1280, 1280), -1);
33
+ assert.equal(Cam.edgePush(null, 1280), 0, 'no cursor, no push');
34
+ });
@@ -0,0 +1,119 @@
1
+ // tests/home_city_world.mjs — game mode: Home's city is the whole view (task 1005305).
2
+ //
3
+ // Runs city-draw.js + home-city-draw.js + home-camera.js + home-city.js in a real page with
4
+ // html[data-game="on"]: the page holds nothing but the city and cannot scroll, the arrow keys
5
+ // move the world and never past its edge, the time is handed to the visor (OTBVisor.timeline)
6
+ // and a drag on it sends the city back through the weeks, and classic mode is as it was.
7
+ // Skips where no system Edge or Chrome is installed.
8
+ //
9
+ // Run: node --test tests/home_city_world.mjs
10
+ import assert from 'node:assert/strict';
11
+ import { test, after } from 'node:test';
12
+ import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
20
+ const pub = (f) => fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', f), 'utf8');
21
+ const FIXTURE = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'home__city.json'), 'utf8');
22
+
23
+ let browser = null;
24
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
25
+ after(() => browser && browser.close());
26
+ const skip = !browser && 'no system Edge or Chrome';
27
+
28
+ const stubs = `
29
+ window.__CITY = ${FIXTURE};
30
+ window.BongosClient = { createClient: () => ({ request: async (m, p) =>
31
+ (p.startsWith('/api/bongos/home/city') ? { ok: true, status: 200, data: window.__CITY } : { ok: false, status: 404, data: null }) }) };
32
+ window.OTBCraftMode = { iconHtml: () => '', choose: () => {}, current: () => 'artisan' };
33
+ document.documentElement.dataset.craft = 'artisan';
34
+ window.__tl = []; window.__scenes = [];
35
+ window.OTBVisor = { timeline: (t) => window.__tl.push(t), scene: (s) => window.__scenes.push(s), isOpen: () => false };
36
+ window.OTB = { contributeHallWidget(w) { window.__widget = w; } };`;
37
+
38
+ async function boot({ game = true } = {}) {
39
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, reducedMotion: 'reduce' });
40
+ const page = await ctx.newPage();
41
+ await page.setContent(`<!doctype html><html${game ? ' data-game="on"' : ''}><head><style>[hidden]{display:none!important}${pub('home-city.css')}</style></head><body>
42
+ <div class="page-head--hall">Hello</div><section id="home-city" class="scroll home-city" hidden></section>
43
+ <div id="zone-home-main" style="height:3000px">cards</div><footer class="app-foot">foot</footer></body></html>`);
44
+ await page.addScriptTag({ content: stubs });
45
+ for (const f of ['city-draw.js', 'home-city-draw.js', 'home-camera.js', 'home-city.js']) await page.addScriptTag({ content: pub(f) });
46
+ await page.evaluate(() => window.__widget.mount(document.getElementById('home-city')));
47
+ await page.waitForFunction(() => document.querySelector('.hc-svg').childNodes.length > 0);
48
+ return { ctx, page };
49
+ }
50
+ const shown = (page, sel) => page.evaluate((s) => { const e = document.querySelector(s); return !!e && getComputedStyle(e).display !== 'none'; }, sel);
51
+ const worldX = (page) => page.evaluate(() => Math.round(document.querySelector('.hc-svg').getBoundingClientRect().x));
52
+ const lastTl = (page, pick) => page.evaluate(pick);
53
+
54
+ test('game mode: the page is only the city, it cannot scroll, and the world is larger than the view', { skip }, async () => {
55
+ const { ctx, page } = await boot();
56
+ const s = await page.evaluate(() => {
57
+ const r = document.querySelector('.hc-svg').getBoundingClientRect(), h = document.documentElement;
58
+ window.scrollTo(0, 500);
59
+ return { world: h.classList.contains('hc-world'), overflow: getComputedStyle(h).overflowY, scrolled: window.scrollY, w: r.width, h: r.height, x: r.x, y: r.y };
60
+ });
61
+ const parts = {};
62
+ for (const sel of ['.page-head--hall', '#zone-home-main', '.app-foot', '.hc-ladder', '.hc-time', '.hc-svg']) parts[sel] = await shown(page, sel);
63
+ await ctx.close();
64
+ assert.equal(s.world, true);
65
+ assert.equal(s.overflow, 'hidden');
66
+ assert.equal(s.scrolled, 0, 'the page does not scroll');
67
+ assert.ok(s.w > 1280 && s.h > 800, 'the city is a world larger than the view');
68
+ assert.ok(s.x < 0 && s.y < 0, 'the view opens on the middle of it');
69
+ assert.deepEqual(parts, { '.page-head--hall': false, '#zone-home-main': false, '.app-foot': false, '.hc-ladder': false, '.hc-time': false, '.hc-svg': true });
70
+ });
71
+
72
+ test('game mode: the arrow keys move the city, and never past its edge', { skip }, async () => {
73
+ const { ctx, page } = await boot();
74
+ const start = await worldX(page);
75
+ await page.keyboard.down('ArrowRight');
76
+ await page.waitForFunction((x0) => Math.round(document.querySelector('.hc-svg').getBoundingClientRect().x) < x0 - 40, start);
77
+ await page.keyboard.up('ArrowRight');
78
+ await page.keyboard.down('ArrowLeft');
79
+ await page.waitForFunction(() => Math.round(document.querySelector('.hc-svg').getBoundingClientRect().x) === 0, null, { timeout: 15000 });
80
+ await page.waitForTimeout(200);
81
+ const end = await worldX(page);
82
+ const scrolled = await page.evaluate(() => window.scrollX + window.scrollY);
83
+ await page.keyboard.up('ArrowLeft');
84
+ await ctx.close();
85
+ assert.equal(end, 0, 'held against the left edge, the world stops at it');
86
+ assert.equal(scrolled, 0);
87
+ });
88
+
89
+ test('game mode: time is on the visor; a drag sends the city back, and Home registers its rows, keys and the cursor sight', { skip }, async () => {
90
+ const { ctx, page } = await boot();
91
+ const read = () => lastTl(page, () => { const t = window.__tl[window.__tl.length - 1]; return { live: t.live, value: t.value, label: t.label, speed: t.speed, len: document.querySelector('.hc-svg').innerHTML.length }; });
92
+ const before = await read();
93
+ await page.evaluate(() => window.__tl[window.__tl.length - 1].seek(0.3));
94
+ const back = await read();
95
+ await page.evaluate(() => window.__tl[window.__tl.length - 1].faster());
96
+ const sped = (await read()).speed;
97
+ const scene = await page.evaluate(() => { const s = window.__visorScene; return { aim: s.aim, keys: s.keys.map((k) => k.key), rows: s.actions.map((a) => a.label) }; });
98
+ await page.evaluate(() => window.__tl[window.__tl.length - 1].toLive());
99
+ const live = (await read()).live;
100
+ await ctx.close();
101
+ assert.deepEqual([before.live, before.value, before.label, before.speed], [true, 1, 'Live', '1×']);
102
+ assert.equal(back.live, false);
103
+ assert.ok(Math.abs(back.value - 0.3) < 0.01 && back.label !== 'Live', 'the city is where the drag sent it: ' + back.label);
104
+ assert.notEqual(back.len, before.len, 'the city is redrawn for that week');
105
+ assert.equal(sped, '2×');
106
+ assert.equal(scene.aim, true);
107
+ assert.deepEqual(scene.keys, ['Arrows']);
108
+ assert.ok(scene.rows.includes('Play the city over time') && scene.rows.includes('Back to live') && scene.rows.some((r) => r.startsWith('Speed')), scene.rows.join(' | '));
109
+ assert.equal(live, true);
110
+ });
111
+
112
+ test('classic mode is as it was: the ladder, the slider and the cards are on the page, and no timeline is handed over', { skip }, async () => {
113
+ const { ctx, page } = await boot({ game: false });
114
+ const s = await page.evaluate(() => ({ world: document.documentElement.classList.contains('hc-world'), tl: window.__tl.filter(Boolean).length, aim: window.__visorScene.aim, transform: document.querySelector('.hc-svg').style.transform }));
115
+ const parts = { ladder: await shown(page, '.hc-ladder'), time: await shown(page, '.hc-time'), cards: await shown(page, '#zone-home-main') };
116
+ await ctx.close();
117
+ assert.deepEqual(s, { world: false, tl: 0, aim: false, transform: '' });
118
+ assert.deepEqual(parts, { ladder: true, time: true, cards: true });
119
+ });
@@ -0,0 +1,96 @@
1
+ // tests/platform_sandbox.mjs — Cloud Bongos's own sandbox (task 1005292; ADR 0406 §7).
2
+ //
3
+ // The platform runs a second copy of itself that takes every release before the live hub.
4
+ // That copy is the same server started with BONGOS_SANDBOX=1. Pinned here:
5
+ // 1. the hub server installs the sandbox middleware, before any surface answers, so every
6
+ // page of the copy carries the band — and on the live hub (no variable) it is a no-op;
7
+ // 2. the sandbox page only claims Render's blocked mail ports where Render is the host,
8
+ // because the platform's sandbox runs on a plain server where that sentence is false.
9
+ //
10
+ // Run: node tests/platform_sandbox.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const { install, pageHtml } = require('../src/sandbox/index.js');
21
+
22
+ let passed = 0, failed = 0;
23
+ async function t(name, fn) {
24
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
25
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
26
+ }
27
+
28
+ const src = fs.readFileSync(path.join(ROOT, 'src/platform-server.js'), 'utf8')
29
+ .replace(/^\s*\/\/.*$/gm, ''); // comments may cite the rule; only code counts
30
+
31
+ await t('the hub server mounts the sandbox middleware', () => {
32
+ assert.match(src, /app\.use\(require\('\.\/sandbox'\)\.install\(\)\.middleware\)/);
33
+ });
34
+
35
+ await t('it is mounted before any surface or module answers', () => {
36
+ const at = src.indexOf("require('./sandbox')");
37
+ const divert = src.indexOf("resolveOptional('request.divert')");
38
+ const surfaces = src.indexOf('mountInternalSurfaces(');
39
+ assert.ok(at > 0 && divert > 0 && at < divert, 'sandbox must come before the request.divert seam');
40
+ // the import of mountInternalSurfaces sits at the top; the CALL is what matters
41
+ const call = src.indexOf('mountInternalSurfaces(', surfaces + 1);
42
+ assert.ok(call < 0 || at < call, 'sandbox must come before the surfaces mount');
43
+ });
44
+
45
+ await t('on the live hub (no BONGOS_SANDBOX) it changes nothing', () => {
46
+ const r = install({ env: {} });
47
+ assert.equal(r.active, false);
48
+ let called = false;
49
+ r.middleware({}, {}, () => { called = true; });
50
+ assert.equal(called, true);
51
+ });
52
+
53
+ const log = { items: [] };
54
+ await t('on a plain server the page makes no claim about blocked mail ports', () => {
55
+ assert.doesNotMatch(pageHtml(log, { BONGOS_SANDBOX: '1' }), /email ports/);
56
+ });
57
+ await t('on Render it still says the free plan blocks them', () => {
58
+ assert.match(pageHtml(log, { BONGOS_SANDBOX: '1', RENDER: 'true' }), /blocks outgoing email ports/);
59
+ });
60
+
61
+ // 3. The one named exception: creating a repository (owner's choice, 2026-11-14).
62
+ const { decide, parseAllowances } = require('../src/sandbox/gate.js');
63
+ const ALLOW = parseAllowances('github-repo-create');
64
+ await t('without the exception, creating a GitHub repository is refused', () => {
65
+ assert.equal(decide({ method: 'POST', host: 'api.github.com', path: '/user/repos' }).allow, false);
66
+ });
67
+ await t('with it, creating a repository for a person or an organisation passes', () => {
68
+ assert.equal(decide({ method: 'POST', host: 'api.github.com', path: '/user/repos', allowances: ALLOW }).allow, true);
69
+ assert.equal(decide({ method: 'POST', host: 'api.github.com', path: '/orgs/acme-co/repos', allowances: ALLOW }).allow, true);
70
+ });
71
+ await t('every other GitHub change stays refused with it on', () => {
72
+ for (const [m, p] of [['POST', '/repos/o/r/pulls'], ['PUT', '/repos/o/r/pulls/1/merge'], ['PATCH', '/repos/o/r'], ['DELETE', '/repos/o/r'], ['POST', '/repos/o/r/keys'], ['PUT', '/repos/o/r/collaborators/x'], ['POST', '/user/repos/extra']]) {
73
+ assert.equal(decide({ method: m, host: 'api.github.com', path: p, allowances: ALLOW }).allow, false, `${m} ${p}`);
74
+ }
75
+ });
76
+ await t('an unknown exception name allows nothing', () => {
77
+ assert.deepEqual(parseAllowances('everything, github-*'), []);
78
+ });
79
+ await t('install() reads the exception from BONGOS_SANDBOX_ALLOW', async () => {
80
+ const seen = [];
81
+ const host = { fetch: async (u) => { seen.push(String(u)); return new Response('{}', { status: 201 }); } };
82
+ const fakeMod = () => ({ request() {}, get() {} });
83
+ const idx = require('../src/sandbox/index.js');
84
+ // a fresh module instance, since install() keeps one state per process
85
+ delete require.cache[require.resolve('../src/sandbox/index.js')];
86
+ const fresh = require('../src/sandbox/index.js');
87
+ fresh.install({ env: { BONGOS_SANDBOX: '1', BONGOS_SANDBOX_ALLOW: 'github-repo-create' }, fetchHost: host, http: fakeMod(), https: fakeMod(), write() {} });
88
+ void idx;
89
+ return host.fetch('https://api.github.com/user/repos', { method: 'POST' }).then((r) => {
90
+ assert.equal(r.status, 201);
91
+ assert.equal(seen.length, 1);
92
+ });
93
+ });
94
+
95
+ console.log(`\n${passed} passed, ${failed} failed`);
96
+ if (failed) process.exit(1);