@elvatis_com/openclaw-cli-bridge-elvatis 0.2.23 → 0.2.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,13 @@
11
11
  import http from "node:http";
12
12
  import { randomBytes } from "node:crypto";
13
13
  import { type ChatMessage, routeToCliRunner } from "./cli-runner.js";
14
- import { scheduleTokenRefresh, setAuthLogger } from "./claude-auth.js";
14
+ import { scheduleTokenRefresh, setAuthLogger, stopTokenRefresh } from "./claude-auth.js";
15
+ import { grokComplete, grokCompleteStream, type ChatMessage as GrokChatMessage } from "./grok-client.js";
16
+ import type { BrowserContext } from "playwright";
17
+
18
+ export type GrokCompleteOptions = Parameters<typeof grokComplete>[1];
19
+ export type GrokCompleteStreamOptions = Parameters<typeof grokCompleteStream>[1];
20
+ export type GrokCompleteResult = Awaited<ReturnType<typeof grokComplete>>;
15
21
 
16
22
  export interface ProxyServerOptions {
17
23
  port: number;
@@ -19,6 +25,12 @@ export interface ProxyServerOptions {
19
25
  timeoutMs?: number;
20
26
  log: (msg: string) => void;
21
27
  warn: (msg: string) => void;
28
+ /** Returns the current authenticated Grok BrowserContext (null if not logged in) */
29
+ getGrokContext?: () => BrowserContext | null;
30
+ /** Override for testing — replaces grokComplete */
31
+ _grokComplete?: typeof grokComplete;
32
+ /** Override for testing — replaces grokCompleteStream */
33
+ _grokCompleteStream?: typeof grokCompleteStream;
22
34
  }
23
35
 
24
36
  /** Available CLI bridge models for GET /v1/models */
@@ -59,6 +71,11 @@ export const CLI_MODELS = [
59
71
  contextWindow: 200_000,
60
72
  maxTokens: 8192,
61
73
  },
74
+ // Grok web-session models (requires /grok-login)
75
+ { id: "web-grok/grok-3", name: "Grok 3 (web session)", contextWindow: 131_072, maxTokens: 131_072 },
76
+ { id: "web-grok/grok-3-fast", name: "Grok 3 Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
77
+ { id: "web-grok/grok-3-mini", name: "Grok 3 Mini (web session)", contextWindow: 131_072, maxTokens: 131_072 },
78
+ { id: "web-grok/grok-3-mini-fast", name: "Grok 3 Mini Fast (web session)", contextWindow: 131_072, maxTokens: 131_072 },
62
79
  ];
63
80
 
64
81
  // ──────────────────────────────────────────────────────────────────────────────
@@ -77,6 +94,11 @@ export function startProxyServer(opts: ProxyServerOptions): Promise<http.Server>
77
94
  });
78
95
  });
79
96
 
97
+ // Stop the token refresh interval when the server closes (timer-leak prevention)
98
+ server.on("close", () => {
99
+ stopTokenRefresh();
100
+ });
101
+
80
102
  server.on("error", (err) => reject(err));
81
103
  server.listen(opts.port, "127.0.0.1", () => {
82
104
  opts.log(
@@ -171,6 +193,57 @@ async function handleRequest(
171
193
 
172
194
  opts.log(`[cli-bridge] ${model} · ${messages.length} msg(s) · stream=${stream}`);
173
195
 
196
+ const id = `chatcmpl-cli-${randomBytes(6).toString("hex")}`;
197
+ const created = Math.floor(Date.now() / 1000);
198
+
199
+ // ── Grok web-session routing ──────────────────────────────────────────────
200
+ if (model.startsWith("web-grok/")) {
201
+ const grokCtx = opts.getGrokContext?.() ?? null;
202
+ if (!grokCtx) {
203
+ res.writeHead(503, { "Content-Type": "application/json" });
204
+ res.end(JSON.stringify({ error: { message: "No active grok.com session. Use /grok-login to authenticate.", code: "no_grok_session" } }));
205
+ return;
206
+ }
207
+ const grokModel = model.replace("web-grok/", "");
208
+ const timeoutMs = opts.timeoutMs ?? 120_000;
209
+ const grokMessages = messages as GrokChatMessage[];
210
+ const doGrokComplete = opts._grokComplete ?? grokComplete;
211
+ const doGrokCompleteStream = opts._grokCompleteStream ?? grokCompleteStream;
212
+ try {
213
+ if (stream) {
214
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", ...corsHeaders() });
215
+ sendSseChunk(res, { id, created, model, delta: { role: "assistant" }, finish_reason: null });
216
+ const result = await doGrokCompleteStream(
217
+ grokCtx,
218
+ { messages: grokMessages, model: grokModel, timeoutMs },
219
+ (token) => sendSseChunk(res, { id, created, model, delta: { content: token }, finish_reason: null }),
220
+ opts.log
221
+ );
222
+ sendSseChunk(res, { id, created, model, delta: {}, finish_reason: result.finishReason });
223
+ res.write("data: [DONE]\n\n");
224
+ res.end();
225
+ } else {
226
+ const result = await doGrokComplete(grokCtx, { messages: grokMessages, model: grokModel, timeoutMs }, opts.log);
227
+ res.writeHead(200, { "Content-Type": "application/json", ...corsHeaders() });
228
+ res.end(JSON.stringify({
229
+ id, object: "chat.completion", created, model,
230
+ choices: [{ index: 0, message: { role: "assistant", content: result.content }, finish_reason: result.finishReason }],
231
+ usage: { prompt_tokens: result.promptTokens ?? 0, completion_tokens: result.completionTokens ?? 0, total_tokens: (result.promptTokens ?? 0) + (result.completionTokens ?? 0) },
232
+ }));
233
+ }
234
+ } catch (err) {
235
+ const msg = (err as Error).message;
236
+ opts.warn(`[cli-bridge] Grok error for ${model}: ${msg}`);
237
+ if (!res.headersSent) {
238
+ res.writeHead(500, { "Content-Type": "application/json" });
239
+ res.end(JSON.stringify({ error: { message: msg, type: "grok_error" } }));
240
+ }
241
+ }
242
+ return;
243
+ }
244
+ // ─────────────────────────────────────────────────────────────────────────
245
+
246
+ // ── CLI runner routing (Gemini / Claude Code) ─────────────────────────────
174
247
  let content: string;
175
248
  try {
176
249
  content = await routeToCliRunner(model, messages, opts.timeoutMs ?? 120_000);
@@ -182,9 +255,6 @@ async function handleRequest(
182
255
  return;
183
256
  }
184
257
 
185
- const id = `chatcmpl-cli-${randomBytes(6).toString("hex")}`;
186
- const created = Math.floor(Date.now() / 1000);
187
-
188
258
  if (stream) {
189
259
  res.writeHead(200, {
190
260
  "Content-Type": "text/event-stream",
@@ -0,0 +1,301 @@
1
+ /**
2
+ * test/grok-proxy.test.ts
3
+ *
4
+ * Tests for Grok web-session routing integrated into the cli-bridge proxy.
5
+ * Uses _grokComplete/_grokCompleteStream overrides (DI) instead of vi.mock,
6
+ * which avoids ESM hoisting issues entirely.
7
+ */
8
+
9
+ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
10
+ import http from "node:http";
11
+ import type { AddressInfo } from "node:net";
12
+ import { startProxyServer, CLI_MODELS } from "../src/proxy-server.js";
13
+ import type { BrowserContext } from "playwright";
14
+ import type { GrokCompleteOptions, GrokCompleteResult } from "../src/proxy-server.js";
15
+
16
+ // ──────────────────────────────────────────────────────────────────────────────
17
+ // Stub implementations (no browser needed)
18
+ // ──────────────────────────────────────────────────────────────────────────────
19
+
20
+ const stubGrokComplete = vi.fn(async (
21
+ _ctx: BrowserContext,
22
+ opts: GrokCompleteOptions,
23
+ _log: (msg: string) => void
24
+ ): Promise<GrokCompleteResult> => ({
25
+ content: `grok mock: ${opts.messages[opts.messages.length - 1]?.content ?? ""}`,
26
+ model: opts.model ?? "grok-3",
27
+ finishReason: "stop",
28
+ promptTokens: 8,
29
+ completionTokens: 4,
30
+ }));
31
+
32
+ const stubGrokCompleteStream = vi.fn(async (
33
+ _ctx: BrowserContext,
34
+ opts: GrokCompleteOptions,
35
+ onToken: (t: string) => void,
36
+ _log: (msg: string) => void
37
+ ): Promise<GrokCompleteResult> => {
38
+ const tokens = ["grok ", "stream ", "mock"];
39
+ for (const t of tokens) onToken(t);
40
+ return {
41
+ content: tokens.join(""),
42
+ model: opts.model ?? "grok-3",
43
+ finishReason: "stop",
44
+ promptTokens: 8,
45
+ completionTokens: 3,
46
+ };
47
+ });
48
+
49
+ // ──────────────────────────────────────────────────────────────────────────────
50
+ // Helpers
51
+ // ──────────────────────────────────────────────────────────────────────────────
52
+
53
+ async function httpPost(
54
+ url: string,
55
+ body: unknown,
56
+ headers: Record<string, string> = {}
57
+ ): Promise<{ status: number; body: unknown }> {
58
+ return new Promise((resolve, reject) => {
59
+ const data = JSON.stringify(body);
60
+ const urlObj = new URL(url);
61
+ const req = http.request(
62
+ {
63
+ hostname: urlObj.hostname,
64
+ port: parseInt(urlObj.port),
65
+ path: urlObj.pathname,
66
+ method: "POST",
67
+ headers: {
68
+ "Content-Type": "application/json",
69
+ "Content-Length": Buffer.byteLength(data),
70
+ ...headers,
71
+ },
72
+ },
73
+ (res) => {
74
+ let resp = "";
75
+ res.on("data", (c) => (resp += c));
76
+ res.on("end", () => {
77
+ try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(resp) }); }
78
+ catch { resolve({ status: res.statusCode ?? 0, body: resp }); }
79
+ });
80
+ }
81
+ );
82
+ req.on("error", reject);
83
+ req.write(data);
84
+ req.end();
85
+ });
86
+ }
87
+
88
+ async function httpGet(
89
+ url: string,
90
+ headers: Record<string, string> = {}
91
+ ): Promise<{ status: number; body: unknown }> {
92
+ return new Promise((resolve, reject) => {
93
+ const req = http.get(url, { headers }, (res) => {
94
+ let data = "";
95
+ res.on("data", (c) => (data += c));
96
+ res.on("end", () => {
97
+ try { resolve({ status: res.statusCode ?? 0, body: JSON.parse(data) }); }
98
+ catch { resolve({ status: res.statusCode ?? 0, body: data }); }
99
+ });
100
+ });
101
+ req.on("error", reject);
102
+ });
103
+ }
104
+
105
+ // ──────────────────────────────────────────────────────────────────────────────
106
+ // Setup: three servers
107
+ // - withCtx: has mock BrowserContext + stub overrides
108
+ // - noCtx: no BrowserContext (returns null)
109
+ // - noCtxNoOverride: no context, no overrides (tests 503)
110
+ // ──────────────────────────────────────────────────────────────────────────────
111
+
112
+ const TEST_KEY = "test-grok-key";
113
+ const MOCK_CONTEXT = {} as BrowserContext;
114
+
115
+ let serverWithCtx: http.Server;
116
+ let serverNoCtx: http.Server;
117
+ let urlWith: string;
118
+ let urlNo: string;
119
+
120
+ beforeAll(async () => {
121
+ serverWithCtx = await startProxyServer({
122
+ port: 0,
123
+ apiKey: TEST_KEY,
124
+ log: () => {},
125
+ warn: () => {},
126
+ getGrokContext: () => MOCK_CONTEXT,
127
+ _grokComplete: stubGrokComplete,
128
+ _grokCompleteStream: stubGrokCompleteStream,
129
+ });
130
+ const addrWith = serverWithCtx.address() as AddressInfo;
131
+ urlWith = `http://127.0.0.1:${addrWith.port}`;
132
+
133
+ serverNoCtx = await startProxyServer({
134
+ port: 0,
135
+ apiKey: TEST_KEY,
136
+ log: () => {},
137
+ warn: () => {},
138
+ getGrokContext: () => null,
139
+ _grokComplete: stubGrokComplete,
140
+ _grokCompleteStream: stubGrokCompleteStream,
141
+ });
142
+ const addrNo = serverNoCtx.address() as AddressInfo;
143
+ urlNo = `http://127.0.0.1:${addrNo.port}`;
144
+ });
145
+
146
+ afterAll(async () => {
147
+ await new Promise<void>((r) => serverWithCtx.close(() => r()));
148
+ await new Promise<void>((r) => serverNoCtx.close(() => r()));
149
+ });
150
+
151
+ // ──────────────────────────────────────────────────────────────────────────────
152
+ // Tests
153
+ // ──────────────────────────────────────────────────────────────────────────────
154
+
155
+ describe("GET /v1/models includes Grok web-session models", () => {
156
+ it("lists web-grok/* models", async () => {
157
+ const { status, body } = await httpGet(`${urlWith}/v1/models`, {
158
+ Authorization: `Bearer ${TEST_KEY}`,
159
+ });
160
+ expect(status).toBe(200);
161
+ const b = body as { data: Array<{ id: string }> };
162
+ const grokModels = b.data.filter((m) => m.id.startsWith("web-grok/"));
163
+ expect(grokModels.length).toBe(4);
164
+ expect(grokModels.map((m) => m.id)).toContain("web-grok/grok-3");
165
+ expect(grokModels.map((m) => m.id)).toContain("web-grok/grok-3-mini");
166
+ });
167
+
168
+ it("CLI_MODELS exports 4 grok models", () => {
169
+ const grok = CLI_MODELS.filter((m) => m.id.startsWith("web-grok/"));
170
+ expect(grok).toHaveLength(4);
171
+ });
172
+ });
173
+
174
+ describe("POST /v1/chat/completions — Grok routing", () => {
175
+ const auth = { Authorization: `Bearer ${TEST_KEY}` };
176
+
177
+ it("returns 503 when no Grok session", async () => {
178
+ const { status, body } = await httpPost(
179
+ `${urlNo}/v1/chat/completions`,
180
+ { model: "web-grok/grok-3", messages: [{ role: "user", content: "Hi" }] },
181
+ auth
182
+ );
183
+ expect(status).toBe(503);
184
+ const b = body as { error: { code: string } };
185
+ expect(b.error.code).toBe("no_grok_session");
186
+ });
187
+
188
+ it("returns 200 with mock context (non-streaming)", async () => {
189
+ stubGrokComplete.mockClear();
190
+ const { status, body } = await httpPost(
191
+ `${urlWith}/v1/chat/completions`,
192
+ { model: "web-grok/grok-3", messages: [{ role: "user", content: "Hello Grok" }], stream: false },
193
+ auth
194
+ );
195
+ expect(status).toBe(200);
196
+ const b = body as {
197
+ object: string;
198
+ model: string;
199
+ choices: Array<{ message: { content: string }; finish_reason: string }>;
200
+ usage: { prompt_tokens: number; completion_tokens: number };
201
+ };
202
+ expect(b.object).toBe("chat.completion");
203
+ expect(b.model).toBe("web-grok/grok-3");
204
+ expect(b.choices[0].message.content).toContain("Hello Grok");
205
+ expect(b.choices[0].finish_reason).toBe("stop");
206
+ expect(b.usage.prompt_tokens).toBe(8);
207
+ expect(b.usage.completion_tokens).toBe(4);
208
+ expect(stubGrokComplete).toHaveBeenCalledOnce();
209
+ // stub receives stripped model name
210
+ expect(stubGrokComplete.mock.calls[0][1].model).toBe("grok-3");
211
+ });
212
+
213
+ it("strips web-grok/ prefix before passing to grokComplete", async () => {
214
+ stubGrokComplete.mockClear();
215
+ const { status, body } = await httpPost(
216
+ `${urlWith}/v1/chat/completions`,
217
+ { model: "web-grok/grok-3-mini", messages: [{ role: "user", content: "test" }] },
218
+ auth
219
+ );
220
+ expect(status).toBe(200);
221
+ // response model should still have web-grok/ prefix
222
+ const b = body as { model: string };
223
+ expect(b.model).toBe("web-grok/grok-3-mini");
224
+ // stub receives stripped model
225
+ expect(stubGrokComplete.mock.calls[0][1].model).toBe("grok-3-mini");
226
+ });
227
+
228
+ it("returns SSE stream for web-grok models", async () => {
229
+ return new Promise<void>((resolve, reject) => {
230
+ const data = JSON.stringify({
231
+ model: "web-grok/grok-3",
232
+ messages: [{ role: "user", content: "stream test" }],
233
+ stream: true,
234
+ });
235
+ const urlObj = new URL(`${urlWith}/v1/chat/completions`);
236
+ const req = http.request(
237
+ {
238
+ hostname: urlObj.hostname,
239
+ port: parseInt(urlObj.port),
240
+ path: urlObj.pathname,
241
+ method: "POST",
242
+ headers: {
243
+ "Content-Type": "application/json",
244
+ "Content-Length": Buffer.byteLength(data),
245
+ Authorization: `Bearer ${TEST_KEY}`,
246
+ },
247
+ },
248
+ (res) => {
249
+ expect(res.statusCode).toBe(200);
250
+ expect(res.headers["content-type"]).toContain("text/event-stream");
251
+ let raw = "";
252
+ res.on("data", (c) => (raw += c));
253
+ res.on("end", () => {
254
+ const lines = raw.split("\n").filter((l) => l.startsWith("data: "));
255
+ expect(lines[lines.length - 1]).toBe("data: [DONE]");
256
+ const tokens = lines
257
+ .filter((l) => l !== "data: [DONE]")
258
+ .map((l) => { try { return JSON.parse(l.slice(6)); } catch { return null; } })
259
+ .filter(Boolean)
260
+ .flatMap((c) => c.choices?.[0]?.delta?.content ?? []);
261
+ expect(tokens.join("")).toBe("grok stream mock");
262
+ resolve();
263
+ });
264
+ }
265
+ );
266
+ req.on("error", reject);
267
+ req.write(data);
268
+ req.end();
269
+ });
270
+ });
271
+
272
+ it("non-web-grok models bypass Grok routing (go to CLI runner)", async () => {
273
+ stubGrokComplete.mockClear();
274
+
275
+ // Use a separate server with very short CLI timeout so this test finishes quickly
276
+ const fastSrv = await startProxyServer({
277
+ port: 0,
278
+ apiKey: TEST_KEY,
279
+ timeoutMs: 500, // fail fast — gemini CLI won't be available in test
280
+ log: () => {},
281
+ warn: () => {},
282
+ getGrokContext: () => MOCK_CONTEXT,
283
+ _grokComplete: stubGrokComplete,
284
+ _grokCompleteStream: stubGrokCompleteStream,
285
+ });
286
+ const addr = fastSrv.address() as AddressInfo;
287
+ const fastUrl = `http://127.0.0.1:${addr.port}`;
288
+
289
+ try {
290
+ const { status } = await httpPost(
291
+ `${fastUrl}/v1/chat/completions`,
292
+ { model: "cli-gemini/gemini-2.5-pro", messages: [{ role: "user", content: "test" }] },
293
+ auth
294
+ );
295
+ expect(status).not.toBe(503); // must NOT be "no_grok_session" — routing is different
296
+ expect(stubGrokComplete).not.toHaveBeenCalled(); // grokComplete never called for non-grok models
297
+ } finally {
298
+ await new Promise<void>((r) => fastSrv.close(() => r()));
299
+ }
300
+ }, 10_000);
301
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * test/session.test.ts
3
+ *
4
+ * Unit tests for grok-session.ts — persistence, age check, cookie handling.
5
+ * No browser needed (mocked).
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
9
+ import { existsSync, unlinkSync, readFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import {
13
+ loadSession,
14
+ saveSession,
15
+ isSessionExpiredByAge,
16
+ type GrokSession,
17
+ } from "../src/grok-session.js";
18
+
19
+ const TMP_SESSION = join(tmpdir(), `grok-test-session-${process.pid}.json`);
20
+
21
+ const MOCK_SESSION: GrokSession = {
22
+ cookies: [
23
+ {
24
+ name: "auth_token",
25
+ value: "test-token-123",
26
+ domain: ".grok.com",
27
+ path: "/",
28
+ expires: Date.now() / 1000 + 86400,
29
+ httpOnly: true,
30
+ secure: true,
31
+ sameSite: "Lax",
32
+ },
33
+ {
34
+ name: "ct0",
35
+ value: "csrf-token-abc",
36
+ domain: ".x.com",
37
+ path: "/",
38
+ expires: Date.now() / 1000 + 86400,
39
+ httpOnly: false,
40
+ secure: true,
41
+ sameSite: "Lax",
42
+ },
43
+ ],
44
+ savedAt: Date.now(),
45
+ userAgent: "Mozilla/5.0 (test)",
46
+ };
47
+
48
+ describe("grok-session persistence", () => {
49
+ afterEach(() => {
50
+ if (existsSync(TMP_SESSION)) unlinkSync(TMP_SESSION);
51
+ });
52
+
53
+ it("returns null when file does not exist", () => {
54
+ const result = loadSession(TMP_SESSION);
55
+ expect(result).toBeNull();
56
+ });
57
+
58
+ it("saves and loads a session round-trip", () => {
59
+ saveSession(TMP_SESSION, MOCK_SESSION);
60
+ expect(existsSync(TMP_SESSION)).toBe(true);
61
+
62
+ const loaded = loadSession(TMP_SESSION);
63
+ expect(loaded).not.toBeNull();
64
+ expect(loaded!.cookies).toHaveLength(2);
65
+ expect(loaded!.cookies[0].name).toBe("auth_token");
66
+ expect(loaded!.cookies[0].value).toBe("test-token-123");
67
+ expect(loaded!.userAgent).toBe("Mozilla/5.0 (test)");
68
+ });
69
+
70
+ it("returns null for corrupted JSON", () => {
71
+ const { writeFileSync } = require("node:fs");
72
+ writeFileSync(TMP_SESSION, "{ bad json }", "utf-8");
73
+ const result = loadSession(TMP_SESSION);
74
+ expect(result).toBeNull();
75
+ });
76
+
77
+ it("returns null for missing cookies field", () => {
78
+ const { writeFileSync } = require("node:fs");
79
+ writeFileSync(TMP_SESSION, JSON.stringify({ savedAt: Date.now() }), "utf-8");
80
+ const result = loadSession(TMP_SESSION);
81
+ expect(result).toBeNull();
82
+ });
83
+
84
+ it("returns null for cookies field not being array", () => {
85
+ const { writeFileSync } = require("node:fs");
86
+ writeFileSync(TMP_SESSION, JSON.stringify({ cookies: "not-array", savedAt: Date.now() }), "utf-8");
87
+ const result = loadSession(TMP_SESSION);
88
+ expect(result).toBeNull();
89
+ });
90
+
91
+ it("saves valid JSON that can be parsed independently", () => {
92
+ saveSession(TMP_SESSION, MOCK_SESSION);
93
+ const raw = readFileSync(TMP_SESSION, "utf-8");
94
+ expect(() => JSON.parse(raw)).not.toThrow();
95
+ const parsed = JSON.parse(raw);
96
+ expect(parsed.savedAt).toBeTypeOf("number");
97
+ });
98
+
99
+ it("overwrites existing session file", () => {
100
+ saveSession(TMP_SESSION, MOCK_SESSION);
101
+
102
+ const updated: GrokSession = { ...MOCK_SESSION, userAgent: "updated-ua", savedAt: Date.now() };
103
+ saveSession(TMP_SESSION, updated);
104
+
105
+ const loaded = loadSession(TMP_SESSION);
106
+ expect(loaded!.userAgent).toBe("updated-ua");
107
+ });
108
+ });
109
+
110
+ describe("session age check", () => {
111
+ it("fresh session is not expired", () => {
112
+ const session: GrokSession = { ...MOCK_SESSION, savedAt: Date.now() };
113
+ expect(isSessionExpiredByAge(session)).toBe(false);
114
+ });
115
+
116
+ it("session saved 6 days ago is not expired", () => {
117
+ const sixDaysAgo = Date.now() - 6 * 24 * 60 * 60 * 1000;
118
+ const session: GrokSession = { ...MOCK_SESSION, savedAt: sixDaysAgo };
119
+ expect(isSessionExpiredByAge(session)).toBe(false);
120
+ });
121
+
122
+ it("session saved 8 days ago is expired", () => {
123
+ const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000;
124
+ const session: GrokSession = { ...MOCK_SESSION, savedAt: eightDaysAgo };
125
+ expect(isSessionExpiredByAge(session)).toBe(true);
126
+ });
127
+
128
+ it("session saved exactly 7 days ago is expired", () => {
129
+ const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000 - 1;
130
+ const session: GrokSession = { ...MOCK_SESSION, savedAt: sevenDaysAgo };
131
+ expect(isSessionExpiredByAge(session)).toBe(true);
132
+ });
133
+ });