Skip to content
Source
Sign inSign up
Open History to see the last commit

nativeWebSocket.ios.test.ts

131 lines5.3 KB

1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Socket from "effect/socket/Socket";
4import { vi } from "vite-plus/test";
5​
6vi.mock("expo", () => ({ requireOptionalNativeModule: () => null }));
7​
8import * as NativeWebSocket from "./nativeWebSocket.ios";
9​
10function setup() {
11 const listeners = new Map<string, (event: never) => void>();
12 let nextId = 7;
13 const native = {
14 connect: vi.fn(() => nextId++),
15 sendText: vi.fn(),
16 sendBinary: vi.fn(),
17 close: vi.fn(),
18 addListener: vi.fn((event: string, listener: (event: never) => void) => {
19 listeners.set(event, listener);
20 return { remove: () => listeners.delete(event) };
21 }),
22 } satisfies NativeWebSocket.NativeModule;
23 const emit = (event: "open" | "message" | "error" | "close", payload: Record<string, unknown>) =>
24 listeners.get(event)?.(payload as never);
25 const sockets = new NativeWebSocket.Sockets(native);
26 const events: Array<Record<string, unknown>> = [];
27 const socket = new NativeWebSocket.Connection(sockets, "ws://host/ws", ["t3"]);
28 for (const type of ["open", "message", "error", "close"] as const) {
29 socket.addEventListener(type, (event) => events.push({ ...event }));
30 }
31 return { native, emit, socket, events, sockets, id: 7 };
33​
34describe("NativeWebSocket", () => {
35 it("opens, then delivers text and binary frames as string and ArrayBuffer", () => {
36 const { native, emit, socket, events, id } = setup();
37 expect(native.connect).toHaveBeenCalledWith("ws://host/ws", ["t3"]);
38 expect(socket.readyState).toBe(NativeWebSocket.Connection.CONNECTING);
39 expect(() => socket.send("early")).toThrow(/connecting/);
40​
41 emit("open", { id });
42 expect(socket.readyState).toBe(NativeWebSocket.Connection.OPEN);
43​
44 const bytes = new Uint8Array([1, 2, 3]);
45 emit("message", { id, text: "hello" });
46 emit("message", { id, data: bytes.subarray(1) });
47 emit("message", { id: id + 1, text: "other socket" });
48 expect(events).toEqual([
49 { type: "open" },
50 { type: "message", data: "hello" },
51 { type: "message", data: new Uint8Array([2, 3]) },
52 ]);
53​
54 socket.send("out");
55 socket.send(bytes);
56 expect(native.sendText).toHaveBeenCalledWith(id, "out");
57 expect(native.sendBinary).toHaveBeenCalledWith(id, bytes);
58 });
59​
60 it("reports the server's close frame once and ignores later events", () => {
61 const { emit, socket, events, id } = setup();
62 emit("open", { id });
63 emit("close", { id, code: 1001, reason: "going away", wasClean: true });
64 emit("close", { id, code: 1006, reason: "", wasClean: false });
65 emit("message", { id, text: "late" });
66 expect(socket.readyState).toBe(NativeWebSocket.Connection.CLOSED);
67 expect(events.slice(1)).toEqual([
68 { type: "close", code: 1001, reason: "going away", wasClean: true },
69 ]);
70 });
71​
72 it("fires error before close when the handshake fails", () => {
73 const { emit, events, id } = setup();
74 emit("error", { id, message: "connection refused" });
75 emit("close", { id, code: 1006, reason: "", wasClean: false });
76 expect(events).toEqual([
77 { type: "error", message: "connection refused" },
78 { type: "close", code: 1006, reason: "", wasClean: false },
79 ]);
80 });
81​
82 it("closes through the native module once and honours once listeners", () => {
83 const { native, emit, socket, id } = setup();
84 const once = vi.fn();
85 const always = vi.fn();
86 socket.addEventListener("close", once, { once: true });
87 socket.addEventListener("close", always);
88 socket.addEventListener("close", always);
89 emit("open", { id });
90 socket.close(1000, "done");
91 socket.close(1000, "again");
92 expect(socket.readyState).toBe(NativeWebSocket.Connection.CLOSING);
93 expect(native.close).toHaveBeenCalledTimes(1);
94 expect(native.close).toHaveBeenCalledWith(id, 1000, "done");
95 expect(() => socket.send("after close")).not.toThrow();
96 expect(native.sendText).not.toHaveBeenCalled();
97​
98 socket.removeEventListener("close", always);
99 emit("close", { id, code: 1000, reason: "", wasClean: true });
100 expect(once).toHaveBeenCalledTimes(1);
101 expect(always).not.toHaveBeenCalled();
102 });
103​
104 it("closes open sockets and stops listening when disposed", () => {
105 const { native, emit, socket, sockets, events, id } = setup();
106 emit("open", { id });
107 sockets.dispose();
108 expect(native.close).toHaveBeenCalledWith(id, 1001, "");
109 expect(socket.readyState).toBe(NativeWebSocket.Connection.CLOSED);
110 expect(events.at(-1)).toEqual({ type: "close", code: 1001, reason: "", wasClean: false });
111 expect(emit("message", { id, text: "late" })).toBeUndefined();
112 });
113​
114 it.effect("surfaces a server close through Effect's socket as SocketCloseError", () =>
115 Effect.gen(function* () {
116 const { emit, socket, id } = setup();
117 emit("open", { id });
118 const effectSocket = yield* Socket.fromWebSocket(Effect.succeed(socket));
119 const reader = yield* effectSocket.reader;
120 emit("message", { id, text: "hi" });
121 emit("close", { id, code: 1011, reason: "boom", wasClean: true });
122 expect(yield* reader.pull).toEqual(["hi"]);
123 const error = yield* Effect.flip(reader.pull);
124 expect(error.reason).toMatchObject({
125 _tag: "SocketCloseError",
126 code: 1011,
127 closeReason: "boom",
128 });
129 }).pipe(Effect.scoped),
130 );
131});