getjoinable 0.0.0-stage → 0.1.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 +15 -2
- package/getjoinable.mjs +132 -0
- package/package.json +11 -4
package/README.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
-
#
|
|
1
|
+
# getjoinable
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The self-test for [Joinable](https://getjoinable.com), the service that makes web games multiplayer.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx getjoinable test http://localhost:5173/
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It opens two headless players on your game's page and checks the multiplayer integration end to
|
|
10
|
+
end: both players start a round with the same seed, see each other, finish with one result, and
|
|
11
|
+
can rematch. Each failure says what to fix. Made for coding agents as much as for people.
|
|
12
|
+
|
|
13
|
+
Options: `--round 90` (seconds to wait for a round to finish on its own), `--headed` (show the
|
|
14
|
+
browsers), `--channel chrome` (use your installed Chrome with its GPU, for heavy 3D games).
|
|
15
|
+
|
|
16
|
+
It needs Playwright's Chromium: `npx playwright-core install chromium` if it isn't installed.
|
package/getjoinable.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Joinable's self-test: two headless players on your game's page, checking that the
|
|
3
|
+
// multiplayer contract works end to end. Made for coding agents: every failure says what to fix.
|
|
4
|
+
// npx getjoinable test http://localhost:5600/ (your game's page, with the Joinable script on it)
|
|
5
|
+
// The npm package is `getjoinable` (not `joinable`, which is someone else's package). Until it's
|
|
6
|
+
// published, run this file directly: node bin/getjoinable.mjs test URL
|
|
7
|
+
// options: --round 90 seconds to wait for a round to finish on its own (default 60)
|
|
8
|
+
// --headed show the two browsers
|
|
9
|
+
// --channel chrome use your installed Chrome (with the GPU) instead of the bundled
|
|
10
|
+
// headless Chromium, which draws WebGL in software: heavy 3D games can
|
|
11
|
+
// drop to a few frames a second there and look broken when they aren't
|
|
12
|
+
import { chromium } from 'playwright-core';
|
|
13
|
+
|
|
14
|
+
const [cmd, url, ...rest] = process.argv.slice(2);
|
|
15
|
+
if (cmd !== 'test' || !url) {
|
|
16
|
+
console.log('Usage: npx getjoinable test <url of your game page> [--round 60] [--headed] [--channel chrome]');
|
|
17
|
+
process.exit(cmd ? 1 : 0);
|
|
18
|
+
}
|
|
19
|
+
const flag = (n, d) => {
|
|
20
|
+
const i = rest.indexOf(`--${n}`);
|
|
21
|
+
return i < 0 ? d : rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[i + 1] : true;
|
|
22
|
+
};
|
|
23
|
+
const roundSec = Number(flag('round', 60)) || 60;
|
|
24
|
+
|
|
25
|
+
const results = [];
|
|
26
|
+
let failed = false;
|
|
27
|
+
const pass = (msg) => (results.push(`✓ ${msg}`), console.log(`✓ ${msg}`));
|
|
28
|
+
const warn = (msg) => (results.push(`! ${msg}`), console.log(`! ${msg}`));
|
|
29
|
+
function fail(msg, hint) {
|
|
30
|
+
failed = true;
|
|
31
|
+
console.log(`✗ ${msg}${hint ? `\n → ${hint}` : ''}`);
|
|
32
|
+
throw new Error('stop');
|
|
33
|
+
}
|
|
34
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
35
|
+
|
|
36
|
+
const channel = flag('channel', null);
|
|
37
|
+
const browser = await chromium.launch({ headless: flag('headed', false) !== true, ...(typeof channel === 'string' ? { channel } : {}) });
|
|
38
|
+
const errors = [];
|
|
39
|
+
async function open(u, name) {
|
|
40
|
+
const page = await (await browser.newContext({ viewport: { width: 1000, height: 700 } })).newPage();
|
|
41
|
+
page.on('pageerror', (e) => errors.push(`${name}: ${e.message}`));
|
|
42
|
+
page.on('console', (m) => m.type() === 'error' && errors.push(`${name}: ${m.text()}`));
|
|
43
|
+
await page.goto(u, { waitUntil: 'domcontentloaded' });
|
|
44
|
+
return page;
|
|
45
|
+
}
|
|
46
|
+
// Poll on a timer, not on animation frames: a slow game's frames are exactly what's in question.
|
|
47
|
+
const waitFor = (p, fn, arg, ms) => p.waitForFunction(fn, arg, { timeout: ms, polling: 100 }).then(() => true, () => false);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const a = await open(url, 'player 1');
|
|
51
|
+
if (!(await waitFor(a, () => window.Joinable, null, 10000))) fail('The Joinable script is not on the page', 'Add <script src="RELAY/sdk.js" data-key="KEY"></script> before the game\'s own scripts.');
|
|
52
|
+
if (!(await waitFor(a, () => window.Joinable._test.spec, null, 10000)))
|
|
53
|
+
fail('The game never called Joinable.game({ profile, start, … })', 'Call Joinable.game once the game has loaded. See kit/AGENTS.md, "Full rounds".');
|
|
54
|
+
const spec = await a.evaluate(() => window.Joinable._test.spec);
|
|
55
|
+
pass(`game registered: profile "${spec.profile}", ${spec.min}–${spec.max} players${spec.public ? ', public rooms on' : ''}`);
|
|
56
|
+
|
|
57
|
+
const link = await a.evaluate(() => window.Joinable.inviteLink());
|
|
58
|
+
if (!(await waitFor(a, () => window.Joinable.me && window.Joinable.session, null, 15000)))
|
|
59
|
+
fail('Player 1 could not connect to the relay', 'Check data-key and that this page\'s origin is allowed for the key (the console shows the reason), and that the script loads from https://relay.getjoinable.com (or staging.getjoinable.com), not a *.workers.dev address.');
|
|
60
|
+
const b = await open(link, 'player 2');
|
|
61
|
+
if (!(await waitFor(b, () => window.Joinable?.me && window.Joinable.players.length >= 2, null, 15000))) fail('Player 2 could not join from the invite link', 'Is ?join= being removed from the address bar by the game? Keep it.');
|
|
62
|
+
pass('two players in one room by invite link');
|
|
63
|
+
|
|
64
|
+
await b.evaluate(() => window.Joinable.ready(true));
|
|
65
|
+
await a.evaluate(() => window.Joinable.ready(true));
|
|
66
|
+
if (!(await waitFor(a, () => ['countdown', 'playing'].includes(window.Joinable.session?.phase), null, 15000))) fail('The round did not start when both were ready');
|
|
67
|
+
const lead = spec.countdown * 1000;
|
|
68
|
+
await sleep(lead + 500);
|
|
69
|
+
const [sa, sb] = [await a.evaluate(() => window.Joinable._test.starts.at(-1)), await b.evaluate(() => window.Joinable._test.starts.at(-1))];
|
|
70
|
+
if (!sa || !sb) fail('start() was not called on both players', 'Joinable calls the start(round) you passed to Joinable.game; check it is a function.');
|
|
71
|
+
if (sa.seed !== sb.seed) fail('The players got different seeds');
|
|
72
|
+
const apart = Math.abs(sa.calledAt - sb.calledAt);
|
|
73
|
+
if (apart > 250) warn(`start() ran ${apart} ms apart on the two players (expected under 250 ms)`);
|
|
74
|
+
else pass(`start() ran on both with seed ${sa.seed}, ${apart} ms apart`);
|
|
75
|
+
|
|
76
|
+
if (!(await waitFor(a, () => window.Joinable.session?.phase === 'playing', null, 10000))) fail('The countdown never reached "go"');
|
|
77
|
+
await sleep(1500);
|
|
78
|
+
const needsLocal = ['race', 'arena', 'score'].includes(spec.profile);
|
|
79
|
+
if (needsLocal) {
|
|
80
|
+
const l1 = await a.evaluate(() => window.Joinable._test.local());
|
|
81
|
+
if (!l1 || typeof l1 !== 'object') fail('local() returned nothing', `For "${spec.profile}", local() returns the player's state, e.g. ${spec.profile === 'score' ? '{ score }' : '{ x, y, a, vx, vy, progress }'}.`);
|
|
82
|
+
const bad = Object.entries(l1).filter(([, v]) => typeof v === 'object' && v !== null);
|
|
83
|
+
if (bad.length) warn(`local() has nested objects (${bad.map(([k]) => k).join(', ')}); keep it flat numbers for smoothing`);
|
|
84
|
+
if (JSON.stringify(l1).length > 1500) fail('local() is too big', 'Keep it under 1.5 KB: positions, velocities, progress or score. Not the whole game state.');
|
|
85
|
+
pass(`local() reports ${Object.keys(l1).join(', ')}`);
|
|
86
|
+
}
|
|
87
|
+
if (['race', 'arena'].includes(spec.profile)) {
|
|
88
|
+
if (!(await waitFor(a, () => window.Joinable.remote().some((r) => r.state), null, 6000))) {
|
|
89
|
+
const fps = await a.evaluate(() => new Promise((r) => { let n = 0; const t0 = performance.now(); const f = () => (++n, performance.now() - t0 < 1000 ? requestAnimationFrame(f) : r(n)); requestAnimationFrame(f); }));
|
|
90
|
+
fail("Player 1 never received player 2's state", fps < 15 && channel !== 'chrome' ? `The page ran at ${fps} frames a second in headless Chromium. Heavy 3D games need the GPU: add --channel chrome.` : 'local() must return an object while the round is on.');
|
|
91
|
+
}
|
|
92
|
+
const calls = await a.evaluate(() => window.Joinable._test.remoteCalls);
|
|
93
|
+
if (!calls) fail('remote(players) was never called', 'Pass remote(players) to Joinable.game, and run the game loop with requestAnimationFrame.');
|
|
94
|
+
const keys = await a.evaluate(() => Object.keys(window.Joinable.remote()[0].state));
|
|
95
|
+
if (!keys.includes('x') || !keys.includes('y')) warn('remote players have no x and y: smoothing and bump prediction work on x, y, z with vx, vy, vz');
|
|
96
|
+
if (!keys.includes('vx')) warn('local() has no vx/vy: remote players are drawn smoothly, but bumps will use slightly old positions');
|
|
97
|
+
pass(`remote(players) is called every frame (${calls} so far) with the other player's ${keys.join(', ')}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Finish: wait for the game to call Joinable.finish on its own.
|
|
101
|
+
console.log(` … waiting up to ${roundSec} s for the round to finish on its own`);
|
|
102
|
+
const ended = await waitFor(a, () => window.Joinable.session?.phase === 'results', null, roundSec * 1000);
|
|
103
|
+
if (!ended) {
|
|
104
|
+
warn(`the round didn't end within ${roundSec} s. Finishing it for the test (use --round to wait longer, or check that the game calls Joinable.finish({ time }) or ({ score }))`);
|
|
105
|
+
for (const p of [a, b]) await p.evaluate(() => window.Joinable.finish({ time: 999, score: 0 }));
|
|
106
|
+
if (!(await waitFor(a, () => window.Joinable.session?.phase === 'results', null, 5000))) fail('Results never showed after both finished');
|
|
107
|
+
} else pass('the game called Joinable.finish and the round ended');
|
|
108
|
+
const ra = await a.evaluate(() => window.Joinable.session.results);
|
|
109
|
+
const rb = await b.evaluate(() => window.Joinable.session.results);
|
|
110
|
+
if (JSON.stringify(ra) !== JSON.stringify(rb)) fail('The two players see different results');
|
|
111
|
+
pass(`one result on both: ${ra.map((r) => `${r.name} ${r.dnf ? 'DNF' : (r.time ?? r.score)}`).join(', ')}`);
|
|
112
|
+
|
|
113
|
+
await a.evaluate(() => window.Joinable._test.rematch());
|
|
114
|
+
await b.evaluate(() => window.Joinable._test.rematch());
|
|
115
|
+
if (!(await waitFor(a, () => window.Joinable.session?.round === 2, null, 10000))) fail('Rematch did not start round 2');
|
|
116
|
+
await sleep(lead + 300);
|
|
117
|
+
const n = await b.evaluate(() => window.Joinable._test.starts.length);
|
|
118
|
+
if (n < 2) fail('start() was not called again for the rematch', 'Make start(round) reset the game fully each time it is called.');
|
|
119
|
+
pass('rematch: start() called again for round 2');
|
|
120
|
+
} catch (e) {
|
|
121
|
+
if (e.message !== 'stop') {
|
|
122
|
+
failed = true;
|
|
123
|
+
console.log(`✗ ${e.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const real = errors.filter((e) => !/clipboard|share|favicon/i.test(e));
|
|
127
|
+
if (real.length) {
|
|
128
|
+
console.log(`! errors in the page:\n ${real.slice(0, 8).join('\n ')}`);
|
|
129
|
+
}
|
|
130
|
+
await browser.close();
|
|
131
|
+
console.log(failed ? '\nJoinable self-test: FAILED' : '\nJoinable self-test: passed');
|
|
132
|
+
process.exit(failed ? 1 : 0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getjoinable",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Joinable's self-test: two headless players check that your web game's multiplayer integration works. https://getjoinable.com",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "getjoinable": "getjoinable.mjs" },
|
|
7
|
+
"files": ["getjoinable.mjs", "README.md"],
|
|
8
|
+
"engines": { "node": ">=20" },
|
|
9
|
+
"dependencies": { "playwright-core": "^1.64.1" },
|
|
10
|
+
"homepage": "https://getjoinable.com",
|
|
11
|
+
"keywords": ["multiplayer", "web games", "game", "netcode", "joinable", "test"],
|
|
12
|
+
"license": "UNLICENSED"
|
|
13
|
+
}
|