diff --git a/package.json b/package.json index 552b8e47..6bba1b9e 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "typescript": "^5.4.5", "uuid": "^11.1.0", "webidl-conversions": "^7.0.0", + "ws": "^8.18.3", "zod": "^3.22.4" }, "devDependencies": { diff --git a/src/app.ts b/src/app.ts index b581f7b5..b42d86fc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,9 @@ import errorHandler from "./middleware/error-handler"; import attendeeRouter from "./services/attendee/attendee-router"; import staffRouter from "./services/staff/staff-router"; import checkinRouter from "./services/checkin/checkin-router"; +import dashboardRouter, { + handleWs as handleWsDashboard, +} from "./services/dashboard/dashboard-router"; import authRouter from "./services/auth/auth-router"; import eventsRouter from "./services/events/events-router"; import notificationsRouter from "./services/notifications/notifications-router"; @@ -29,8 +32,13 @@ import leaderboardRouter from "./services/leaderboard/leaderboard-router"; import cors from "cors"; import { JwtPayloadValidator } from "./services/auth/auth-models"; +import { createServer } from "http"; +import { WebSocketServer } from "ws"; const app = express(); +const server = createServer(app); +const wss = new WebSocketServer({ server }); + app.enable("trust proxy"); // to prevent server-side caching/returning status code 200 @@ -84,6 +92,7 @@ app.use("/attendee", attendeeRouter); app.use("/staff", staffRouter); app.use("/auth", authRouter); app.use("/checkin", checkinRouter); +app.use("/dashboard", dashboardRouter); app.use("/events", eventsRouter); app.use("/leaderboard", leaderboardRouter); app.use("/notifications", notificationsRouter); @@ -113,10 +122,30 @@ app.use("/", (req, res) => app.use(errorHandler); +// Websocket handling +wss.on("connection", (ws, request) => { + try { + if (request.url === "/dashboard") { + handleWsDashboard(ws); + } else { + ws.send("Unknown url"); + ws.close(1008); // 1008 = policy violation + } + } catch (err) { + console.error("WebSocket connection handle issue:", err); + ws.close(1008); // 1008 = policy violation + } +}); + +wss.on("error", (err) => { + console.error("WebSocket server issue:", err); +}); + +// Start the server if (!isTest()) { - app.listen(Config.DEFAULT_APP_PORT, async () => { + server.listen(Config.DEFAULT_APP_PORT, async () => { process.send?.("ready"); console.log("Server is listening on port 3000..."); }); } -export default app; +export { app, server }; diff --git a/src/config.ts b/src/config.ts index c2b8ef34..f12f19c0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -108,6 +108,9 @@ export const Config = { VERIFY_EXP_TIME_MS: 10 * 60 * 1000, SPONSOR_ENTIRES_PER_PAGE: 60, + DASHBOARD_PING_EVERY_MS: 5 * 1000, + DASHBOARD_TIMEOUT_MS: 15 * 1000, + // QR Scanning QR_HASH_ITERATIONS: 10000, QR_HASH_SECRET: getEnv("QR_HASH_SECRET"), diff --git a/src/services/auth/auth-utils.ts b/src/services/auth/auth-utils.ts index ad3073ff..f9a6b561 100644 --- a/src/services/auth/auth-utils.ts +++ b/src/services/auth/auth-utils.ts @@ -63,6 +63,10 @@ export async function updateDatabaseWithAuthPayload( Config.DEV_ADMIN_EMAIL && email === Config.DEV_ADMIN_EMAIL ) { + await SupabaseDB.AUTH_ROLES.upsert({ + userId, + role: Role.Enum.SUPER_ADMIN, + }); await SupabaseDB.AUTH_ROLES.upsert({ userId, role: Role.Enum.ADMIN, diff --git a/src/services/dashboard/dashboard-router.test.ts b/src/services/dashboard/dashboard-router.test.ts new file mode 100644 index 00000000..21af693d --- /dev/null +++ b/src/services/dashboard/dashboard-router.test.ts @@ -0,0 +1,509 @@ +import { beforeAll, describe } from "@jest/globals"; +import { server } from "../../app"; +import { DashboardMessage, DisplayMetadata } from "./dashboard-schema"; +import { + getAsAdmin, + getAsStaff, + postAsAdmin, + postAsStaff, +} from "../../../testing/testingTools"; +import TestWebSocket from "../../../testing/testWebSocket"; +import { StatusCodes } from "http-status-codes"; +import Config from "../../config"; + +const serverPort = 4000; +const wsBaseURL = `ws://localhost:${serverPort}`; +const DISPLAY_0_METADATA: DisplayMetadata = { + screenWidth: 1920, + screenHeight: 1080, + devicePixelRatio: 1, + platform: "Linux - I Use Arch btw", + unixTime: Date.now(), + userAgent: "BrowserIMadeMyselfSoItNeverRendersProperly/0.1.2", +}; +const DISPLAY_1_METADATA: DisplayMetadata = { + screenWidth: 3840, + screenHeight: 2160, + devicePixelRatio: 1, + platform: "Windows", + unixTime: Date.now(), + userAgent: "Chrome/140.0.0.0 Windows NT/3", +}; + +const DASHBOARD_MESSAGE: DashboardMessage = { + message: "test message", +}; + +const pingsInTimeout = Math.floor( + Config.DASHBOARD_TIMEOUT_MS / Config.DASHBOARD_PING_EVERY_MS +); + +Config.DASHBOARD_PING_EVERY_MS = 500; +Config.DASHBOARD_TIMEOUT_MS = Config.DASHBOARD_PING_EVERY_MS * pingsInTimeout; + +function sleep(delay: number) { + return new Promise((res) => setTimeout(res, delay)); +} + +beforeAll((done) => { + server.listen(serverPort, () => { + done(); + }); +}); + +afterAll((done) => { + server.close(done); +}); + +describe("ws /dashboard", () => { + it("ws accepts good input", async () => { + const ws = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws.start(); + ws.send(JSON.stringify(DISPLAY_0_METADATA)); + await sleep(Config.DASHBOARD_PING_EVERY_MS / 4); + const result = await ws.close(); + expect(result).toEqual({ + code: 1005, + received: [JSON.stringify({ type: "ping" })], + }); + }); + it("ws rejects bad input", async () => { + const ws = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws.start(); + ws.send("bad input"); + await sleep(Config.DASHBOARD_PING_EVERY_MS / 4); + const result = await ws.close(); + expect(result).toEqual({ + code: 1008, + received: [JSON.stringify({ type: "ping" }), "Invalid message"], + }); + }); +}); + +describe("GET /dashboard", () => { + it("shows metadata of connected", async () => { + const ws0 = new TestWebSocket(`${wsBaseURL}/dashboard`); + const ws1 = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws0.start(); + ws0.send(JSON.stringify(DISPLAY_0_METADATA)); + await ws1.start(); + ws1.send(JSON.stringify(DISPLAY_1_METADATA)); + const result = await getAsAdmin("/dashboard").expect(StatusCodes.OK); + const wsResults = await Promise.all([ws0.close(), ws1.close()]); + + expect(wsResults).toEqual([ + { + code: 1005, + received: [JSON.stringify({ type: "ping" })], + }, + { + code: 1005, + received: [JSON.stringify({ type: "ping" })], + }, + ]); + expect(result.body).toEqual([ + { + id: 0, + metadata: DISPLAY_0_METADATA, + lastUpdate: expect.any(Number), + }, + { + id: 1, + metadata: DISPLAY_1_METADATA, + lastUpdate: expect.any(Number), + }, + ]); + }); + + it("removes metadata when display disconnected", async () => { + const ws0 = new TestWebSocket(`${wsBaseURL}/dashboard`); + const ws1 = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws0.start(); + ws0.send(JSON.stringify(DISPLAY_0_METADATA)); + await ws1.start(); + ws1.send(JSON.stringify(DISPLAY_1_METADATA)); + const ws0Result = await ws0.close(); + + const result = await getAsAdmin("/dashboard").expect(StatusCodes.OK); + const ws1Result = await ws1.close(); + + expect(ws0Result).toEqual({ + code: 1005, + received: [JSON.stringify({ type: "ping" })], + }); + expect(ws1Result).toEqual({ + code: 1005, + received: [JSON.stringify({ type: "ping" })], + }); + expect(result.body).toEqual([ + { + id: 1, + metadata: DISPLAY_1_METADATA, + lastUpdate: expect.any(Number), + }, + ]); + }); + + it("removes metadata when display times out", async () => { + const ws = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws.start(); + ws.send(JSON.stringify(DISPLAY_0_METADATA)); + await sleep(Config.DASHBOARD_PING_EVERY_MS); + + const beforeTimeoutResult = await getAsAdmin("/dashboard").expect( + StatusCodes.OK + ); + await sleep(Config.DASHBOARD_TIMEOUT_MS); + const afterTimeoutResult = await getAsAdmin("/dashboard").expect( + StatusCodes.OK + ); + + const wsResult = await ws.close(); + + const received = []; + for (let i = 0; i < pingsInTimeout + 1; i++) { + received.push(JSON.stringify({ type: "ping" })); + } + + expect(wsResult).toEqual({ + code: 1005, + received, + }); + expect(beforeTimeoutResult.body).toEqual([ + { + id: 0, + metadata: DISPLAY_0_METADATA, + lastUpdate: expect.any(Number), + }, + ]); + expect(afterTimeoutResult.body).toEqual([]); + }); + + it("returns nothing if none connected", async () => { + const res = await getAsAdmin("/dashboard").expect(StatusCodes.OK); + expect(res.body).toEqual([]); + }); + + it("fails for non admin", async () => { + const res = await getAsStaff("/dashboard").expect( + StatusCodes.FORBIDDEN + ); + expect(res.body).toMatchObject({ error: "Forbidden" }); + }); +}); + +describe("POST /dashboard/identify", () => { + it("sends identify message to all displays", async () => { + const ws0 = new TestWebSocket(`${wsBaseURL}/dashboard`); + const ws1 = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws0.start(); + ws0.send(JSON.stringify(DISPLAY_0_METADATA)); + await ws1.start(); + ws1.send(JSON.stringify(DISPLAY_1_METADATA)); + + const res = await postAsAdmin("/dashboard/identify").expect( + StatusCodes.OK + ); + const wsResults = await Promise.all([ws0.close(), ws1.close()]); + + expect(wsResults).toEqual([ + { + code: 1005, + received: [ + JSON.stringify({ type: "ping" }), + JSON.stringify({ type: "message", message: "0" }), + ], + }, + { + code: 1005, + received: [ + JSON.stringify({ type: "ping" }), + JSON.stringify({ type: "message", message: "1" }), + ], + }, + ]); + + expect(res.body).toEqual({ sentTo: [0, 1] }); + }); + + it("fails for non admin", async () => { + const res = await postAsStaff("/dashboard/identify").expect( + StatusCodes.FORBIDDEN + ); + expect(res.body).toMatchObject({ error: "Forbidden" }); + }); +}); + +describe("POST /dashboard/identify/:id", () => { + it("sends identify message to specified display", async () => { + const ws0 = new TestWebSocket(`${wsBaseURL}/dashboard`); + const ws1 = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws0.start(); + ws0.send(JSON.stringify(DISPLAY_0_METADATA)); + await ws1.start(); + ws1.send(JSON.stringify(DISPLAY_1_METADATA)); + + const res = await postAsAdmin("/dashboard/identify/1").expect( + StatusCodes.OK + ); + const wsResults = await Promise.all([ws0.close(), ws1.close()]); + + expect(wsResults).toEqual([ + { + code: 1005, + received: [JSON.stringify({ type: "ping" })], + }, + { + code: 1005, + received: [ + JSON.stringify({ type: "ping" }), + JSON.stringify({ type: "message", message: "1" }), + ], + }, + ]); + + expect(res.body).toEqual({ sentTo: [1] }); + }); + + it("fails if the display is not found", async () => { + const ws = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws.start(); + ws.send(JSON.stringify(DISPLAY_0_METADATA)); + + const result = await postAsAdmin("/dashboard/identify/2").expect( + StatusCodes.NOT_FOUND + ); + + const wsResult = await ws.close(); + + expect(wsResult).toEqual({ + code: 1005, + received: [JSON.stringify({ type: "ping" })], + }); + + expect(result.body).toMatchObject({ error: "NotFound" }); + }); + + it("fails for non admin", async () => { + const res = await postAsStaff("/dashboard/identify/1").expect( + StatusCodes.FORBIDDEN + ); + expect(res.body).toMatchObject({ error: "Forbidden" }); + }); +}); + +describe("POST /dashboard/reload", () => { + it("sends reload message to all displays", async () => { + const ws0 = new TestWebSocket(`${wsBaseURL}/dashboard`); + const ws1 = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws0.start(); + ws0.send(JSON.stringify(DISPLAY_0_METADATA)); + await ws1.start(); + ws1.send(JSON.stringify(DISPLAY_1_METADATA)); + + const res = await postAsAdmin("/dashboard/reload").expect( + StatusCodes.OK + ); + const wsResults = await Promise.all([ws0.close(), ws1.close()]); + + expect(wsResults).toEqual([ + { + code: 1005, + received: [ + JSON.stringify({ type: "ping" }), + JSON.stringify({ type: "reload" }), + ], + }, + { + code: 1005, + received: [ + JSON.stringify({ type: "ping" }), + JSON.stringify({ type: "reload" }), + ], + }, + ]); + + expect(res.body).toEqual({ sentTo: [0, 1] }); + }); + + it("fails for non admin", async () => { + const res = await postAsStaff("/dashboard/reload").expect( + StatusCodes.FORBIDDEN + ); + expect(res.body).toMatchObject({ error: "Forbidden" }); + }); +}); + +describe("POST /dashboard/reload/:id", () => { + it("sends reload message to specified display", async () => { + const ws0 = new TestWebSocket(`${wsBaseURL}/dashboard`); + const ws1 = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws0.start(); + ws0.send(JSON.stringify(DISPLAY_0_METADATA)); + await ws1.start(); + ws1.send(JSON.stringify(DISPLAY_1_METADATA)); + + const res = await postAsAdmin("/dashboard/reload/1").expect( + StatusCodes.OK + ); + const wsResults = await Promise.all([ws0.close(), ws1.close()]); + + expect(wsResults).toEqual([ + { + code: 1005, + received: [JSON.stringify({ type: "ping" })], + }, + { + code: 1005, + received: [ + JSON.stringify({ type: "ping" }), + JSON.stringify({ type: "reload" }), + ], + }, + ]); + + expect(res.body).toEqual({ sentTo: [1] }); + }); + + it("fails if the display is not found", async () => { + const ws = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws.start(); + ws.send(JSON.stringify(DISPLAY_0_METADATA)); + + const result = await postAsAdmin("/dashboard/reload/2").expect( + StatusCodes.NOT_FOUND + ); + + const wsResult = await ws.close(); + + expect(wsResult).toEqual({ + code: 1005, + received: [JSON.stringify({ type: "ping" })], + }); + + expect(result.body).toMatchObject({ error: "NotFound" }); + }); + + it("fails for non admin", async () => { + const res = await postAsStaff("/dashboard/reload/1").expect( + StatusCodes.FORBIDDEN + ); + expect(res.body).toMatchObject({ error: "Forbidden" }); + }); +}); + +describe("POST /dashboard/message", () => { + it("sends a message to all displays", async () => { + const ws0 = new TestWebSocket(`${wsBaseURL}/dashboard`); + const ws1 = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws0.start(); + ws0.send(JSON.stringify(DISPLAY_0_METADATA)); + await ws1.start(); + ws1.send(JSON.stringify(DISPLAY_1_METADATA)); + + const res = await postAsAdmin("/dashboard/message") + .send(DASHBOARD_MESSAGE) + .expect(StatusCodes.OK); + const wsResults = await Promise.all([ws0.close(), ws1.close()]); + + expect(wsResults).toEqual([ + { + code: 1005, + received: [ + JSON.stringify({ type: "ping" }), + JSON.stringify({ type: "message", ...DASHBOARD_MESSAGE }), + ], + }, + { + code: 1005, + received: [ + JSON.stringify({ type: "ping" }), + JSON.stringify({ type: "message", ...DASHBOARD_MESSAGE }), + ], + }, + ]); + + expect(res.body).toEqual({ sentTo: [0, 1] }); + }); + + it("fails for invalid payload", async () => { + const result = await postAsAdmin("/dashboard/message") + .send({ invalid: "whatever" }) + .expect(StatusCodes.BAD_REQUEST); + + expect(result.body).toMatchObject({ error: "BadRequest" }); + }); + + it("fails for non admin", async () => { + const res = await postAsStaff("/dashboard/message").expect( + StatusCodes.FORBIDDEN + ); + expect(res.body).toMatchObject({ error: "Forbidden" }); + }); +}); + +describe("POST /dashboard/message/:id", () => { + it("sends a message to specified display", async () => { + const ws0 = new TestWebSocket(`${wsBaseURL}/dashboard`); + const ws1 = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws0.start(); + ws0.send(JSON.stringify(DISPLAY_0_METADATA)); + await ws1.start(); + ws1.send(JSON.stringify(DISPLAY_1_METADATA)); + + const res = await postAsAdmin("/dashboard/message/1") + .send(DASHBOARD_MESSAGE) + .expect(StatusCodes.OK); + const wsResults = await Promise.all([ws0.close(), ws1.close()]); + + expect(wsResults).toEqual([ + { + code: 1005, + received: [JSON.stringify({ type: "ping" })], + }, + { + code: 1005, + received: [ + JSON.stringify({ type: "ping" }), + JSON.stringify({ type: "message", ...DASHBOARD_MESSAGE }), + ], + }, + ]); + + expect(res.body).toEqual({ sentTo: [1] }); + }); + + it("fails if the display is not found", async () => { + const ws = new TestWebSocket(`${wsBaseURL}/dashboard`); + await ws.start(); + ws.send(JSON.stringify(DISPLAY_0_METADATA)); + + const result = await postAsAdmin("/dashboard/message/2") + .send(DASHBOARD_MESSAGE) + .expect(StatusCodes.NOT_FOUND); + + const wsResult = await ws.close(); + + expect(wsResult).toEqual({ + code: 1005, + received: [JSON.stringify({ type: "ping" })], + }); + + expect(result.body).toMatchObject({ error: "NotFound" }); + }); + + it("fails for invalid payload", async () => { + const result = await postAsAdmin("/dashboard/message/0") + .send({ invalid: "whatever" }) + .expect(StatusCodes.BAD_REQUEST); + + expect(result.body).toMatchObject({ error: "BadRequest" }); + }); + + it("fails for non admin", async () => { + const res = await postAsStaff("/dashboard/message/1").expect( + StatusCodes.FORBIDDEN + ); + expect(res.body).toMatchObject({ error: "Forbidden" }); + }); +}); diff --git a/src/services/dashboard/dashboard-router.ts b/src/services/dashboard/dashboard-router.ts new file mode 100644 index 00000000..bbadd03d --- /dev/null +++ b/src/services/dashboard/dashboard-router.ts @@ -0,0 +1,158 @@ +import express, { Request, Response } from "express"; +import { WebSocket } from "ws"; +import { + DashboardMessageValidator, + Display, + DisplayId, + DisplayMetadataSchema, +} from "./dashboard-schema"; +import Config from "../../config"; +import { StatusCodes } from "http-status-codes"; +import RoleChecker from "../../middleware/role-checker"; +import { Role } from "../auth/auth-models"; + +const dashboardRouter = express.Router(); +const displays: Display[] = []; +const websockets: WebSocket[] = []; + +function findFirstFreeId(): number { + const foundFree = displays.findIndex((d) => !d); + if (foundFree == -1) { + return displays.length; + } + + return foundFree; +} + +// Handle an incoming websocket connection +export function handleWs(ws: WebSocket) { + const id = findFirstFreeId(); + websockets[id] = ws; + const display: Display = { + id, + lastUpdate: Date.now(), + }; + displays[id] = display; + + ws.on("message", (message) => { + try { + display.lastUpdate = Date.now(); + const json = JSON.parse(message.toString()); + const metadata = DisplayMetadataSchema.parse(json); + display.metadata = metadata; + } catch { + ws.send("Invalid message"); + ws.close(1008); // 1008 = policy violation + } + }); + + function pingForUpdate() { + const sinceLastUpdate = Date.now() - display.lastUpdate; + if (sinceLastUpdate >= Config.DASHBOARD_TIMEOUT_MS) { + ws.close(); + return; + } + + ws.send( + JSON.stringify({ + type: "ping", + }) + ); + } + + const interval = setInterval(pingForUpdate, Config.DASHBOARD_PING_EVERY_MS); + + ws.on("close", () => { + clearInterval(interval); + delete displays[id]; + delete websockets[id]; + }); + + pingForUpdate(); +} + +dashboardRouter.get("/", RoleChecker([Role.Enum.ADMIN]), (req, res) => { + // Displays can contain gaps - this endpoint just returns each display + const displaysWithoutSpaces = displays.filter((display) => display); + return res.status(StatusCodes.OK).send(displaysWithoutSpaces); +}); + +function send(message: object | ((id: number) => object)) { + return (req: Request, res: Response) => { + const target = + "id" in req.params ? DisplayId.parse(req.params["id"]) : undefined; + if (target !== undefined) { + const ws = websockets[target]; + if (!ws) { + return res + .status(StatusCodes.NOT_FOUND) + .send({ error: "NotFound" }); + } + + const toSend = + typeof message === "object" ? message : message(target); + ws.send(JSON.stringify(toSend)); + + return res.status(StatusCodes.OK).send({ sentTo: [target] }); + } + + const sentTo = []; + for (const [i, ws] of websockets.entries()) { + if (!ws) continue; + const toSend = typeof message === "object" ? message : message(i); + sentTo.push(i); + ws.send(JSON.stringify(toSend)); + } + + return res.status(StatusCodes.OK).send({ sentTo }); + }; +} + +dashboardRouter.post( + "/identify", + RoleChecker([Role.Enum.ADMIN]), + send((id) => ({ + type: "message", + message: id.toString(), + })) +); +dashboardRouter.post( + "/identify/:id", + RoleChecker([Role.Enum.ADMIN]), + send((id) => ({ + type: "message", + message: id.toString(), + })) +); + +dashboardRouter.post( + "/reload", + RoleChecker([Role.Enum.ADMIN]), + send({ type: "reload" }) +); +dashboardRouter.post( + "/reload/:id", + RoleChecker([Role.Enum.ADMIN]), + send({ type: "reload" }) +); + +dashboardRouter.post("/message", RoleChecker([Role.Enum.ADMIN]), (req, res) => { + const message = DashboardMessageValidator.parse(req.body); + return send({ + type: "message", + ...message, + })(req, res); +}); +dashboardRouter.post( + "/message/:id", + RoleChecker([Role.Enum.ADMIN]), + (req, res) => { + const message = DashboardMessageValidator.parse(req.body); + return send({ + type: "message", + ...message, + })(req, res); + } +); + +export default dashboardRouter; diff --git a/src/services/dashboard/dashboard-schema.ts b/src/services/dashboard/dashboard-schema.ts new file mode 100644 index 00000000..8c4e3951 --- /dev/null +++ b/src/services/dashboard/dashboard-schema.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +export const DisplayMetadataSchema = z.object({ + screenWidth: z.number(), + screenHeight: z.number(), + devicePixelRatio: z.number(), + userAgent: z.string(), + platform: z.string(), + unixTime: z.number(), +}); +export type DisplayMetadata = z.infer; + +export const DisplayId = z.coerce.number(); + +export const DisplaySchema = z.object({ + id: DisplayId, + metadata: DisplayMetadataSchema.optional(), + lastUpdate: z.number(), +}); +export type Display = z.infer; + +export const DashboardMessageValidator = z.union([ + z.object({ + message: z.string(), + }), + z.object({ + url: z.string(), + fullscreen: z.boolean().optional(), + iframe: z.boolean().optional(), + }), +]); +export type DashboardMessage = z.infer; diff --git a/testing/testWebSocket.ts b/testing/testWebSocket.ts new file mode 100644 index 00000000..038d0347 --- /dev/null +++ b/testing/testWebSocket.ts @@ -0,0 +1,72 @@ +const WS_TIMEOUT = 30 * 1000; + +export default class TestWebSocket { + private url: string; + private ws: WebSocket | undefined = undefined; + private received: string[] = []; + private closePromise: Promise | undefined = undefined; + private resolveClosePromise: ((code: number) => void) | undefined = + undefined; + private rejectClosePromise: ((reason?: unknown) => void) | undefined = + undefined; + + constructor(url: string) { + this.url = url; + } + + start() { + return new Promise((resolve, reject) => { + if (this.ws) return; + + this.ws = new WebSocket(this.url); + + this.closePromise = new Promise((resolve, reject) => { + this.resolveClosePromise = resolve; + this.rejectClosePromise = reject; + }); + + const timeout = setTimeout(reject, WS_TIMEOUT); + + this.ws.onopen = () => { + clearTimeout(timeout); + resolve(); + }; + + this.ws.onmessage = (event) => { + this.received.push(event.data); + }; + + this.ws.onclose = (event) => { + if (!this.resolveClosePromise) return; + this.resolveClosePromise(event.code); + this.resolveClosePromise = undefined; + this.rejectClosePromise = undefined; + }; + + this.ws.onerror = (error) => { + if (!this.rejectClosePromise) return; + this.rejectClosePromise(error); + this.resolveClosePromise = undefined; + this.rejectClosePromise = undefined; + }; + }); + } + + send(message: string) { + if (!this.ws) throw new Error("Cannot send - websocket not open"); + this.ws.send(message); + } + + async close() { + if (!this.ws || !this.closePromise) return; + this.ws.close(); + this.ws = undefined; + + const code = await this.closePromise; + this.closePromise = undefined; + this.resolveClosePromise = undefined; + this.rejectClosePromise = undefined; + + return { code, received: this.received }; + } +} diff --git a/testing/testingTools.ts b/testing/testingTools.ts index 64c29ba7..663a5748 100644 --- a/testing/testingTools.ts +++ b/testing/testingTools.ts @@ -17,7 +17,7 @@ export const TESTER = { function app() { // eslint-disable-next-line @typescript-eslint/no-require-imports const appExports = require("../src/app"); - return appExports.default; + return appExports.app; } function setRole(request: request.Test, role?: RoleType) { diff --git a/yarn.lock b/yarn.lock index 6eeb1458..b5c047b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6598,6 +6598,11 @@ ws@^8.18.2: resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.2.tgz#42738b2be57ced85f46154320aabb51ab003705a" integrity sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ== +ws@^8.18.3: + version "8.18.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== + xtend@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"