@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,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
|
+
}
|
package/lib/static-server.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// /shared/… → the engine bundle's shared/ tree
|
|
6
6
|
// /__reload → SSE; tells open tabs the version they run on is replaced
|
|
7
7
|
// /__joystack/dev → the versions' state; reload, and auto-reload on or off
|
|
8
|
+
// /__joystack/warm/<slug>/ → a page that downloads the game's start ahead of it
|
|
8
9
|
//
|
|
9
10
|
// Mounts are ORDERED — first prefix match wins. That ordering is how
|
|
10
11
|
// overrides/shared/… will shadow the engine bundle (Slice 2): the game's
|
|
@@ -17,7 +18,8 @@ import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'n
|
|
|
17
18
|
import { extname, join, normalize, relative, sep } from 'node:path';
|
|
18
19
|
import { fileURLToPath } from 'node:url';
|
|
19
20
|
import { gunzipSync } from 'node:zlib';
|
|
20
|
-
import { injectHeadTags, injectNoteTag, rewriteHtmlScripts, rewriteJsImports, NOTE_URL, PLATFORM_URL, TOOLBOX_URL, DEVTOOLS_URL, ENGINE_TOOLS_URL, POINT_URL } from './inject.mjs';
|
|
21
|
+
import { injectHeadTags, injectNoteTag, stampedKinds, rewriteHtmlScripts, rewriteJsImports, NOTE_URL, PLATFORM_URL, TOOLBOX_URL, DEVTOOLS_URL, ENGINE_TOOLS_URL, POINT_URL } from './inject.mjs';
|
|
22
|
+
import { cacheHeaders, closureStamps, declaredAssets, notModified, startFiles, WARM_PAGE, WARM_PATH } from './fingerprint/fingerprint.mjs';
|
|
21
23
|
import { createRelay, RELAY_PATH } from './relay/relay.mjs';
|
|
22
24
|
import { WHOAMI_PATH } from './ports.mjs';
|
|
23
25
|
import {
|
|
@@ -70,7 +72,10 @@ const reloadClient = (kind, version, token) => `<script>
|
|
|
70
72
|
const version = ${JSON.stringify(version)};
|
|
71
73
|
let alive = false;
|
|
72
74
|
let leaving = false;
|
|
73
|
-
|
|
75
|
+
// Loaded again by a navigation to its own address: Safari makes a reload
|
|
76
|
+
// without its cache, every module downloaded again however long it may be
|
|
77
|
+
// kept. An address with a #fragment would only move to it, so that one reloads.
|
|
78
|
+
const leave = () => { if (!leaving) { leaving = true; if (location.hash) location.reload(); else location.replace(location.href); } };
|
|
74
79
|
function connect() {
|
|
75
80
|
const es = new EventSource('/__reload?page=' + kind + '&token=${token}');
|
|
76
81
|
es.onopen = () => { alive = true; };
|
|
@@ -100,8 +105,13 @@ export function errorSummary(text, gameDir = null) {
|
|
|
100
105
|
const lines = String(text ?? '').split('\n').map((l) => l.trim()).filter(Boolean)
|
|
101
106
|
// Node's own warnings ride along on the build's output.
|
|
102
107
|
.filter((l) => !/^\(node:\d+\)|^\(Use `node --trace-warnings|^Reparsing as ES module|^To eliminate this warning|package\.json\.$/.test(l));
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
// A gate refusal first; then the error a build that threw printed (Node
|
|
109
|
+
// prints the line of code that threw above it, which says "Error" too);
|
|
110
|
+
// then any line that reads like one.
|
|
111
|
+
const first = lines.find((l) => l.startsWith('✘'))
|
|
112
|
+
?? lines.find((l) => /^(?:[A-Z]\w*)?Error:\s/.test(l))
|
|
113
|
+
?? lines.find((l) => /error|cannot|not defined|unexpected/i.test(l)) ?? lines[0] ?? '';
|
|
114
|
+
let line = first.replace(/^✘\s*/, '').replace(/^\S+:\d+(?::\d+)?\s+—\s+/, '').replace(/^Expansion threw:\s*/, '').replace(/^Error:\s+/, '');
|
|
105
115
|
if (gameDir) {
|
|
106
116
|
// Any absolute path into the game folder, however it is spelled: with
|
|
107
117
|
// either separator, a drive letter, /private on macOS, or a Windows short
|
|
@@ -452,12 +462,36 @@ export function createStaticServer({
|
|
|
452
462
|
|
|
453
463
|
const gameEntry = `/games/${slug}/index.html`;
|
|
454
464
|
|
|
465
|
+
// The page in place of the game while it updates or after its build failed.
|
|
466
|
+
// A failed build answers as an error, so a check that fetches the page (an
|
|
467
|
+
// agent's curl, a test) sees it failed; a browser still shows it. The
|
|
468
|
+
// header says why, so a proxy that passes the page on (the maker's live
|
|
469
|
+
// links) can answer it as the page it is rather than its own 5xx.
|
|
470
|
+
function sendStatus(res, status) {
|
|
471
|
+
const failed = status.state === 'failed';
|
|
472
|
+
return sendBody(res, failed ? 500 : 200, statusPage(status, projectDir) + reloadClient('status', currentVersion, 0), MIME['.html'], failed ? { 'x-joystack-build': 'failed' } : {});
|
|
473
|
+
}
|
|
474
|
+
|
|
455
475
|
function serveHtml(req, res, fsPath, urlPath, query) {
|
|
456
476
|
const gamePage = /^\/games\/[^/]+\//.test(urlPath);
|
|
457
477
|
const status = versions?.status();
|
|
458
|
-
if (gamePage && status && status.state !== 'ready')
|
|
459
|
-
|
|
478
|
+
if (gamePage && status && status.state !== 'ready') return sendStatus(res, status);
|
|
479
|
+
let html = pageHtml(req, fsPath, urlPath, query);
|
|
480
|
+
if (wantsNote(req)) html = injectNoteTag(html);
|
|
481
|
+
if (versions) {
|
|
482
|
+
let token = 0;
|
|
483
|
+
if (gamePage) {
|
|
484
|
+
token = nextToken++;
|
|
485
|
+
gamePages.set(token, { version: currentVersion, connected: false, servedAt: Date.now() });
|
|
486
|
+
}
|
|
487
|
+
const client = reloadClient(gamePage ? 'game' : 'other', currentVersion, token);
|
|
488
|
+
html = html.includes('</body>') ? html.replace('</body>', client + '</body>') : html + client;
|
|
460
489
|
}
|
|
490
|
+
sendBody(res, 200, html, MIME['.html']);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// A page as this server serves it, before the note and the reload client.
|
|
494
|
+
function pageHtml(req, fsPath, urlPath, query) {
|
|
461
495
|
let html = readFileSync(fsPath, 'utf8');
|
|
462
496
|
html = rewriteHtmlScripts(html, { baseUrl: urlPath, resolveUrl });
|
|
463
497
|
html = withLegacyImportMap(html);
|
|
@@ -490,28 +524,49 @@ export function createStaticServer({
|
|
|
490
524
|
// and earlier); a newer engine has the dev tools runtime instead.
|
|
491
525
|
// Per-request, so a bundle swap mid-session is honoured.
|
|
492
526
|
const toolbox = !shared && !!resolveUrl(TOOLBOX_URL);
|
|
527
|
+
const stamps = closureStamps(resolveUrl);
|
|
528
|
+
const stamp = (url) => { const v = stamps.stamp(url); return v ? `${url}?v=${v}` : url; };
|
|
493
529
|
html = injectHeadTags(html, m[1], {
|
|
530
|
+
stamp,
|
|
494
531
|
...(tabIdentity ? { identity: tabIdentity } : {}),
|
|
495
532
|
toolbox,
|
|
496
533
|
...devtoolsFor(shared, m[1]),
|
|
497
|
-
skeleton: currentSkeleton,
|
|
534
|
+
skeleton: currentSkeleton && stampedKinds(currentSkeleton, urlPath, stamp),
|
|
498
535
|
...(shared ? { shareHost, replay: shareIsOwner } : {}),
|
|
499
536
|
});
|
|
500
537
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
538
|
+
return html;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// GET /__joystack/warm/<slug>/ the warm page (WARM_PAGE)
|
|
542
|
+
// GET /__joystack/warm/<slug>/files.json { files: [url…] }
|
|
543
|
+
//
|
|
544
|
+
// The files the game's start downloads: its page's stamped closure as this
|
|
545
|
+
// request's page would name it (fingerprint/, startFiles), then the assets
|
|
546
|
+
// its assets.js declares, which it asks for under their plain URLs. The
|
|
547
|
+
// page is not a game page: it carries no platform layer, no dev tools, no
|
|
548
|
+
// recording and no room, and a version switch does not wait for it. While
|
|
549
|
+
// no version is ready the list is empty.
|
|
550
|
+
async function serveWarm(req, res, urlPath) {
|
|
551
|
+
const m = /^\/__joystack\/warm\/([^/]+)\/(files\.json)?$/.exec(urlPath);
|
|
552
|
+
if (!m || decodeURIComponent(m[1]) !== slug) {
|
|
553
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
554
|
+
return res.end('404');
|
|
510
555
|
}
|
|
511
|
-
sendBody(res, 200,
|
|
556
|
+
if (!m[2]) return sendBody(res, 200, WARM_PAGE, MIME['.html']);
|
|
557
|
+
const status = versions?.status();
|
|
558
|
+
const fsPath = resolveUrl(gameEntry);
|
|
559
|
+
let files = [];
|
|
560
|
+
if (fsPath && (!status || status.state === 'ready')) {
|
|
561
|
+
const html = pageHtml(req, fsPath, gameEntry, new URLSearchParams());
|
|
562
|
+
const { engineTools = [], gameTools = [] } = devtoolsFor(isShareHost(req.headers.host, shareHost), slug);
|
|
563
|
+
const plain = [...engineTools, ...gameTools];
|
|
564
|
+
files = [...startFiles({ html, pageUrl: gameEntry, resolveUrl, plain }), ...(await declaredAssets({ slug, resolveUrl }))];
|
|
565
|
+
}
|
|
566
|
+
return sendBody(res, 200, JSON.stringify({ files }), 'application/json');
|
|
512
567
|
}
|
|
513
568
|
|
|
514
|
-
function serveJs(res, fsPath, urlPath) {
|
|
569
|
+
function serveJs(req, res, fsPath, urlPath, query) {
|
|
515
570
|
const raw = readFileSync(fsPath);
|
|
516
571
|
let body;
|
|
517
572
|
try {
|
|
@@ -519,12 +574,36 @@ export function createStaticServer({
|
|
|
519
574
|
} catch {
|
|
520
575
|
body = raw;
|
|
521
576
|
}
|
|
522
|
-
|
|
577
|
+
sendFile(req, res, body, MIME['.js'], urlPath, query);
|
|
523
578
|
}
|
|
524
579
|
|
|
525
|
-
|
|
580
|
+
// A file of the game or the engine (never a page): kept by the browser for
|
|
581
|
+
// good under a URL whose stamp is current, otherwise checked with this
|
|
582
|
+
// server by its tag (fingerprint/).
|
|
583
|
+
function sendFile(req, res, body, type, urlPath, query) {
|
|
584
|
+
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
585
|
+
const requested = query?.get('v') ?? null;
|
|
586
|
+
const headers = cacheHeaders({ requested, current: requested ? closureStamps(resolveUrl).stamp(urlPath) : null, body: buf });
|
|
587
|
+
if (headers.ETag && notModified(req, headers.ETag)) {
|
|
588
|
+
// Closed after: the maker's container proxy (workerd) fails the next
|
|
589
|
+
// request on a connection that has carried a bodiless 304 ("Container
|
|
590
|
+
// port connection closed unexpectedly").
|
|
591
|
+
res.writeHead(304, { ...headers, 'Access-Control-Allow-Origin': '*', Connection: 'close' });
|
|
592
|
+
return res.end();
|
|
593
|
+
}
|
|
594
|
+
res.writeHead(200, {
|
|
595
|
+
...headers,
|
|
596
|
+
'Content-Type': type,
|
|
597
|
+
'Content-Length': buf.length,
|
|
598
|
+
'Access-Control-Allow-Origin': '*',
|
|
599
|
+
});
|
|
600
|
+
res.end(req.method === 'HEAD' ? undefined : buf);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function sendBody(res, status, body, type, headers = {}) {
|
|
526
604
|
const buf = Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
527
605
|
res.writeHead(status, {
|
|
606
|
+
...headers,
|
|
528
607
|
'Content-Type': type,
|
|
529
608
|
'Content-Length': buf.length,
|
|
530
609
|
'Cache-Control': 'no-store, must-revalidate',
|
|
@@ -985,6 +1064,9 @@ export function createStaticServer({
|
|
|
985
1064
|
const reqUrl = new URL(req.url, 'http://x');
|
|
986
1065
|
const urlPath = canonicalDevPath(reqUrl.pathname);
|
|
987
1066
|
if (urlPath === '/__reload') return handleSse(res, reqUrl.searchParams);
|
|
1067
|
+
if (urlPath.startsWith(`${WARM_PATH}/`)) {
|
|
1068
|
+
return serveWarm(req, res, urlPath).catch(() => res.headersSent ? res.end() : (res.writeHead(500), res.end('500')));
|
|
1069
|
+
}
|
|
988
1070
|
if (versions && (urlPath === DEV_CONTROL_PATH || urlPath.startsWith(`${DEV_CONTROL_PATH}/`))) {
|
|
989
1071
|
return handleDevControl(req, res, urlPath).catch(() => res.headersSent ? res.end() : (res.writeHead(500), res.end('500')));
|
|
990
1072
|
}
|
|
@@ -1073,8 +1155,8 @@ export function createStaticServer({
|
|
|
1073
1155
|
const ext = extname(fsPath).toLowerCase();
|
|
1074
1156
|
try {
|
|
1075
1157
|
if (ext === '.html') return serveHtml(req, res, fsPath, urlPath, reqUrl.searchParams);
|
|
1076
|
-
if (ext === '.js' || ext === '.mjs') return serveJs(res, fsPath, urlPath);
|
|
1077
|
-
|
|
1158
|
+
if (ext === '.js' || ext === '.mjs') return serveJs(req, res, fsPath, urlPath, reqUrl.searchParams);
|
|
1159
|
+
sendFile(req, res, readFileSync(fsPath), MIME[ext] ?? 'application/octet-stream', urlPath, reqUrl.searchParams);
|
|
1078
1160
|
} catch {
|
|
1079
1161
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
1080
1162
|
res.end('500');
|
package/lib/test-cmd.mjs
CHANGED
|
@@ -47,18 +47,35 @@ export const posixRelative = (dir, file, p = nodePath) => p.relative(dir, file).
|
|
|
47
47
|
// The path module is injectable for the same reason: with `path.win32` the
|
|
48
48
|
// real readdir → join → relative → skip chain runs with Windows semantics
|
|
49
49
|
// against real files, on any host.
|
|
50
|
-
|
|
50
|
+
//
|
|
51
|
+
// The walk never opens a folder it skips: the dev server keeps changing what
|
|
52
|
+
// is under .joystack/ (frozen copies come and go) and node_modules/ is large,
|
|
53
|
+
// and listing everything to filter after fails the whole run on a file that
|
|
54
|
+
// vanished mid-listing. A folder that vanishes while the walk looks at it is
|
|
55
|
+
// simply not there. `readdir` is injectable to check both.
|
|
56
|
+
export function discoverTestFiles(dir, { path: p = nodePath, readdir = readdirSync } = {}) {
|
|
51
57
|
const unit = [];
|
|
52
58
|
const smokes = [];
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// `real` is the folder as this host's file system names it; `shown`, as the
|
|
60
|
+
// injected path module does (the two differ only under path.win32).
|
|
61
|
+
const walk = (real, shown) => {
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = readdir(real, { withFileTypes: true });
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err?.code === 'ENOENT' && real !== dir) return;
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
const path = p.join(shown, entry.name);
|
|
71
|
+
if (isSkippedPath(p.relative(dir, path))) continue;
|
|
72
|
+
if (entry.isDirectory()) walk(nodePath.join(real, entry.name), path);
|
|
73
|
+
else if (!entry.isFile()) continue;
|
|
74
|
+
else if (entry.name.endsWith('.test.mjs')) unit.push(path);
|
|
75
|
+
else if (entry.name.endsWith('.smoke.mjs')) smokes.push(path);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
walk(dir, dir);
|
|
62
79
|
unit.sort();
|
|
63
80
|
smokes.sort();
|
|
64
81
|
return { unit, smokes };
|
|
@@ -330,7 +347,7 @@ async function freeTestPort(base = 8100) {
|
|
|
330
347
|
|
|
331
348
|
// Preload that GPU-offloads each smoke's browser (see smoke-gpu-preload.mjs).
|
|
332
349
|
// A file URL so `node --import` resolves it regardless of the smoke's cwd.
|
|
333
|
-
const SMOKE_PRELOAD = new URL('./smoke-gpu-preload.mjs', import.meta.url).href;
|
|
350
|
+
export const SMOKE_PRELOAD = new URL('./smoke-gpu-preload.mjs', import.meta.url).href;
|
|
334
351
|
|
|
335
352
|
// The compact node:test reporter (see test-reporter.mjs), as a file URL for the
|
|
336
353
|
// same reason.
|
|
@@ -343,7 +360,7 @@ const REPORTER = new URL('./test-reporter.mjs', import.meta.url).href;
|
|
|
343
360
|
// engine is absent (pruned by an npm install — the next dev/publish self-heals
|
|
344
361
|
// it) or too old to ship the preload — the spawn then runs without it, as
|
|
345
362
|
// before.
|
|
346
|
-
function joystackTestPreload(projectDir) {
|
|
363
|
+
export function joystackTestPreload(projectDir) {
|
|
347
364
|
try {
|
|
348
365
|
const preload = join(resolveEngine(projectDir).dir, 'shared', 'framework', 'build', 'joystack-test-preload.mjs');
|
|
349
366
|
return existsSync(preload) ? pathToFileURL(preload).href : null;
|
package/package.json
CHANGED