Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
409f2c258e | ||
|
|
7f6e5bd151 | ||
|
|
41811ead1c | ||
|
|
601dc9e8b0 |
@@ -461,7 +461,8 @@ const menu = moduleApi.header.addMenu({
|
|||||||
build(menuEl) {
|
build(menuEl) {
|
||||||
// called on every open with an emptied menu element — fill it with
|
// called on every open with an emptied menu element — fill it with
|
||||||
// buttons (give them your scoped classes; the container is styled and
|
// buttons (give them your scoped classes; the container is styled and
|
||||||
// fixed-positioned by the shell, so the tile's overflow can't clip it)
|
// positioned by the shell, hosted on the body under an element carrying
|
||||||
|
// your data-module, so your scoped CSS applies and nothing clips it)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
menu.setLabel('Channels (2/4) ▾'); // update the button text any time
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ export default function create({ root, moduleApi }) {
|
|||||||
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
}
|
}
|
||||||
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
||||||
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
||||||
|
|
||||||
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
||||||
const flowBtn = moduleApi.header.addButton({
|
const flowBtn = moduleApi.header.addButton({
|
||||||
@@ -95,7 +95,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
moduleApi.header.addButton({
|
const latestBtn = moduleApi.header.addButton({
|
||||||
icon: JUMP_SVG,
|
icon: JUMP_SVG,
|
||||||
title: 'Latest',
|
title: 'Latest',
|
||||||
onClick() {
|
onClick() {
|
||||||
@@ -112,6 +112,82 @@ export default function create({ root, moduleApi }) {
|
|||||||
});
|
});
|
||||||
clearBtn.classList.add('pt-clear');
|
clearBtn.classList.add('pt-clear');
|
||||||
|
|
||||||
|
/* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ──
|
||||||
|
Seven controls need ~175 px, and the shell clips whatever the bar
|
||||||
|
cannot fit — on a 3-column tile among many, or one squeezed by the
|
||||||
|
fullscreen fit, that used to take the channel filter with it. Below
|
||||||
|
COMPACT_PX the six secondary controls hide and a single ⋯ menu offers
|
||||||
|
the same actions, so the filter button is the last thing to go. */
|
||||||
|
const MORE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>';
|
||||||
|
const COMPACT_PX = 380; // tile width below which the full bar no longer fits
|
||||||
|
|
||||||
|
let moreMenuEl = null;
|
||||||
|
const moreMenu = moduleApi.header.addMenu({
|
||||||
|
icon: MORE_SVG,
|
||||||
|
title: 'More',
|
||||||
|
build(menu) {
|
||||||
|
menu.classList.add('pt-menu');
|
||||||
|
moreMenuEl = menu;
|
||||||
|
rebuildMoreMenu();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd
|
||||||
|
const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn];
|
||||||
|
|
||||||
|
function addMoreItem(label, { on = null, onClick } = {}) {
|
||||||
|
const item = document.createElement('button');
|
||||||
|
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
||||||
|
if (on !== null) {
|
||||||
|
item.setAttribute('role', 'menuitemcheckbox');
|
||||||
|
item.setAttribute('aria-checked', String(on));
|
||||||
|
} else {
|
||||||
|
item.setAttribute('role', 'menuitem');
|
||||||
|
}
|
||||||
|
const check = document.createElement('span');
|
||||||
|
check.className = 'check';
|
||||||
|
check.textContent = '✓';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'name';
|
||||||
|
name.textContent = label;
|
||||||
|
item.append(check, name);
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation(); // the rebuild detaches this item — keep the menu open
|
||||||
|
onClick();
|
||||||
|
});
|
||||||
|
moreMenuEl.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildMoreMenu() {
|
||||||
|
if (!moreMenuEl) return;
|
||||||
|
moreMenuEl.innerHTML = '';
|
||||||
|
const s = moduleApi.instanceSettings;
|
||||||
|
addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() });
|
||||||
|
addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() });
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'pt-menu-divider';
|
||||||
|
moreMenuEl.appendChild(divider);
|
||||||
|
addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) });
|
||||||
|
addMoreItem('Larger text', { onClick: () => bumpTextSize(2) });
|
||||||
|
addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } });
|
||||||
|
addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let compact = null; // unknown until the first measurement
|
||||||
|
function applyCompact(width) {
|
||||||
|
const next = width > 0 && width < COMPACT_PX;
|
||||||
|
if (next === compact) return;
|
||||||
|
compact = next;
|
||||||
|
for (const btn of secondaryBtns) btn.hidden = compact;
|
||||||
|
moreWrap.hidden = !compact;
|
||||||
|
if (!compact) moreMenu.close();
|
||||||
|
}
|
||||||
|
// the tile's own body tells the width; onResize only knows grid cells
|
||||||
|
const sizeWatch = new ResizeObserver((entries) => {
|
||||||
|
applyCompact(entries[0]?.contentRect.width || root.clientWidth);
|
||||||
|
});
|
||||||
|
sizeWatch.observe(root);
|
||||||
|
applyCompact(root.clientWidth);
|
||||||
|
|
||||||
function applyInstanceSettings() {
|
function applyInstanceSettings() {
|
||||||
const s = moduleApi.instanceSettings;
|
const s = moduleApi.instanceSettings;
|
||||||
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
||||||
@@ -126,6 +202,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
flowBtn.classList.toggle('active', up);
|
flowBtn.classList.toggle('active', up);
|
||||||
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
||||||
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
||||||
|
rebuildMoreMenu(); // its checkmarks mirror the buttons
|
||||||
}
|
}
|
||||||
applyInstanceSettings();
|
applyInstanceSettings();
|
||||||
|
|
||||||
@@ -415,6 +492,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
},
|
},
|
||||||
stop() {
|
stop() {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
sizeWatch.disconnect();
|
||||||
stream?.close();
|
stream?.close();
|
||||||
root.innerHTML = ''; // header controls are removed by the shell
|
root.innerHTML = ''; // header controls are removed by the shell
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "prodcom-transcript",
|
"id": "prodcom-transcript",
|
||||||
"name": "ProdCom",
|
"name": "ProdCom",
|
||||||
"version": "1.3.2",
|
"version": "1.3.3",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.1.0",
|
||||||
"description": "Live ProdCom transcript",
|
"description": "Live ProdCom transcript",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -20,9 +20,13 @@
|
|||||||
}
|
}
|
||||||
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
[data-module="prodcom-transcript"] .pt-count[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* narrow tile: the secondary controls fold into the ⋯ menu (client.js) */
|
||||||
|
[data-module="prodcom-transcript"] .tile-btn[hidden],
|
||||||
|
[data-module="prodcom-transcript"] .tile-dd[hidden] { display: none; }
|
||||||
|
|
||||||
/* ── channels menu ──
|
/* ── channels menu ──
|
||||||
The menu container is created and positioned by the shell (.tile-menu);
|
The menu container is created and positioned by the shell (.tile-menu);
|
||||||
it lives inside the tile, so [data-module] scoping still applies. */
|
it hangs off the body under a [data-module] host, so scoping still applies. */
|
||||||
|
|
||||||
[data-module="prodcom-transcript"] .pt-channel-item {
|
[data-module="prodcom-transcript"] .pt-channel-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.10.0",
|
"version": "1.10.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
+41
-10
@@ -640,8 +640,10 @@ function buildTile(tile) {
|
|||||||
el.addEventListener('pointermove', (ev) => {
|
el.addEventListener('pointermove', (ev) => {
|
||||||
if (!headIsOff(tile)) return;
|
if (!headIsOff(tile)) return;
|
||||||
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
// Mid-drag the tile moves under the pointer, and mid-resize the edges do
|
||||||
// — neither is a moment to take the bar away.
|
// — neither is a moment to take the bar away. Nor is an open header
|
||||||
|
// menu, which hangs below the bar and is about to be pointed at.
|
||||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
if (el.classList.contains('menu-open')) return;
|
||||||
const y = ev.clientY - el.getBoundingClientRect().top;
|
const y = ev.clientY - el.getBoundingClientRect().top;
|
||||||
const shown = el.classList.contains('head-peek');
|
const shown = el.classList.contains('head-peek');
|
||||||
// It takes less to hold the bar open than to open it, so a bar that
|
// It takes less to hold the bar open than to open it, so a bar that
|
||||||
@@ -652,6 +654,7 @@ function buildTile(tile) {
|
|||||||
|
|
||||||
el.addEventListener('pointerleave', () => {
|
el.addEventListener('pointerleave', () => {
|
||||||
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
if (el.classList.contains('dragging') || el.classList.contains('resizing')) return;
|
||||||
|
if (el.classList.contains('menu-open')) return; // the pointer is on its way to the menu
|
||||||
el.classList.remove('head-peek');
|
el.classList.remove('head-peek');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -659,7 +662,7 @@ function buildTile(tile) {
|
|||||||
applyHeadState(tile, el);
|
applyHeadState(tile, el);
|
||||||
grid.appendChild(el);
|
grid.appendChild(el);
|
||||||
|
|
||||||
const entry = { el, body, dot, titleEl: title, controls, instance: null };
|
const entry = { el, body, dot, titleEl: title, controls, instance: null, openMenus: new Set() };
|
||||||
tileEls.set(tile.id, entry);
|
tileEls.set(tile.id, entry);
|
||||||
applyRect(tile);
|
applyRect(tile);
|
||||||
|
|
||||||
@@ -1426,34 +1429,60 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
menu.className = 'tile-menu';
|
menu.className = 'tile-menu';
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
|
|
||||||
|
/* The menu lives on the body, not in the bar. A folded-away bar
|
||||||
|
(auto-hide, fullscreen) carries a backdrop-filter and a transform,
|
||||||
|
and either one makes a fixed-positioned descendant position
|
||||||
|
itself against the bar instead of the viewport — the menu landed
|
||||||
|
a screen-height below its button, inside the tile's overflow, and
|
||||||
|
never showed. Its host carries the tile's data-module so the
|
||||||
|
module's scoped CSS still reaches the items. */
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.className = 'tile-menu-host';
|
||||||
|
if (man) host.dataset.module = man.id;
|
||||||
|
host.appendChild(menu);
|
||||||
|
|
||||||
|
const setOpen = (on) => {
|
||||||
|
if (on === !menu.hidden) return;
|
||||||
|
menu.hidden = !on;
|
||||||
|
btn.classList.toggle('is-open', on);
|
||||||
|
if (on) entry.openMenus.add(menu);
|
||||||
|
else entry.openMenus.delete(menu);
|
||||||
|
// an open menu holds a folded-away bar out; when the last one
|
||||||
|
// closes with the pointer elsewhere, the bar goes back
|
||||||
|
entry.el.classList.toggle('menu-open', entry.openMenus.size > 0);
|
||||||
|
if (!on && !entry.openMenus.size && !entry.el.matches(':hover')) entry.el.classList.remove('head-peek');
|
||||||
|
};
|
||||||
const open = () => {
|
const open = () => {
|
||||||
if (typeof build === 'function') {
|
if (typeof build === 'function') {
|
||||||
menu.innerHTML = '';
|
menu.innerHTML = '';
|
||||||
build(menu);
|
build(menu);
|
||||||
}
|
}
|
||||||
menu.hidden = false;
|
setOpen(true);
|
||||||
// fixed positioning (escapes the tile's overflow): under the
|
// under the button, right-aligned, kept whole inside the viewport
|
||||||
// button, right-aligned, clamped to the viewport
|
|
||||||
const r = btn.getBoundingClientRect();
|
const r = btn.getBoundingClientRect();
|
||||||
menu.style.top = Math.min(r.bottom + 6, window.innerHeight - 80) + 'px';
|
|
||||||
menu.style.left = '0px';
|
menu.style.left = '0px';
|
||||||
|
menu.style.top = '0px';
|
||||||
const w = menu.offsetWidth;
|
const w = menu.offsetWidth;
|
||||||
|
const h = menu.offsetHeight;
|
||||||
|
menu.style.top = Math.max(8, Math.min(r.bottom + 6, window.innerHeight - h - 8)) + 'px';
|
||||||
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
menu.style.left = Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8)) + 'px';
|
||||||
};
|
};
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (menu.hidden) open();
|
if (menu.hidden) open();
|
||||||
else menu.hidden = true;
|
else setOpen(false);
|
||||||
});
|
});
|
||||||
const onDocClick = (e) => {
|
const onDocClick = (e) => {
|
||||||
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) menu.hidden = true;
|
if (!menu.hidden && !menu.contains(e.target) && !btn.contains(e.target)) setOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('click', onDocClick);
|
document.addEventListener('click', onDocClick);
|
||||||
docListeners.add(onDocClick);
|
docListeners.add(onDocClick);
|
||||||
|
|
||||||
wrap.append(btn, menu);
|
wrap.append(btn);
|
||||||
entry.controls.appendChild(wrap);
|
entry.controls.appendChild(wrap);
|
||||||
|
document.body.appendChild(host);
|
||||||
headerEls.add(wrap);
|
headerEls.add(wrap);
|
||||||
|
headerEls.add(host);
|
||||||
return {
|
return {
|
||||||
button: btn,
|
button: btn,
|
||||||
menu,
|
menu,
|
||||||
@@ -1461,7 +1490,7 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
btn.textContent = text;
|
btn.textContent = text;
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
menu.hidden = true;
|
setOpen(false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -1473,6 +1502,8 @@ function buildModuleApi(tile, man, entry) {
|
|||||||
for (const handle of [...sseHandles]) handle.close();
|
for (const handle of [...sseHandles]) handle.close();
|
||||||
for (const el of headerEls) el.remove();
|
for (const el of headerEls) el.remove();
|
||||||
headerEls.clear();
|
headerEls.clear();
|
||||||
|
entry.openMenus.clear();
|
||||||
|
entry.el.classList.remove('menu-open');
|
||||||
for (const fn of docListeners) document.removeEventListener('click', fn);
|
for (const fn of docListeners) document.removeEventListener('click', fn);
|
||||||
docListeners.clear();
|
docListeners.clear();
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-2
@@ -445,8 +445,9 @@ body.menu-hidden #topbar {
|
|||||||
}
|
}
|
||||||
.tile-dd { position: relative; display: inline-flex; }
|
.tile-dd { position: relative; display: inline-flex; }
|
||||||
|
|
||||||
/* a module header menu — fixed-positioned by the shell so the tile's
|
/* a module header menu — hosted on the body (shell.js addMenu) so neither
|
||||||
overflow:hidden can't clip it */
|
the tile's overflow nor a folded-away bar's backdrop-filter can touch it */
|
||||||
|
.tile-menu-host { display: contents; }
|
||||||
.tile-menu {
|
.tile-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 70;
|
z-index: 70;
|
||||||
|
|||||||
@@ -956,9 +956,21 @@ async function fetchArchive() {
|
|||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
|
headers: { 'User-Agent': `ProdDash/${SHELL_VERSION}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${REPO.owner}/${REPO.repo}@${REPO.branch}`);
|
const where = `${REPO.owner}/${REPO.repo}@${REPO.branch}`;
|
||||||
|
// A forge that wants a sign-in does not refuse: it redirects the download
|
||||||
|
// to its login page, which arrives as a 200 of HTML. Say that, not
|
||||||
|
// zlib's "incorrect header check".
|
||||||
|
if (res.redirected && /\/user\/login\b|\/login\b/.test(new URL(res.url).pathname)) {
|
||||||
|
throw new Error(`${FORGE.base} requires a sign-in to read ${where} — turn off "require sign-in to view" in Gitea, or make the repo readable without one`);
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${where}`);
|
||||||
const gz = Buffer.from(await res.arrayBuffer());
|
const gz = Buffer.from(await res.arrayBuffer());
|
||||||
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
|
if (gz.length > MAX_ARCHIVE_BYTES) throw new Error('archive is unexpectedly large');
|
||||||
|
if (gz.length < 2 || gz[0] !== 0x1f || gz[1] !== 0x8b) {
|
||||||
|
const type = String(res.headers.get('content-type') || 'unknown type').split(';')[0].trim();
|
||||||
|
const looksHtml = /^\s*<(!doctype|html)/i.test(gz.subarray(0, 64).toString('utf8'));
|
||||||
|
throw new Error(`${FORGE.base} answered with ${looksHtml ? 'a web page' : type} instead of the ${where} archive — check the forge address and that the repo is readable without a sign-in`);
|
||||||
|
}
|
||||||
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
const { files, comment } = parseTar(zlib.gunzipSync(gz));
|
||||||
const stripped = new Map(); // drop the archive's single top-level folder
|
const stripped = new Map(); // drop the archive's single top-level folder
|
||||||
for (const [p, data] of files) {
|
for (const [p, data] of files) {
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The update check against a forge that misbehaves: the admin page must say
|
||||||
|
* what went wrong in words, never zlib's "incorrect header check".
|
||||||
|
*
|
||||||
|
* node tools/update-check.test.js
|
||||||
|
*
|
||||||
|
* Plain Node, no dependencies. Starts a mock forge whose answer to the
|
||||||
|
* branch-archive download can be switched at runtime, starts server.js on a
|
||||||
|
* spare port pointed at it, and reads the shell status the admin page shows
|
||||||
|
* after each kind of answer:
|
||||||
|
*
|
||||||
|
* - a Gitea that requires a sign-in redirects the download to its login
|
||||||
|
* page (a 200 of HTML) — the case that hit both booths on 2026-09-29;
|
||||||
|
* - a captive portal or a wrong address answers HTML outright;
|
||||||
|
* - a plain HTTP error;
|
||||||
|
* - and a real archive, which must still parse and offer the update.
|
||||||
|
*
|
||||||
|
* Exits non-zero on the first failure and always stops what it started.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { spawn, execFileSync } = require('node:child_process');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const http = require('node:http');
|
||||||
|
const net = require('node:net');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
function freePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const probe = net.createServer();
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const { port } = probe.address();
|
||||||
|
probe.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
probe.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(url, ms = 15000) {
|
||||||
|
const deadline = Date.now() + ms;
|
||||||
|
let last = '';
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (r.ok) return;
|
||||||
|
last = 'HTTP ' + r.status;
|
||||||
|
} catch (err) {
|
||||||
|
last = err.message;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
throw new Error('server did not come up: ' + last);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A branch archive the way a forge would build one: one top-level folder. */
|
||||||
|
function buildArchive(dir) {
|
||||||
|
const top = path.join(dir, 'ProdDash-main');
|
||||||
|
fs.mkdirSync(path.join(top, 'modules', 'demo'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(top, 'package.json'), JSON.stringify({ name: 'proddash', version: '9.9.9' }));
|
||||||
|
fs.writeFileSync(path.join(top, 'modules', 'demo', 'module.json'), JSON.stringify({ id: 'demo', name: 'Demo', version: '1.0.0', client: 'client.js' }));
|
||||||
|
fs.writeFileSync(path.join(top, 'modules', 'demo', 'client.js'), 'export default function () {}\n');
|
||||||
|
const out = path.join(dir, 'main.tar.gz');
|
||||||
|
execFileSync('tar', ['-czf', out, '-C', dir, 'ProdDash-main']);
|
||||||
|
return fs.readFileSync(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-update-test-'));
|
||||||
|
const dataDir = path.join(work, 'data');
|
||||||
|
fs.mkdirSync(dataDir);
|
||||||
|
const archive = buildArchive(path.join(work, 'forge'));
|
||||||
|
|
||||||
|
// ── the mock forge ──
|
||||||
|
let mode = 'ok';
|
||||||
|
const LOGIN_PAGE = '<!DOCTYPE html>\n<html><head><title>Sign in</title></head><body>Sign in to continue</body></html>';
|
||||||
|
const forge = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url, 'http://forge.invalid');
|
||||||
|
if (url.pathname === '/user/login') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
return res.end(LOGIN_PAGE);
|
||||||
|
}
|
||||||
|
if (url.pathname !== '/michael/ProdDash/archive/main.tar.gz') {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
return res.end('not here');
|
||||||
|
}
|
||||||
|
switch (mode) {
|
||||||
|
case 'login-redirect':
|
||||||
|
res.writeHead(303, { Location: '/user/login?redirect_to=' + encodeURIComponent(url.pathname) });
|
||||||
|
return res.end();
|
||||||
|
case 'html':
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
return res.end('<html><body>You must log in to this network first</body></html>');
|
||||||
|
case 'notfound':
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
return res.end('no such branch');
|
||||||
|
default:
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/gzip' });
|
||||||
|
return res.end(archive);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const forgePort = await freePort();
|
||||||
|
await new Promise((r) => forge.listen(forgePort, '127.0.0.1', r));
|
||||||
|
const forgeBase = `http://127.0.0.1:${forgePort}`;
|
||||||
|
|
||||||
|
// ── ProdDash, pointed at it ──
|
||||||
|
const port = await freePort();
|
||||||
|
const child = spawn(process.execPath, ['server.js'], {
|
||||||
|
cwd: ROOT,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PRODDASH_DATA_DIR: dataDir,
|
||||||
|
PORT: String(port),
|
||||||
|
PRODDASH_FORGE: forgeBase,
|
||||||
|
PRODDASH_REPO: 'michael/ProdDash',
|
||||||
|
PRODDASH_NO_REMOTE_CHECK: '1',
|
||||||
|
PRODDASH_LAUNCHER: '1',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
let log = '';
|
||||||
|
child.stdout.on('data', (d) => { log += d; });
|
||||||
|
child.stderr.on('data', (d) => { log += d; });
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
/** What the admin page shows after a forced check. Admin is open: a fresh data dir has no PIN. */
|
||||||
|
const check = async (m) => {
|
||||||
|
mode = m;
|
||||||
|
const r = await fetch(base + '/api/admin/catalog?refresh=1');
|
||||||
|
assert.equal(r.status, 200, `catalog: HTTP ${r.status}`);
|
||||||
|
const body = await r.json();
|
||||||
|
return body.shell;
|
||||||
|
};
|
||||||
|
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
await waitFor(base + '/api/theme');
|
||||||
|
|
||||||
|
let shell = await check('login-redirect');
|
||||||
|
assert.match(shell.remote.error, /requires a sign-in/, 'a login redirect is named as such: ' + shell.remote.error);
|
||||||
|
assert.match(shell.remote.error, /require sign-in to view/, 'and the Gitea setting to look at is named');
|
||||||
|
assert.doesNotMatch(shell.remote.error, /header check/, 'never zlib\'s words');
|
||||||
|
assert.equal(shell.updateAvailable, false);
|
||||||
|
|
||||||
|
shell = await check('html');
|
||||||
|
assert.match(shell.remote.error, /answered with a web page instead of the michael\/ProdDash@main archive/, shell.remote.error);
|
||||||
|
|
||||||
|
shell = await check('notfound');
|
||||||
|
assert.match(shell.remote.error, /HTTP 404 fetching michael\/ProdDash@main/, shell.remote.error);
|
||||||
|
|
||||||
|
shell = await check('ok');
|
||||||
|
assert.equal(shell.remote.error, '', 'a real archive clears the error: ' + shell.remote.error);
|
||||||
|
assert.equal(shell.remote.version, '9.9.9', 'and is read');
|
||||||
|
assert.equal(shell.updateAvailable, true, 'and offered');
|
||||||
|
|
||||||
|
// Back to a broken forge: the last good read is not mistaken for a current one.
|
||||||
|
shell = await check('login-redirect');
|
||||||
|
assert.match(shell.remote.error, /requires a sign-in/);
|
||||||
|
|
||||||
|
console.log('update check: every check passed');
|
||||||
|
} catch (err) {
|
||||||
|
failed = true;
|
||||||
|
console.error('FAIL:', err.message);
|
||||||
|
console.error('--- last of the server log ---\n' + log.slice(-2000));
|
||||||
|
} finally {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
forge.close();
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
fs.rmSync(work, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
process.exit(failed ? 1 : 0);
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user