ctb 1.0.0 → 1.1.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctb",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Control Claude Code from Telegram - run multiple bot instances per project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,9 @@
10
10
  "start": "bun run src/bot.ts",
11
11
  "dev": "bun --watch run src/bot.ts",
12
12
  "ctb": "bun run src/cli.ts",
13
- "typecheck": "bun run --bun tsc --noEmit"
13
+ "typecheck": "bun run --bun tsc --noEmit",
14
+ "test": "bun test",
15
+ "test:watch": "bun test --watch"
14
16
  },
15
17
  "keywords": [
16
18
  "claude",
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Unit tests for callback handlers (file sending, bookmarks).
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+
7
+ describe("File sending callback data", () => {
8
+ describe("base64 encoding/decoding", () => {
9
+ test("encodes and decodes simple path", () => {
10
+ const path = "/Users/test/file.txt";
11
+ const encoded = Buffer.from(path).toString("base64");
12
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
13
+ expect(decoded).toBe(path);
14
+ });
15
+
16
+ test("encodes and decodes path with spaces", () => {
17
+ const path = "/Users/test/my file.txt";
18
+ const encoded = Buffer.from(path).toString("base64");
19
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
20
+ expect(decoded).toBe(path);
21
+ });
22
+
23
+ test("encodes and decodes path with special characters", () => {
24
+ const path = "/Users/test/file-name_v2.1.txt";
25
+ const encoded = Buffer.from(path).toString("base64");
26
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
27
+ expect(decoded).toBe(path);
28
+ });
29
+
30
+ test("encodes and decodes unicode path", () => {
31
+ const path = "/Users/test/文件.txt";
32
+ const encoded = Buffer.from(path).toString("base64");
33
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
34
+ expect(decoded).toBe(path);
35
+ });
36
+
37
+ test("encodes and decodes long path", () => {
38
+ const path = "/Users/test/very/deep/nested/directory/structure/file.txt";
39
+ const encoded = Buffer.from(path).toString("base64");
40
+ const decoded = Buffer.from(encoded, "base64").toString("utf-8");
41
+ expect(decoded).toBe(path);
42
+ });
43
+ });
44
+
45
+ describe("callback data format", () => {
46
+ test("creates valid callback data", () => {
47
+ const path = "/tmp/test.txt";
48
+ const encoded = Buffer.from(path).toString("base64");
49
+ const callbackData = `sendfile:${encoded}`;
50
+ expect(callbackData.startsWith("sendfile:")).toBe(true);
51
+ });
52
+
53
+ test("extracts path from callback data", () => {
54
+ const path = "/tmp/test.txt";
55
+ const encoded = Buffer.from(path).toString("base64");
56
+ const callbackData = `sendfile:${encoded}`;
57
+
58
+ const extractedEncoded = callbackData.slice("sendfile:".length);
59
+ const extractedPath = Buffer.from(extractedEncoded, "base64").toString(
60
+ "utf-8",
61
+ );
62
+ expect(extractedPath).toBe(path);
63
+ });
64
+
65
+ test("callback data fits within Telegram limits", () => {
66
+ // Telegram callback data max is 64 bytes
67
+ const maxPath = "/Users/verylongusername/very/deep/path/file.txt";
68
+ const encoded = Buffer.from(maxPath).toString("base64");
69
+ const callbackData = `sendfile:${encoded}`;
70
+ // This might exceed 64 bytes for very long paths
71
+ // The implementation should handle this
72
+ expect(callbackData.length).toBeGreaterThan(0);
73
+ });
74
+ });
75
+
76
+ describe("path validation", () => {
77
+ test("identifies absolute paths", () => {
78
+ const absolutePaths = [
79
+ "/tmp/file.txt",
80
+ "/Users/test/doc.pdf",
81
+ "/home/user/data.json",
82
+ "/var/log/app.log",
83
+ ];
84
+
85
+ for (const path of absolutePaths) {
86
+ expect(path.startsWith("/")).toBe(true);
87
+ }
88
+ });
89
+
90
+ test("identifies relative paths", () => {
91
+ const relativePaths = [
92
+ "./file.txt",
93
+ "../parent/file.txt",
94
+ "subdir/file.txt",
95
+ "file.txt",
96
+ ];
97
+
98
+ for (const path of relativePaths) {
99
+ expect(path.startsWith("/")).toBe(false);
100
+ }
101
+ });
102
+ });
103
+ });
104
+
105
+ describe("Bookmark callback data", () => {
106
+ describe("callback data format", () => {
107
+ test("creates add bookmark callback", () => {
108
+ const path = "/Users/test/project";
109
+ const callbackData = `bookmark:add:${path}`;
110
+ expect(callbackData).toBe("bookmark:add:/Users/test/project");
111
+ });
112
+
113
+ test("creates remove bookmark callback", () => {
114
+ const path = "/Users/test/project";
115
+ const callbackData = `bookmark:remove:${path}`;
116
+ expect(callbackData).toBe("bookmark:remove:/Users/test/project");
117
+ });
118
+
119
+ test("creates new session callback", () => {
120
+ const path = "/Users/test/project";
121
+ const callbackData = `bookmark:new:${path}`;
122
+ expect(callbackData).toBe("bookmark:new:/Users/test/project");
123
+ });
124
+
125
+ test("handles paths with colons", () => {
126
+ // Path parsing should handle colons in path
127
+ const callbackData = "bookmark:add:/path/with:colon/file.txt";
128
+ const parts = callbackData.split(":");
129
+ expect(parts[0]).toBe("bookmark");
130
+ expect(parts[1]).toBe("add");
131
+ // Path is everything after second colon
132
+ const path = parts.slice(2).join(":");
133
+ expect(path).toBe("/path/with:colon/file.txt");
134
+ });
135
+ });
136
+
137
+ describe("action parsing", () => {
138
+ test("parses add action", () => {
139
+ const callbackData = "bookmark:add:/path";
140
+ const parts = callbackData.split(":");
141
+ expect(parts[1]).toBe("add");
142
+ });
143
+
144
+ test("parses remove action", () => {
145
+ const callbackData = "bookmark:remove:/path";
146
+ const parts = callbackData.split(":");
147
+ expect(parts[1]).toBe("remove");
148
+ });
149
+
150
+ test("parses new action", () => {
151
+ const callbackData = "bookmark:new:/path";
152
+ const parts = callbackData.split(":");
153
+ expect(parts[1]).toBe("new");
154
+ });
155
+
156
+ test("parses noop action", () => {
157
+ const callbackData = "bookmark:noop:";
158
+ const parts = callbackData.split(":");
159
+ expect(parts[1]).toBe("noop");
160
+ });
161
+ });
162
+ });
163
+
164
+ describe("Ask user callback data", () => {
165
+ describe("callback data format", () => {
166
+ test("creates valid ask user callback", () => {
167
+ const requestId = "abc123";
168
+ const optionIndex = 0;
169
+ const callbackData = `askuser:${requestId}:${optionIndex}`;
170
+ expect(callbackData).toBe("askuser:abc123:0");
171
+ });
172
+
173
+ test("parses request ID", () => {
174
+ const callbackData = "askuser:request-id-123:2";
175
+ const parts = callbackData.split(":");
176
+ expect(parts[1]).toBe("request-id-123");
177
+ });
178
+
179
+ test("parses option index", () => {
180
+ const callbackData = "askuser:abc:3";
181
+ const parts = callbackData.split(":");
182
+ expect(Number.parseInt(parts[2] ?? "", 10)).toBe(3);
183
+ });
184
+
185
+ test("handles multi-digit option index", () => {
186
+ const callbackData = "askuser:abc:15";
187
+ const parts = callbackData.split(":");
188
+ expect(Number.parseInt(parts[2] ?? "", 10)).toBe(15);
189
+ });
190
+ });
191
+
192
+ describe("validation", () => {
193
+ test("validates callback format", () => {
194
+ const validCallbacks = [
195
+ "askuser:id:0",
196
+ "askuser:long-request-id:5",
197
+ "askuser:123:99",
198
+ ];
199
+
200
+ for (const cb of validCallbacks) {
201
+ const parts = cb.split(":");
202
+ expect(parts).toHaveLength(3);
203
+ expect(parts[0]).toBe("askuser");
204
+ expect(parts[1]?.length).toBeGreaterThan(0);
205
+ expect(Number.parseInt(parts[2] ?? "", 10)).toBeGreaterThanOrEqual(0);
206
+ }
207
+ });
208
+
209
+ test("detects invalid callback format", () => {
210
+ const invalidCallbacks = [
211
+ "askuser:", // Missing parts
212
+ "askuser:id", // Missing option
213
+ "askuser::", // Empty parts
214
+ "other:id:0", // Wrong prefix
215
+ ];
216
+
217
+ for (const cb of invalidCallbacks) {
218
+ const parts = cb.split(":");
219
+ const isValid =
220
+ parts.length === 3 &&
221
+ parts[0] === "askuser" &&
222
+ (parts[1]?.length ?? 0) > 0 &&
223
+ !Number.isNaN(Number.parseInt(parts[2] ?? "", 10));
224
+ expect(isValid).toBe(false);
225
+ }
226
+ });
227
+ });
228
+ });
229
+
230
+ describe("Inline keyboard button labels", () => {
231
+ const BUTTON_LABEL_MAX_LENGTH = 30;
232
+
233
+ test("truncates long labels", () => {
234
+ const longLabel = "This is a very long option that should be truncated";
235
+ const display =
236
+ longLabel.length > BUTTON_LABEL_MAX_LENGTH
237
+ ? `${longLabel.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
238
+ : longLabel;
239
+ expect(display.length).toBeLessThanOrEqual(BUTTON_LABEL_MAX_LENGTH + 3);
240
+ });
241
+
242
+ test("keeps short labels unchanged", () => {
243
+ const shortLabel = "Short option";
244
+ const display =
245
+ shortLabel.length > BUTTON_LABEL_MAX_LENGTH
246
+ ? `${shortLabel.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
247
+ : shortLabel;
248
+ expect(display).toBe(shortLabel);
249
+ });
250
+
251
+ test("handles exact length labels", () => {
252
+ const exactLabel = "A".repeat(BUTTON_LABEL_MAX_LENGTH);
253
+ const display =
254
+ exactLabel.length > BUTTON_LABEL_MAX_LENGTH
255
+ ? `${exactLabel.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
256
+ : exactLabel;
257
+ expect(display).toBe(exactLabel);
258
+ });
259
+
260
+ test("handles empty labels", () => {
261
+ const emptyLabel = "";
262
+ const display =
263
+ emptyLabel.length > BUTTON_LABEL_MAX_LENGTH
264
+ ? `${emptyLabel.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
265
+ : emptyLabel;
266
+ expect(display).toBe("");
267
+ });
268
+
269
+ test("handles unicode labels", () => {
270
+ const unicodeLabel = "选择这个选项";
271
+ const display =
272
+ unicodeLabel.length > BUTTON_LABEL_MAX_LENGTH
273
+ ? `${unicodeLabel.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
274
+ : unicodeLabel;
275
+ expect(display).toBe(unicodeLabel);
276
+ });
277
+
278
+ test("handles emoji labels", () => {
279
+ const emojiLabel = "📁 Download file";
280
+ const display =
281
+ emojiLabel.length > BUTTON_LABEL_MAX_LENGTH
282
+ ? `${emojiLabel.slice(0, BUTTON_LABEL_MAX_LENGTH)}...`
283
+ : emojiLabel;
284
+ expect(display).toBe(emojiLabel);
285
+ });
286
+ });
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Unit tests for CLI argument parsing and env loading.
3
+ */
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ rmdirSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ // Helper to create a test directory with .env file
17
+ function createTestDir(): string {
18
+ const testDir = `/tmp/ctb-test-${Date.now()}`;
19
+ mkdirSync(testDir, { recursive: true });
20
+ return testDir;
21
+ }
22
+
23
+ function cleanupTestDir(dir: string): void {
24
+ try {
25
+ const envPath = join(dir, ".env");
26
+ if (existsSync(envPath)) unlinkSync(envPath);
27
+ rmdirSync(dir);
28
+ } catch {
29
+ // Ignore cleanup errors
30
+ }
31
+ }
32
+
33
+ describe("CLI .env file parsing", () => {
34
+ let testDir: string;
35
+
36
+ beforeEach(() => {
37
+ testDir = createTestDir();
38
+ });
39
+
40
+ afterEach(() => {
41
+ cleanupTestDir(testDir);
42
+ });
43
+
44
+ test("parses simple key=value pairs", () => {
45
+ const envPath = join(testDir, ".env");
46
+ writeFileSync(
47
+ envPath,
48
+ `TELEGRAM_BOT_TOKEN=test-token
49
+ TELEGRAM_ALLOWED_USERS=123,456`,
50
+ );
51
+
52
+ const content = readFileSync(envPath, "utf-8");
53
+ const lines = content.split("\n");
54
+ const env: Record<string, string> = {};
55
+
56
+ for (const line of lines) {
57
+ const trimmed = line.trim();
58
+ if (!trimmed || trimmed.startsWith("#")) continue;
59
+ const eqIndex = trimmed.indexOf("=");
60
+ if (eqIndex === -1) continue;
61
+ env[trimmed.slice(0, eqIndex).trim()] = trimmed.slice(eqIndex + 1).trim();
62
+ }
63
+
64
+ expect(env.TELEGRAM_BOT_TOKEN).toBe("test-token");
65
+ expect(env.TELEGRAM_ALLOWED_USERS).toBe("123,456");
66
+ });
67
+
68
+ test("handles quoted values", () => {
69
+ const envPath = join(testDir, ".env");
70
+ writeFileSync(
71
+ envPath,
72
+ `SINGLE='single quoted'
73
+ DOUBLE="double quoted"`,
74
+ );
75
+
76
+ const content = readFileSync(envPath, "utf-8");
77
+ const lines = content.split("\n");
78
+ const env: Record<string, string> = {};
79
+
80
+ for (const line of lines) {
81
+ const trimmed = line.trim();
82
+ if (!trimmed || trimmed.startsWith("#")) continue;
83
+ const eqIndex = trimmed.indexOf("=");
84
+ if (eqIndex === -1) continue;
85
+ let value = trimmed.slice(eqIndex + 1).trim();
86
+ if (
87
+ (value.startsWith('"') && value.endsWith('"')) ||
88
+ (value.startsWith("'") && value.endsWith("'"))
89
+ ) {
90
+ value = value.slice(1, -1);
91
+ }
92
+ env[trimmed.slice(0, eqIndex).trim()] = value;
93
+ }
94
+
95
+ expect(env.SINGLE).toBe("single quoted");
96
+ expect(env.DOUBLE).toBe("double quoted");
97
+ });
98
+
99
+ test("ignores comments", () => {
100
+ const envPath = join(testDir, ".env");
101
+ writeFileSync(
102
+ envPath,
103
+ `# This is a comment
104
+ TELEGRAM_BOT_TOKEN=token
105
+ # Another comment
106
+ TELEGRAM_ALLOWED_USERS=123`,
107
+ );
108
+
109
+ const content = readFileSync(envPath, "utf-8");
110
+ const lines = content.split("\n");
111
+ const env: Record<string, string> = {};
112
+
113
+ for (const line of lines) {
114
+ const trimmed = line.trim();
115
+ if (!trimmed || trimmed.startsWith("#")) continue;
116
+ const eqIndex = trimmed.indexOf("=");
117
+ if (eqIndex === -1) continue;
118
+ env[trimmed.slice(0, eqIndex).trim()] = trimmed.slice(eqIndex + 1).trim();
119
+ }
120
+
121
+ expect(Object.keys(env)).toHaveLength(2);
122
+ expect(env.TELEGRAM_BOT_TOKEN).toBe("token");
123
+ });
124
+
125
+ test("handles empty lines", () => {
126
+ const envPath = join(testDir, ".env");
127
+ writeFileSync(
128
+ envPath,
129
+ `TELEGRAM_BOT_TOKEN=token
130
+
131
+ TELEGRAM_ALLOWED_USERS=123
132
+
133
+ `,
134
+ );
135
+
136
+ const content = readFileSync(envPath, "utf-8");
137
+ const lines = content.split("\n");
138
+ const env: Record<string, string> = {};
139
+
140
+ for (const line of lines) {
141
+ const trimmed = line.trim();
142
+ if (!trimmed || trimmed.startsWith("#")) continue;
143
+ const eqIndex = trimmed.indexOf("=");
144
+ if (eqIndex === -1) continue;
145
+ env[trimmed.slice(0, eqIndex).trim()] = trimmed.slice(eqIndex + 1).trim();
146
+ }
147
+
148
+ expect(Object.keys(env)).toHaveLength(2);
149
+ });
150
+
151
+ test("handles values with equals signs", () => {
152
+ const envPath = join(testDir, ".env");
153
+ writeFileSync(envPath, `API_KEY=key=with=equals`);
154
+
155
+ const content = readFileSync(envPath, "utf-8");
156
+ const lines = content.split("\n");
157
+ const env: Record<string, string> = {};
158
+
159
+ for (const line of lines) {
160
+ const trimmed = line.trim();
161
+ if (!trimmed || trimmed.startsWith("#")) continue;
162
+ const eqIndex = trimmed.indexOf("=");
163
+ if (eqIndex === -1) continue;
164
+ env[trimmed.slice(0, eqIndex).trim()] = trimmed.slice(eqIndex + 1).trim();
165
+ }
166
+
167
+ expect(env.API_KEY).toBe("key=with=equals");
168
+ });
169
+
170
+ test("handles missing .env file gracefully", () => {
171
+ const nonExistentPath = join(testDir, "nonexistent", ".env");
172
+ expect(existsSync(nonExistentPath)).toBe(false);
173
+ });
174
+ });
175
+
176
+ describe("CLI argument parsing", () => {
177
+ function parseArgs(args: string[]): {
178
+ token?: string;
179
+ users?: string;
180
+ dir?: string;
181
+ help?: boolean;
182
+ version?: boolean;
183
+ } {
184
+ const options: {
185
+ token?: string;
186
+ users?: string;
187
+ dir?: string;
188
+ help?: boolean;
189
+ version?: boolean;
190
+ } = {};
191
+
192
+ for (const arg of args) {
193
+ if (arg === "--help" || arg === "-h") {
194
+ options.help = true;
195
+ } else if (arg === "--version" || arg === "-v") {
196
+ options.version = true;
197
+ } else if (arg.startsWith("--token=")) {
198
+ options.token = arg.slice(8);
199
+ } else if (arg.startsWith("--users=")) {
200
+ options.users = arg.slice(8);
201
+ } else if (arg.startsWith("--dir=")) {
202
+ options.dir = arg.slice(6);
203
+ }
204
+ }
205
+
206
+ return options;
207
+ }
208
+
209
+ test("parses --help flag", () => {
210
+ expect(parseArgs(["--help"])).toEqual({ help: true });
211
+ });
212
+
213
+ test("parses -h shorthand", () => {
214
+ expect(parseArgs(["-h"])).toEqual({ help: true });
215
+ });
216
+
217
+ test("parses --version flag", () => {
218
+ expect(parseArgs(["--version"])).toEqual({ version: true });
219
+ });
220
+
221
+ test("parses -v shorthand", () => {
222
+ expect(parseArgs(["-v"])).toEqual({ version: true });
223
+ });
224
+
225
+ test("parses --token option", () => {
226
+ const result = parseArgs(["--token=my-bot-token"]);
227
+ expect(result.token).toBe("my-bot-token");
228
+ });
229
+
230
+ test("parses --users option", () => {
231
+ const result = parseArgs(["--users=123,456,789"]);
232
+ expect(result.users).toBe("123,456,789");
233
+ });
234
+
235
+ test("parses --dir option", () => {
236
+ const result = parseArgs(["--dir=/path/to/project"]);
237
+ expect(result.dir).toBe("/path/to/project");
238
+ });
239
+
240
+ test("parses multiple options", () => {
241
+ const result = parseArgs([
242
+ "--token=token123",
243
+ "--users=111,222",
244
+ "--dir=/home/user/project",
245
+ ]);
246
+ expect(result.token).toBe("token123");
247
+ expect(result.users).toBe("111,222");
248
+ expect(result.dir).toBe("/home/user/project");
249
+ });
250
+
251
+ test("handles empty args array", () => {
252
+ expect(parseArgs([])).toEqual({});
253
+ });
254
+
255
+ test("ignores unknown flags", () => {
256
+ const result = parseArgs(["--unknown", "--another=value"]);
257
+ expect(result).toEqual({});
258
+ });
259
+
260
+ test("handles token with special characters", () => {
261
+ const result = parseArgs(["--token=123:ABC-xyz_789"]);
262
+ expect(result.token).toBe("123:ABC-xyz_789");
263
+ });
264
+
265
+ test("handles path with spaces when quoted", () => {
266
+ // This tests the arg as it would come from shell
267
+ const result = parseArgs(["--dir=/path/with spaces/project"]);
268
+ expect(result.dir).toBe("/path/with spaces/project");
269
+ });
270
+ });
271
+
272
+ describe("Path resolution with base directory", () => {
273
+ function resolvePath(path: string, baseDir?: string): string {
274
+ const homedir = () => "/Users/test";
275
+ const expanded = path.replace(/^~/, homedir());
276
+ if (baseDir && !expanded.startsWith("/")) {
277
+ // Simulate resolve(baseDir, expanded)
278
+ return `${baseDir}/${expanded}`.replace(/\/+/g, "/");
279
+ }
280
+ return expanded.startsWith("/") ? expanded : `/${expanded}`;
281
+ }
282
+
283
+ test("resolves absolute path unchanged", () => {
284
+ const result = resolvePath("/absolute/path", "/base/dir");
285
+ expect(result).toBe("/absolute/path");
286
+ });
287
+
288
+ test("resolves relative path from base directory", () => {
289
+ const result = resolvePath("relative/path", "/base/dir");
290
+ expect(result).toBe("/base/dir/relative/path");
291
+ });
292
+
293
+ test("expands tilde to home directory", () => {
294
+ const result = resolvePath("~/projects", "/base/dir");
295
+ expect(result).toBe("/Users/test/projects");
296
+ });
297
+
298
+ test("tilde path is not affected by base directory", () => {
299
+ const result = resolvePath("~/projects", "/base/dir");
300
+ expect(result).toBe("/Users/test/projects");
301
+ });
302
+
303
+ test("handles dot-relative paths", () => {
304
+ const result = resolvePath("./subdir", "/base/dir");
305
+ expect(result).toBe("/base/dir/./subdir");
306
+ });
307
+
308
+ test("handles parent directory paths", () => {
309
+ const result = resolvePath("../sibling", "/base/dir");
310
+ expect(result).toBe("/base/dir/../sibling");
311
+ });
312
+
313
+ test("works without base directory for absolute paths", () => {
314
+ const result = resolvePath("/absolute/path");
315
+ expect(result).toBe("/absolute/path");
316
+ });
317
+ });
318
+
319
+ describe("Instance directory hashing", () => {
320
+ function hashDir(dir: string): string {
321
+ let hash = 0;
322
+ for (let i = 0; i < dir.length; i++) {
323
+ const char = dir.charCodeAt(i);
324
+ hash = ((hash << 5) - hash + char) | 0;
325
+ }
326
+ return Math.abs(hash).toString(36).slice(0, 8);
327
+ }
328
+
329
+ test("generates consistent hash for same path", () => {
330
+ const path = "/Users/test/project";
331
+ expect(hashDir(path)).toBe(hashDir(path));
332
+ });
333
+
334
+ test("generates different hash for different paths", () => {
335
+ const hash1 = hashDir("/Users/test/project1");
336
+ const hash2 = hashDir("/Users/test/project2");
337
+ expect(hash1).not.toBe(hash2);
338
+ });
339
+
340
+ test("generates short hash (max 8 chars)", () => {
341
+ const hash = hashDir("/very/long/path/to/some/project/directory");
342
+ expect(hash.length).toBeLessThanOrEqual(8);
343
+ });
344
+
345
+ test("generates alphanumeric hash", () => {
346
+ const hash = hashDir("/Users/test/project");
347
+ expect(hash).toMatch(/^[a-z0-9]+$/);
348
+ });
349
+
350
+ test("handles empty string", () => {
351
+ const hash = hashDir("");
352
+ expect(hash).toBe("0");
353
+ });
354
+
355
+ test("handles root path", () => {
356
+ const hash = hashDir("/");
357
+ expect(hash.length).toBeGreaterThan(0);
358
+ });
359
+
360
+ test("handles home directory variations", () => {
361
+ const hash1 = hashDir("/Users/user1/project");
362
+ const hash2 = hashDir("/Users/user2/project");
363
+ expect(hash1).not.toBe(hash2);
364
+ });
365
+ });