History

test(server): make Windows path and async fixtures deterministic (#9576)

Julius Marminge committed 2 months ago

Commit b123cbb1 parent 5f4c7169 files · +125 −75Browse files

Unified · Split

9 files
  1. apps/server/src/bootstrap.test.ts +21 −11
  2. apps/server/src/cli/config.test.ts +15 −4
  3. apps/server/src/cloud/bootService.test.ts +6 −3
  4. apps/server/src/provider/Layers/AntigravityAdapter.test.ts +2 −1
  5. apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +13 −4
  6. apps/server/src/provider/Layers/ProviderRegistry.test.ts +32 −31
  7. apps/server/src/server.test.ts +10 −1
  8. apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +3 −1
  9. apps/server/src/workspace/WorkspaceFileSystem.test.ts +23 −19
apps/server/src/bootstrap.test.ts+21−11
@@ -57,6 +57,22 @@ vi.mock("node:fs", async (importOriginal) => { · 56 unmodified lines
5757
5858 const windowsHost = HostProcessPlatform.defaultValue() === "win32";
5959 const nullDevice = windowsHost ? "\\\\.\\NUL" : "/dev/null";
60+const closeIfOpen = (fd: number) => {
61+ try {
62+ NodeFS.closeSync(fd);
63+ } catch (error) {
64+ if ((error as NodeJS.ErrnoException).code !== "EBADF") throw error;
65+ }
66+};
67+
68+// A successful Windows read streams the inherited fd with autoClose. POSIX
69+// reopens the fd through /proc or /dev, so the test still owns the original.
70+const openBootstrapInputFd = (filePath: string) =>
71+ Effect.acquireRelease(
72+ Effect.sync(() => NodeFS.openSync(filePath, "r")),
73+ (fd) => (windowsHost ? Effect.void : Effect.sync(() => closeIfOpen(fd))),
74+ );
75+
6076 const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String });
6177 const encodeTestEnvelopeSchema = Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema));
6278
@@ -71,10 +87,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { · 8 unmodified lines
7187 `${yield* encodeTestEnvelopeSchema({ mode: "desktop" })}\n`,
7288 );
7389
74− const fd = yield* Effect.acquireRelease(
75− Effect.sync(() => NodeFS.openSync(filePath, "r")),
76− (fd) => Effect.sync(() => NodeFS.closeSync(fd)),
77− );
90+ const fd = yield* openBootstrapInputFd(filePath);
7891
7992 const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 });
8093 assertSome(payload, {
@@ -119,7 +132,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { · 38 unmodified lines
119132 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
120133 const fd = yield* Effect.acquireRelease(
121134 Effect.sync(() => NodeFS.openSync(filePath, "r")),
122− (fd) => Effect.sync(() => NodeFS.closeSync(fd)),
135+ (fd) => Effect.sync(() => closeIfOpen(fd)),
123136 );
124137 const fdPath = `/proc/self/fd/${fd}`;
125138
@@ -160,7 +173,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { · 34 unmodified lines
160173 Effect.gen(function* () {
161174 const fd = yield* Effect.acquireRelease(
162175 Effect.sync(() => NodeFS.openSync(nullDevice, "r")),
163− (fd) => Effect.sync(() => NodeFS.closeSync(fd)),
176+ (fd) => Effect.sync(() => closeIfOpen(fd)),
164177 );
165178
166179 fstatSyncInterceptor.failFd = fd;
@@ -185,10 +198,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { · 18 unmodified lines
185198 const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
186199 yield* fs.writeFileString(filePath, '{"mode":42}\n');
187200
188− const fd = yield* Effect.acquireRelease(
189− Effect.sync(() => NodeFS.openSync(filePath, "r")),
190− (fd) => Effect.sync(() => NodeFS.closeSync(fd)),
191− );
201+ const fd = yield* openBootstrapInputFd(filePath);
192202 const error = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, {
193203 timeoutMs: 100,
194204 }).pipe(Effect.flip);
@@ -228,7 +238,7 @@ it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { · 33 unmodified lines
228238
229239 const fd = yield* Effect.acquireRelease(
230240 Effect.sync(() => NodeFS.openSync(fifoPath, "r")),
231− (fd) => Effect.sync(() => NodeFS.closeSync(fd)),
241+ (fd) => Effect.sync(() => closeIfOpen(fd)),
232242 );
233243
234244 const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, {
apps/server/src/cli/config.test.ts+15−4
@@ -60,7 +60,16 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 59 unmodified lines
6060 yield* fs.writeFileString(filePath, `${encoded}\n`);
6161 return yield* Effect.acquireRelease(
6262 Effect.sync(() => NodeFS.openSync(filePath, "r")),
63− (fd) => Effect.sync(() => NodeFS.closeSync(fd)),
63+ // Without a /proc or /dev/fd path to reopen, the reader consumes the fd
64+ // itself (autoClose), so on Windows it is already closed here.
65+ (fd) =>
66+ Effect.sync(() => {
67+ try {
68+ NodeFS.closeSync(fd);
69+ } catch (error) {
70+ if ((error as NodeJS.ErrnoException).code !== "EBADF") throw error;
71+ }
72+ }),
6473 );
6574 });
6675
@@ -281,13 +290,15 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 214 unmodified lines
281290
282291 it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () =>
283292 Effect.gen(function* () {
284− const { join } = yield* Path.Path;
285− const baseDir = "/tmp/t3-bootstrap-home";
293+ const { join, resolve } = yield* Path.Path;
294+ // The resolver absolutises the configured home, so the expectation must
295+ // carry the host's drive on Windows.
296+ const baseDir = resolve("/tmp/t3-bootstrap-home");
286297 const fd = yield* openBootstrapFd(
287298 makeDesktopBootstrap({
288299 port: 4888,
289300 host: "127.0.0.2",
290− t3Home: baseDir,
301+ t3Home: "/tmp/t3-bootstrap-home",
291302 noBrowser: true,
292303 desktopBootstrapToken: "desktop-token",
293304 desktopTelemetryFd: 4,
apps/server/src/cloud/bootService.test.ts+6−3
@@ -529,11 +529,14 @@ it.layer(NodeServices.layer)("boot service install", (it) => { · 528 unmodified lines
529529 it.effect("installs, reports current state, and uninstalls on macOS", () =>
530530 Effect.gen(function* () {
531531 const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin");
532+ const path = yield* Path.Path;
532533 const plan = yield* service.install();
533534
534− expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe(
535− true,
536− );
535+ expect(
536+ plan.unitPath.endsWith(
537+ path.join("Library", "LaunchAgents", "com.t3tools.t3code.service.plist"),
538+ ),
539+ ).toBe(true);
537540 expect(yield* fs.readFileString(plan.unitPath)).toContain(
538541 ` <key>PATH</key>\n <string>${macInstallerPath}:/usr/local/bin:/usr/sbin:/sbin</string>`,
539542 );
apps/server/src/provider/Layers/AntigravityAdapter.test.ts+2−1
@@ -421,7 +421,8 @@ it.layer(layer)("AntigravityAdapter", (it) => { · 420 unmodified lines
421421 modelSelection: { instanceId, model: nativeAlternative },
422422 });
423423 expect(second.model).toBe(nativeAlternative);
424− expect(second.cwd).toBe("/tmp");
424+ // The adapter resolves the cwd it was given through the host Path.
425+ expect(second.cwd).toBe((yield* Path.Path).resolve("/tmp"));
425426 expect(h.launches[1]?.resumeSessionId).toBe(nativeSessionId);
426427 expect(h.calls).toEqual([
427428 "start",
apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts+13−4
@@ -24,6 +24,7 @@ · 23 unmodified lines
2424 */
2525 import { describe, expect, it } from "@effect/vitest";
2626 import * as NodeServices from "@effect/platform-node/NodeServices";
27+import * as Path from "effect/Path";
2728 import {
2829 type ClaudeSettings,
2930 type CodexSettings,
@@ -214,15 +215,19 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { · 184 unmodified lines
214215 expect(personalSnapshot.instanceId).toBe(personalId);
215216 expect(personalSnapshot.driver).toBe(codexDriverKind);
216217 expect(personalSnapshot.enabled).toBe(false);
218+ // The layout resolves the configured home through the host Path.
219+ const path = yield* Path.Path;
217220 expect(personalSnapshot.continuation?.groupKey).toBe(
218− "codex:home:/home/julius/.codex_personal",
221+ `codex:home:${path.resolve("/home/julius/.codex_personal")}`,
219222 );
220223
221224 const workSnapshot = yield* work!.snapshot.getSnapshot;
222225 expect(workSnapshot.instanceId).toBe(workId);
223226 expect(workSnapshot.driver).toBe(codexDriverKind);
224227 expect(workSnapshot.enabled).toBe(false);
225− expect(workSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex");
228+ expect(workSnapshot.continuation?.groupKey).toBe(
229+ `codex:home:${path.resolve("/home/julius/.codex")}`,
230+ );
226231
227232 // Nothing goes to the unavailable bucket — both drivers are registered.
228233 const unavailable = yield* registry.listUnavailable;
@@ -451,13 +456,17 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { · 222 unmodified lines
451456 expect(codexSnapshot.instanceId).toBe(codexId);
452457 expect(codexSnapshot.driver).toBe(codexDriverKind);
453458 expect(codexSnapshot.enabled).toBe(false);
454− expect(codexSnapshot.continuation?.groupKey).toBe("codex:home:/home/julius/.codex");
459+ expect(codexSnapshot.continuation?.groupKey).toBe(
460+ `codex:home:${(yield* Path.Path).resolve("/home/julius/.codex")}`,
461+ );
455462
456463 const claudeSnapshot = yield* claude!.snapshot.getSnapshot;
457464 expect(claudeSnapshot.instanceId).toBe(claudeId);
458465 expect(claudeSnapshot.driver).toBe(claudeDriverKind);
459466 expect(claudeSnapshot.enabled).toBe(false);
460− expect(claudeSnapshot.continuation?.groupKey).toBe("claude:home:/home/julius/.claude-work");
467+ expect(claudeSnapshot.continuation?.groupKey).toBe(
468+ `claude:home:${(yield* Path.Path).resolve("/home/julius/.claude-work")}`,
469+ );
461470
462471 const cursorSnapshot = yield* cursor!.snapshot.getSnapshot;
463472 expect(cursorSnapshot.instanceId).toBe(cursorId);
apps/server/src/provider/Layers/ProviderRegistry.test.ts+32−31
@@ -3,6 +3,7 @@ import { describe, it, assert } from "@effect/vitest"; · 2 unmodified lines
33 import * as DateTime from "effect/DateTime";
44 import * as Deferred from "effect/Deferred";
55 import * as Effect from "effect/Effect";
6+import * as Path from "effect/Path";
67 import * as Exit from "effect/Exit";
78 import * as Fiber from "effect/Fiber";
89 import * as Layer from "effect/Layer";
@@ -346,6 +347,21 @@ function makeMutableServerSettingsService( · 337 unmodified lines
346347 });
347348 }
348349
350+// The registry writes the status cache and only then publishes the change, so
351+// a subscriber that sees `checkedAt` on the stream knows the file is on disk.
352+// Subscribed before the publish that triggers it; a spin on the file would
353+// race the write and lose on a slow host.
354+const awaitPersistedProvider = (
355+ registry: ProviderRegistry.ProviderRegistry["Service"],
356+ checkedAt: string,
357+) =>
358+ registry.streamChanges.pipe(
359+ Stream.filter((providers) => providers.some((provider) => provider.checkedAt === checkedAt)),
360+ Stream.take(1),
361+ Stream.runDrain,
362+ Effect.forkScoped,
363+ );
364+
349365 it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), TestHttpClientLive))(
350366 "ProviderRegistry",
351367 (it) => {
@@ -1752,18 +1768,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 1400 unmodified lines
17521768 assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [
17531769 ...initialProvider.models,
17541770 ]);
1771+ const persisted = yield* awaitPersistedProvider(registry, refreshedProvider.checkedAt);
17551772 yield* PubSub.publish(changes, refreshedProvider);
1756−
1757− let cachedProvider = yield* readProviderStatusCache(filePath);
1758− for (
1759− let attempt = 0;
1760− attempt < 50 && cachedProvider?.checkedAt !== refreshedProvider.checkedAt;
1761− attempt += 1
1762− ) {
1763− yield* TestClock.adjust("10 millis");
1764− yield* Effect.yieldNow;
1765− cachedProvider = yield* readProviderStatusCache(filePath);
1766− }
1773+ yield* Fiber.join(persisted);
1774+ const cachedProvider = yield* readProviderStatusCache(filePath);
17671775
17681776 assert.deepStrictEqual(cachedProvider, {
17691777 ...refreshedProvider,
@@ -1878,31 +1886,23 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 108 unmodified lines
18781886 instanceId: openCodeInstanceId,
18791887 });
18801888
1889+ const authoritativePersisted = yield* awaitPersistedProvider(
1890+ registry,
1891+ authoritativeProvider.checkedAt,
1892+ );
18811893 yield* PubSub.publish(changes, authoritativeProvider);
1882−
1894+ yield* Fiber.join(authoritativePersisted);
18831895 let cachedProvider = yield* readProviderStatusCache(filePath);
1884− for (
1885− let attempt = 0;
1886− attempt < 50 && cachedProvider?.checkedAt !== authoritativeProvider.checkedAt;
1887− attempt += 1
1888− ) {
1889− yield* TestClock.adjust("10 millis");
1890− yield* Effect.yieldNow;
1891− cachedProvider = yield* readProviderStatusCache(filePath);
1892− }
18931896
18941897 assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]);
18951898
1899+ const failedPersisted = yield* awaitPersistedProvider(
1900+ registry,
1901+ failedProvider.checkedAt,
1902+ );
18961903 yield* PubSub.publish(changes, failedProvider);
1897− for (
1898− let attempt = 0;
1899− attempt < 50 && cachedProvider?.checkedAt !== failedProvider.checkedAt;
1900− attempt += 1
1901− ) {
1902− yield* TestClock.adjust("10 millis");
1903− yield* Effect.yieldNow;
1904− cachedProvider = yield* readProviderStatusCache(filePath);
1905− }
1904+ yield* Fiber.join(failedPersisted);
1905+ cachedProvider = yield* readProviderStatusCache(filePath);
19061906
19071907 assert.deepStrictEqual(cachedProvider?.models, [authoritativeProvider.models[0]!]);
19081908 assert.deepStrictEqual((yield* registry.getProviders)[0]?.models, [
@@ -2715,9 +2715,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te · 806 unmodified lines
27152715 claudeCapabilities(),
27162716 );
27172717 assert.strictEqual(status.status, "ready");
2718+ // The home is resolved through the host Path before it reaches the env.
27182719 assert.deepStrictEqual(
27192720 recorded.commands.map((command) => command.env?.CLAUDE_CONFIG_DIR),
2720− [claudeConfigDir],
2721+ [(yield* Path.Path).resolve(claudeConfigDir)],
27212722 );
27222723 }).pipe(Effect.provide(recorded.layer));
27232724 });
apps/server/src/server.test.ts+10−1
@@ -1855,12 +1855,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 1854 unmodified lines
18551855 const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-replace-" });
18561856 const beforeOpenPath = path.join(staticDir, "before-open.txt");
18571857 const afterOpenPath = path.join(staticDir, "after-open.txt");
1858+ const afterOpenSnapshotPath = path.join(staticDir, "after-open-snapshot.txt");
1859+ const windowsHost = HostProcessPlatform.defaultValue() === "win32";
18581860 const original = "original bytes";
18591861 const replacement = "replacement bytes with a different size";
18601862 for (const filePath of [beforeOpenPath, afterOpenPath]) {
18611863 yield* fileSystem.writeFileString(filePath, original);
18621864 yield* fileSystem.writeFileString(`${filePath}.next`, replacement);
18631865 }
1866+ if (windowsHost) {
1867+ // Windows cannot replace an open destination, so model the race with its original handle.
1868+ yield* fileSystem.writeFileString(afterOpenSnapshotPath, original);
1869+ }
18641870 const replaced = new Set<string>();
18651871 const replaceOnce = Effect.fnUntraced(function* (filePath: string) {
18661872 if (replaced.has(filePath)) return;
@@ -1877,7 +1883,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 10 unmodified lines
18771883 ),
18781884 open: (filePath, options) =>
18791885 fileSystem
1880− .open(filePath, options)
1886+ .open(
1887+ filePath === afterOpenPath && windowsHost ? afterOpenSnapshotPath : filePath,
1888+ options,
1889+ )
18811890 .pipe(
18821891 Effect.tap(() => (filePath === afterOpenPath ? replaceOnce(filePath) : Effect.void)),
18831892 ),
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+3−1
@@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; · 2 unmodified lines
33 import { assert, it } from "@effect/vitest";
44 import * as Effect from "effect/Effect";
55 import * as FileSystem from "effect/FileSystem";
6+import * as Path from "effect/Path";
67 import * as Layer from "effect/Layer";
78 import * as PlatformError from "effect/PlatformError";
89 import { ChildProcessSpawner } from "effect/unstable/process";
@@ -153,10 +154,11 @@ it.effect("preserves provider failures without deriving the repository message f · 144 unmodified lines
153154 it.effect("clones a looked-up repository into the requested destination", () =>
154155 Effect.gen(function* () {
155156 const fs = yield* FileSystem.FileSystem;
157+ const path = yield* Path.Path;
156158 const parent = yield* fs.makeTempDirectoryScoped({
157159 prefix: "t3-source-control-clone-parent-",
158160 });
159− const destinationPath = `${parent}/t3code`;
161+ const destinationPath = path.join(parent, "t3code");
160162 const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = [];
161163
162164 yield* Effect.gen(function* () {
apps/server/src/workspace/WorkspaceFileSystem.test.ts+23−19
@@ -14,6 +14,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; · 13 unmodified lines
1414 import * as WorkspaceEntries from "./WorkspaceEntries.ts";
1515 import * as WorkspaceFileSystem from "./WorkspaceFileSystem.ts";
1616 import * as WorkspacePaths from "./WorkspacePaths.ts";
17+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1718 import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
1819
1920 const ProjectLayer = WorkspaceFileSystem.layer.pipe(
@@ -100,28 +101,31 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 80 unmodified lines
100101 }),
101102 );
102103
103− it.effect("rejects a FIFO without blocking on open", () =>
104− Effect.gen(function* () {
105− const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
106− const path = yield* Path.Path;
107− const cwd = yield* makeTempDir;
108− const outsideDir = yield* makeTempDir;
109− const fifoPath = path.join(outsideDir, "pipe");
110− yield* Effect.promise(
111− () =>
112− new Promise<void>((resolve, reject) =>
113− NodeChildProcess.execFile("mkfifo", [fifoPath], (error) =>
114− error ? reject(error) : resolve(),
104+ // Needs mkfifo; Windows has no FIFOs to reject.
105+ it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")(
106+ "rejects a FIFO without blocking on open",
107+ () =>
108+ Effect.gen(function* () {
109+ const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
110+ const path = yield* Path.Path;
111+ const cwd = yield* makeTempDir;
112+ const outsideDir = yield* makeTempDir;
113+ const fifoPath = path.join(outsideDir, "pipe");
114+ yield* Effect.promise(
115+ () =>
116+ new Promise<void>((resolve, reject) =>
117+ NodeChildProcess.execFile("mkfifo", [fifoPath], (error) =>
118+ error ? reject(error) : resolve(),
119+ ),
115120 ),
116− ),
117− );
121+ );
118122
119− const error = yield* workspaceFileSystem
120− .readFile({ cwd, relativePath: fifoPath })
121− .pipe(Effect.flip);
123+ const error = yield* workspaceFileSystem
124+ .readFile({ cwd, relativePath: fifoPath })
125+ .pipe(Effect.flip);
122126
123− expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError);
124− }),
127+ expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError);
128+ }),
125129 );
126130
127131 it.effect("rejects reads outside the workspace root", () =>