1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { expect, it } from "@effect/vitest";
4import { ProviderInstanceId } from "@t3tools/contracts";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Path from "effect/Path";
10import * as Sink from "effect/Sink";
11import * as Stream from "effect/Stream";
12import * as HttpClient from "effect/http/HttpClient";
13import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
14
15import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
16import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
17import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
18import { GrokDriver } from "./driver.ts";
19import { resolveLatestProviderVersion } from "@t3tools/provider-core/server/maintenanceResolver";
20
21import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
22import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
23
24const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe(
25 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
26 Layer.provideMerge(IdAllocator.layer),
27 Layer.provideMerge(McpProviderSessions.layer),
28 Layer.provideMerge(ProviderLatestVersions.layer),
29 Layer.provideMerge(
30 Layer.succeed(
31 ProviderEventLoggers.ProviderEventLoggers,
32 ProviderEventLoggers.NoOpProviderEventLoggers,
33 ),
34 ),
35 Layer.provideMerge(
36 Layer.succeed(
37 HttpClient.HttpClient,
38 HttpClient.make(() => Effect.die("Disabled Grok must not make an HTTP request")),
39 ),
40 ),
41);
42
43const noSpawner = ChildProcessSpawner.make(() =>
44 Effect.die("Disabled Grok must not spawn a process"),
45);
46
47/** Answers every spawn with `onSpawn(command, args)` on stdout, like a stub `brew`. */
48function stdoutSpawner(onSpawn: (command: string, args: ReadonlyArray<string>) => string) {
49 return ChildProcessSpawner.make((command) => {
50 const { command: executable, args } = command as unknown as {
51 readonly command: string;
52 readonly args: ReadonlyArray<string>;
53 };
54 return Effect.succeed(
55 ChildProcessSpawner.makeHandle({
56 pid: ChildProcessSpawner.ProcessId(1),
57 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
58 isRunning: Effect.succeed(false),
59 kill: () => Effect.void,
60 unref: Effect.succeed(Effect.void),
61 stdin: Sink.drain,
62 stdout: Stream.encodeText(Stream.make(onSpawn(executable, args))),
63 stderr: Stream.empty,
64 all: Stream.empty,
65 getInputFd: () => Sink.drain,
66 getOutputFd: () => Stream.empty,
67 }),
68 );
69 });
70}
71
72// The `#!/bin/sh` stub below cannot be resolved as an executable on Windows.
73const windowsHost = HostProcess.Platform.defaultValue() === "win32";
74
75it.layer(layerTest)("GrokDriver", (it) => {
76 // Each case puts a stub `brew` at `<prefix>/bin/brew` beside the Grok binary.
77 const homebrewCases = [
78 {
79 name: "upgrades a Homebrew grok-build cask through brew",
80 binary: ["Caskroom", "grok-build", "1.2.3,456", "grok"],
81 reportedPrefix: "own",
82 brewArgs: [["--prefix"]],
83 latestVersion: "1.2.4",
84 update: (brewPath: string, _binaryPath: string) => ({
85 command: "brew upgrade --cask grok-build",
86 executable: brewPath,
87 args: ["upgrade", "--cask", "grok-build"],
88 lockKey: "homebrew",
89 }),
90 },
91 {
92 // `grok update` would install a second copy beside the cask.
93 name: "stays manual-only for a cask another brew prefix owns",
94 binary: ["Caskroom", "grok-build", "1.2.3", "grok"],
95 reportedPrefix: "/opt/homebrew",
96 brewArgs: [["--prefix"]],
97 latestVersion: undefined,
98 update: () => null,
99 },
100 {
101 // A formula keg such as `node` holding an npm global is not Grok's owner.
102 name: "keeps grok update for an npm global inside a Homebrew node keg",
103 binary: [
104 "Cellar",
105 "node",
106 "22",
107 "lib",
108 "node_modules",
109 "@xai-official",
110 "grok",
111 "bin",
112 "grok",
113 ],
114 reportedPrefix: "own",
115 brewArgs: [],
116 latestVersion: undefined,
117 update: (_brewPath: string, binaryPath: string) => ({
118 executable: binaryPath,
119 args: ["update"],
120 lockKey: "grok",
121 // npm 12 otherwise skips the postinstall that installs the new binary.
122 env: expect.objectContaining({ npm_config_allow_scripts: "@xai-official/grok" }),
123 }),
124 },
125 ] as const;
126
127 for (const testCase of homebrewCases) {
128 it.effect.skipIf(windowsHost)(testCase.name, () => {
129 const brewArgs: Array<ReadonlyArray<string>> = [];
130 let prefix = "";
131 return Effect.gen(function* () {
132 const fs = yield* FileSystem.FileSystem;
133 const path = yield* Path.Path;
134 prefix = yield* fs.realPath(yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-brew-" }));
135 const brewPath = path.join(prefix, "bin", "brew");
136 const binaryPath = path.join(prefix, ...testCase.binary);
137 for (const executable of [brewPath, binaryPath]) {
138 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
139 yield* fs.writeFileString(executable, "#!/bin/sh\n");
140 yield* fs.chmod(executable, 0o755);
141 }
142
143 const instance = yield* GrokDriver.create({
144 instanceId: ProviderInstanceId.make("grok-homebrew"),
145 displayName: "Grok test",
146 enabled: false,
147 environment: [],
148 config: { ...GrokDriver.defaultConfig(), binaryPath },
149 });
150 const capabilities = yield* instance.snapshot.resolveMaintenance();
151
152 const update = testCase.update(brewPath, binaryPath);
153 expect(brewArgs).toEqual(testCase.brewArgs);
154 expect(capabilities.update).toEqual(update && expect.objectContaining(update));
155 if (testCase.latestVersion !== undefined) {
156 expect(yield* resolveLatestProviderVersion(capabilities)).toBe(testCase.latestVersion);
157 expect(brewArgs).toEqual([...testCase.brewArgs, ["info", "--json=v2", "grok-build"]]);
158 }
159 }).pipe(
160 Effect.provideService(
161 ChildProcessSpawner.ChildProcessSpawner,
162 stdoutSpawner((command, args) => {
163 expect(command).toBe(`${prefix}/bin/brew`);
164 brewArgs.push(args);
165 if (args[0] === "--prefix") {
166 return `${testCase.reportedPrefix === "own" ? prefix : testCase.reportedPrefix}\n`;
167 }
168 return JSON.stringify({ casks: [{ version: "1.2.4,789" }] });
169 }),
170 ),
171 );
172 });
173 }
174
175 it.effect("stays manual-only when the configured executable does not exist", () =>
176 Effect.gen(function* () {
177 const fs = yield* FileSystem.FileSystem;
178 const path = yield* Path.Path;
179 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-missing-" });
180 const instance = yield* GrokDriver.create({
181 instanceId: ProviderInstanceId.make("grok-missing"),
182 displayName: "Grok test",
183 enabled: false,
184 environment: [],
185 config: { ...GrokDriver.defaultConfig(), binaryPath: path.join(tempDir, "grok") },
186 });
187 expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull();
188 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner)),
189 );
190});