@homie-rocks/studio 0.6.0 → 0.8.0
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/README.md +45 -1
- package/bin/homie-studio.mjs +163 -7
- package/lib/build.mjs +38 -12
- package/lib/check.mjs +50 -4
- package/lib/look.mjs +101 -0
- package/lib/markdown.mjs +121 -0
- package/lib/port-check.mjs +26 -4
- package/lib/progress.mjs +420 -0
- package/lib/scaffold.mjs +68 -5
- package/lib/site.mjs +437 -0
- package/lib/studio.mjs +1 -1
- package/package.json +3 -2
- package/site/SITE.md +128 -0
- package/worker/index.mjs +297 -143
- package/worker/pages.mjs +149 -212
- package/worker/room.mjs +28 -1
- package/worker/site.mjs +1052 -0
- package/worker/stats.mjs +8 -5
- package/worker/version.mjs +2 -0
package/lib/look.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio look [<path>...] --url <site> [--shots <dir>]` — the studio's pages as a stranger sees them:
|
|
3
|
+
* each page on a computer (1440 x 900), a phone held upright (390 x 844) and the phone turned sideways
|
|
4
|
+
* (844 x 390), scrolled to the end so everything that rises into view has, then shot whole and above the fold.
|
|
5
|
+
* Measured on every shot: the answer's status, anything wider than the screen, whether the Play button is on the
|
|
6
|
+
* first screen (Home and a game's landing), pictures that did not load, footage that did not play, and script errors. Pictures go to
|
|
7
|
+
* `--shots` (default .studio/look/), which git ignores. One Chrome, closed at the end; its visits are house QA
|
|
8
|
+
* (the studio's stats leave them out).
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const CHROMES = [
|
|
15
|
+
process.env.CHROME_PATH,
|
|
16
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
17
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
18
|
+
'/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
19
|
+
].filter(Boolean);
|
|
20
|
+
|
|
21
|
+
export const LOOK_DEVICES = {
|
|
22
|
+
computer: { width: 1440, height: 900, deviceScaleFactor: 1 },
|
|
23
|
+
phone: { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
|
|
24
|
+
sideways: { width: 844, height: 390, deviceScaleFactor: 2, isMobile: true, hasTouch: true },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
28
|
+
const nameOf = (path) => path.replace(/^\/+|\/+$/g, '').replace(/[^a-z0-9]+/gi, '-') || 'home';
|
|
29
|
+
|
|
30
|
+
export async function look({ url, paths, shots, devices = Object.keys(LOOK_DEVICES), log = () => {} }) {
|
|
31
|
+
if (!url) return { ok: false, command: 'look', why: 'give --url (the local dev address or the live site)' };
|
|
32
|
+
const chrome = CHROMES.find((p) => existsSync(p));
|
|
33
|
+
if (!chrome) return { ok: false, command: 'look', why: 'no Chrome found (set CHROME_PATH)' };
|
|
34
|
+
let puppeteer;
|
|
35
|
+
try { puppeteer = (await import('puppeteer-core')).default; } catch { return { ok: false, command: 'look', why: 'puppeteer-core is not installed (it comes with @homie-rocks/studio; run npm install)' }; }
|
|
36
|
+
const base = String(url).replace(/\/+$/, '');
|
|
37
|
+
mkdirSync(shots, { recursive: true });
|
|
38
|
+
const profile = mkdtempSync(join(tmpdir(), 'homie-studio-look-'));
|
|
39
|
+
const browser = await puppeteer.launch({
|
|
40
|
+
executablePath: chrome, headless: true, userDataDir: profile,
|
|
41
|
+
args: ['--use-angle=metal', '--enable-gpu-rasterization', '--ignore-gpu-blocklist', '--autoplay-policy=no-user-gesture-required', '--no-first-run', '--no-default-browser-check'],
|
|
42
|
+
});
|
|
43
|
+
const rows = [];
|
|
44
|
+
try {
|
|
45
|
+
for (const path of paths) {
|
|
46
|
+
for (const device of devices) {
|
|
47
|
+
const page = await browser.newPage();
|
|
48
|
+
const errors = [];
|
|
49
|
+
page.on('pageerror', (e) => errors.push(String(e?.message ?? e).slice(0, 200)));
|
|
50
|
+
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 200)); });
|
|
51
|
+
await page.setViewport(LOOK_DEVICES[device]);
|
|
52
|
+
await page.setUserAgent(`${await browser.userAgent()} homie-studio-check look`);
|
|
53
|
+
const t0 = Date.now();
|
|
54
|
+
const res = await page.goto(`${base}${path}`, { waitUntil: 'networkidle2', timeout: 45_000 }).catch((e) => { errors.push(`open: ${e.message}`); return null; });
|
|
55
|
+
const ms = Date.now() - t0;
|
|
56
|
+
await sleep(900);
|
|
57
|
+
const fold = await page.evaluate(() => {
|
|
58
|
+
// Home and a game's landing lead with Play; other pages (a post, the rooms) are not judged on it.
|
|
59
|
+
if (!['home', 'landing'].includes(document.body?.dataset?.page ?? '')) return { play: null };
|
|
60
|
+
const play = [...document.querySelectorAll('.play')].find((a) => { const r = a.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
|
61
|
+
const r = play?.getBoundingClientRect();
|
|
62
|
+
return { play: r ? r.bottom <= innerHeight && r.top >= 0 : false };
|
|
63
|
+
});
|
|
64
|
+
const foldFile = join(shots, `${nameOf(path)}-${device}-fold.png`);
|
|
65
|
+
await page.screenshot({ path: foldFile });
|
|
66
|
+
await page.evaluate(async () => { for (let y = 0; y < document.documentElement.scrollHeight; y += Math.round(innerHeight * 0.7)) { window.scrollTo({ top: y, behavior: 'instant' }); await new Promise((r) => setTimeout(r, 110)); } window.scrollTo({ top: 0, behavior: 'instant' }); });
|
|
67
|
+
await sleep(900);
|
|
68
|
+
const facts = await page.evaluate(() => ({
|
|
69
|
+
title: document.title,
|
|
70
|
+
wide: [...document.querySelectorAll('body *')].filter((e) => { const b = e.getBoundingClientRect(); return b.width > 0 && b.right > innerWidth + 1 && !e.closest('[data-hero-media],.nav,pre,.hero-media'); }).slice(0, 5).map((e) => `${e.tagName.toLowerCase()}${e.className ? `.${String(e.className).split(' ')[0]}` : ''}`),
|
|
71
|
+
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0 && i.getAttribute('src')).map((i) => i.getAttribute('src')).slice(0, 5),
|
|
72
|
+
footage: [...document.querySelectorAll('[data-hero-media] video')].map((v) => ({ playing: !v.paused && v.readyState >= 2, src: (v.currentSrc || '').split('/').pop() })),
|
|
73
|
+
height: document.documentElement.scrollHeight,
|
|
74
|
+
}));
|
|
75
|
+
const file = join(shots, `${nameOf(path)}-${device}.png`);
|
|
76
|
+
await page.screenshot({ path: file, fullPage: true });
|
|
77
|
+
const row = {
|
|
78
|
+
path, device, status: res?.status() ?? null, ms, title: facts.title, height: facts.height, playAboveFold: fold.play,
|
|
79
|
+
tooWide: facts.wide, brokenImages: facts.brokenImages, footage: facts.footage, errors, shot: file, fold: foldFile,
|
|
80
|
+
};
|
|
81
|
+
const bad = [];
|
|
82
|
+
if (!row.status || row.status >= 400) bad.push(`answered ${row.status}`);
|
|
83
|
+
if (row.tooWide.length) bad.push(`wider than the screen: ${row.tooWide.join(', ')}`);
|
|
84
|
+
if (row.playAboveFold === false && device !== 'sideways') bad.push('Play is not on the first screen');
|
|
85
|
+
if (row.brokenImages.length) bad.push(`pictures that did not load: ${row.brokenImages.join(', ')}`);
|
|
86
|
+
if (row.footage.some((f) => !f.playing)) bad.push('the hero footage did not play');
|
|
87
|
+
if (errors.length) bad.push(`${errors.length} script error(s): ${errors[0]}`);
|
|
88
|
+
row.ok = bad.length === 0;
|
|
89
|
+
row.problems = bad;
|
|
90
|
+
rows.push(row);
|
|
91
|
+
log(`${row.ok ? 'ok ' : 'LOOK'} ${path} ${device}: ${row.status} in ${ms} ms${bad.length ? ` · ${bad.join(' · ')}` : ''}`);
|
|
92
|
+
await page.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} finally {
|
|
96
|
+
await browser.close().catch(() => {});
|
|
97
|
+
rmSync(profile, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
writeFileSync(join(shots, 'look.json'), `${JSON.stringify(rows, null, 2)}\n`);
|
|
100
|
+
return { ok: rows.every((r) => r.ok), command: 'look', url: base, shots, rows };
|
|
101
|
+
}
|
package/lib/markdown.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown for a studio's posts, rendered once at build time into HTML the site serves as it is.
|
|
3
|
+
*
|
|
4
|
+
* A small, safe subset (site/SITE.md lists it): headings, paragraphs, **bold**, *italic*, ~~struck~~,
|
|
5
|
+
* `code`, fenced code blocks, links, images, lists, quotes and rules. Everything else is text: raw HTML in
|
|
6
|
+
* a post is escaped, never passed through, and a link or image goes only to an https:// address or a path
|
|
7
|
+
* on the studio's own site (`/…`), so a post can never run a script or point at `javascript:`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
11
|
+
export const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (c) => ESC[c]);
|
|
12
|
+
|
|
13
|
+
/** A link or image address a post may use: https://…, or a path on this site. Anything else is null. */
|
|
14
|
+
export function safeUrl(value, { images = false } = {}) {
|
|
15
|
+
const url = String(value ?? '').trim();
|
|
16
|
+
if (!url || /[\s<>"'`\\]/.test(url)) return null;
|
|
17
|
+
if (url.startsWith('/') && !url.startsWith('//')) return url;
|
|
18
|
+
if (!images && /^#[A-Za-z0-9_-]{1,64}$/.test(url)) return url;
|
|
19
|
+
try {
|
|
20
|
+
const u = new URL(url);
|
|
21
|
+
return u.protocol === 'https:' ? u.href : null;
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const HOLD = /\u0000(\d+)\u0000/g;
|
|
26
|
+
const unescapeHtml = (v) => v.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
27
|
+
|
|
28
|
+
function emphasis(s) {
|
|
29
|
+
return s
|
|
30
|
+
.replace(/\*\*([^*]+)\*\*|__([^_]+)__/g, (m, a, b) => `<strong>${a ?? b}</strong>`)
|
|
31
|
+
.replace(/(^|[^*\w])\*([^*\s][^*]*?)\*(?!\w)/g, (m, pre, t) => `${pre}<em>${t}</em>`)
|
|
32
|
+
.replace(/(^|[^_\w])_([^_\s][^_]*?)_(?!\w)/g, (m, pre, t) => `${pre}<em>${t}</em>`)
|
|
33
|
+
.replace(/~{2}([^~]+)~{2}/g, '<s>$1</s>');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Inline: code spans, images and links are held aside as finished HTML (so emphasis never reaches inside an
|
|
37
|
+
address), then emphasis and line breaks, then the held pieces go back. */
|
|
38
|
+
function inline(text) {
|
|
39
|
+
const kept = [];
|
|
40
|
+
const hold = (html) => { kept.push(html); return `\u0000${kept.length - 1}\u0000`; };
|
|
41
|
+
let s = String(text).replace(/\u0000/g, '').replace(/`([^`\n]+)`/g, (_, code) => hold(`<code>${escapeHtml(code)}</code>`));
|
|
42
|
+
s = escapeHtml(s);
|
|
43
|
+
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^&]*)")?\)/g, (m, alt, src, title) => {
|
|
44
|
+
const url = safeUrl(unescapeHtml(src), { images: true });
|
|
45
|
+
return url ? hold(`<img src="${escapeHtml(url)}" alt="${alt}" loading="lazy" decoding="async"${title ? ` title="${title}"` : ''}>`) : alt;
|
|
46
|
+
});
|
|
47
|
+
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, label, href) => {
|
|
48
|
+
const url = safeUrl(unescapeHtml(href));
|
|
49
|
+
if (!url) return label;
|
|
50
|
+
return hold(`<a href="${escapeHtml(url)}"${/^https:/.test(url) ? ' rel="noopener"' : ''}>${emphasis(label)}</a>`);
|
|
51
|
+
});
|
|
52
|
+
s = s.replace(/<(https:\/\/[^\s&]+)>/g, (m, href) => {
|
|
53
|
+
const url = safeUrl(href);
|
|
54
|
+
return url ? hold(`<a href="${escapeHtml(url)}" rel="noopener">${escapeHtml(url.replace(/^https:\/\//, '').replace(/\/$/, ''))}</a>`) : m;
|
|
55
|
+
});
|
|
56
|
+
s = emphasis(s).replace(/ {2,}\n|\\\n/g, '<br>').replace(/\n/g, ' ');
|
|
57
|
+
for (let guard = 0; guard < 8 && HOLD.test(s); guard++) s = s.replace(HOLD, (_, i) => kept[Number(i)]);
|
|
58
|
+
return s;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Markdown to HTML. Headings shift down one level (a post's page has its title as the one h1), so `#` is h2.
|
|
63
|
+
* Returns the HTML and the plain text (for a feed's summary and the post record's text).
|
|
64
|
+
*/
|
|
65
|
+
export function renderMarkdown(source) {
|
|
66
|
+
const lines = String(source ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
67
|
+
const out = [];
|
|
68
|
+
let para = [];
|
|
69
|
+
const flush = () => { if (para.length) { out.push(`<p>${inline(para.join('\n'))}</p>`); para = []; } };
|
|
70
|
+
const LIST = /^\s{0,3}([-*+]|\d{1,3}[.)])\s+(.*)$/;
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
const line = lines[i];
|
|
73
|
+
const fence = /^(```|~~~)\s*([A-Za-z0-9_+-]{0,20})\s*$/.exec(line);
|
|
74
|
+
if (fence) {
|
|
75
|
+
flush();
|
|
76
|
+
const body = [];
|
|
77
|
+
for (i += 1; i < lines.length && !lines[i].startsWith(fence[1]); i++) body.push(lines[i]);
|
|
78
|
+
out.push(`<pre><code${fence[2] ? ` data-lang="${escapeHtml(fence[2])}"` : ''}>${escapeHtml(body.join('\n'))}</code></pre>`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!line.trim()) { flush(); continue; }
|
|
82
|
+
const h = /^(#{1,5})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
83
|
+
if (h) { flush(); const n = Math.min(6, h[1].length + 1); out.push(`<h${n}>${inline(h[2])}</h${n}>`); continue; }
|
|
84
|
+
if (/^\s{0,3}([-*_])(\s*\1){2,}\s*$/.test(line)) { flush(); out.push('<hr>'); continue; }
|
|
85
|
+
const img = /^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)\s*$/.exec(line.trim());
|
|
86
|
+
if (img) {
|
|
87
|
+
flush();
|
|
88
|
+
const url = safeUrl(img[2], { images: true });
|
|
89
|
+
if (url) out.push(`<figure><img src="${escapeHtml(url)}" alt="${escapeHtml(img[1])}" loading="lazy" decoding="async">${img[3] ? `<figcaption>${inline(img[3])}</figcaption>` : ''}</figure>`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (/^\s{0,3}>/.test(line)) {
|
|
93
|
+
flush();
|
|
94
|
+
const quote = [];
|
|
95
|
+
for (; i < lines.length && /^\s{0,3}>/.test(lines[i]); i++) quote.push(lines[i].replace(/^\s{0,3}>\s?/, ''));
|
|
96
|
+
i -= 1;
|
|
97
|
+
out.push(`<blockquote>${renderMarkdown(quote.join('\n')).html}</blockquote>`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const item = LIST.exec(line);
|
|
101
|
+
if (item) {
|
|
102
|
+
flush();
|
|
103
|
+
const ordered = /\d/.test(item[1]);
|
|
104
|
+
const items = [];
|
|
105
|
+
for (; i < lines.length; i++) {
|
|
106
|
+
const m = LIST.exec(lines[i]);
|
|
107
|
+
if (m && /\d/.test(m[1]) === ordered) items.push([m[2]]);
|
|
108
|
+
else if (items.length && /^\s{2,}\S/.test(lines[i])) items[items.length - 1].push(lines[i].trim());
|
|
109
|
+
else break;
|
|
110
|
+
}
|
|
111
|
+
i -= 1;
|
|
112
|
+
out.push(`<${ordered ? 'ol' : 'ul'}>${items.map((parts) => `<li>${inline(parts.join('\n'))}</li>`).join('')}</${ordered ? 'ol' : 'ul'}>`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
para.push(line);
|
|
116
|
+
}
|
|
117
|
+
flush();
|
|
118
|
+
const html = out.join('\n');
|
|
119
|
+
const text = unescapeHtml(html.replace(/<\/?(?:p|h\d|li|ul|ol|blockquote|pre|figure|figcaption|hr|br)\b[^>]*>/g, ' ').replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
|
|
120
|
+
return { html, text };
|
|
121
|
+
}
|
package/lib/port-check.mjs
CHANGED
|
@@ -379,7 +379,19 @@ async function ownerTests(h, how, log) {
|
|
|
379
379
|
|
|
380
380
|
/* ------------------------------------------------------------------ the check */
|
|
381
381
|
|
|
382
|
-
|
|
382
|
+
/** What a progress feed calls each port check row. */
|
|
383
|
+
export const PORT_CHECK_LABELS = {
|
|
384
|
+
'owner-desk': 'Moves on a computer', 'owner-phone': 'Moves on a phone', 'owner-iphone': 'Moves on an iPhone (WebKit)',
|
|
385
|
+
audio: 'Sound starts on the first touch', sandbox: 'Runs in the sandbox', 'ui-cover': 'UI stays out of the middle',
|
|
386
|
+
round: 'Two browsers finish a round', 'host-kill': 'The host leaves, the round goes on', 'late-join': 'A late joiner takes a bot\'s seat',
|
|
387
|
+
tv: 'The big screen watches', errors: 'No errors in the game',
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* `report` (optional; the open progress feed, lib/progress.mjs) hears each row as it runs, passes or fails, gets a
|
|
392
|
+
* small picture after each screenshot, and can ask the check to stop between rows. Without it nothing changes.
|
|
393
|
+
*/
|
|
394
|
+
export async function portCheck({ url, game, root, only = null, shots = null, log = () => {}, roundTimeoutMs = 240_000, report = null }) {
|
|
383
395
|
if (!url || !game) throw new Error('usage: homie-studio port check <game> --url <site>');
|
|
384
396
|
const base = String(url).replace(/\/+$/, '');
|
|
385
397
|
const want = new Set((only ? String(only).split(',') : ALL).map((s) => s.trim()).filter(Boolean));
|
|
@@ -393,13 +405,21 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
393
405
|
mkdirSync(out, { recursive: true });
|
|
394
406
|
if (root) { try { writeFileSync(join(root, 'games', game, '.port', '.gitignore'), '*\n'); } catch { /* fine */ } }
|
|
395
407
|
const rows = [];
|
|
396
|
-
const
|
|
408
|
+
const tell = (name, state, note) => { try { report?.check?.(name, state, { label: PORT_CHECK_LABELS[name] ?? name, ...(note ? { note } : {}) }); } catch { /* a feed never breaks a check */ } };
|
|
409
|
+
const row = (name, ok, detail) => { rows.push({ name, ok, ...detail }); log(`${ok === null ? 'SKIP' : ok ? 'PASS' : 'FAIL'} ${name}${detail?.why ? `: ${detail.why}` : ''}`); tell(name, ok === null ? 'skip' : ok ? 'pass' : 'fail', detail?.why); };
|
|
397
410
|
const open_ = [];
|
|
398
411
|
const launch = async (kind) => { const h = await chrome(puppeteer, kind, exe); open_.push(h); return h; };
|
|
399
412
|
const close = async (h) => { const i = open_.indexOf(h); if (i >= 0) open_.splice(i, 1); await h.close(); };
|
|
400
|
-
const shot = async (h, name) => {
|
|
413
|
+
const shot = async (h, name) => {
|
|
414
|
+
await T(h.page.screenshot({ path: join(out, `${name}.png`) }), 15_000);
|
|
415
|
+
if (report?.preview && h.vp) {
|
|
416
|
+
const b64 = await T(h.page.screenshot({ type: 'jpeg', quality: 60, clip: { x: 0, y: 0, width: h.vp.width, height: h.vp.height, scale: Math.min(1, 480 / h.vp.width) }, encoding: 'base64' }), 15_000, null);
|
|
417
|
+
if (b64) { try { report.preview({ url: `${base}/${game}/play`, image: `data:image/jpeg;base64,${b64}`, caption: PORT_CHECK_LABELS[name.replace(/-(?:before|after|playing|over|\d+)$/, '')] ?? name }); } catch { /* too big: no picture */ } }
|
|
418
|
+
}
|
|
419
|
+
};
|
|
401
420
|
const privateRoom = (tag) => `chk-${stamp.slice(-6)}-${tag}`.slice(0, 32);
|
|
402
421
|
const errorsSeen = [];
|
|
422
|
+
let stoppedAt = null;
|
|
403
423
|
const collect = (h) => { for (const e of h.errors) errorsSeen.push(`${h.engine}-${h.kind}: ${e}`); for (const r of h.badResponses) errorsSeen.push(`${h.engine}-${h.kind}: HTTP ${r}`); };
|
|
404
424
|
const started = Date.now();
|
|
405
425
|
// The site must answer before each row: a dev server that died mid-check made every later row fail as "no game
|
|
@@ -408,6 +428,8 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
408
428
|
try { const r = await fetch(`${base}/${game}/play`, { signal: AbortSignal.timeout(8000) }); return r.ok; } catch { return false; }
|
|
409
429
|
};
|
|
410
430
|
const gate = async (name) => {
|
|
431
|
+
if (report?.stopped?.()) { row(name, null, { why: 'stopped by the person' }); stoppedAt ??= name; return false; }
|
|
432
|
+
tell(name, 'running');
|
|
411
433
|
if (await alive()) return true;
|
|
412
434
|
row(name, false, { why: `${base} stopped answering: is the dev server still running? (restart it as a background task that outlives this command, then rerun the check)` });
|
|
413
435
|
return false;
|
|
@@ -631,7 +653,7 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
631
653
|
}
|
|
632
654
|
const failed = rows.filter((r) => r.ok === false);
|
|
633
655
|
const skipped = rows.filter((r) => r.ok === null);
|
|
634
|
-
const result = { ok: failed.length === 0 && rows.length > 0, command: 'port check', game, url: base, out, totalMs: Date.now() - started, passed: rows.filter((r) => r.ok === true).map((r) => r.name), failed: failed.map((r) => `${r.name}: ${r.why ?? 'failed'}`), skipped: skipped.map((r) => `${r.name}: ${r.why ?? ''}`), rows };
|
|
656
|
+
const result = { ok: failed.length === 0 && rows.length > 0 && !stoppedAt, command: 'port check', game, url: base, out, totalMs: Date.now() - started, ...(stoppedAt ? { stopped: true, why: `stopped by the person (before ${stoppedAt})` } : {}), passed: rows.filter((r) => r.ok === true).map((r) => r.name), failed: failed.map((r) => `${r.name}: ${r.why ?? 'failed'}`), skipped: skipped.map((r) => `${r.name}: ${r.why ?? ''}`), rows };
|
|
635
657
|
writeFileSync(join(out, 'receipt.json'), `${JSON.stringify(result, null, 1)}\n`);
|
|
636
658
|
return result;
|
|
637
659
|
}
|