352 lines · 10.4 KB · TypeScript
RawDownload
import {  DEFAULT_SERVER_SETTINGS,  EnvironmentId,  ServerConfig,  type ServerConfig as ServerConfigType,  WS_METHODS,} from "@t3tools/contracts";import { describe, expect, it } from "@effect/vitest";import * as Effect from "effect/Effect";import * as Fiber from "effect/Fiber";import * as Layer from "effect/Layer";import * as Schema from "effect/Schema";import * as TestClock from "effect/testing/TestClock";import * as Socket from "effect/unstable/socket/Socket"; import {  ConnectionTransientError,  PrimaryConnectionTarget,  type PreparedConnection,} from "../connection/model.ts";import * as RpcSession from "./session.ts"; type SocketEventType = "open" | "message" | "close" | "error";type SocketEvent = {  readonly code?: number;  readonly data?: unknown;  readonly reason?: string;  readonly type: SocketEventType;};type SocketListener = (event: SocketEvent) => void; class TestWebSocket {  static readonly CONNECTING = 0;  static readonly OPEN = 1;  static readonly CLOSING = 2;  static readonly CLOSED = 3;   readyState = TestWebSocket.CONNECTING;  readonly sent: string[] = [];  readonly url: string;  private readonly listeners = new Map<SocketEventType, Set<SocketListener>>();   constructor(url: string) {    this.url = url;  }   addEventListener(type: SocketEventType, listener: SocketListener) {    const listeners = this.listeners.get(type) ?? new Set<SocketListener>();    listeners.add(listener);    this.listeners.set(type, listeners);  }   removeEventListener(type: SocketEventType, listener: SocketListener) {    this.listeners.get(type)?.delete(listener);  }   send(data: string) {    this.sent.push(data);  }   close(code = 1000, reason = "") {    if (this.readyState === TestWebSocket.CLOSED) {      return;    }    this.readyState = TestWebSocket.CLOSED;    this.emit("close", { code, reason, type: "close" });  }   open() {    this.readyState = TestWebSocket.OPEN;    this.emit("open", { type: "open" });  }   serverMessage(data: string) {    this.emit("message", { data, type: "message" });  }   private emit(type: SocketEventType, event: SocketEvent) {    for (const listener of this.listeners.get(type) ?? []) {      listener(event);    }  }} const TARGET = new PrimaryConnectionTarget({  environmentId: EnvironmentId.make("environment-1"),  label: "Test environment",  httpBaseUrl: "https://environment.example.test",  wsBaseUrl: "wss://environment.example.test",}); const PREPARED: PreparedConnection = {  environmentId: TARGET.environmentId,  label: TARGET.label,  httpBaseUrl: TARGET.httpBaseUrl,  socketUrl: "wss://environment.example.test/ws?wsTicket=test",  httpAuthorization: null,  target: TARGET,}; const SERVER_CONFIG: ServerConfigType = {  environment: {    environmentId: TARGET.environmentId,    label: TARGET.label,    platform: {      os: "darwin",      arch: "arm64",    },    serverVersion: "0.0.0-test",    capabilities: {      repositoryIdentity: true,      connectionProbe: true,    },  },  auth: {    policy: "loopback-browser",    bootstrapMethods: ["one-time-token"],    sessionMethods: ["browser-session-cookie", "bearer-access-token"],    sessionCookieName: "t3_session",  },  cwd: "/tmp/workspace",  keybindingsConfigPath: "/tmp/workspace/keybindings.json",  keybindings: [],  issues: [],  providers: [],  availableEditors: [],  observability: {    logsDirectoryPath: "/tmp/logs",    localTracingEnabled: false,    otlpTracesEnabled: false,    otlpMetricsEnabled: false,  },  settings: DEFAULT_SERVER_SETTINGS,}; const RpcRequest = Schema.TaggedStruct("Request", {  id: Schema.String,  payload: Schema.Unknown,  tag: Schema.String,});const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString);const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest);const encodeJson = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);const encodeServerConfig = Schema.encodeSync(ServerConfig);const ENCODED_SERVER_CONFIG = encodeServerConfig(SERVER_CONFIG);const LEGACY_SERVER_CONFIG = {  ...ENCODED_SERVER_CONFIG,  environment: {    ...ENCODED_SERVER_CONFIG.environment,    capabilities: {      repositoryIdentity: true,    },  },}; const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () {  const sockets: TestWebSocket[] = [];  const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => {    const socket = new TestWebSocket(url);    sockets.push(socket);    return socket as unknown as globalThis.WebSocket;  });  const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer));  const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer));  return { factory, sockets };}); const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* (  sockets: ReadonlyArray<TestWebSocket>,) {  for (let attempt = 0; attempt < 100; attempt += 1) {    const socket = sockets[0];    if (socket) {      return socket;    }    yield* Effect.yieldNow;  }  return yield* Effect.die(new Error("Expected the RPC protocol to create a websocket."));}); const awaitRequest = Effect.fn("TestRpcSessionFactory.awaitRequest")(function* (  socket: TestWebSocket,  index = 0,) {  for (let attempt = 0; attempt < 100; attempt += 1) {    const request = socket.sent[index];    if (request) {      return decodeRpcRequest(decodeJson(request));    }    yield* Effect.yieldNow;  }  return yield* Effect.die(new Error("Expected the RPC protocol to send a request."));}); const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialConfig")(function* (  socket: TestWebSocket,  config: unknown = ENCODED_SERVER_CONFIG,) {  const request = yield* awaitRequest(socket);  expect(request).toMatchObject({    _tag: "Request",    tag: WS_METHODS.serverGetConfig,    payload: {},  });  socket.serverMessage(    encodeJson({      _tag: "Exit",      requestId: request.id,      exit: {        _tag: "Success",        value: config,      },    }),  );}); describe("RpcSessionFactory", () => {  it.effect("owns one scoped websocket attempt and exposes readiness and closure", () =>    Effect.gen(function* () {      const { factory, sockets } = yield* makeFactory();      const session = yield* factory.connect(PREPARED);      const readyFiber = yield* Effect.forkChild(session.ready);      const socket = yield* awaitSocket(sockets);       expect(socket.url).toBe(PREPARED.socketUrl);      socket.open();      yield* completeInitialConfig(socket);      yield* Fiber.join(readyFiber);       const config = yield* session.initialConfig;      expect(config).toEqual(SERVER_CONFIG);      expect(socket.sent).toHaveLength(1);       const probeFiber = yield* Effect.forkChild(session.probe);      const probeRequest = yield* awaitRequest(socket, 1);      expect(probeRequest).toMatchObject({        _tag: "Request",        tag: WS_METHODS.serverProbe,        payload: {},      });      socket.serverMessage(        encodeJson({          _tag: "Exit",          requestId: probeRequest.id,          exit: {            _tag: "Success",            value: {},          },        }),      );      yield* Fiber.join(probeFiber);       expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([        WS_METHODS.serverGetConfig,        WS_METHODS.serverProbe,      ]);       socket.close(1012, "service restart");      const error = yield* Effect.flip(session.closed);       expect(error).toBeInstanceOf(ConnectionTransientError);      expect(error).toMatchObject({        reason: "transport",        message: "Test environment disconnected.",      });      yield* Effect.yieldNow;      expect(sockets).toHaveLength(1);    }),  );   it.effect("closes the websocket when the session scope is released", () =>    Effect.gen(function* () {      const { factory, sockets } = yield* makeFactory();       yield* Effect.scoped(        Effect.gen(function* () {          const session = yield* factory.connect(PREPARED);          const readyFiber = yield* Effect.forkChild(session.ready);          const socket = yield* awaitSocket(sockets);          socket.open();          yield* completeInitialConfig(socket);          yield* Fiber.join(readyFiber);        }),      );       expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED);    }),  );   it.effect("uses the legacy config RPC for probes when the server lacks the capability", () =>    Effect.scoped(      Effect.gen(function* () {        const { factory, sockets } = yield* makeFactory();        const session = yield* factory.connect(PREPARED);        const readyFiber = yield* Effect.forkChild(session.ready);        const socket = yield* awaitSocket(sockets);         socket.open();        yield* completeInitialConfig(socket, LEGACY_SERVER_CONFIG);        yield* Fiber.join(readyFiber);         const probeFiber = yield* Effect.forkChild(session.probe);        const probeRequest = yield* awaitRequest(socket, 1);        expect(probeRequest).toMatchObject({          _tag: "Request",          tag: WS_METHODS.serverGetConfig,          payload: {},        });        socket.serverMessage(          encodeJson({            _tag: "Exit",            requestId: probeRequest.id,            exit: {              _tag: "Success",              value: LEGACY_SERVER_CONFIG,            },          }),        );        yield* Fiber.join(probeFiber);         expect(socket.sent.map((request) => decodeRpcRequest(decodeJson(request)).tag)).toEqual([          WS_METHODS.serverGetConfig,          WS_METHODS.serverGetConfig,        ]);      }),    ),  );   it.effect("fails readiness when the websocket never opens", () =>    Effect.gen(function* () {      const { factory, sockets } = yield* makeFactory();       const error = yield* Effect.scoped(        Effect.gen(function* () {          const session = yield* factory.connect(PREPARED);          const readyFiber = yield* Effect.forkChild(Effect.flip(session.ready));          yield* awaitSocket(sockets);           yield* TestClock.adjust("15 seconds");          return yield* Fiber.join(readyFiber);        }),      );       expect(error).toBeInstanceOf(ConnectionTransientError);      expect(error).toMatchObject({        reason: "transport",        message: "Test environment could not establish a WebSocket connection.",      });      expect(sockets[0]?.readyState).toBe(TestWebSocket.CLOSED);    }).pipe(Effect.provide(TestClock.layer())),  );});