mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
feat: improve collaboration rooms (#1338)
This commit is contained in:
@@ -1,5 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WebSocket } from "ws";
|
||||
|
||||
const runtime = vi.hoisted(() => ({
|
||||
listener: null as ((roomId: string, message: object) => void) | null,
|
||||
publish: vi.fn(async () => undefined),
|
||||
updatePresence: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/collab/runtime-store.js", () => ({
|
||||
collabRuntimeStore: {
|
||||
onEvent: (listener: (roomId: string, message: object) => void) => {
|
||||
runtime.listener = listener;
|
||||
},
|
||||
publish: runtime.publish,
|
||||
updatePresence: runtime.updatePresence,
|
||||
onlineUsers: async (
|
||||
_roomId: string,
|
||||
users: Array<{ userId: string; username: string }>,
|
||||
) => users,
|
||||
},
|
||||
}));
|
||||
|
||||
import { collabRoomHub } from "../../../hosts/collab/room-hub.js";
|
||||
|
||||
function fakeWs(open = true): WebSocket {
|
||||
@@ -11,7 +32,7 @@ function fakeWs(open = true): WebSocket {
|
||||
}
|
||||
|
||||
describe("collabRoomHub", () => {
|
||||
it("announces the online list on subscribe and unsubscribe, deduplicated per user", () => {
|
||||
it("announces the online list on subscribe and unsubscribe, deduplicated per user", async () => {
|
||||
const a1 = fakeWs();
|
||||
const a2 = fakeWs();
|
||||
const b = fakeWs();
|
||||
@@ -19,11 +40,12 @@ describe("collabRoomHub", () => {
|
||||
collabRoomHub.subscribe("room-1", { ws: a2, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-1", { ws: b, userId: "b", username: "B" });
|
||||
|
||||
expect(collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||
{ userId: "a", username: "A" },
|
||||
{ userId: "b", username: "B" },
|
||||
]);
|
||||
|
||||
await vi.waitFor(() => expect(b.send).toHaveBeenCalled());
|
||||
const last = JSON.parse(
|
||||
(b.send as ReturnType<typeof vi.fn>).mock.calls.at(-1)?.[0] as string,
|
||||
);
|
||||
@@ -37,20 +59,22 @@ describe("collabRoomHub", () => {
|
||||
});
|
||||
|
||||
collabRoomHub.unsubscribe(a1);
|
||||
expect(collabRoomHub.onlineUsers("room-1")).toHaveLength(2);
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toHaveLength(2);
|
||||
collabRoomHub.unsubscribe(a2);
|
||||
expect(collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toEqual([
|
||||
{ userId: "b", username: "B" },
|
||||
]);
|
||||
collabRoomHub.unsubscribe(b);
|
||||
expect(collabRoomHub.onlineUsers("room-1")).toEqual([]);
|
||||
expect(await collabRoomHub.onlineUsers("room-1")).toEqual([]);
|
||||
});
|
||||
|
||||
it("subscribing the same socket twice keeps one subscription", () => {
|
||||
it("subscribing the same socket twice keeps one subscription", async () => {
|
||||
const ws = fakeWs();
|
||||
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
||||
collabRoomHub.subscribe("room-2", { ws, userId: "a", username: "A" });
|
||||
expect((ws.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1);
|
||||
await vi.waitFor(() =>
|
||||
expect((ws.send as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(1),
|
||||
);
|
||||
collabRoomHub.unsubscribe(ws);
|
||||
});
|
||||
|
||||
@@ -74,4 +98,16 @@ describe("collabRoomHub", () => {
|
||||
collabRoomHub.unsubscribe(open);
|
||||
collabRoomHub.unsubscribe(closed);
|
||||
});
|
||||
|
||||
it("fans out remote Redis events but keeps internal events server-side", () => {
|
||||
const ws = fakeWs();
|
||||
collabRoomHub.subscribe("room-4", { ws, userId: "a", username: "A" });
|
||||
(ws.send as ReturnType<typeof vi.fn>).mockClear();
|
||||
|
||||
runtime.listener?.("room-4", { type: "collab_members_changed" });
|
||||
runtime.listener?.("room-4", { type: "collab_internal_stage_revoked" });
|
||||
|
||||
expect(ws.send).toHaveBeenCalledTimes(1);
|
||||
collabRoomHub.unsubscribe(ws);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,11 @@ const state = vi.hoisted(() => ({
|
||||
liveOwned: new Map<string, string>(), // sessionId -> owner
|
||||
broadcasts: [] as Array<Record<string, unknown>>,
|
||||
control: [] as Array<unknown[]>,
|
||||
controllers: new Map<string, string>(),
|
||||
requests: new Map<
|
||||
string,
|
||||
Map<string, { userId: string; username: string; requestedAt: string }>
|
||||
>(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
@@ -67,6 +72,39 @@ vi.mock("../../../hosts/collab/room-hub.js", () => ({
|
||||
onlineUsers: () => [],
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/collab/runtime-store.js", () => ({
|
||||
collabRuntimeStore: {
|
||||
onEvent: vi.fn(),
|
||||
publish: vi.fn(async () => undefined),
|
||||
getController: async (roomId: string) =>
|
||||
state.controllers.get(roomId) ?? null,
|
||||
setController: async (roomId: string, userId: string | null) => {
|
||||
if (userId) state.controllers.set(roomId, userId);
|
||||
else state.controllers.delete(roomId);
|
||||
},
|
||||
listRequests: async (roomId: string) =>
|
||||
Array.from(state.requests.get(roomId)?.values() ?? []).sort((a, b) =>
|
||||
a.requestedAt.localeCompare(b.requestedAt),
|
||||
),
|
||||
upsertRequest: async (
|
||||
roomId: string,
|
||||
request: { userId: string; username: string; requestedAt: string },
|
||||
) => {
|
||||
let requests = state.requests.get(roomId);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
state.requests.set(roomId, requests);
|
||||
}
|
||||
requests.set(request.userId, request);
|
||||
},
|
||||
removeRequest: async (roomId: string, userId: string) => {
|
||||
state.requests.get(roomId)?.delete(userId);
|
||||
},
|
||||
clearRequests: async (roomId: string) => {
|
||||
state.requests.delete(roomId);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/terminal/session-manager.js", () => ({
|
||||
sessionManager: {
|
||||
setRoomShareControl: (...args: unknown[]) => {
|
||||
@@ -302,6 +340,8 @@ describe("collab room routes", () => {
|
||||
state.liveOwned.clear();
|
||||
state.broadcasts.length = 0;
|
||||
state.control.length = 0;
|
||||
state.controllers.clear();
|
||||
state.requests.clear();
|
||||
state.sharingEnabled = true;
|
||||
});
|
||||
|
||||
@@ -434,13 +474,13 @@ describe("collab room routes", () => {
|
||||
params: { id: roomId },
|
||||
body: { userId: "alice" },
|
||||
});
|
||||
expect(getStageController(roomId)).toBe("alice");
|
||||
expect(await getStageController(roomId)).toBe("alice");
|
||||
|
||||
await as("alice", () => present(roomId, "s2"));
|
||||
const room = state.rooms.get(roomId)!;
|
||||
expect(room.presenterUserId).toBe("alice");
|
||||
expect(state.shares.get(firstShare)!.revokedAt).toBeTruthy();
|
||||
expect(getStageController(roomId)).toBeNull();
|
||||
expect(await getStageController(roomId)).toBeNull();
|
||||
});
|
||||
|
||||
it("stop is for the presenter or host", async () => {
|
||||
@@ -533,14 +573,16 @@ describe("collab room routes", () => {
|
||||
state.rooms.get(roomId)!.stageShareId,
|
||||
"alice",
|
||||
]);
|
||||
expect(state.broadcasts.at(-1)).toMatchObject({
|
||||
type: "collab_control_changed",
|
||||
controllerUserId: "alice",
|
||||
});
|
||||
expect(state.broadcasts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "collab_control_changed",
|
||||
controllerUserId: "alice",
|
||||
}),
|
||||
);
|
||||
|
||||
expect((await control("bob", null)).statusCode).toBe(403);
|
||||
expect((await control("alice", null)).statusCode).toBe(200);
|
||||
expect(getStageController(roomId)).toBeNull();
|
||||
expect(await getStageController(roomId)).toBeNull();
|
||||
|
||||
const asked = await as("bob", () =>
|
||||
invoke("post", "/rooms/:id/control/request", { params: { id: roomId } }),
|
||||
@@ -551,6 +593,31 @@ describe("collab room routes", () => {
|
||||
userId: "bob",
|
||||
username: "BOB",
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await as("bob", () =>
|
||||
invoke("get", "/rooms/:id", { params: { id: roomId } }),
|
||||
)
|
||||
).jsonBody!.controlRequests,
|
||||
).toEqual([expect.objectContaining({ userId: "bob" })]);
|
||||
|
||||
const repeated = await as("bob", () =>
|
||||
invoke("post", "/rooms/:id/control/request", {
|
||||
params: { id: roomId },
|
||||
}),
|
||||
);
|
||||
expect((repeated as { statusCode: number }).statusCode).toBe(429);
|
||||
|
||||
const listed = await invoke("get", "/rooms/:id/control/requests", {
|
||||
params: { id: roomId },
|
||||
});
|
||||
expect(listed.jsonBody!.requests).toEqual([
|
||||
expect.objectContaining({ userId: "bob" }),
|
||||
]);
|
||||
await invoke("delete", "/rooms/:id/control/requests/:userId", {
|
||||
params: { id: roomId, userId: "bob" },
|
||||
});
|
||||
expect(state.requests.get(roomId)?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("guest link: host-only toggle, anonymous resolve follows the stage, rate limited", async () => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collabRuntimeStore } from "../../../hosts/collab/runtime-store.js";
|
||||
|
||||
describe("collabRuntimeStore local fallback", () => {
|
||||
it("stores stage control and an ordered, deduplicated request queue", async () => {
|
||||
const roomId = `fallback-${crypto.randomUUID()}`;
|
||||
|
||||
await collabRuntimeStore.setController(roomId, "alice");
|
||||
expect(await collabRuntimeStore.getController(roomId)).toBe("alice");
|
||||
|
||||
await collabRuntimeStore.upsertRequest(roomId, {
|
||||
userId: "bob",
|
||||
username: "Bob",
|
||||
requestedAt: "2026-08-25T00:00:02.000Z",
|
||||
});
|
||||
await collabRuntimeStore.upsertRequest(roomId, {
|
||||
userId: "alice",
|
||||
username: "Alice",
|
||||
requestedAt: "2026-08-25T00:00:01.000Z",
|
||||
});
|
||||
await collabRuntimeStore.upsertRequest(roomId, {
|
||||
userId: "bob",
|
||||
username: "Bob",
|
||||
requestedAt: "2026-08-25T00:00:03.000Z",
|
||||
});
|
||||
|
||||
expect(await collabRuntimeStore.listRequests(roomId)).toEqual([
|
||||
expect.objectContaining({ userId: "alice" }),
|
||||
expect.objectContaining({ userId: "bob" }),
|
||||
]);
|
||||
await collabRuntimeStore.removeRequest(roomId, "alice");
|
||||
expect(await collabRuntimeStore.listRequests(roomId)).toHaveLength(1);
|
||||
|
||||
await collabRuntimeStore.clearRequests(roomId);
|
||||
await collabRuntimeStore.setController(roomId, null);
|
||||
expect(await collabRuntimeStore.listRequests(roomId)).toEqual([]);
|
||||
expect(await collabRuntimeStore.getController(roomId)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user