feat: improve collaboration rooms (#1338)

This commit is contained in:
ZacharyZcR
2026-08-25 02:15:44 +08:00
committed by GitHub
parent 8d0bcb3b1f
commit 0ab7cf2ab8
17 changed files with 1260 additions and 222 deletions
@@ -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);
});
});
+74 -7
View File
@@ -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();
});
});