Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ jobs:
working-directory: plugins/draw/ui
- run: npm run build
working-directory: plugins/draw/ui
- run: npm run test:integration

ci-success:
name: CI Success
Expand Down
8 changes: 6 additions & 2 deletions plugins/draw/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Draw development checks

From `plugins/draw`, install the server dependencies and run its build and session tests:
From `plugins/draw`, install the server dependencies and run its build, session, and parser tests:

```sh
npm ci
Expand All @@ -13,6 +13,10 @@ Build the UI from its own lockfile:
cd ui
npm ci
npm run build
cd ..
npm run test:integration
```

The tests exercise Nano ID's six-character session suffixes and Draw storage in temporary directories. They do not open a browser or sharing tunnel. CI runs these checks on Node.js 20, 22, and 24 alongside the repository's Bats and Shellcheck jobs.
The integration tests connect the UI Socket.IO client to the Draw server over loopback WebSocket and polling transports. They verify room joins, scene updates, cursor updates, state requests, and disconnects.

The unit tests exercise parser compatibility, rejection of malformed binary headers, Nano ID's six-character session suffixes and Draw storage in temporary directories. They do not open a browser or sharing tunnel. CI runs these checks on Node.js 20, 22, and 24 alongside the repository's Bats and Shellcheck jobs.
6 changes: 3 additions & 3 deletions plugins/draw/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion plugins/draw/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"build:server": "tsc",
"build:ui": "cd ui && npm install && npm run build",
"dev": "tsc --watch",
"test": "npm run build:server && node --test test/*.test.mjs"
"test": "npm run build:server && node --test test/*.test.mjs",
"test:integration": "npm run build:server && node --test test/integration/*.test.mjs"
},
"dependencies": {
"nanoid": "^5.1.16",
Expand Down
69 changes: 69 additions & 0 deletions plugins/draw/test/integration/room.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import http from 'node:http';
import { createRequire } from 'node:module';
import { once } from 'node:events';
import { test } from 'node:test';
import { createRoomServer } from '../../dist/server/room.js';

// Use the actual UI lockfile's client against the actual Draw room server.
const requireUi = createRequire(new URL('../../ui/package.json', import.meta.url));
const { io } = requireUi('socket.io-client');

function event(socket, name) {
return once(socket, name, { signal: AbortSignal.timeout(5000) }).then(([data]) => data);
}

for (const transport of ['websocket', 'polling']) {
test(`room collaboration works over ${transport}`, { timeout: 15000 }, async (t) => {
const server = http.createServer();
const room = createRoomServer(server, 'local-fixture-room', []);
const clients = [];
t.after(async () => {
for (const socket of clients) socket.disconnect();
await new Promise((resolve) => room.io.close(resolve));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const url = `http://127.0.0.1:${server.address().port}`;
async function join(username) {
const socket = io(url, { transports: [transport], reconnection: false, autoConnect: false });
clients.push(socket);
const connected = event(socket, 'connect');
socket.connect();
await connected;
const state = event(socket, 'full-state');
socket.emit('join', { roomId: 'local-fixture-room', username });
assert.deepEqual((await state).elements, []);
return socket;
}

const alice = await join('Alice fixture');
const joined = event(alice, 'participant-joined');
const bob = await join('Bob fixture');
assert.equal((await joined).username, 'Bob fixture');
assert.deepEqual(room.getParticipants().sort(), ['Alice fixture', 'Bob fixture']);

const elements = [{ id: 'fixture-rectangle', type: 'rectangle', version: 1, x: 10, y: 20 }];
const update = event(bob, 'scene-update');
alice.emit('scene-update', { elements });
assert.deepEqual(await update, { type: 'scene-update', elements, from: 'Alice fixture' });
assert.deepEqual(room.getElements(), elements);

const cursor = event(bob, 'cursor-update');
alice.emit('cursor-update', { pointer: { x: 30, y: 40 } });
const pointer = await cursor;
assert.deepEqual(pointer.pointer, { x: 30, y: 40 });
assert.equal(pointer.username, 'Alice fixture');

const state = event(bob, 'full-state');
bob.emit('request-state');
const snapshot = await state;
assert.deepEqual(snapshot.elements, elements);
assert.deepEqual(snapshot.participants.map((p) => p.username).sort(), ['Alice fixture', 'Bob fixture']);

const left = event(alice, 'participant-left');
bob.disconnect();
assert.equal((await left).username, 'Bob fixture');
assert.deepEqual(room.getParticipants(), ['Alice fixture']);
});
}
71 changes: 71 additions & 0 deletions plugins/draw/test/parser.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { test } from 'node:test';
import * as esmParser from 'socket.io-parser';

const require = createRequire(import.meta.url);

// Socket.IO uses the CommonJS entry; also cover the ESM API used by tooling.
for (const [entry, { Encoder, Decoder, PacketType }] of [
['CommonJS', require('socket.io-parser')],
['ESM', esmParser],
]) {
function roundTrip(data) {
const decoder = new Decoder();
const decoded = [];
decoder.on('decoded', (packet) => decoded.push(packet));
try {
const encoded = new Encoder().encode({ type: PacketType.EVENT, nsp: '/', data });
for (const frame of encoded) decoder.add(frame);
assert.equal(decoded.length, 1);
assert.equal(decoded[0].type, PacketType.EVENT);
assert.equal(decoded[0].nsp, '/');
return decoded[0].data;
} finally {
decoder.destroy();
}
}

test(`${entry}: preserves Draw JSON event shapes`, () => {
for (const event of [
['join', { roomId: 'local-room', username: 'fixture' }],
['full-state', { type: 'full-state', elements: [], participants: [] }],
['scene-update', { elements: [{ id: 'rectangle', version: 1, x: 10, y: 20 }] }],
['cursor-update', { pointer: { x: 12, y: 34 } }],
['request-state'],
]) {
assert.deepEqual(roundTrip(event), event);
}
});

test(`${entry}: preserves a normal binary event`, () => {
const event = ['fixture', { bytes: Buffer.from('small local fixture') }];
assert.deepEqual(roundTrip(event), event);
});

test(`${entry}: honors JSON serialization alongside binary data`, () => {
const bytes = Buffer.from('local drawing fixture');
class DrawingAttachment {
constructor(value) {
this.internalBytes = value;
}
toJSON() {
return { attachment: this.internalBytes, label: 'fixture' };
}
}
assert.deepEqual(roundTrip(['fixture', new DrawingAttachment(bytes)]), ['fixture', { attachment: bytes, label: 'fixture' }]);
});

test(`${entry}: rejects a binary header without attachments`, () => {
const decoder = new Decoder();
let decoded = false;
decoder.on('decoded', () => { decoded = true; });
try {
// One short local header; no attachments or resource-exhaustion traffic.
assert.throws(() => decoder.add('50-["fixture"]'), /Illegal attachments/);
assert.equal(decoded, false);
} finally {
decoder.destroy();
}
});
}
6 changes: 3 additions & 3 deletions plugins/draw/ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.