From 6f6b00fd05455d47312f449033e51635316a55b5 Mon Sep 17 00:00:00 2001 From: Hamcha Date: Fri, 6 Sep 2019 12:36:11 +0000 Subject: [PATCH] Basic networking (#9) Added PeerJS-powered client/server architecture. Clients can join, leave, chat rooms, Server can take parts as players too via a local client shim. Closes #4 Signed-off-by: Hamcha --- .gitignore | 1 + README.md | 2 + jest.config.js | 38 ++-- package.json | 1 + src/network/Client.ts | 98 ++++++++++ src/network/LocalClient.ts | 20 +++ src/network/PeerClient.ts | 45 +++++ src/network/PeerServer.ts | 239 +++++++++++++++++++++++++ src/network/client.ts | 14 -- src/network/index.ts | 5 + src/network/peer.ts | 16 -- src/network/server.ts | 34 ---- src/network/types.ts | 73 +++++++- src/testing/EventHook.ts | 63 +++++++ src/testing/MockDataConnection.ts | 221 +++++++++++++++++++++++ src/testing/MockHelper.ts | 27 +++ src/testing/MockPeer.ts | 95 ++++++++++ src/testing/index.ts | 3 + {tests => src/tests}/unit/.eslintrc.js | 0 src/tests/unit/network.spec.ts | 151 ++++++++++++++++ src/utils/index.ts | 1 + src/utils/timeout.ts | 9 + yarn.lock | 5 + 23 files changed, 1070 insertions(+), 91 deletions(-) create mode 100644 src/network/Client.ts create mode 100644 src/network/LocalClient.ts create mode 100644 src/network/PeerClient.ts create mode 100644 src/network/PeerServer.ts delete mode 100644 src/network/client.ts create mode 100644 src/network/index.ts delete mode 100644 src/network/peer.ts delete mode 100644 src/network/server.ts create mode 100644 src/testing/EventHook.ts create mode 100644 src/testing/MockDataConnection.ts create mode 100644 src/testing/MockHelper.ts create mode 100644 src/testing/MockPeer.ts create mode 100644 src/testing/index.ts rename {tests => src/tests}/unit/.eslintrc.js (100%) create mode 100644 src/tests/unit/network.spec.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/timeout.ts diff --git a/.gitignore b/.gitignore index a0dddc6..4583f0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store node_modules /dist +coverage # local env files .env.local diff --git a/README.md b/README.md index 627e18d..36c499a 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,5 @@ Work in progress name, work in progress game ## License Code is ISC, Assets "depends", some stuff is taken from FreeSound and DeviantArt so your best bet is to just praise the copyright gods. + +PeerJS mocking is based on works by Rolf Erik Lekang, which is licensed under MIT. Check [peerjs-mock](https://github.com/relekang/peerjs-mock) for more details. diff --git a/jest.config.js b/jest.config.js index 0d19e00..c1b9492 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,37 +1,27 @@ module.exports = { - moduleFileExtensions: [ - 'js', - 'jsx', - 'json', - 'vue', - 'ts', - 'tsx' - ], + moduleFileExtensions: ["js", "jsx", "json", "vue", "ts", "tsx"], transform: { - '^.+\\.vue$': 'vue-jest', - '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', - '^.+\\.tsx?$': 'ts-jest' + "^.+\\.vue$": "vue-jest", + ".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$": + "jest-transform-stub", + "^.+\\.tsx?$": "ts-jest" }, - transformIgnorePatterns: [ - '/node_modules/' - ], + transformIgnorePatterns: ["/node_modules/"], moduleNameMapper: { - '^@/(.*)$': '/src/$1' + "^@/(.*)$": "/src/$1" }, - snapshotSerializers: [ - 'jest-serializer-vue' - ], + snapshotSerializers: ["jest-serializer-vue"], testMatch: [ - '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' + "**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)" ], - testURL: 'http://localhost/', + testURL: "http://localhost/", watchPlugins: [ - 'jest-watch-typeahead/filename', - 'jest-watch-typeahead/testname' + "jest-watch-typeahead/filename", + "jest-watch-typeahead/testname" ], globals: { - 'ts-jest': { + "ts-jest": { babelConfig: true } } -} +}; diff --git a/package.json b/package.json index 78af567..26304fa 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "buefy": "^0.8.2", "core-js": "^2.6.5", "dexie": "^2.0.4", + "eventemitter3": "^4.0.0", "peerjs": "^1.0.4", "register-service-worker": "^1.6.2", "vue": "^2.6.10", diff --git a/src/network/Client.ts b/src/network/Client.ts new file mode 100644 index 0000000..a6a43c1 --- /dev/null +++ b/src/network/Client.ts @@ -0,0 +1,98 @@ +import { + PeerMetadata, + NetworkMessage, + PasswordResponse, + RoomInfo +} from "./types"; +import EventEmitter from "eventemitter3"; + +export abstract class Client extends EventEmitter { + public metadata: PeerMetadata; + public players!: string[]; + public roomInfo!: RoomInfo; + + public constructor(metadata: PeerMetadata) { + super(); + this.metadata = metadata; + } + + protected _received(data: NetworkMessage) { + this.emit("data", data); + switch (data.kind) { + // Server is sending over player list and room info + case "room-info": + this.roomInfo = data.room; + this.players = data.players; + break; + // Someone changed name (or was forced to) + case "rename": + if (data.oldname == this.metadata.name) { + // We got a name change! + this.metadata.name = data.newname; + } else { + let idx = this.players.indexOf(data.oldname); + if (idx < 0) { + // Weird + console.warn( + `Someone (${data.oldname}) changed name but wasn't on the player list` + ); + this.players.push(data.newname); + break; + } + this.players[idx] = data.newname; + } + this.emit("rename", data.oldname, data.newname); + break; + // A new player joined the room (this includes us) + case "player-joined": + this.players.push(data.name); + this.emit("player-joined", data.name); + break; + case "player-left": + let idx = this.players.indexOf(data.name); + if (idx < 0) { + // Weird + console.warn( + `Someone (${data.name}) left but wasn't on the player list` + ); + break; + } + this.players.splice(idx, 1); + case "password-req": + this.emit("password-required"); + default: + // For most cases, we can just use the kind as event type + this.emit(data.kind, data); + } + } + + public password(password: string) { + this.send({ + kind: "password-resp", + password + }); + } + + public get name(): string { + return this.metadata.name; + } + + public abstract send(data: T): void; + + public leave(): void { + this.send({ + kind: "leave-req" + }); + } + + public say(to: string | null, message: string) { + this.send({ + kind: "chat", + from: this.name, + to: to ? to : "", + message + }); + } +} + +export default Client; diff --git a/src/network/LocalClient.ts b/src/network/LocalClient.ts new file mode 100644 index 0000000..152bb59 --- /dev/null +++ b/src/network/LocalClient.ts @@ -0,0 +1,20 @@ +import { PeerMetadata, NetworkMessage } from "./types"; +import Client from "./Client"; + +export class LocalClient extends Client { + public receiver!: (data: NetworkMessage) => void; + + public constructor(metadata: PeerMetadata) { + super(metadata); + } + + public receive(data: NetworkMessage) { + this._received(data); + } + + public send(data: T) { + this.receiver(data); + } +} + +export default LocalClient; diff --git a/src/network/PeerClient.ts b/src/network/PeerClient.ts new file mode 100644 index 0000000..b728dab --- /dev/null +++ b/src/network/PeerClient.ts @@ -0,0 +1,45 @@ +import Peer, { DataConnection } from "peerjs"; +import { PeerMetadata, NetworkMessage } from "./types"; +import Client from "./Client"; + +export class PeerClient extends Client { + protected peer: Peer; + private connection?: DataConnection; + + public constructor(metadata: PeerMetadata, customPeer?: Peer) { + super(metadata); + this.peer = customPeer ? customPeer : new Peer(); + this.peer.on("open", function(id) { + console.info("Peer ID assigned: %s", id); + }); + } + + public connect(peerid: string) { + this.connection = this.peer.connect(peerid, { + metadata: this.metadata, + reliable: true + }); + if (!this.connection) { + throw new Error("Could not connect"); + } + this.connection.on("open", () => { + this.emit("connected"); + }); + this.connection.on("data", data => { + this._received(data); + }); + } + + public get name(): string { + return this.metadata.name; + } + + public send(data: T) { + if (!this.connection) { + throw new Error("Client is not connected to a server"); + } + this.connection.send(data); + } +} + +export default PeerClient; diff --git a/src/network/PeerServer.ts b/src/network/PeerServer.ts new file mode 100644 index 0000000..bfadda2 --- /dev/null +++ b/src/network/PeerServer.ts @@ -0,0 +1,239 @@ +import Peer, { DataConnection } from "peerjs"; +import { + RoomInfo, + PasswordRequest, + Room, + ErrorMessage, + PasswordResponse, + PeerMetadata, + JoinMessage, + RoomInfoMessage, + Player, + NetworkMessage, + RenameMessage, + LeaveMessage, + NetworkPlayer, + AckMessage, + ChatMessage +} from "./types"; +import { LocalClient } from "."; +import EventEmitter from "eventemitter3"; + +// Increment name, add number at the end if not present +// Examples: +// Guest -> Guest1 +// Guest1 -> Guest2 +// Guest9 -> Guest10 +function nextName(name: string): string { + let i = 1; + for (; i < name.length; i++) { + if (!isNaN(Number(name.slice(i - name.length)))) { + break; + } + } + return name.substr(0, i) + (Number(name.slice(i)) + 1); +} + +export class PeerServer extends EventEmitter { + protected peer: Peer; + private room: Room; + + public constructor( + roomInfo: RoomInfo, + local: LocalClient, + customPeer?: Peer + ) { + super(); + let players: Record = {}; + + // Add local player to server + players[local.name] = { + kind: "local", + name: local.name, + client: local + }; + local.receiver = this._received.bind(this, players[local.name]); + + this.room = { + info: roomInfo, + players + }; + + local.players = Object.keys(this.players); + local.roomInfo = this.room.info; + + // Setup peer + this.peer = customPeer ? customPeer : new Peer(); + this.peer.on("open", function(id) { + console.info("Peer ID assigned: %s", id); + }); + this.peer.on("connection", conn => { + this._connection(conn); + }); + } + + private _connection(conn: DataConnection) { + const metadata = conn.metadata as PeerMetadata; + + let player: NetworkPlayer = { + kind: "remote", + name: metadata.name, + conn: conn + }; + + // + // Check if this connection should be allowed + // + + // Check if room is full + if (this.playerCount >= this.room.info.max_players) { + this.send(player, { kind: "error", error: "room is full" }); + conn.close(); + return; + } + + // Check if there is already a player called that way + if (this.room.players[metadata.name]) { + // Force rename + let newname = metadata.name; + while (this.room.players[newname]) { + newname = nextName(metadata.name); + } + this.send(player, { + kind: "rename", + oldname: metadata.name, + newname: newname + }); + player.name = newname; + player.conn.metadata.name = newname; + } + + // Check for password + if (this.room.info.password != "") { + const checkPasswordResponse = (data: any) => { + try { + let resp = data as PasswordResponse; + if (resp.password != this.room.info.password) { + this.send(player, { + kind: "error", + error: "invalid password" + }); + return; + } + conn.off("data", checkPasswordResponse); + this.addPlayer(player); + } catch (e) { + this.send(player, { + kind: "error", + error: "not a password" + }); + } + }; + + this.send(player, { kind: "password-req" }); + conn.on("data", checkPasswordResponse.bind(this)); + return; + } + + this.addPlayer(player); + } + + private addPlayer(player: NetworkPlayer) { + const playerName = player.name; + this.room.players[playerName] = player; + + // Start listening for new messages + player.conn.on( + "data", + this._received.bind(this, this.room.players[playerName]) + ); + + // Send the player info about the room + this.send(player, { + kind: "room-info", + room: { + ...this.room.info, + password: "" + }, + players: Object.keys(this.room.players) + }); + + // Notify other players + this.broadcast({ + kind: "player-joined", + name: playerName + }); + } + + private removePlayer(player: NetworkPlayer) { + // Tell the player everything's fine + this.send(player, { kind: "ok", what: "leave-req" }); + + // Close connection with player + player.conn.close(); + + // Notify other players + this.broadcast({ + kind: "player-left", + name: player.name + }); + } + + private _received(player: Player, data: NetworkMessage) { + switch (data.kind) { + // Player wants to say something in chat + case "chat": + data.from = player.name; + if (data.to == "") { + // Players is saying that out loud + this.broadcast(data); + } else { + // Player is telling someone specifically + if (data.to in this.players) { + this.send(this.players[data.to], data); + } else { + this.send(player, { + kind: "error", + error: `player not found: ${data.to}` + }); + } + } + break; + // Player is leaving! + case "leave-req": + // If we're leaving, end the server + if (player.kind == "local") { + //TODO + } else { + // Remove and disconnect player + this.removePlayer(player); + } + break; + } + } + + public get playerCount(): number { + return Object.keys(this.room.players).length; + } + + public get players() { + return this.room.players; + } + + protected send(player: Player, message: T) { + if (player.kind == "remote") { + player.conn.send(message); + } else { + player.client.receive(message); + } + } + + private broadcast(message: T) { + for (const playerName in this.room.players) { + const player = this.room.players[playerName]; + this.send(player, message); + } + } +} + +export default PeerServer; diff --git a/src/network/client.ts b/src/network/client.ts deleted file mode 100644 index 243a3bb..0000000 --- a/src/network/client.ts +++ /dev/null @@ -1,14 +0,0 @@ -import NetworkPeer from "./peer"; -import { DataConnection } from "peerjs"; - -export default class PeerClient extends NetworkPeer { - private connection: DataConnection; - public constructor(peerid: string, metadata: Object) { - super(); - this.connection = this.peer.connect(peerid, { - label: "server", - metadata, - reliable: true - }); - } -} diff --git a/src/network/index.ts b/src/network/index.ts new file mode 100644 index 0000000..b90e236 --- /dev/null +++ b/src/network/index.ts @@ -0,0 +1,5 @@ +export * from "./PeerClient"; +export * from "./LocalClient"; +export * from "./Client"; +export * from "./PeerServer"; +export * from "./types"; diff --git a/src/network/peer.ts b/src/network/peer.ts deleted file mode 100644 index d4b8db3..0000000 --- a/src/network/peer.ts +++ /dev/null @@ -1,16 +0,0 @@ -import Peer, { DataConnection } from "peerjs"; - -export default class NetworkPeer { - protected peer: Peer; - protected constructor() { - this.peer = new Peer(); - this.peer.on("open", function(id) { - console.info("Peer ID assigned: %s", id); - }); - } - - protected send(conn: DataConnection, data: T) { - //TODO Debugging support? - conn.send(data); - } -} diff --git a/src/network/server.ts b/src/network/server.ts deleted file mode 100644 index 4f2709e..0000000 --- a/src/network/server.ts +++ /dev/null @@ -1,34 +0,0 @@ -import NetworkPeer from "./peer"; -import { DataConnection } from "peerjs"; -import { RoomInfo, PasswordRequest, Room } from "./types"; - -export default class PeerServer extends NetworkPeer { - private room: Room; - public constructor(roomInfo: RoomInfo) { - super(); - this.room = { - info: roomInfo, - players: {} - }; - this.peer.on("connection", this._connection); - } - private async _connection(conn: DataConnection) { - // Check if this connection should be allowed - console.info("%s (%s) connected!", conn.metadata.name, conn.label); - - // Check if room is full - if (this.playerCount >= this.room.info.max_players) { - //TODO Reject - } - - if (this.room.info.password != "") { - this.send(conn, { kind: "password-req" }); - } else { - //TODO Add player - } - } - - private get playerCount(): number { - return Object.keys(this.room.players).length; - } -} diff --git a/src/network/types.ts b/src/network/types.ts index 7ee6881..a01b192 100644 --- a/src/network/types.ts +++ b/src/network/types.ts @@ -1,19 +1,33 @@ import { DataConnection } from "peerjs"; +import LocalClient from "./LocalClient"; + +export interface LocalPlayer { + kind: "local"; + name: string; + client: LocalClient; +} export interface NetworkPlayer { + kind: "remote"; name: string; conn: DataConnection; } +export type Player = NetworkPlayer | LocalPlayer; + +export interface PeerMetadata { + name: string; +} + export interface Room { info: RoomInfo; - players: Record; + players: Record; } export interface RoomInfo { max_players: number; password: string; - game: GameInfo; + game?: GameInfo; } type GameInfo = MatchInfo | DraftInfo; @@ -46,7 +60,17 @@ type DraftInfo = { // Message schemas -export type NetworkMessage = PasswordRequest | PasswordResponse; +export type NetworkMessage = + | PasswordRequest + | PasswordResponse + | LeaveRequest + | ErrorMessage + | RoomInfoMessage + | JoinMessage + | LeaveMessage + | RenameMessage + | ChatMessage + | AckMessage; export interface PasswordRequest { kind: "password-req"; @@ -56,3 +80,46 @@ export interface PasswordResponse { kind: "password-resp"; password: string; } + +export interface RoomInfoMessage { + kind: "room-info"; + room: RoomInfo; + players: string[]; +} + +export interface LeaveRequest { + kind: "leave-req"; +} + +export interface JoinMessage { + kind: "player-joined"; + name: string; +} + +export interface LeaveMessage { + kind: "player-left"; + name: string; +} + +export interface RenameMessage { + kind: "rename"; + oldname: string; + newname: string; +} + +export interface ChatMessage { + kind: "chat"; + from: string; + to: string; // "" means everyone + message: string; +} + +export interface AckMessage { + kind: "ok"; + what: string; +} + +export interface ErrorMessage { + kind: "error"; + error: string; +} diff --git a/src/testing/EventHook.ts b/src/testing/EventHook.ts new file mode 100644 index 0000000..2ba2333 --- /dev/null +++ b/src/testing/EventHook.ts @@ -0,0 +1,63 @@ +import EventEmitter from "eventemitter3"; +import { withTimeout } from "@/utils"; + +interface HookedEvent { + date: Date; + arguments: any[]; +} + +const defaultCmp = () => {}; + +export class EventHook extends EventEmitter { + public backlog: Record = {}; + + public hookEmitter(emitter: EventEmitter, event: string, id: string) { + emitter.on(event, this._receivedEmitter.bind(this, id)); + } + + public expect( + id: string, + timeout: number = 1000, + cmp: (...args: any) => void = defaultCmp + ): Promise { + // Build promise + const promise = new Promise((resolve, reject) => { + const getLastEvent = () => { + // Take first element in the backlog + let event = this.backlog[id].shift(); + if (!event) { + // Should never happen + return reject(`undefined event in list (??)`); + } + // Send to check function, then resolve + resolve(cmp(...event.arguments)); + return; + }; + if (this.backlog[id] && this.backlog[id].length > 0) { + // Event is already in the backlog + getLastEvent(); + } else { + // If the event hasn't fired yet, subscribe to the next time it will + this.once(id, getLastEvent); + } + }); + + // Return promise with added timeout + return withTimeout(promise, timeout); + } + + // When events are received + private _receivedEmitter(id: string, ...args: any) { + const event: HookedEvent = { + date: new Date(), + arguments: args + }; + // Create backlog if it doesn't exist + if (!this.backlog[id]) { + this.backlog[id] = []; + } + // Push to backlog and emit event (for pending expects) + this.backlog[id].push(event); + this.emit(id, event); + } +} diff --git a/src/testing/MockDataConnection.ts b/src/testing/MockDataConnection.ts new file mode 100644 index 0000000..92cda23 --- /dev/null +++ b/src/testing/MockDataConnection.ts @@ -0,0 +1,221 @@ +import EventEmitter from "eventemitter3"; +import MockPeer from "./MockPeer"; +import { PeerConnectOption } from "peerjs"; + +export class MockDataConnection extends EventEmitter { + public options: PeerConnectOption; + public peer: string; + public provider: MockPeer; + public open: boolean = false; + private _connection?: MockDataConnection; + public id: string; + public label: string; + + public constructor( + peer: string, + provider: MockPeer, + options?: PeerConnectOption + ) { + super(); + this.options = { + serialization: "binary", + reliable: false + }; + if (options) { + this.options = Object.assign(this.options, options); + } + this.id = "fake_" + Math.random().toString(32); + this.label = this.options.label ? this.options.label : this.id; + this.provider = provider; + this.peer = peer; + } + + public send(message: any) { + if (!this.open) { + this.emit( + "error", + new Error( + "Connection is not open. You should listen for the `open` event before sending messages." + ) + ); + } + const connection = this._connection; + if (connection) { + connection.receive(message); + } + } + + public receive(message: any) { + this.emit("data", message); + } + + public close() { + if (!this.open) { + return; + } + this.open = false; + this.emit("close"); + } + + public _setRemote(connection: MockDataConnection) { + this._connection = connection; + this._connection.on("open", () => { + if (!this.open) { + this.open = true; + this.emit("open"); + } + }); + } + + public _configureDataChannel( + mocks: Record, + options?: PeerConnectOption + ) { + if (!this._connection && this.peer in mocks) { + this._setRemote( + mocks[this.peer]._negotiate(this.provider.id, this, options) + ); + this.open = true; + this.emit("open"); + } else { + throw new Error(`Peer(${this.peer}) not found`); + } + } + + public get metadata() { + return this.options.metadata; + } + + public get reliable() { + return this.options.reliable ? this.options.reliable : false; + } + + public get serialization() { + return this.options.serialization ? this.options.serialization : "binary"; + } + + public get peerConnection() { + return this._connection; + } + + /* + UNIMPLEMENTED STUFF + */ + + public dataChannel: unknown; + public bufferSize: unknown; + public type: unknown; + public canTrickleIceCandidates: unknown; + public connectionState: unknown; + public currentLocalDescription: unknown; + public currentRemoteDescription: unknown; + public iceConnectionState: unknown; + public iceGatheringState: unknown; + public idpErrorInfo: unknown; + public idpLoginUrl: unknown; + public localDescription: unknown; + public onconnectionstatechange: unknown; + public ondatachannel: unknown; + public onicecandidate: unknown; + public onicecandidateerror: unknown; + public oniceconnectionstatechange: unknown; + public onicegatheringstatechange: unknown; + public onnegotiationneeded: unknown; + public onsignalingstatechange: unknown; + public onstatsended: unknown; + public ontrack: unknown; + public peerIdentity: unknown; + public pendingLocalDescription: unknown; + public pendingRemoteDescription: unknown; + public remoteDescription: unknown; + public sctp: unknown; + public signalingState: unknown; + + public addIceCandidate( + candidate: RTCIceCandidateInit | RTCIceCandidate + ): Promise { + throw new Error("not implemented"); + } + public addTrack(track: MediaStreamTrack, ...streams: MediaStream[]): unknown { + throw new Error("not implemented"); + } + public addTransceiver( + trackOrKind: MediaStreamTrack | string, + init?: RTCRtpTransceiverInit + ): unknown { + throw new Error("not implemented"); + } + public createAnswer(options?: RTCOfferOptions): Promise { + throw new Error("not implemented"); + } + public createDataChannel( + label: string, + dataChannelDict?: RTCDataChannelInit + ): unknown { + throw new Error("not implemented"); + } + public createOffer(options?: RTCOfferOptions): Promise { + throw new Error("not implemented"); + } + public getConfiguration(): unknown { + throw new Error("not implemented"); + } + public getIdentityAssertion(): Promise { + throw new Error("not implemented"); + } + public getReceivers(): unknown[] { + throw new Error("not implemented"); + } + public getSenders(): unknown[] { + throw new Error("not implemented"); + } + public getStats(selector?: MediaStreamTrack | null): Promise { + throw new Error("not implemented"); + } + public getTransceivers(): unknown[] { + throw new Error("not implemented"); + } + public removeTrack(sender: RTCRtpSender): void { + throw new Error("not implemented"); + } + public setConfiguration(configuration: RTCConfiguration): void { + throw new Error("not implemented"); + } + public setIdentityProvider( + provider: string, + options?: RTCIdentityProviderOptions + ) { + throw new Error("not implemented"); + } + public setLocalDescription( + description: RTCSessionDescriptionInit + ): Promise { + throw new Error("not implemented"); + } + public setRemoteDescription( + description: RTCSessionDescriptionInit + ): Promise { + throw new Error("not implemented"); + } + + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ): void { + throw new Error("not implemented"); + } + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions + ): void { + throw new Error("not implemented"); + } + + dispatchEvent(event: Event): boolean { + throw new Error("not implemented"); + } +} + +export default MockDataConnection; diff --git a/src/testing/MockHelper.ts b/src/testing/MockHelper.ts new file mode 100644 index 0000000..dc1bdd1 --- /dev/null +++ b/src/testing/MockHelper.ts @@ -0,0 +1,27 @@ +import Peer from "peerjs"; +import { MockPeer } from "."; +import { LocalClient, PeerServer, PeerClient, RoomInfo } from "@/network"; + +export class MockHelper { + private mocks: Record = {}; + + createServer( + room: RoomInfo, + id: string = "test-server", + player?: LocalClient + ) { + const serverPeer = new MockPeer(this.mocks, id, {}); + return new PeerServer( + room, + player ? player : new LocalClient({ name: "server-player" }), + serverPeer as Peer + ); + } + + createClient(id: string = "test-client", name: string = "client-peer") { + const clientPeer = new MockPeer(this.mocks, id, {}); + return new PeerClient({ name }, clientPeer as Peer); + } +} + +export default MockHelper; diff --git a/src/testing/MockPeer.ts b/src/testing/MockPeer.ts new file mode 100644 index 0000000..ee02a24 --- /dev/null +++ b/src/testing/MockPeer.ts @@ -0,0 +1,95 @@ +import EventEmitter from "eventemitter3"; +import Peer, { PeerConnectOption, PeerJSOption } from "peerjs"; +import MockDataConnection from "./MockDataConnection"; + +export class MockPeer extends EventEmitter { + public connections: Record; + public id: string; + public options: PeerJSOption; + public disconnected: boolean; + public destroyed: boolean; + public prototype: unknown; + private mocks: Record; + + public constructor( + mockSource: Record, + id: string, + options?: PeerJSOption + ) { + super(); + this.id = id; + this.options = options ? options : {}; + this.connections = {}; + this.disconnected = false; + this.destroyed = false; + this.mocks = mockSource; + this.mocks[id] = this; + } + + public connect(id: string, options?: PeerConnectOption): MockDataConnection { + let connection = new MockDataConnection(id, this, options); + // hacky way to avoid race conditions + setTimeout(() => connection._configureDataChannel(this.mocks, options), 1); + this._addConnection(id, connection); + return connection; + } + + public disconnect() { + this.disconnected = true; + this.emit("disconnected", this.id); + this.id = ""; + } + + public destroy() { + this.destroyed = true; + this.emit("destroyed", this.id); + this.disconnect(); + } + + private _addConnection(peer: string, connection: MockDataConnection) { + if (!this.connections[peer]) { + this.connections[peer] = []; + } + this.connections[peer].push(connection); + } + + public _negotiate( + peer: string, + remoteConnection: MockDataConnection, + options?: PeerConnectOption + ) { + let localConnection = new MockDataConnection(peer, this, options); + localConnection._setRemote(remoteConnection); + this._addConnection(peer, localConnection); + this.emit("connection", localConnection); + return localConnection; + } + + public call( + id: string, + stream: MediaStream, + options?: Peer.CallOption + ): Peer.MediaConnection { + throw new Error("unimplemented"); + } + + public reconnect() {} + + public getConnection( + peerId: string, + connectionId: string + ): MockDataConnection | null { + if (peerId in this.mocks) { + let map = this.mocks[peerId].connections; + if (connectionId in map) { + return map[connectionId][0]; + } + return null; + } + return null; + } + + public listAllPeers() {} +} + +export default MockPeer; diff --git a/src/testing/index.ts b/src/testing/index.ts new file mode 100644 index 0000000..647d674 --- /dev/null +++ b/src/testing/index.ts @@ -0,0 +1,3 @@ +export * from "./MockDataConnection"; +export * from "./MockPeer"; +export * from "./MockHelper"; diff --git a/tests/unit/.eslintrc.js b/src/tests/unit/.eslintrc.js similarity index 100% rename from tests/unit/.eslintrc.js rename to src/tests/unit/.eslintrc.js diff --git a/src/tests/unit/network.spec.ts b/src/tests/unit/network.spec.ts new file mode 100644 index 0000000..dfe425d --- /dev/null +++ b/src/tests/unit/network.spec.ts @@ -0,0 +1,151 @@ +import { MockHelper } from "@/testing"; +import { NetworkMessage, LocalClient, ChatMessage } from "@/network"; +import { EventHook } from "@/testing/EventHook"; + +const sampleRoom = () => ({ + max_players: 3, + password: "" +}); + +function messageKind(kind: string): (msg: NetworkMessage) => void { + return msg => { + expect(msg.kind).toEqual(kind); + }; +} + +describe("network/PeerServer", () => { + test("A server can be created", () => { + const mox = new MockHelper(); + mox.createServer(sampleRoom()); + }); + + test("Clients can join servers and receive data", async () => { + const mox = new MockHelper(); + const hook = new EventHook(); + mox.createServer(sampleRoom()); + const client = mox.createClient(); + hook.hookEmitter(client, "data", "client-data"); + client.connect("test-server"); + await hook.expect("client-data", 1000, messageKind("room-info")); + await hook.expect("client-data", 1000, messageKind("player-joined")); + }); + + test("Servers handle multiple clients and resolve name collisions", async () => { + const mox = new MockHelper(); + const hook = new EventHook(); + mox.createServer(sampleRoom()); + const client1 = mox.createClient(); + const client2 = mox.createClient(); + hook.hookEmitter(client1, "data", "client1-data"); + hook.hookEmitter(client2, "data", "client2-data"); + client1.connect("test-server"); + await hook.expect("client1-data", 1000, messageKind("room-info")); + await hook.expect("client1-data", 1000, messageKind("player-joined")); + client2.connect("test-server"); + await hook.expect("client2-data", 1000, messageKind("rename")); + await hook.expect("client2-data", 1000, messageKind("room-info")); + await hook.expect("client2-data", 1000, messageKind("player-joined")); + await hook.expect("client1-data", 1000, messageKind("player-joined")); + }); + + test("Local server clients receives client events", async () => { + const mox = new MockHelper(); + const hook = new EventHook(); + const local = new LocalClient({ name: "server-player" }); + const server = mox.createServer(sampleRoom(), "test-server", local); + const client = mox.createClient(); + hook.hookEmitter(local, "player-joined", "local-joined"); + client.connect("test-server"); + await hook.expect("local-joined", 1000); + }); + + test("Server enforces password protection", async () => { + const mox = new MockHelper(); + const hook = new EventHook(); + const room = sampleRoom(); + const roomPassword = "test-pwd"; + room.password = roomPassword; + const server = mox.createServer(room, "test-server"); + + // This client will try the right password + const client1 = mox.createClient(); + hook.hookEmitter(client1, "data", "client1-data"); + client1.connect("test-server"); + await hook.expect("client1-data", 1000, messageKind("password-req")); + client1.password(roomPassword); + await hook.expect("client1-data", 1000, messageKind("room-info")); + await hook.expect("client1-data", 1000, messageKind("player-joined")); + + // This client will try an incorrect password + const client2 = mox.createClient("test-client2", "another-client"); + hook.hookEmitter(client2, "data", "client2-data"); + client2.connect("test-server"); + await hook.expect("client2-data", 1000, messageKind("password-req")); + client2.password("not the right password"); + await hook.expect("client2-data", 1000, messageKind("error")); + }); +}); + +describe("network/PeerClient", () => { + test("Client handles forced name change", async () => { + const mox = new MockHelper(); + const hook = new EventHook(); + mox.createServer(sampleRoom()); + const client1 = mox.createClient(); + const client2 = mox.createClient(); + hook.hookEmitter(client2, "rename", "client-rename"); + client1.connect("test-server"); + client2.connect("test-server"); + await hook.expect("client-rename", 1000); + // Client must have changed its internal data to match the new name + expect(client1.name).not.toEqual(client2.name); + }); + + test("Client can leave the room", async () => { + const mox = new MockHelper(); + const hook = new EventHook(); + const local = new LocalClient({ name: "server-player" }); + const server = mox.createServer(sampleRoom(), "test-server", local); + const client = mox.createClient(); + hook.hookEmitter(local, "player-joined", "client-joined"); + hook.hookEmitter(local, "player-left", "client-left"); + client.connect("test-server"); + await hook.expect("client-joined", 1000); + client.leave(); + await hook.expect("client-left", 1000); + }); + + test("Clients can chat", async () => { + const mox = new MockHelper(); + const hook = new EventHook(); + const local = new LocalClient({ name: "server-player" }); + const server = mox.createServer(sampleRoom(), "test-server", local); + const client = mox.createClient(); + hook.hookEmitter(local, "player-joined", "client-joined"); + hook.hookEmitter(local, "chat", "client-chat"); + client.connect("test-server"); + await hook.expect("client-joined", 1000); + const hellomsg = "Hello everyone"; + client.say("", hellomsg); + await hook.expect("client-chat", 1000, (msg: ChatMessage) => { + expect(msg.from).toEqual("client-peer"); + expect(msg.message).toEqual(hellomsg); + }); + }); + + test("Client internal player list gets correctly updated", async () => { + const mox = new MockHelper(); + const hook = new EventHook(); + const local = new LocalClient({ name: "server-player" }); + const server = mox.createServer(sampleRoom(), "test-server", local); + const client = mox.createClient(); + hook.hookEmitter(local, "player-joined", "client-joined"); + hook.hookEmitter(local, "player-left", "client-left"); + client.connect("test-server"); + await hook.expect("client-joined", 1000); + expect(local.players).toHaveLength(2); + client.leave(); + await hook.expect("client-left", 1000); + expect(local.players).toHaveLength(1); + }); +}); diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..1a5ee3d --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./timeout"; diff --git a/src/utils/timeout.ts b/src/utils/timeout.ts new file mode 100644 index 0000000..55ca11b --- /dev/null +++ b/src/utils/timeout.ts @@ -0,0 +1,9 @@ +export function withTimeout(promise: Promise, ms: number): Promise { + const timeout = new Promise((resolve, reject) => { + let id = setTimeout(() => { + clearTimeout(id); + reject("Timed out after " + ms + "ms."); + }, ms); + }); + return Promise.race([promise, timeout]); +} diff --git a/yarn.lock b/yarn.lock index cf86421..6cdb382 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3889,6 +3889,11 @@ eventemitter3@^3.0.0, eventemitter3@^3.1.2: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7" integrity sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q== +eventemitter3@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" + integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== + events@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88"