@joystack/cli 0.1.54 → 0.1.56

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,320 @@
1
+ // `joystack shot <script>`: a short Playwright script of the agent's own, run
2
+ // against the running game, with the parts that go wrong when every script
3
+ // does them for itself done once here.
4
+ //
5
+ // joystack shot .joystack/look.mjs
6
+ //
7
+ // The script exports one function. It is handed a page already open on the
8
+ // game, at a phone's size, under a player name of its own, with the game
9
+ // started; it drives the game to the moment it wants and calls
10
+ // `shot(name)` for each picture:
11
+ //
12
+ // export default async ({ page, game, shot }) => {
13
+ // await page.selectOption('#jump', '6');
14
+ // await game.waitFor((w) => w.query('boss').length > 0);
15
+ // await shot('boss');
16
+ // };
17
+ //
18
+ // What the command owns, so a script never does it:
19
+ // - the check before anything starts: the file parses, exports a function, and
20
+ // neither launches a browser nor opens an address of its own (check.mjs);
21
+ // - the dev stack: the one running for the game (in the maker, the draft's), or
22
+ // one started for the length of the command;
23
+ // - the moment the page opens: only once the dev server has finished updating
24
+ // after an edit (ready.mjs), and the run is stopped, saying so, when the game
25
+ // updates under it;
26
+ // - the browser: one, with the same flags every time, closed afterwards, and
27
+ // one script at a time on a computer (lock.mjs);
28
+ // - time: every step of the page waits at most a few seconds, the whole script
29
+ // a minute unless --timeout says otherwise.
30
+ //
31
+ // The script runs in a child process (run.mjs) with the same preloads a
32
+ // browser smoke gets, so it can import the joystack doors and a stuck script
33
+ // is stopped by ending that process.
34
+
35
+ import { spawn } from 'node:child_process';
36
+ import { resolve } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { findProject } from '../project.mjs';
39
+ import { stackOrigin, stateFolderIn } from '../assets-sheet.mjs';
40
+ import { makerDevPort, makerDraft } from '../maker.mjs';
41
+ import { joystackTestPreload, SMOKE_PRELOAD } from '../test-cmd.mjs';
42
+ import { checkScript } from './check.mjs';
43
+ import { devState, untilReady } from './ready.mjs';
44
+ import { takeLock } from './lock.mjs';
45
+
46
+ export const SHOT_HELP = `joystack shot <script.mjs> [--size phone|tablet|desktop] [--landscape] [--query <k=v&…>] [--timeout <seconds>] [--out <dir>] [--port <n>]
47
+
48
+ Runs a short Playwright script against the running game and writes the
49
+ pictures it takes to .joystack/shots/<name>.png. The script exports one
50
+ function, handed a page already open on the game:
51
+
52
+ export default async ({ page, game, shot, log }) => {
53
+ await page.keyboard.press('Space'); // real input
54
+ await game.waitFor((w) => w.query('enemy').length >= 3);
55
+ await shot('three-enemies');
56
+ };
57
+
58
+ page the Playwright page, on the game, started and showing what a player
59
+ sees; never goto or launch a browser of your own
60
+ game the world handle openPage gives a smoke (not the page's window.game):
61
+ me(), mine(), ownIds(), entity(), snapshot(), query(selector: a component
62
+ name, tag:<name> or def:<name>), get(), set() (a dev room only),
63
+ send(), driveTo(), waitFor(), pause(), resume(), setTimeScale(),
64
+ players(). Every read is a promise: await game.query('boss')
65
+ shot shot(name) writes the page as it is now; the dev tools are left out.
66
+ game.pause() first makes it a picture of one instant
67
+ log log(...) prints a line
68
+
69
+ Wait for the moment, not for a time: game.waitFor or page.waitForSelector
70
+ wait only until it comes, where page.waitForTimeout is padded for the
71
+ slowest computer and still misses on it. A debug overlay the game has is
72
+ turned on as a player would (its key, or --query), then pictured.
73
+
74
+ --size phone (390×844, the default), tablet (820×1180) or desktop
75
+ (1280×800); phone and tablet at twice the pixels
76
+ --landscape the screen turned on its side
77
+ --query added to the game's address: --query "level=3"
78
+ --timeout seconds the whole script may take (default 60, at most 300);
79
+ each step on the page, and game.waitFor and driveTo, wait at
80
+ most 5 s
81
+ --out the folder the pictures go to (outside the game's own files,
82
+ or each picture is an edit the game reloads for)
83
+ --port the dev server's port, when this game's is not the one found
84
+
85
+ Write the script under .joystack/ (.joystack/look.mjs), which is not part
86
+ of the game. It is checked before anything starts, and the page opens only
87
+ once the dev server has finished updating after an edit; the run stops if
88
+ the game updates while it runs, and fails if the game throws. One page, one
89
+ player: in a multiplayer game it joins the game's one room, beside anyone
90
+ playing, so set(), pause() and setTimeScale() change their game too (a
91
+ time scale is put back to 1 when the script ends). It cannot stage an
92
+ entity the game does not spawn; that is a smoke's, openPage({ stage }). One
93
+ script at a time on a computer. Uses the dev stack already running for this
94
+ game, or starts one for the length of the command. Needs playwright in the
95
+ game (npm i -D playwright).`;
96
+
97
+ export const SIZES = {
98
+ phone: { width: 390, height: 844, scale: 2, touch: true },
99
+ tablet: { width: 820, height: 1180, scale: 2, touch: true },
100
+ desktop: { width: 1280, height: 800, scale: 1, touch: false },
101
+ };
102
+ export const TIMEOUT_DEFAULT_S = 60;
103
+ export const TIMEOUT_MOST_S = 300;
104
+ // How long the game has to start once the page is opened, and how often the
105
+ // dev server is asked whether it moved on while the script runs.
106
+ export const START_MOST_MS = 45_000;
107
+ const WATCH_MS = 500;
108
+
109
+ const VALUED = {
110
+ '--size': ['size', String],
111
+ '--query': ['query', (v) => v.replace(/^\?/, '')],
112
+ '--timeout': ['timeout', Number],
113
+ '--out': ['out', String],
114
+ '--port': ['port', Number],
115
+ };
116
+
117
+ // The arguments after `joystack shot`.
118
+ export function parseShotArgs(rest = []) {
119
+ const opts = { script: null, size: 'phone', landscape: false, query: '', timeout: TIMEOUT_DEFAULT_S, out: null, port: null };
120
+ for (let i = 0; i < rest.length; i += 1) {
121
+ const a = rest[i];
122
+ if (a === '--landscape') { opts.landscape = true; continue; }
123
+ if (a === '--help') continue;
124
+ if (!a.startsWith('--')) {
125
+ if (opts.script) throw new Error(`joystack shot runs one script, given two: ${opts.script} and ${a}`);
126
+ opts.script = a;
127
+ continue;
128
+ }
129
+ if (!VALUED[a]) throw new Error(`joystack shot has no option ${a}.\n\n${SHOT_HELP}`);
130
+ const v = rest[i + 1];
131
+ if (v == null || v.startsWith('--')) throw new Error(`${a} needs a value.\n\n${SHOT_HELP}`);
132
+ const [key, read] = VALUED[a];
133
+ opts[key] = read(v);
134
+ i += 1;
135
+ }
136
+ if (!opts.script) throw new Error(`joystack shot needs the script to run.\n\n${SHOT_HELP}`);
137
+ if (!SIZES[opts.size]) throw new Error(`--size is phone, tablet or desktop, not "${opts.size}"`);
138
+ if (!Number.isFinite(opts.timeout) || opts.timeout <= 0 || opts.timeout > TIMEOUT_MOST_S) throw new Error(`--timeout is a number of seconds above 0 and at most ${TIMEOUT_MOST_S}`);
139
+ if (opts.port !== null && !(Number.isInteger(opts.port) && opts.port > 0 && opts.port < 65536)) throw new Error('--port needs a port number');
140
+ return opts;
141
+ }
142
+
143
+ // The browser context's options for a size.
144
+ export function viewFor(size, landscape) {
145
+ const s = SIZES[size];
146
+ const [width, height] = landscape ? [s.height, s.width] : [s.width, s.height];
147
+ return { viewport: { width, height }, deviceScaleFactor: s.scale, isMobile: s.touch, hasTouch: s.touch };
148
+ }
149
+
150
+ // The dev stack the script runs against: the one running for the game (in the
151
+ // maker, the draft's, on a port of the maker's choosing), or one started with
152
+ // its room server for the length of the command. In the maker none is
153
+ // started: a second dev server beside the draft's would show nobody anything.
154
+ async function stackFor({ cwd, project, port, startDev, env }) {
155
+ const at = makerDraft(env);
156
+ const devPort = port ?? (at ? await makerDevPort(at) : null);
157
+ try {
158
+ return await stackOrigin({ cwd, projectDir: project.dir, port: devPort, startDev: at ? null : startDev, mp: true });
159
+ } catch (err) {
160
+ if (!at) throw err;
161
+ throw new Error(`the draft's game is not running yet (the maker is starting it): try again in a moment (${String(err?.message ?? err).split('\n')[0].slice(0, 200)})`);
162
+ }
163
+ }
164
+
165
+ // Whether the dev server moved on from the version the script started on.
166
+ export const moved = (now, version) => !!now && (now.state !== 'ready' || (now.pending && now.auto !== false) || (version != null && now.version !== version));
167
+
168
+ const RUNNER = fileURLToPath(new URL('./run.mjs', import.meta.url));
169
+ // How long the runner has to close its browser after being asked to stop,
170
+ // before it is killed outright.
171
+ const STOP_GRACE_MS = 2000;
172
+ // How long a stop waits for a dev stack still starting, to stop it as well.
173
+ const STACK_WAIT_MS = 10_000;
174
+
175
+ // Runs the checked script in its own process. Resolves with how it ended:
176
+ // { code } when it exited by itself, { signal } when something else killed it,
177
+ // { moved: true } when the game updated under it, { timedOut: true } when it ran
178
+ // past its limit, { stopped: true } after `stop()`. Returns { done, stop }.
179
+ export function runChild({ projectDir, job, timeoutMs, origin, version, touch = () => {}, env, runner = RUNNER, graceMs = START_MOST_MS + 15_000, watchMs = WATCH_MS }) {
180
+ const preload = joystackTestPreload(projectDir);
181
+ const args = ['--import', SMOKE_PRELOAD, ...(preload ? ['--import', preload] : []), runner];
182
+ const child = spawn(process.execPath, args, { cwd: projectDir, env: { ...env, JOYSTACK_SHOT_JOB: JSON.stringify(job) }, stdio: ['ignore', 'inherit', 'inherit'] });
183
+ let ended = null;
184
+ let resolveP;
185
+ const done = new Promise((r) => { resolveP = r; });
186
+ let asking = false;
187
+ const watch = setInterval(async () => {
188
+ touch();
189
+ // One question at a time: a busy server answers slower than the ticks.
190
+ if (asking) return;
191
+ asking = true;
192
+ try {
193
+ if (moved(await devState(origin), version)) end({ moved: true });
194
+ } finally { asking = false; }
195
+ }, watchMs);
196
+ // The runner keeps its own limit on the script; this one is for a runner
197
+ // stuck past it (a page that stopped answering).
198
+ const limit = setTimeout(() => end({ timedOut: true }), timeoutMs + graceMs);
199
+ let kill = null;
200
+ function end(how) {
201
+ if (ended) return;
202
+ ended = how;
203
+ clearInterval(watch);
204
+ clearTimeout(limit);
205
+ if (how.code === undefined && how.signal === undefined) {
206
+ // Asked first, so Playwright closes its browser; then made to.
207
+ child.kill('SIGTERM');
208
+ kill = setTimeout(() => child.kill('SIGKILL'), STOP_GRACE_MS);
209
+ }
210
+ }
211
+ child.on('exit', (code, signal) => {
212
+ end(signal && code === null ? { signal } : { code: code ?? 0 });
213
+ clearTimeout(kill);
214
+ resolveP(ended);
215
+ });
216
+ child.on('error', (err) => { end({ code: 1, error: err }); resolveP(ended); });
217
+ return { done, stop: () => end({ stopped: true }) };
218
+ }
219
+
220
+ // The error a run's end makes, or null when it succeeded.
221
+ function failureOf(how, { timeout, interrupted }) {
222
+ if (how.stopped) return Object.assign(new Error(`stopped (${interrupted ?? 'asked'}); the script's browser was closed`), { exitCode: 130 });
223
+ if (how.moved) return new Error('the game updated while the script ran (a file of the game changed), so its page reloaded: run it again once nothing is being edited');
224
+ if (how.timedOut) return new Error(`the script did not finish in ${timeout} s and was stopped`);
225
+ if (how.error) return how.error;
226
+ if (how.signal) return new Error(`the script's process was killed (${how.signal}), most often for want of memory`);
227
+ // The runner has said what went wrong in its own words.
228
+ if (how.code !== 0) return Object.assign(new Error('the script failed'), { reported: true });
229
+ return null;
230
+ }
231
+
232
+ // A stack still starting when a stop came, waited for a while so it can be
233
+ // stopped too (in the maker none is ever started), or null.
234
+ async function lateStack(stacking, { interrupted, log }) {
235
+ if (interrupted) log(' stopping the dev server it was starting…');
236
+ const late = await Promise.race([stacking.catch(() => null), new Promise((r) => setTimeout(r, STACK_WAIT_MS).unref())]);
237
+ return late?.started ?? null;
238
+ }
239
+
240
+ // Run one script. `startDev` starts a dev stack when none is running for the
241
+ // game; `origin` is for a caller that already has one, and `runner` a stand-in
242
+ // for run.mjs (both a test's). Throws with a line fit to print when the run
243
+ // fails; resolves when it succeeded.
244
+ export async function shot({
245
+ cwd = process.cwd(), script, size = 'phone', landscape = false, query = '', timeout = TIMEOUT_DEFAULT_S, out = null, port = null,
246
+ startDev, log = console.log, origin, env = process.env, lockDir, runner,
247
+ } = {}) {
248
+ const file = resolve(cwd, script);
249
+ // Before anything starts, so a script that cannot work fails in a moment.
250
+ checkScript(file);
251
+ const project = findProject(cwd);
252
+ let started = null;
253
+ let stacking = null;
254
+ let stackSettled = false;
255
+ let release = null;
256
+ let child = null;
257
+ let interrupted = null;
258
+ // Ctrl-C, or a tool's limit on the command: the runner and its browser, the
259
+ // stack started for the command and the lock go with it. A wait in progress
260
+ // (the stack starting, an update, the lock) is given up at once. A second
261
+ // signal, while the first is still being acted on, ends the command there
262
+ // and then, giving back the lock.
263
+ let stop;
264
+ const stopped = new Promise((_, reject) => { stop = reject; });
265
+ stopped.catch(() => {});
266
+ const onSignal = (signal) => {
267
+ if (interrupted) {
268
+ // Its stop is begun, not waited for: a started stack's processes are
269
+ // signalled before it returns.
270
+ Promise.resolve().then(() => started?.stop()).catch(() => {});
271
+ release?.();
272
+ process.exit(130);
273
+ }
274
+ interrupted = signal;
275
+ child?.stop();
276
+ stop(Object.assign(new Error(`stopped (${signal})`), { exitCode: 130 }));
277
+ };
278
+ const orStop = (p) => Promise.race([p, stopped]);
279
+ process.on('SIGINT', onSignal);
280
+ process.on('SIGTERM', onSignal);
281
+ try {
282
+ if (!origin) {
283
+ stacking = stackFor({ cwd, project, port, startDev, env });
284
+ // Whatever it started is kept the moment it is, a stop or not.
285
+ stacking.then((r) => { started ??= r?.started ?? null; stackSettled = true; }, () => { stackSettled = true; });
286
+ ({ origin, started } = await orStop(stacking));
287
+ }
288
+ // This game's update is waited out before the lock is taken, so another
289
+ // game's script never waits on it.
290
+ await orStop(untilReady(origin, { log }));
291
+ const taking = takeLock({ dir: lockDir, log });
292
+ // A lock taken after a stop is given back with the rest.
293
+ taking.then((r) => { release = r; if (interrupted) r(); }, () => {});
294
+ release = await orStop(taking);
295
+ const version = await orStop(untilReady(origin, { log }));
296
+ const job = {
297
+ script: file,
298
+ origin,
299
+ slug: project.slug,
300
+ query,
301
+ view: viewFor(size, landscape),
302
+ out: out ? resolve(cwd, out) : stateFolderIn(project.dir, 'shots'),
303
+ projectDir: project.dir,
304
+ timeoutMs: timeout * 1000,
305
+ startMs: START_MOST_MS,
306
+ maker: !!makerDraft(env),
307
+ };
308
+ child = runChild({ projectDir: project.dir, job, timeoutMs: job.timeoutMs, origin, version, touch: release.touch, env, runner });
309
+ const failed = failureOf(await child.done, { timeout, interrupted });
310
+ if (failed) throw failed;
311
+ } finally {
312
+ if (!started && stacking && !stackSettled && !makerDraft(env)) started = await lateStack(stacking, { interrupted, log });
313
+ // Each step on its own, so one that fails, even at once, still lets the
314
+ // next run.
315
+ await Promise.resolve().then(() => started?.stop()).catch(() => {});
316
+ release?.();
317
+ process.removeListener('SIGINT', onSignal);
318
+ process.removeListener('SIGTERM', onSignal);
319
+ }
320
+ }
@@ -0,0 +1,110 @@
1
+ // POST /__joystack/dev/start-failed { message, file, line, column, stack }
2
+ //
3
+ // A start that failed in a browser, which the dev tools runtime posts (the
4
+ // injected tag names this route, inject.mjs), written to the dev server's log
5
+ // as one block. `joystack dev` keeps that log in `.joystack/dev/last.log`
6
+ // (dev-log.mjs), where the game's agent reads it: a failure on a phone, or in
7
+ // the maker's Play, is otherwise seen only by the person holding it.
8
+ //
9
+ // The body is written by a page, which may be anyone's on a shared link: it is
10
+ // taken only from a page the dev server served, size-limited, every field cut
11
+ // and cleaned of control characters, and at most PER_MINUTE are written a
12
+ // minute.
13
+ import { relative, sep } from 'node:path';
14
+ import { canonicalDevPath } from './legacy-names.mjs';
15
+
16
+ export const START_FAILED_PATH = '/__joystack/dev/start-failed';
17
+ const MAX_BYTES = 16 * 1024;
18
+ const PER_MINUTE = 10;
19
+ const STACK_LINES = 30;
20
+ const SHAPE = 'expected { message, file, line, column, stack }';
21
+
22
+ // Text from a page, for one line of the log: no control characters (an
23
+ // escape sequence would move the terminal, a newline would forge a line of
24
+ // the log), no bidirectional or zero-width marks (a line would read one way
25
+ // in a terminal and another to the agent), cut to `max`.
26
+ function logLine(value, max) {
27
+ const s = String(value ?? '').replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g, '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').trim();
28
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
29
+ }
30
+
31
+ // The block a failed start is written as: what failed, where (the file as it
32
+ // is in the game's folder, or on disk for an engine file), the browser, who
33
+ // sent it, and the stack when there is one, indented under it.
34
+ export function startFailedBlock({ message, file, line, column, stack = [], browser, from }) {
35
+ const place = file ? `${file}${line ? `:${line}${column ? `:${column}` : ''}` : ''}` : '';
36
+ const lines = [`✗ The game didn't start in a browser: ${message || 'unknown error'}`];
37
+ if (place) lines.push(` at ${place}`);
38
+ if (browser) lines.push(` browser: ${browser}`);
39
+ if (from) lines.push(` from: ${from}`);
40
+ for (const l of stack) lines.push(` ${l}`);
41
+ return lines.join('\n');
42
+ }
43
+
44
+ // `log(block)`: where the block goes. `allowed(req)`: whether the request
45
+ // came from a page the dev server served, to the creator. `from(req)`: who
46
+ // sent it, said in the block. `ownHost(host)`: whether a URL's
47
+ // host is the dev server's own. `slug`, `projectDir`, `resolveUrl(urlPath)`:
48
+ // the game served, its folder, and the file a URL serves, to name the file
49
+ // the way the agent finds it.
50
+ export function createStartFailed({ log, allowed, from = () => '', ownHost, slug, projectDir, resolveUrl }) {
51
+ const times = [];
52
+
53
+ // A file the page names, as the agent finds it: a game file by its path in
54
+ // the game's folder (the server may serve a copy of it), a file of the
55
+ // server's otherwise by where it is on disk (from the game's folder when
56
+ // inside it), anything else as it was given.
57
+ function placeOnDisk(file) {
58
+ if (!file) return '';
59
+ let urlPath;
60
+ try {
61
+ const url = new URL(file, 'http://x');
62
+ if (!file.startsWith('/') && !ownHost(url.host)) return file;
63
+ urlPath = canonicalDevPath(decodeURIComponent(url.pathname));
64
+ } catch { return file; }
65
+ const inGame = `/games/${slug}/`;
66
+ if (urlPath.startsWith(inGame)) return urlPath.slice(inGame.length);
67
+ const fsPath = resolveUrl(urlPath);
68
+ if (!fsPath) return urlPath;
69
+ const rel = projectDir ? relative(projectDir, fsPath) : '';
70
+ return (rel && !rel.startsWith('..') && !/^[A-Za-z]:/.test(rel) ? rel : fsPath).split(sep).join('/');
71
+ }
72
+
73
+ // Whether another block may be written now.
74
+ function room() {
75
+ const now = Date.now();
76
+ while (times.length && now - times[0] > 60_000) times.shift();
77
+ if (times.length >= PER_MINUTE) return false;
78
+ times.push(now);
79
+ return true;
80
+ }
81
+
82
+ return async function handle(req, res) {
83
+ const answer = (status, body) => {
84
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...(body ? {} : { 'Content-Length': 0 }) });
85
+ res.end(body ? JSON.stringify(body) : undefined);
86
+ };
87
+ if (req.method !== 'POST') return answer(405, { error: 'POST only' });
88
+ if (!allowed(req)) return answer(403, { error: 'only from a page this server served' });
89
+ let text = '';
90
+ for await (const c of req) {
91
+ text += c;
92
+ if (text.length > MAX_BYTES) return answer(413, { error: 'body too large' });
93
+ }
94
+ let body;
95
+ try { body = JSON.parse(text); } catch { return answer(400, { error: SHAPE }); }
96
+ if (!body || typeof body !== 'object') return answer(400, { error: SHAPE });
97
+ if (!room()) return answer(204);
98
+ const count = (v) => (Number.isInteger(v) && v > 0 && v < 1e7 ? v : 0);
99
+ log(startFailedBlock({
100
+ message: logLine(body.message, 500),
101
+ file: placeOnDisk(logLine(body.file, 500)),
102
+ line: count(body.line),
103
+ column: count(body.column),
104
+ stack: String(body.stack ?? '').split('\n').map((l) => logLine(l, 300)).filter(Boolean).slice(0, STACK_LINES),
105
+ browser: logLine(req.headers['user-agent'], 200),
106
+ from: from(req),
107
+ }));
108
+ return answer(204);
109
+ };
110
+ }