@joystack/cli 0.1.54 → 0.1.55
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/CHANGELOG.md +41 -0
- package/bin/joystack.mjs +38 -0
- package/lib/assets-export.mjs +198 -0
- package/lib/assets-json.mjs +1 -1
- package/lib/assets-page.mjs +15 -5
- package/lib/assets-sheet.mjs +25 -17
- package/lib/dev.mjs +15 -10
- package/lib/fingerprint/README.md +16 -0
- package/lib/fingerprint/fingerprint.mjs +195 -0
- package/lib/fingerprint/warm.mjs +60 -0
- package/lib/inject.mjs +64 -148
- package/lib/maker.mjs +16 -2
- package/lib/shot/README.md +75 -0
- package/lib/shot/check.mjs +57 -0
- package/lib/shot/lock.mjs +109 -0
- package/lib/shot/ready.mjs +45 -0
- package/lib/shot/run.mjs +217 -0
- package/lib/shot/shot.mjs +320 -0
- package/lib/static-server.mjs +104 -22
- package/lib/test-cmd.mjs +29 -12
- package/package.json +1 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The check a `joystack shot` script passes before anything starts: no dev
|
|
2
|
+
// server is asked, no browser launched, so a script that cannot work fails in
|
|
3
|
+
// a moment rather than after the game has loaded.
|
|
4
|
+
//
|
|
5
|
+
// It refuses a script that does what the command does for it: launching a
|
|
6
|
+
// browser or a page, opening an address, killing processes. A script that
|
|
7
|
+
// opens its own page leaves the one it was given, the one that waited for the
|
|
8
|
+
// game to finish updating; one that names an
|
|
9
|
+
// address guesses a port the dev server may not be on.
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
12
|
+
import { extname } from 'node:path';
|
|
13
|
+
|
|
14
|
+
// A script is a short file; a larger one is something else passed by mistake.
|
|
15
|
+
const SIZE_MOST = 200_000;
|
|
16
|
+
|
|
17
|
+
// What a script must not do, each with what to do instead.
|
|
18
|
+
export const REFUSED = [
|
|
19
|
+
[/\b(chromium|firefox|webkit)\s*\.\s*launch\w*\s*\(/, 'launches a browser: use the `page` your function is given'],
|
|
20
|
+
[/\.\s*new(Page|Context)\s*\(/, 'opens a page of its own: use the `page` your function is given'],
|
|
21
|
+
[/\.\s*goto\s*\(/, "opens an address: the page is already on the game (pass the game's own parameters with --query)"],
|
|
22
|
+
[/\b(pkill|killall)\b|process\s*\.\s*kill\s*\(/, 'kills processes: the command closes its browser itself'],
|
|
23
|
+
[/(localhost|127\.0\.0\.1):\d+/, 'names an address and a port: the page is already on the game'],
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// Throws with a line fit to print when the script at `file` cannot be run;
|
|
27
|
+
// returns nothing when it can.
|
|
28
|
+
export function checkScript(file, { node = process.execPath } = {}) {
|
|
29
|
+
let st;
|
|
30
|
+
try {
|
|
31
|
+
st = statSync(file);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new Error(`no script at ${file}`);
|
|
34
|
+
}
|
|
35
|
+
if (!st.isFile()) throw new Error(`${file} is not a file`);
|
|
36
|
+
// .mjs alone: a .js file is a module or not by the nearest package.json, so
|
|
37
|
+
// the same file would parse in the game's folder and fail in /tmp.
|
|
38
|
+
if (extname(file) !== '.mjs') throw new Error(`the script is a JavaScript module ending in .mjs, not ${extname(file) || 'a file with no extension'}`);
|
|
39
|
+
if (st.size > SIZE_MOST) throw new Error(`the script is ${Math.round(st.size / 1000)} kB; a shot script is a few lines that drive the game`);
|
|
40
|
+
const text = readFileSync(file, 'utf8');
|
|
41
|
+
const lines = text.split('\n');
|
|
42
|
+
for (const [pattern, why] of REFUSED) {
|
|
43
|
+
// A line that is all comment is passed over; a comment at the end of a
|
|
44
|
+
// line is not stripped, since `//` also opens every address.
|
|
45
|
+
const at = lines.findIndex((l) => !/^\s*\/\//.test(l) && pattern.test(l));
|
|
46
|
+
if (at >= 0) throw new Error(`line ${at + 1} ${why}:\n ${lines[at].trim().slice(0, 160)}`);
|
|
47
|
+
}
|
|
48
|
+
if (!/\bexport\s+default\b|\bas\s+default\b|\bexport\s*\{[^}]*\bdefault\b/.test(text)) throw new Error('the script exports no function: write `export default async ({ page, game, shot }) => { … }`');
|
|
49
|
+
// Parsed as a module, as it will be run, so a syntax error is reported with
|
|
50
|
+
// its line before the game is opened.
|
|
51
|
+
const parsed = spawnSync(node, ['--check', file], { encoding: 'utf8', timeout: 10_000 });
|
|
52
|
+
if (parsed.error?.code === 'ETIMEDOUT') throw new Error('the script could not be checked: node --check took over 10 s (the computer is very busy); run it again');
|
|
53
|
+
if (parsed.status !== 0) {
|
|
54
|
+
const said = String(parsed.stderr || parsed.error?.message || 'it does not parse').trim().split('\n').filter((l) => l && !/^\s+at /.test(l) && !/^Node\.js v/.test(l));
|
|
55
|
+
throw new Error(`the script does not parse:\n ${said.slice(0, 6).join('\n ')}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// One `joystack shot` script at a time on a computer. A headless browser
|
|
2
|
+
// drawing a 3D game without a GPU takes a whole core, and a maker machine has
|
|
3
|
+
// one, shared by every draft's agent and dev server; two at once make both slow
|
|
4
|
+
// enough to time out. The lock is a folder in the temporary folder, holding the
|
|
5
|
+
// owner's process id, so one left by a process that died is taken over.
|
|
6
|
+
// The folder's name carries the user's id, since the temporary folder can be
|
|
7
|
+
// shared by every user of a computer. Windows has no user id (`userInfo()`
|
|
8
|
+
// gives -1), so there it carries the user's name. JOYSTACK_SHOT_LOCK names another folder
|
|
9
|
+
// (a test's own).
|
|
10
|
+
import { closeSync, constants, lstatSync, mkdirSync, openSync, readSync, rmdirSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { tmpdir, userInfo } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const user = userInfo();
|
|
15
|
+
export const LOCK_NAME = `joystack-shot-${user.uid >= 0 ? user.uid : user.username.replace(/[^\w-]/g, '_')}.lock`;
|
|
16
|
+
// A lock not touched for this long is taken over whoever holds it, for the
|
|
17
|
+
// process id a dead holder left that the system has since given to another
|
|
18
|
+
// process. Its holder touches it while the script runs (`touch`); untouched,
|
|
19
|
+
// a run waits out an update for at most READY_MOST_MS (ready.mjs, two
|
|
20
|
+
// minutes) before its runner starts touching it.
|
|
21
|
+
export const LOCK_STALE_MS = 10 * 60_000;
|
|
22
|
+
|
|
23
|
+
const alive = (pid) => {
|
|
24
|
+
try {
|
|
25
|
+
process.kill(pid, 0);
|
|
26
|
+
return true;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
return err.code === 'EPERM';
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// The process id in a lock, or null. Only a plain file is read, and without
|
|
33
|
+
// following a link or waiting on a pipe someone left in its place.
|
|
34
|
+
function holder(dir) {
|
|
35
|
+
try {
|
|
36
|
+
const fd = openSync(join(dir, 'pid'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
37
|
+
try {
|
|
38
|
+
const buf = Buffer.alloc(16);
|
|
39
|
+
const n = readSync(fd, buf, 0, 16, 0);
|
|
40
|
+
const pid = Number(buf.toString('utf8', 0, n));
|
|
41
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
42
|
+
} finally { closeSync(fd); }
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Whether `dir` is a lock this user could have made: a folder of its own, not
|
|
49
|
+
// a link to one. Anything else in its place (another user's link, on a
|
|
50
|
+
// computer whose temporary folder every user shares) is never taken down, only
|
|
51
|
+
// waited for.
|
|
52
|
+
function ours(dir) {
|
|
53
|
+
try {
|
|
54
|
+
const st = lstatSync(dir);
|
|
55
|
+
return st.isDirectory() && !st.isSymbolicLink() && (typeof process.getuid !== 'function' || st.uid === process.getuid());
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Whether the lock at `dir` was left by a process that is gone, or was not
|
|
62
|
+
// touched for longer than any attempt takes.
|
|
63
|
+
function stale(dir, now) {
|
|
64
|
+
if (!ours(dir)) return false;
|
|
65
|
+
try {
|
|
66
|
+
if (now - lstatSync(dir).mtimeMs > LOCK_STALE_MS) return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const pid = holder(dir);
|
|
71
|
+
// No pid yet: made a moment ago, and someone is taking it.
|
|
72
|
+
return pid !== null && !alive(pid);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Takes a stale lock down: its pid file, then the folder, which fails if
|
|
76
|
+
// anything else is in it. Never a recursive delete of a folder someone else
|
|
77
|
+
// may have made.
|
|
78
|
+
function takeDown(dir) {
|
|
79
|
+
if (!ours(dir)) return;
|
|
80
|
+
try { unlinkSync(join(dir, 'pid')); } catch { /* already gone */ }
|
|
81
|
+
try { rmdirSync(dir); } catch { /* taken again, or not ours to remove */ }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Waits for the lock and takes it; resolves with `release`, which gives it
|
|
85
|
+
// back if it is still this process's, and `release.touch()`, which says it is
|
|
86
|
+
// still in use. Says once what it waits for. Rejects after `mostMs`.
|
|
87
|
+
export async function takeLock({ dir = process.env.JOYSTACK_SHOT_LOCK || join(tmpdir(), LOCK_NAME), mostMs = 3 * 60_000, pollMs = 250, log = () => {} } = {}) {
|
|
88
|
+
const until = Date.now() + mostMs;
|
|
89
|
+
let said = false;
|
|
90
|
+
for (;;) {
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
93
|
+
writeFileSync(join(dir, 'pid'), String(process.pid), { flag: 'wx' });
|
|
94
|
+
const release = () => { if (holder(dir) === process.pid) takeDown(dir); };
|
|
95
|
+
release.touch = () => { try { const t = new Date(); utimesSync(dir, t, t); } catch { /* gone */ } };
|
|
96
|
+
return release;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (err.code !== 'EEXIST') throw err;
|
|
99
|
+
}
|
|
100
|
+
if (stale(dir, Date.now())) {
|
|
101
|
+
takeDown(dir);
|
|
102
|
+
try { lstatSync(dir); } catch { continue; }
|
|
103
|
+
// Still there: not ours to take down. Wait as for any holder.
|
|
104
|
+
}
|
|
105
|
+
if (Date.now() >= until) throw new Error('another `joystack shot` script was still running after 3 minutes');
|
|
106
|
+
if (!said) { log(' waiting for another `joystack shot` script to finish…'); said = true; }
|
|
107
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// When the dev server is between updates, so a page loaded now stays loaded.
|
|
2
|
+
//
|
|
3
|
+
// Under `joystack dev` a game page reloads as soon as an update starts (an
|
|
4
|
+
// edit, a restored file) and is answered with the "Updating…" page until the
|
|
5
|
+
// new version is ready (static-server.mjs, reloadClient). A script whose page
|
|
6
|
+
// is caught in that reload loses it, so the page is opened only while the
|
|
7
|
+
// server says `ready` with nothing pending. With auto-reload off, changed
|
|
8
|
+
// files wait for someone to ask for a version, and no page reloads meanwhile,
|
|
9
|
+
// so pending changes then hold nothing up.
|
|
10
|
+
import { loopbackJson } from '../assets-page.mjs';
|
|
11
|
+
import { printable } from '../assets-sheet.mjs';
|
|
12
|
+
|
|
13
|
+
// How often the state is asked for, and for how long an update is waited
|
|
14
|
+
// out.
|
|
15
|
+
export const READY_POLL_MS = 500;
|
|
16
|
+
export const READY_MOST_MS = 120_000;
|
|
17
|
+
|
|
18
|
+
// The dev server's state (GET /__joystack/dev): `{ state, pending, error,
|
|
19
|
+
// version }`, or null when the server keeps no versions (started without
|
|
20
|
+
// reloads) or does not answer.
|
|
21
|
+
export function devState(origin, { timeoutMs = 3000 } = {}) {
|
|
22
|
+
return loopbackJson({ port: Number(new URL(origin).port), path: '/__joystack/dev', timeoutMs });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Resolves with the version that is ready, or null when the server keeps no
|
|
26
|
+
// versions; rejects with the build's error when the newest one failed, or
|
|
27
|
+
// when no update finishes in `mostMs`. Says once, through `log`, that it waits.
|
|
28
|
+
export async function untilReady(origin, { mostMs = READY_MOST_MS, pollMs = READY_POLL_MS, state = devState, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), log = () => {} } = {}) {
|
|
29
|
+
const until = Date.now() + mostMs;
|
|
30
|
+
let said = false;
|
|
31
|
+
for (;;) {
|
|
32
|
+
const s = await state(origin);
|
|
33
|
+
if (!s) return null;
|
|
34
|
+
// Failed with nothing coming: with auto-reload off, changed files make
|
|
35
|
+
// no version until someone asks, so a failure stands. The game's error is
|
|
36
|
+
// its own text, so it is kept to one printable line.
|
|
37
|
+
if (s.state === 'failed' && !(s.pending && s.auto !== false)) {
|
|
38
|
+
throw new Error(`the game has an error, so it cannot be opened: ${printable(String(s.error ?? 'the update failed')).slice(0, 500)}`);
|
|
39
|
+
}
|
|
40
|
+
if (s.state === 'ready' && !(s.pending && s.auto !== false)) return s.version ?? null;
|
|
41
|
+
if (Date.now() >= until) throw new Error(`the game was still updating after ${Math.round(mostMs / 1000)} s`);
|
|
42
|
+
if (!said) { log(' waiting for the game to finish updating…'); said = true; }
|
|
43
|
+
await sleep(pollMs);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/lib/shot/run.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// The process a `joystack shot` script runs in (started by shot.mjs, with the
|
|
2
|
+
// preloads a browser smoke gets). It reads its job from JOYSTACK_SHOT_JOB,
|
|
3
|
+
// loads the script, opens the game with the engine's own `openPage`, hands the
|
|
4
|
+
// script the page, the world handle and `shot`, and says in one line what went
|
|
5
|
+
// wrong when something does. It exits 0 when the script finished and the game
|
|
6
|
+
// threw nothing, 1 otherwise.
|
|
7
|
+
import { mkdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
8
|
+
import { join, relative, sep } from 'node:path';
|
|
9
|
+
import { pathToFileURL } from 'node:url';
|
|
10
|
+
import { gpuArgs } from '../smoke-gpu-preload.mjs';
|
|
11
|
+
import { loadPlaywright, printable, safeName, writeReplacing } from '../assets-sheet.mjs';
|
|
12
|
+
import { replyLine } from '../assets-export.mjs';
|
|
13
|
+
import { printableLines } from '../printable.mjs';
|
|
14
|
+
|
|
15
|
+
// How long one step on the page (a click, a wait for a selector, a
|
|
16
|
+
// `game.waitFor`) may take. A script that waits for something that never comes
|
|
17
|
+
// learns it in seconds.
|
|
18
|
+
export const STEP_MS = 5000;
|
|
19
|
+
// The dev tools' button and panel, which the dev server adds to a game page
|
|
20
|
+
// and no player sees, left out of every picture.
|
|
21
|
+
const NO_DEV_TOOLS = 'joystack-devtools { display: none !important; }';
|
|
22
|
+
const ERROR_MOST = 300;
|
|
23
|
+
// Fixed waits longer than this, added up, earn a line saying what to wait on.
|
|
24
|
+
const SLEPT_SAY_MS = 1000;
|
|
25
|
+
|
|
26
|
+
const job = JSON.parse(process.env.JOYSTACK_SHOT_JOB || 'null');
|
|
27
|
+
const say = (line) => process.stdout.write(`${line}\n`);
|
|
28
|
+
// Output read through a pipe that closes early (`| head`) is no failure of the
|
|
29
|
+
// script: what is not read is dropped, and the run goes on.
|
|
30
|
+
process.stdout.on('error', () => {});
|
|
31
|
+
process.stderr.on('error', () => {});
|
|
32
|
+
// Ends the process once the line is written: stderr to a pipe is written
|
|
33
|
+
// asynchronously on macOS, and an exit straight after the write can drop it.
|
|
34
|
+
const fail = async (message) => {
|
|
35
|
+
await new Promise((r) => process.stderr.write(`joystack shot: ${printableLines(message)}\n`, r));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
};
|
|
38
|
+
if (!job) await fail('run.mjs is started by `joystack shot`, not on its own');
|
|
39
|
+
|
|
40
|
+
const scriptUrl = pathToFileURL(job.script).href;
|
|
41
|
+
const shownPath = (file) => (file.startsWith(`${job.projectDir}${sep}`) ? relative(job.projectDir, file) : file);
|
|
42
|
+
|
|
43
|
+
// The line of the script an error came from, as `line 12: await page.click(…)`,
|
|
44
|
+
// or '' when the stack does not pass through it.
|
|
45
|
+
function scriptLineOf(err) {
|
|
46
|
+
const stack = String(err?.stack ?? '');
|
|
47
|
+
// The script may be named through a link (macOS's /var is /private/var), so
|
|
48
|
+
// its frames can carry either name.
|
|
49
|
+
let real = job.script;
|
|
50
|
+
try { real = realpathSync(job.script); } catch { /* removed since: its own name only */ }
|
|
51
|
+
const names = new Set([job.script, real].flatMap((p) => [pathToFileURL(p).href, p]));
|
|
52
|
+
let line = null;
|
|
53
|
+
for (const name of names) {
|
|
54
|
+
const m = stack.match(new RegExp(`${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:(\\d+)`));
|
|
55
|
+
if (m) { line = Number(m[1]); break; }
|
|
56
|
+
}
|
|
57
|
+
if (!line) return '';
|
|
58
|
+
let text = '';
|
|
59
|
+
try { text = readFileSync(job.script, 'utf8').split('\n')[line - 1] ?? ''; } catch { /* removed since */ }
|
|
60
|
+
return `\n at line ${line} of the script: ${text.trim().slice(0, 160)}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Playwright's timeouts, and the world handle's `waitFor` and `driveTo`, which
|
|
64
|
+
// give up in the page with a plain error.
|
|
65
|
+
const timedOut = (err) => err?.name === 'TimeoutError' || /\b(waitFor|driveTo)\b[^\n]*(timed out|gave up after)/.test(String(err?.message ?? ''));
|
|
66
|
+
|
|
67
|
+
// What went wrong, in a line or two: Playwright's first line (its call log is
|
|
68
|
+
// for a person debugging Playwright), what a timeout means here, and where.
|
|
69
|
+
function describe(err) {
|
|
70
|
+
// An error thrown in the page comes back behind Playwright's own words.
|
|
71
|
+
const first = String(err?.message ?? err).split('\n')[0].replace(/^page\.evaluate: Error: /, '').slice(0, 400);
|
|
72
|
+
const waited = timedOut(err) ? `\n each step waits at most ${STEP_MS / 1000} s: the thing it waited for did not happen (a selector the page has not got, a state the game did not reach)` : '';
|
|
73
|
+
return `${first}${waited}${scriptLineOf(err)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let mod;
|
|
77
|
+
try {
|
|
78
|
+
mod = await import(scriptUrl);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
await fail(`the script did not load: ${describe(err)}`);
|
|
81
|
+
}
|
|
82
|
+
if (typeof mod.default !== 'function') await fail('the script exports no function: write `export default async ({ page, game, shot }) => { … }`');
|
|
83
|
+
|
|
84
|
+
// The engine's door for a process that drives a browser, reachable through the
|
|
85
|
+
// joystack resolver the preload registers.
|
|
86
|
+
let openPage;
|
|
87
|
+
try {
|
|
88
|
+
({ openPage } = await import('joystack/smoke'));
|
|
89
|
+
} catch (err) {
|
|
90
|
+
await fail(`this game's engine has no openPage to open the game with (${String(err?.message ?? err).split('\n')[0]}): npx joystack update`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let game = null;
|
|
94
|
+
let code = 0;
|
|
95
|
+
let slept = 0;
|
|
96
|
+
// A room keeps a time scale after the page that set it has gone (only a pause
|
|
97
|
+
// is let go), and in a multiplayer game the room is the one people play in, so
|
|
98
|
+
// the scale it had before the script changed it is put back, on every ending.
|
|
99
|
+
// A page stuck in a loop never answers, so the putting back is given a second.
|
|
100
|
+
let scaledFrom = null;
|
|
101
|
+
const putBackTimeScale = () => (scaledFrom === null ? null : Promise.race([
|
|
102
|
+
scaledFrom.then((from) => game.setTimeScale(from)).catch(() => {}),
|
|
103
|
+
new Promise((r) => setTimeout(r, 1000).unref()),
|
|
104
|
+
]));
|
|
105
|
+
|
|
106
|
+
let browser;
|
|
107
|
+
// Asked to stop (by `joystack shot`, or a Ctrl-C reaching the whole group):
|
|
108
|
+
// the browser is closed and the run ends without a word, since what stopped it
|
|
109
|
+
// says why. Playwright's own handlers are off, as they close the browser and
|
|
110
|
+
// leave the script to fail on the closed page, reporting that instead.
|
|
111
|
+
let stopping = false;
|
|
112
|
+
const onStop = async () => {
|
|
113
|
+
if (stopping) return;
|
|
114
|
+
stopping = true;
|
|
115
|
+
await putBackTimeScale();
|
|
116
|
+
await browser?.close().catch(() => {});
|
|
117
|
+
process.exit(130);
|
|
118
|
+
};
|
|
119
|
+
process.on('SIGTERM', onStop);
|
|
120
|
+
process.on('SIGINT', onStop);
|
|
121
|
+
try {
|
|
122
|
+
const { chromium } = await loadPlaywright(job.projectDir, 'a shot script runs');
|
|
123
|
+
browser = await chromium.launch({ args: [...gpuArgs(), '--disable-dev-shm-usage'], handleSIGTERM: false, handleSIGINT: false, handleSIGHUP: false });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
await fail(`no browser to run the script in: ${String(err?.message ?? err).split('\n').slice(0, 2).join(' ').slice(0, 400)}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const context = await browser.newContext(job.view);
|
|
130
|
+
const id = Math.random().toString(36).slice(2, 10);
|
|
131
|
+
const url = `${job.origin}/games/${encodeURIComponent(job.slug)}/index.html${job.query ? `?${job.query}` : ''}`;
|
|
132
|
+
try {
|
|
133
|
+
game = await openPage({ browser, context, url, as: `shot-${id}`, room: `shot-${id}`, render: 'full', timeoutMs: job.startMs });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
throw Object.assign(new Error(`the game did not start: ${String(err?.message ?? err).split('\n').slice(0, 2).join(' ').slice(0, 600)}`), { plain: true });
|
|
136
|
+
}
|
|
137
|
+
const { page } = game;
|
|
138
|
+
page.setDefaultTimeout(STEP_MS);
|
|
139
|
+
page.setDefaultNavigationTimeout(STEP_MS * 2);
|
|
140
|
+
// The handle's own waits default to the time the game had to start; the
|
|
141
|
+
// script's are steps like any other.
|
|
142
|
+
const handle = {
|
|
143
|
+
...game,
|
|
144
|
+
waitFor: (predicate, opts = {}) => game.waitFor(predicate, { timeoutMs: STEP_MS, ...opts }),
|
|
145
|
+
driveTo: (entity, target, opts = {}) => game.driveTo(entity, target, { timeoutMs: STEP_MS, ...opts }),
|
|
146
|
+
setTimeScale: async (value, opts) => {
|
|
147
|
+
scaledFrom ??= game.timeScale().catch(() => 1);
|
|
148
|
+
await scaledFrom;
|
|
149
|
+
if (stopping) return undefined;
|
|
150
|
+
return game.setTimeScale(value, opts);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
// Every load after the first is the game reloading under the script.
|
|
154
|
+
let reloads = 0;
|
|
155
|
+
page.on('domcontentloaded', () => { reloads += 1; });
|
|
156
|
+
// Fixed waits, added up: a script that sleeps is padded for the slowest
|
|
157
|
+
// machine and still misses on it.
|
|
158
|
+
const sleep = page.waitForTimeout.bind(page);
|
|
159
|
+
page.waitForTimeout = (ms) => { slept += Number(ms) || 0; return sleep(ms); };
|
|
160
|
+
|
|
161
|
+
const written = new Map();
|
|
162
|
+
let n = 0;
|
|
163
|
+
const shot = async (name) => {
|
|
164
|
+
n += 1;
|
|
165
|
+
const base = safeName(name ?? `shot-${n}`);
|
|
166
|
+
// Two frames drawn first, so the picture is of a whole frame.
|
|
167
|
+
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
|
168
|
+
const png = await page.screenshot({ type: 'png', style: NO_DEV_TOOLS, timeout: STEP_MS * 3 });
|
|
169
|
+
mkdirSync(job.out, { recursive: true });
|
|
170
|
+
const file = join(job.out, `${base}.png`);
|
|
171
|
+
writeReplacing(file, png);
|
|
172
|
+
written.set(file, { name: printable(String(name ?? base)), path: shownPath(file) });
|
|
173
|
+
say(` 📷 ${shownPath(file)}`);
|
|
174
|
+
return file;
|
|
175
|
+
};
|
|
176
|
+
const log = (...parts) => say(printable(parts.map((p) => (typeof p === 'string' ? p : JSON.stringify(p))).join(' ')));
|
|
177
|
+
|
|
178
|
+
let timer;
|
|
179
|
+
const limit = new Promise((_, reject) => {
|
|
180
|
+
timer = setTimeout(() => reject(Object.assign(new Error(`the script did not finish in ${job.timeoutMs / 1000} s (--timeout)`), { plain: true })), job.timeoutMs);
|
|
181
|
+
});
|
|
182
|
+
try {
|
|
183
|
+
await Promise.race([mod.default({ page, game: handle, shot, log }), limit]);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (reloads) throw Object.assign(new Error('the game reloaded while the script ran (a file of the game changed): run it again once nothing is being edited'), { plain: true });
|
|
186
|
+
throw err;
|
|
187
|
+
} finally {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
}
|
|
190
|
+
if (!written.size) say(' the script took no picture: call shot("name") for each one');
|
|
191
|
+
if (job.maker && written.size) {
|
|
192
|
+
say(' To show one in your reply, write its line:');
|
|
193
|
+
for (const w of written.values()) say(` ${replyLine(w.name, w.path)}`);
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// The page failed because the stop closed it: the stop ends the process.
|
|
197
|
+
if (stopping) await new Promise(() => {});
|
|
198
|
+
process.stderr.write(`joystack shot: ${printableLines(err?.plain ? err.message : describe(err))}\n`);
|
|
199
|
+
code = 1;
|
|
200
|
+
}
|
|
201
|
+
// A script that caught the closed page itself and returned says nothing more
|
|
202
|
+
// either.
|
|
203
|
+
if (stopping) await new Promise(() => {});
|
|
204
|
+
if (slept > SLEPT_SAY_MS) say(` slept ${(slept / 1000).toFixed(1)} s in fixed waits: game.waitFor((w) => …) or page.waitForSelector(…) waits only until the moment comes`);
|
|
205
|
+
// A game that threw while the script drove it is the finding, whether or not
|
|
206
|
+
// the script finished: a picture of a broken game is no evidence of its look.
|
|
207
|
+
if (game?.errors.length) {
|
|
208
|
+
process.stderr.write(`joystack shot: the game threw while the script ran: ${printable(game.errors.slice(0, 3).map((e) => String(e).slice(0, ERROR_MOST)).join('; '))}\n`);
|
|
209
|
+
code = 1;
|
|
210
|
+
}
|
|
211
|
+
await putBackTimeScale();
|
|
212
|
+
await browser.close().catch(() => {});
|
|
213
|
+
// Exits rather than waiting for the event loop to empty, since a script given
|
|
214
|
+
// up on can still hold a timer of its own; both streams are written out first.
|
|
215
|
+
await new Promise((r) => process.stdout.write('', r));
|
|
216
|
+
await new Promise((r) => process.stderr.write('', r));
|
|
217
|
+
process.exit(code);
|