| ... | ... | @@ -1,58 +1,59 @@ |
| 1 | | // @effect-diagnostics nodeBuiltinImport:off - The channel reads real file descriptors. |
| 2 | | import * as NodeFS from "node:fs"; |
| 3 | | import * as NodeOS from "node:os"; |
| 4 | | import * as NodePath from "node:path"; |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Exercises the real inherited descriptor lifecycle in a subprocess. |
| 2 | import * as NodeChildProcess from "node:child_process"; |
| 3 | import * as NodeURL from "node:url"; |
| 5 | 4 | |
| 6 | | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | | import { expect, it } from "@effect/vitest"; |
| 8 | | import * as Context from "effect/Context"; |
| 9 | | import * as Effect from "effect/Effect"; |
| 10 | | import * as Exit from "effect/Exit"; |
| 11 | | import * as Layer from "effect/Layer"; |
| 5 | import { expect, it } from "vite-plus/test"; |
| 12 | 6 | |
| 13 | | import * as ServerConfig from "../config.ts"; |
| 14 | | import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; |
| 15 | | |
| 16 | | const key = { threadId: "thread-1", tabId: "tab-1" }; |
| 17 | | |
| 18 | | /** The channel over two files: what the desktop sent, and where commands go. */ |
| 19 | | const channelOver = (events: ReadonlyArray<Record<string, unknown>>) => |
| 20 | | Effect.gen(function* () { |
| 21 | | const directory = yield* Effect.acquireRelease( |
| 22 | | Effect.sync(() => |
| 23 | | NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-desktop-browser-channel-")), |
| 24 | | ), |
| 25 | | (directory) => Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true })), |
| 26 | | ); |
| 27 | | const inbound = NodePath.join(directory, "events.ndjson"); |
| 28 | | NodeFS.writeFileSync(inbound, events.map((event) => `${JSON.stringify(event)}\n`).join("")); |
| 29 | | const control = yield* Effect.acquireRelease( |
| 30 | | Effect.sync(() => NodeFS.openSync(NodePath.join(directory, "commands.ndjson"), "w")), |
| 31 | | (fd) => Effect.sync(() => NodeFS.closeSync(fd)), |
| 32 | | ); |
| 33 | | const base = yield* ServerConfig.ServerConfig.pipe( |
| 34 | | Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-desktop-browser-" })), |
| 35 | | ); |
| 36 | | const config = Layer.succeed(ServerConfig.ServerConfig, { |
| 37 | | ...base, |
| 38 | | desktopBrowserFd: NodeFS.openSync(inbound, "r"), |
| 39 | | desktopBrowserControlFd: control, |
| 40 | | }); |
| 41 | | // Built in the caller's scope, so the channel outlives this setup. |
| 42 | | const context = yield* Layer.build(DesktopBrowserChannel.layer.pipe(Layer.provide(config))); |
| 43 | | return Context.get(context, DesktopBrowserChannel.DesktopBrowserChannel); |
| 44 | | }); |
| 45 | | |
| 46 | | it.layer(NodeServices.layer)("DesktopBrowserChannel", (it) => { |
| 47 | | it.effect("refuses an endpoint for a tab that is no longer attached", () => |
| 48 | | Effect.gen(function* () { |
| 49 | | const channel = yield* channelOver([ |
| 50 | | { type: "attached", ...key }, |
| 51 | | { type: "detached", ...key }, |
| 52 | | ]); |
| 53 | | // Whether or not the reader has reached these lines yet, the tab is not attached. |
| 54 | | const exit = yield* Effect.exit(Effect.scoped(channel.endpoint(key))); |
| 55 | | expect(Exit.isFailure(exit)).toBe(true); |
| 56 | | }).pipe(Effect.scoped), |
| 7 | it("receives desktop messages and exits while the parent keeps both input pipes open", async () => { |
| 8 | const child = NodeChildProcess.spawn( |
| 9 | process.execPath, |
| 10 | [NodeURL.fileURLToPath(new URL("./testing/DesktopPipeLifecycle.fixture.ts", import.meta.url))], |
| 11 | { stdio: ["ignore", "pipe", "pipe", "pipe", "pipe", "pipe", "pipe"] }, |
| 57 | 12 | ); |
| 13 | // A failed exit must not leave a test process running. This never fires on success. |
| 14 | // @effect-diagnostics-next-line globalTimers:off -- Bounds the native subprocess on failure; success waits for process exit. |
| 15 | const watchdog = setTimeout(() => child.kill("SIGKILL"), 8_000); |
| 16 | let output = ""; |
| 17 | let errors = ""; |
| 18 | let verified = false; |
| 19 | child.stderr?.on("data", (chunk: Buffer) => { |
| 20 | errors += chunk.toString(); |
| 21 | }); |
| 22 | const write = (fd: number, value: Record<string, unknown>) => { |
| 23 | const stream = child.stdio[fd]; |
| 24 | if (!stream || !("write" in stream)) throw new Error(`Missing pipe ${fd}`); |
| 25 | stream.write(`${JSON.stringify(value)}\n`); |
| 26 | }; |
| 27 | const key = { threadId: "thread-1", tabId: "tab-1" }; |
| 28 | child.stdout?.on("data", (chunk: Buffer) => { |
| 29 | output += chunk.toString(); |
| 30 | let end: number; |
| 31 | while ((end = output.indexOf("\n")) >= 0) { |
| 32 | const line = output.slice(0, end); |
| 33 | output = output.slice(end + 1); |
| 34 | if (line === "ready") { |
| 35 | write(3, { type: "attached", ...key }); |
| 36 | write(5, { version: 1, type: "desktopTelemetryHello", electronPid: process.pid }); |
| 37 | } else if (line === "attached") { |
| 38 | write(3, { type: "detached", ...key }); |
| 39 | } else if (line === "verified") { |
| 40 | verified = true; |
| 41 | } |
| 42 | } |
| 43 | }); |
| 44 | try { |
| 45 | const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( |
| 46 | (resolve, reject) => { |
| 47 | child.once("error", reject); |
| 48 | child.once("exit", (code, signal) => resolve({ code, signal })); |
| 49 | }, |
| 50 | ); |
| 51 | expect(errors).not.toContain("Error"); |
| 52 | expect(verified).toBe(true); |
| 53 | expect(result).toEqual({ code: 0, signal: null }); |
| 54 | } finally { |
| 55 | clearTimeout(watchdog); |
| 56 | if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); |
| 57 | for (const stream of child.stdio) stream?.destroy(); |
| 58 | } |
| 58 | 59 | }); |