anvilsign in

collin/mahjong

1import { writeFileSync, rmSync } from 'node:fs';
2import { createServer } from 'node:http';
3import { tmpdir } from 'node:os';
4import { join as joinPath } from 'node:path';
5import { afterAll, beforeAll, expect, test } from 'vitest';
6import { joinRoom } from '../src/net/room.ts';
7import { attachRooms } from './rooms.ts';
8
9// The real client against the real relay, in one process. Plain .mjs on
10// purpose: the client is typed for the browser and the relay for node, and
11// neither tsconfig wants to swallow the other — vitest transforms both.
12
13// A fake rsgrok, so the relay's own tunnel-spawning runs for real without
14// leaving the machine: prints the tunnel-up line and then holds, like the
15// real one. Installed before any server exists — the relay spawns on listen.
16const stub = joinPath(tmpdir(), `fake-rsgrok-${process.pid}.sh`);
17writeFileSync(
18 stub,
19 '#!/bin/sh\necho "rsgrok: https://ours.vibe.richardscollin.com -> $2"\nexec sleep 60\n',
20 { mode: 0o755 },
21);
22process.env.RSGROK_BIN = stub;
23
24let server;
25let url;
26const opened = [];
27
28beforeAll(async () => {
29 server = createServer();
30 attachRooms(server);
31 await new Promise((r) => server.listen(0, '127.0.0.1', r));
32 url = `ws://127.0.0.1:${server.address().port}/ws`;
33});
34afterAll(async () => {
35 for (const r of opened) r.leave();
36 await new Promise((r) => server.close(r));
37 delete process.env.RSGROK_BIN;
38 rmSync(stub, { force: true });
39});
40
41const join = async (roomCode) => {
42 const r = await joinRoom({ room: roomCode, server: url });
43 opened.push(r);
44 return r;
45};
46const next = (sub) => new Promise((r) => {
47 const un = sub((...args) => {
48 un();
49 r(args);
50 });
51});
52
53test('a full little room: join, state, events, migration', async () => {
54 const a = await join();
55 expect(a.code).toMatch(/^[A-Z2-9]{6}$/);
56 expect(a.isHost).toBe(true);
57 expect(a.me.name).toBe('Guest 1');
58
59 const joinSeen = next((cb) => a.onJoin(cb));
60 const b = await join(a.code);
61 expect(b.code).toBe(a.code);
62 expect(b.isHost).toBe(false);
63 expect(b.players.length).toBe(2);
64 expect((await joinSeen)[0].id).toBe(b.me.id);
65
66 // Host writes, mirror reads.
67 const stateSeen = next((cb) => b.onState(cb));
68 a.setState({ mode: 'party', game: { turn: 2 } });
69 await stateSeen;
70 expect(b.state.mode).toBe('party');
71 expect(b.state.game).toEqual({ turn: 2 });
72
73 // Only the host writes.
74 expect(() => b.setState({ evil: true })).toThrow();
75
76 // Events go the other way, stamped with the sender.
77 const actSeen = next((cb) => a.on('act', (payload, from) => cb(payload, from)));
78 b.send('act', { seat: 1 });
79 const [payload, from] = await actSeen;
80 expect(payload).toEqual({ seat: 1 });
81 expect(from).toBe(b.me.id);
82
83 // The host leaves; the room passes to the next in line, state intact.
84 const leaveSeen = next((cb) => b.onLeave(cb));
85 const hostSeen = next((cb) => b.onHostChange(cb));
86 a.leave();
87 expect((await leaveSeen)[0].id).toBe(a.me.id);
88 await hostSeen;
89 expect(b.isHost).toBe(true);
90 expect(b.state.mode).toBe('party');
91 b.setState({ mode: 'party', n: 1 }); // and the crown is real
92});
93
94test('a late joiner walks in on the state as it stands', async () => {
95 const a = await join();
96 a.setState({ mode: 'online', seats: [1, 2, 3, 4] });
97 // The relay applies the (microtask-coalesced) write before answering a
98 // later hello, both being one client's messages in order — but ours is a
99 // second connection, so give the flush a beat.
100 await new Promise((r) => setTimeout(r, 50));
101 const b = await join(a.code);
102 expect(b.state.mode).toBe('online');
103 expect(b.state.seats).toEqual([1, 2, 3, 4]);
104});
105
106test('an unknown code becomes a fresh room, not an error', async () => {
107 const a = await join('REBORN');
108 expect(a.code).toBe('REBORN');
109 expect(a.isHost).toBe(true);
110 expect(a.state).toEqual({});
111});
112
113test('the join carries the url of the tunnel the relay opened itself', async () => {
114 const r = await join();
115 expect(r.publicBase).toBe('https://ours.vibe.richardscollin.com');
116});
117
118test('no rsgrok, no link — a join still lands, just local', async () => {
119 // A "binary" that dies at once: the relay must shrug, not stall the hello.
120 const dead = joinPath(tmpdir(), `dead-rsgrok-${process.pid}.sh`);
121 writeFileSync(dead, '#!/bin/sh\nexit 1\n', { mode: 0o755 });
122 const was = process.env.RSGROK_BIN;
123 process.env.RSGROK_BIN = dead;
124 const relay = createServer();
125 attachRooms(relay);
126 await new Promise((r) => relay.listen(0, '127.0.0.1', r));
127 try {
128 const r = await joinRoom({ server: `ws://127.0.0.1:${relay.address().port}/ws` });
129 expect(r.publicBase).toBe('');
130 r.leave();
131 } finally {
132 process.env.RSGROK_BIN = was;
133 rmSync(dead, { force: true });
134 await new Promise((r) => relay.close(r));
135 }
136});
137
138test('PUBLIC_URL outranks any tunnel', async () => {
139 process.env.PUBLIC_URL = 'https://mahjong.example.com';
140 try {
141 const a = await join();
142 expect(a.publicBase).toBe('https://mahjong.example.com');
143 } finally {
144 delete process.env.PUBLIC_URL;
145 }
146});