From 95de7ec5fe497373d134a4aecf89aa54deb5d0c9 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 10 Mar 2026 01:35:46 +0100 Subject: [PATCH] tests --- src/collect-files-for-ai/index.test.ts | 453 ++++++++++++++++++++++++ src/files-to-prompt/index.test.ts | 444 +++++++++++++++++++++++ src/git-last-commits-diff/index.test.ts | 245 +++++++++++++ src/github-release-notes/index.test.ts | 232 ++++++++++++ src/hold-ai/client.test.ts | 155 ++++++++ src/hold-ai/server.test.ts | 177 +++++++++ src/t3chat-length/index.test.ts | 162 +++++++++ src/watch/index.test.ts | 249 +++++++++++++ 8 files changed, 2117 insertions(+) create mode 100644 src/collect-files-for-ai/index.test.ts create mode 100644 src/files-to-prompt/index.test.ts create mode 100644 src/git-last-commits-diff/index.test.ts create mode 100644 src/github-release-notes/index.test.ts create mode 100644 src/hold-ai/client.test.ts create mode 100644 src/hold-ai/server.test.ts create mode 100644 src/t3chat-length/index.test.ts create mode 100644 src/watch/index.test.ts diff --git a/src/collect-files-for-ai/index.test.ts b/src/collect-files-for-ai/index.test.ts new file mode 100644 index 000000000..a9a897e94 --- /dev/null +++ b/src/collect-files-for-ai/index.test.ts @@ -0,0 +1,453 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { realpathSync } from "node:fs"; +import { stat as fsStat, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +// Path to the script to be tested +const scriptPath = resolve(__dirname, "./index.ts"); + +interface ExecResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +async function runScript(args: string[]): Promise { + const proc = Bun.spawn({ + cmd: ["bun", "run", scriptPath, ...args], + cwd: process.cwd(), // Or a specific test directory if needed + env: { ...process.env, BUN_DEBUG: "1" }, // Enable debug logging for Bun + stdio: ["ignore", "pipe", "pipe"], + }); + + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + + return { stdout, stderr, exitCode }; +} + +// Helper to run git commands in a specific directory +async function runGit(args: string[], cwd: string): Promise { + // console.log(` M Running git ${args.join(" ")} in ${cwd}`); + const proc = Bun.spawn({ + cmd: ["git", ...args], + cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + // console.error(`Git command [git ${args.join(" ")}] failed in ${cwd}:`); + // console.error("STDOUT:", stdout); + // console.error("STDERR:", stderr); + } + return { stdout, stderr, exitCode }; +} + +describe("collect-files-for-ai", () => { + let testRepoDir: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + // Create a temporary directory for the Git repository + // realpathSync needed because macOS /var is a symlink to /private/var + const baseTmpDir = realpathSync(tmpdir()); + testRepoDir = await mkdtemp(join(baseTmpDir, "test-repo-")); + process.chdir(testRepoDir); // Change CWD to the repo for easier relative paths + + // Initialize Git repository + await runGit(["init"], testRepoDir); + // Configure git user for commits + await runGit(["config", "user.name", "Test User"], testRepoDir); + await runGit(["config", "user.email", "test@example.com"], testRepoDir); + }); + + afterEach(async () => { + process.chdir(originalCwd); // Restore original CWD + // Clean up the temporary Git repository directory + if (testRepoDir) { + await rm(testRepoDir, { recursive: true, force: true }); + } + // Clean up any .ai directories created by tests if not inside testRepoDir + // For now, assume target dirs are within testRepoDir or specified and cleaned up per test + }); + + const setupFiles = async (files: Record) => { + for (const [path, content] of Object.entries(files)) { + const dir = dirname(path); + if (dir !== ".") { + await mkdir(join(testRepoDir, dir), { recursive: true }); + } + await writeFile(join(testRepoDir, path), content); + } + }; + + const getFilesInDir = async (dir: string, baseDir = ""): Promise => { + let entries: string[] = []; + try { + const items = await readdir(dir, { withFileTypes: true }); + for (const item of items) { + const fullPath = join(dir, item.name); + const relativePath = baseDir ? join(baseDir, item.name) : item.name; + if (item.isDirectory()) { + entries = entries.concat(await getFilesInDir(fullPath, relativePath)); + } else { + entries.push(relativePath); + } + } + } catch (e: unknown) { + if (e instanceof Error && "code" in e && e.code === "ENOENT") { + return []; // If dir doesn't exist, return empty + } + throw e; + } + return entries.sort(); + }; + + it("should show help with --help flag", async () => { + const { stdout, exitCode } = await runScript(["--help"]); + // console.log("Help STDOUT:", stdout); + // console.log("Help STDERR:", stderr); + expect(exitCode).toBe(0); + expect(stdout).toContain("Usage: collect-uncommitted-files.ts [options]"); + expect(stdout).toContain("-c, --commits NUM"); + expect(stdout).toContain("-s, --staged"); + expect(stdout).toContain("-f, --flat"); + }); + + it("should show help and exit with 1 if no directory is provided", async () => { + const { stdout, exitCode } = await runScript([]); + // console.log("No dir STDOUT:", stdout); + // console.log("No dir STDERR:", stderr); + expect(exitCode).toBe(1); + expect(stdout).toContain("Usage: collect-uncommitted-files.ts [options]"); + }); + + it("should exit with error if directory is not a git repository", async () => { + const nonRepoDir = await mkdtemp(join(realpathSync(tmpdir()), "non-repo-")); + process.chdir(originalCwd); // Run script from outside the temp non-repo dir + + const { stdout: _stdout, stderr, exitCode } = await runScript([nonRepoDir]); + // console.log("Non-repo STDOUT:", stdout); + // console.log("Non-repo STDERR:", stderr); + + expect(exitCode).toBe(1); + // The script's logger outputs to stdout for info/error by default in the provided script + // It might be better to configure logger to use stderr for errors in the script itself + expect(stderr).toMatch(/Error: '.*' does not appear to be a valid Git repository/); + + process.chdir(testRepoDir); // Change back for other tests + await rm(nonRepoDir, { recursive: true, force: true }); + }); + + it("should exit with error for mutually exclusive mode flags", async () => { + const { stderr, exitCode } = await runScript([testRepoDir, "--staged", "--unstaged"]); + expect(exitCode).toBe(1); + expect(stderr).toContain("Error: Options --commits, --staged, --unstaged, --all are mutually exclusive."); + }); + + it("should exit with error for invalid --commits value", async () => { + let result = await runScript([testRepoDir, "--commits", "0"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Error: --commits must be a positive integer."); + + result = await runScript([testRepoDir, "--commits", "-1"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Error: --commits must be a positive integer."); + + result = await runScript([testRepoDir, "--commits", "abc"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Error: --commits must be a positive integer."); + }); + + describe("File Collection Modes", () => { + let targetOutputDir: string; + + beforeEach(async () => { + // Create a unique target directory for each test within this describe block + // Note: The script itself creates a timestamped dir if -t is not given. + // For predictable testing, we'll usually provide -t. + targetOutputDir = join(testRepoDir, ".test-output"); + await mkdir(targetOutputDir, { recursive: true }); + }); + + afterEach(async () => { + // Clean up the specific target output directory + if (targetOutputDir) { + await rm(targetOutputDir, { recursive: true, force: true }); + } + // Clean up default .ai directories, if any were created due to -t not being used + const defaultAiDir = join(testRepoDir, ".ai"); + try { + const stats = await fsStat(defaultAiDir); + if (stats.isDirectory()) { + await rm(defaultAiDir, { recursive: true, force: true }); + } + } catch (e: unknown) { + if (e instanceof Error && "code" in e && e.code !== "ENOENT") { + throw e; + } + } + }); + + it("should collect staged files with --staged", async () => { + await setupFiles({ "file1.txt": "content1", "file2.txt": "content2" }); + await runGit(["add", "file1.txt"], testRepoDir); + + const { stdout, exitCode } = await runScript([testRepoDir, "--staged", "-t", targetOutputDir]); + // console.log("--staged STDOUT:", stdout); + // console.log("--staged STDERR:", stderr); + expect(exitCode).toBe(0); + expect(stdout).toContain("Found 1 file(s) to copy."); + expect(stdout).toContain("Copied: file1.txt"); + + const copiedFiles = await getFilesInDir(targetOutputDir); + expect(copiedFiles).toEqual(["file1.txt"]); + const file1Content = await Bun.file(join(targetOutputDir, "file1.txt")).text(); + expect(file1Content).toBe("content1"); + }); + + it("should collect staged files with --staged and --flat", async () => { + await setupFiles({ "dir1/file1.txt": "content1", "file2.txt": "content2" }); + await runGit(["add", "dir1/file1.txt"], testRepoDir); + + const { stdout, exitCode } = await runScript([testRepoDir, "--staged", "-t", targetOutputDir, "--flat"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Found 1 file(s) to copy."); + expect(stdout).toContain("Copied: dir1/file1.txt as file1.txt"); + + const copiedFiles = await getFilesInDir(targetOutputDir); + expect(copiedFiles).toEqual(["file1.txt"]); + const file1Content = await Bun.file(join(targetOutputDir, "file1.txt")).text(); + expect(file1Content).toBe("content1"); + }); + + it("should collect unstaged (modified) files with --unstaged", async () => { + await setupFiles({ "file1.txt": "initial content", "file2.txt": "content2" }); + await runGit(["add", "."], testRepoDir); + await runGit(["commit", "-m", "Initial commit"], testRepoDir); + + await writeFile(join(testRepoDir, "file1.txt"), "modified content"); // Unstaged modification + + const { stdout, exitCode } = await runScript([testRepoDir, "--unstaged", "-t", targetOutputDir]); + // console.log("--unstaged STDOUT:", stdout); + // console.log("--unstaged STDERR:", stderr); + expect(exitCode).toBe(0); + expect(stdout).toContain("Found 1 file(s) to copy."); + expect(stdout).toContain("Copied: file1.txt"); + + const copiedFiles = await getFilesInDir(targetOutputDir); + expect(copiedFiles).toEqual(["file1.txt"]); + const file1Content = await Bun.file(join(targetOutputDir, "file1.txt")).text(); + expect(file1Content).toBe("modified content"); + }); + + it("should collect all uncommitted (staged + unstaged) with --all", async () => { + await setupFiles({ + "tracked_modified.txt": "initial", + "tracked_staged.txt": "initial", + "new_unstaged.txt": "new", // This won't be picked by 'git diff --name-only HEAD' unless added + "new_staged.txt": "new staged", + }); + await runGit(["add", "tracked_modified.txt", "tracked_staged.txt"], testRepoDir); + await runGit(["commit", "-m", "Initial commit for tracked files"], testRepoDir); + + // Modify a tracked file (becomes unstaged) + await writeFile(join(testRepoDir, "tracked_modified.txt"), "modified"); + + // Stage a different tracked file + await writeFile(join(testRepoDir, "tracked_staged.txt"), "staged modification"); + await runGit(["add", "tracked_staged.txt"], testRepoDir); + + // Stage a new file + await runGit(["add", "new_staged.txt"], testRepoDir); + + const { stdout, exitCode } = await runScript([testRepoDir, "--all", "-t", targetOutputDir]); + // console.log("--all STDOUT:", stdout); + // console.log("--all STDERR:", stderr); + + expect(exitCode).toBe(0); + // The script uses `git diff --name-only HEAD` for --all, which shows staged and unstaged *modifications* to *tracked* files. + // It does not show newly created, unstaged files that are not yet tracked. + // It does not list untracked files. + // `git diff --name-only HEAD` shows: + // 1. tracked_modified.txt (unstaged change to a tracked file) + // 2. tracked_staged.txt (staged change to a tracked file) + // 3. new_staged.txt (staged new file) + expect(stdout).toContain("Found 3 file(s) to copy."); + expect(stdout).toContain("Copied: tracked_modified.txt"); + expect(stdout).toContain("Copied: tracked_staged.txt"); + expect(stdout).toContain("Copied: new_staged.txt"); + + const copiedFiles = await getFilesInDir(targetOutputDir); + expect(copiedFiles).toEqual( + expect.arrayContaining(["tracked_modified.txt", "tracked_staged.txt", "new_staged.txt"]) + ); + expect(copiedFiles.length).toBe(3); + + const modContent = await Bun.file(join(targetOutputDir, "tracked_modified.txt")).text(); + expect(modContent).toBe("modified"); + const stagedContent = await Bun.file(join(targetOutputDir, "tracked_staged.txt")).text(); + expect(stagedContent).toBe("staged modification"); + const newStagedContent = await Bun.file(join(targetOutputDir, "new_staged.txt")).text(); + expect(newStagedContent).toBe("new staged"); + }); + + it("should use default mode 'all' if no mode specified", async () => { + await setupFiles({ "file1.txt": "initial" }); + await runGit(["add", "."], testRepoDir); + await runGit(["commit", "-m", "initial"], testRepoDir); + await writeFile(join(testRepoDir, "file1.txt"), "modified"); // Unstaged + + const { stdout, exitCode } = await runScript([testRepoDir, "-t", targetOutputDir]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Found 1 file(s) to copy."); // file1.txt is modified + expect(stdout).toContain("Copied: file1.txt"); + const copiedFiles = await getFilesInDir(targetOutputDir); + expect(copiedFiles).toEqual(["file1.txt"]); + }); + + describe("--commits mode", () => { + beforeEach(async () => { + // Commit 1 + await setupFiles({ "file_c1.txt": "c1", "shared.txt": "c1" }); + await runGit(["add", "."], testRepoDir); + await runGit(["commit", "-m", "commit 1"], testRepoDir); + + // Commit 2 + await setupFiles({ "file_c2.txt": "c2", "shared.txt": "c2" }); // shared.txt modified + await runGit(["add", "."], testRepoDir); + await runGit(["commit", "-m", "commit 2"], testRepoDir); + + // Commit 3 + await setupFiles({ "file_c3.txt": "c3", "shared.txt": "c3" }); // shared.txt modified again + await runGit(["add", "."], testRepoDir); + await runGit(["commit", "-m", "commit 3"], testRepoDir); + + // Uncommitted changes (should not be picked by --commits) + await writeFile(join(testRepoDir, "uncommitted.txt"), "uncommitted"); + await runGit(["add", "uncommitted.txt"], testRepoDir); // stage it + }); + + it("should collect files from the last commit with --commits 1", async () => { + const { stdout, exitCode } = await runScript([testRepoDir, "--commits", "1", "-t", targetOutputDir]); + // console.log("--commits 1 STDOUT:", stdout); + expect(exitCode).toBe(0); + // Diff between HEAD~1 and HEAD + // file_c3.txt was added in HEAD, shared.txt was modified in HEAD + expect(stdout).toContain("Found 2 file(s) to copy."); + expect(stdout).toContain("Copied: file_c3.txt"); + expect(stdout).toContain("Copied: shared.txt"); + + const copiedFiles = await getFilesInDir(targetOutputDir); + expect(copiedFiles).toEqual(expect.arrayContaining(["file_c3.txt", "shared.txt"])); + expect(copiedFiles.length).toBe(2); + + const c3Content = await Bun.file(join(targetOutputDir, "file_c3.txt")).text(); + expect(c3Content).toBe("c3"); + const sharedContent = await Bun.file(join(targetOutputDir, "shared.txt")).text(); + expect(sharedContent).toBe("c3"); // Content from HEAD + }); + + it("should collect files from the last 2 commits with --commits 2", async () => { + const { stdout, exitCode } = await runScript([testRepoDir, "--commits", "2", "-t", targetOutputDir]); + // console.log("--commits 2 STDOUT:", stdout); + expect(exitCode).toBe(0); + // Diff between HEAD~2 and HEAD + // file_c2.txt (from commit 2) + // file_c3.txt (from commit 3) + // shared.txt (modified in commit 2 and commit 3, so it's included, content from HEAD) + expect(stdout).toContain("Found 3 file(s) to copy."); + expect(stdout).toContain("Copied: file_c2.txt"); + expect(stdout).toContain("Copied: file_c3.txt"); + expect(stdout).toContain("Copied: shared.txt"); + + const copiedFiles = await getFilesInDir(targetOutputDir); + expect(copiedFiles).toEqual(expect.arrayContaining(["file_c2.txt", "file_c3.txt", "shared.txt"])); + expect(copiedFiles.length).toBe(3); + + const sharedContent = await Bun.file(join(targetOutputDir, "shared.txt")).text(); + expect(sharedContent).toBe("c3"); // Content from HEAD + }); + + it("should collect files with --commits and --flat", async () => { + await setupFiles({ "dir/file_c4.txt": "c4" }); + await runGit(["add", "."], testRepoDir); + await runGit(["commit", "-m", "commit 4 with dir"], testRepoDir); // This is now HEAD + + // HEAD is commit 4, HEAD~1 is commit 3 + const { stdout, exitCode } = await runScript([ + testRepoDir, + "--commits", + "1", + "-t", + targetOutputDir, + "--flat", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Found 1 file(s) to copy."); // Only dir/file_c4.txt from the latest commit + expect(stdout).toContain("Copied: dir/file_c4.txt as file_c4.txt"); + + const copiedFiles = await getFilesInDir(targetOutputDir); + expect(copiedFiles).toEqual(["file_c4.txt"]); // Flattened + }); + + it("should handle --commits NUM greater than history (collects all based on git diff behavior)", async () => { + const { stdout, exitCode } = await runScript([testRepoDir, "--commits", "10", "-t", targetOutputDir]); // We have 3 commits initially in this describe block + expect(exitCode).toBe(0); + // Git diff HEAD~10 HEAD will effectively be all tracked files if 10 > num_commits in the repo + // For this specific setup (Commit 1, 2, 3): file_c1.txt, file_c2.txt, file_c3.txt, shared.txt + expect(stdout).toContain("Found 4 file(s) to copy."); + const copiedFiles = await getFilesInDir(targetOutputDir); + expect(copiedFiles).toEqual( + expect.arrayContaining(["file_c1.txt", "file_c2.txt", "file_c3.txt", "shared.txt"]) + ); + expect(copiedFiles.length).toBe(4); + }); + }); + + it("should create default target directory if -t is not specified", async () => { + await setupFiles({ "file.txt": "content" }); + await runGit(["add", "file.txt"], testRepoDir); + // No commit, so it's uncommitted/staged. Default mode is 'all'. + + // Mock getTimestampDirName to return a predictable name for this test + // This is tricky as the script is run as a separate process. + // Instead, we'll check for the existence of a .ai directory and its contents. + // We'll need to clean up this .ai directory in afterEach more robustly. + + const { stdout, exitCode } = await runScript([testRepoDir]); // No -t + // console.log("Default target STDOUT:", stdout); + // console.log("Default target STDERR:", stderr); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Found 1 file(s) to copy."); + expect(stdout).toContain("Copied: file.txt"); + + const aiDir = join(testRepoDir, ".ai"); + const subDirs = await readdir(aiDir); + expect(subDirs.length).toBe(1); // Expect one timestamped directory + + const timestampDir = join(aiDir, subDirs[0]); + const copiedFiles = await getFilesInDir(timestampDir); + expect(copiedFiles).toEqual(["file.txt"]); + const fileContent = await Bun.file(join(timestampDir, "file.txt")).text(); + expect(fileContent).toBe("content"); + }); + + it("should handle no files matching criteria", async () => { + // Fresh repo, no commits, no staged/unstaged files + await runGit(["commit", "--allow-empty", "-m", "empty initial"], testRepoDir); // Make sure HEAD exists + + const { stdout, exitCode } = await runScript([testRepoDir, "--staged", "-t", targetOutputDir]); + expect(exitCode).toBe(0); + expect(stdout).toContain("No files found matching the criteria."); + const copiedFiles = await getFilesInDir(targetOutputDir); + expect(copiedFiles.length).toBe(0); + }); + }); +}); diff --git a/src/files-to-prompt/index.test.ts b/src/files-to-prompt/index.test.ts new file mode 100644 index 000000000..311d6ee11 --- /dev/null +++ b/src/files-to-prompt/index.test.ts @@ -0,0 +1,444 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { realpathSync } from "node:fs"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import type { FileSink, Subprocess } from "bun"; // Import Subprocess and FileSink types + +// Path to the script to be tested +const scriptPath = resolve(__dirname, "./index.ts"); + +interface ExecResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +// Define a type for stdio elements based on common usage for this test helper +type StdioPipeOrIgnore = "pipe" | "ignore"; + +interface TestSpawnOptions { + cmd: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + stdio?: [StdioPipeOrIgnore, StdioPipeOrIgnore, StdioPipeOrIgnore]; // Stdin, Stdout, Stderr +} + +async function runScript(args: string[], stdinContent: string | null = null, cwd?: string): Promise { + const opts: TestSpawnOptions = { + cmd: ["bun", "run", scriptPath, ...args], + cwd: cwd || process.cwd(), + env: { ...process.env }, + stdio: [stdinContent ? "pipe" : "ignore", "pipe", "pipe"], + }; + + const proc: Subprocess = Bun.spawn(opts); + + if (stdinContent && proc.stdin) { + // When stdio[0] is "pipe", proc.stdin is a FileSink. + const stdinSink = proc.stdin as FileSink; + stdinSink.write(stdinContent); // Call write directly on FileSink + await stdinSink.end(); // Call end directly on FileSink + } + + // When stdio[1] and stdio[2] are "pipe", proc.stdout/stderr are ReadableStream. + const stdout = await new Response(proc.stdout as ReadableStream).text(); + const stderr = await new Response(proc.stderr as ReadableStream).text(); + const exitCode = await proc.exited; + + return { stdout, stderr, exitCode }; +} + +// Helper to create a directory structure +async function createStructure(basePath: string, structure: Record) { + for (const [path, content] of Object.entries(structure)) { + const fullPath = join(basePath, path); + await mkdir(dirname(fullPath), { recursive: true }); + if (content !== null) { + await writeFile(fullPath, content); + } + } +} + +// Helper to get all file paths in a directory recursively (for verifying output dir contents) +async function _getFilesInDirRecursive(dir: string, baseDir = dir): Promise { + let entries: string[] = []; + try { + const items = await readdir(dir, { withFileTypes: true }); + for (const item of items) { + const fullPath = join(dir, item.name); + const relativePath = fullPath.substring(baseDir.length + 1); // +1 for the slash + if (item.isDirectory()) { + entries = entries.concat(await _getFilesInDirRecursive(fullPath, baseDir)); + } else { + entries.push(relativePath); + } + } + } catch (e: unknown) { + if (e instanceof Error && "code" in e && (e as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw e; + } + return entries.sort(); +} + +describe("files-to-prompt", () => { + let testDir: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + const baseTmpDir = realpathSync(tmpdir()); + testDir = await mkdtemp(join(baseTmpDir, "test-files-prompt-")); + // Most tests will run with testDir as CWD or pass paths relative to it + }); + + afterEach(async () => { + process.chdir(originalCwd); + if (testDir) { + await rm(testDir, { recursive: true, force: true }); + } + }); + + it("should show help with --help flag", async () => { + const { stdout, exitCode } = await runScript(["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Usage: files-to-prompt [options] [paths...]"); + expect(stdout).toContain("-e, --extension"); + expect(stdout).toContain("--include-hidden"); + expect(stdout).toContain("--cxml"); + expect(stdout).toContain("--markdown"); + expect(stdout).toMatch(/-n, --line-numbers\s+Add line numbers to the output/); + expect(stdout).toMatch(/files-to-prompt v\d+\.\d+\.\d+/); + }); + + it("should output version with --version flag", async () => { + // Assuming version is hardcoded or accessible. The provided script snippet doesn't show version implementation. + // This test might need adjustment if version handling is different. + const { stdout, exitCode } = await runScript(["--version"]); + expect(exitCode).toBe(0); + // A simple check, actual version string might vary. + // The provided script shows 'version?: boolean' in options, but no implementation for it. + // The actual script might have it or this test will fail until implemented. + // For now, let's assume it prints something like "files-to-prompt version x.y.z" + // If not implemented, it might just show help or exit cleanly. Let's check for non-error exit. + // Based on current script structure (if --version leads to no action and no error), it might show help. + // The script shows `if (argv.version || argv.v) { showVersion(); process.exit(0); }` + // but showVersion() is not in the provided snippet. + // If showVersion() is missing, it would error. Let's assume for now it's a placeholder or exits cleanly. + // The original script actually has `if (argv.version) { logger.info(VERSION); process.exit(0); }` + // and `const VERSION = "1.2.0";` so this test should work if that part of script is present. + // For now, let's assume it contains the word "version" or exits cleanly. + // The provided code doesn't have VERSION or showVersion. Let's assume it doesn't crash. + expect(stdout).toMatch(/files-to-prompt v\d+\.\d+\.\d+/); + }); + + describe("Basic File/Directory Processing", () => { + it("should process a single file", async () => { + await createStructure(testDir, { "file1.txt": "Hello World" }); + const { stdout, exitCode } = await runScript([join(testDir, "file1.txt")]); + expect(exitCode).toBe(0); + expect(stdout).toContain(join(testDir, "file1.txt")); + expect(stdout).toContain("---"); + expect(stdout).toContain("Hello World"); + }); + + it("should process multiple files", async () => { + await createStructure(testDir, { + "file1.txt": "Content1", + "file2.log": "Content2", + }); + const { stdout, exitCode } = await runScript([join(testDir, "file1.txt"), join(testDir, "file2.log")]); + expect(exitCode).toBe(0); + expect(stdout).toContain(join(testDir, "file1.txt")); + expect(stdout).toContain("Content1"); + expect(stdout).toContain(join(testDir, "file2.log")); + expect(stdout).toContain("Content2"); + }); + + it("should process a directory recursively", async () => { + await createStructure(testDir, { + "file1.txt": "Root file", + "subdir/file2.txt": "Nested file", + "subdir/another.log": "Nested log", + }); + const { stdout, exitCode } = await runScript([testDir]); + expect(exitCode).toBe(0); + expect(stdout).toContain(join(testDir, "file1.txt")); + expect(stdout).toContain("Root file"); + expect(stdout).toContain(join(testDir, "subdir/file2.txt")); + expect(stdout).toContain("Nested file"); + expect(stdout).toContain(join(testDir, "subdir/another.log")); + expect(stdout).toContain("Nested log"); + }); + + it("should output to a specified file with -o", async () => { + await createStructure(testDir, { "file1.txt": "Output this" }); + const outputFile = join(testDir, "output.txt"); + const { stdout, exitCode } = await runScript([join(testDir, "file1.txt"), "-o", outputFile]); + expect(exitCode).toBe(0); + expect(stdout).toBe(""); // No stdout when -o is used + + const outputContent = await readFile(outputFile, "utf-8"); + expect(outputContent).toContain(join(testDir, "file1.txt")); + expect(outputContent).toContain("Output this"); + }); + }); + + describe("Formatting Options", () => { + beforeEach(async () => { + await createStructure(testDir, { "test.js": "console.log('hello');" }); + }); + + it("should output with line numbers using -n", async () => { + const { stdout, exitCode } = await runScript([join(testDir, "test.js"), "--lineNumbers"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(join(testDir, "test.js")); + expect(stdout).toContain("---"); + expect(stdout).toMatch(/1\s+console\.log\('hello'\);/); + }); + + it("should output in Markdown format using -m", async () => { + const { stdout, exitCode } = await runScript([join(testDir, "test.js"), "-m"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(join(testDir, "test.js")); + expect(stdout).toMatch(/```javascript\s*console\.log\('hello'\);\s*```/s); + }); + + it("should output in Claude XML format using -c", async () => { + // Reset globalIndex for predictability if possible, or ensure test is isolated + // The script has globalIndex = 1. If tests run sequentially, this will increment. + // This requires running the script in a way that resets its global state or making globalIndex not global. + // For now, we'll just check for the pattern, assuming it starts at some index. + const { stdout, exitCode } = await runScript([join(testDir, "test.js"), "-c"]); + expect(exitCode).toBe(0); + expect(stdout).toMatch(//); + expect(stdout).toContain(`${join(testDir, "test.js")}`); + expect(stdout).toContain(""); + expect(stdout).toContain("console.log('hello');"); + expect(stdout).toContain(""); + expect(stdout).toContain(""); + }); + + it("should use Markdown with line numbers", async () => { + const { stdout, exitCode } = await runScript([join(testDir, "test.js"), "-m", "--lineNumbers"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(join(testDir, "test.js")); + expect(stdout).toMatch(/```javascript\s*1\s+console\.log\('hello'\);\s*```/s); + }); + + it("should correctly determine backticks for markdown", async () => { + await createStructure(testDir, { "test_with_backticks.md": "```js\nconsole.log('hello');\n```" }); + const { stdout, exitCode } = await runScript([join(testDir, "test_with_backticks.md"), "-m"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(join(testDir, "test_with_backticks.md")); + expect(stdout).toMatch(/````\s*```js\nconsole\.log\('hello'\);\n```\s*````/s); + }); + }); + + describe("Filtering Options", () => { + beforeEach(async () => { + await createStructure(testDir, { + "file.txt": "text content", + "file.js": "javascript content", + "file.ts": "typescript content", + "subdir/another.txt": "more text", + ".hiddenfile": "hidden content", + "subdir/.hidden_in_subdir": "hidden two", + }); + }); + + it("should filter by single extension with -e", async () => { + const { stdout, exitCode } = await runScript([testDir, "-e", "js"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(join(testDir, "file.js")); + expect(stdout).toContain("javascript content"); + expect(stdout).not.toContain("text content"); + expect(stdout).not.toContain("typescript content"); + }); + + it("should filter by multiple extensions with -e", async () => { + const { stdout, exitCode } = await runScript([testDir, "-e", "js", "-e", "ts"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("javascript content"); + expect(stdout).toContain("typescript content"); + expect(stdout).not.toContain("text content"); + }); + + it("should include hidden files with --includeHidden", async () => { + const { stdout, exitCode } = await runScript([testDir, "--includeHidden"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(".hiddenfile"); + expect(stdout).toContain("hidden content"); + expect(stdout).toContain(join(testDir, "subdir/.hidden_in_subdir")); + expect(stdout).toContain("hidden two"); + }); + + it("should exclude hidden files by default", async () => { + const { stdout, exitCode } = await runScript([testDir]); + expect(exitCode).toBe(0); + expect(stdout).not.toContain(".hiddenfile"); + expect(stdout).not.toContain(join(testDir, "subdir/.hidden_in_subdir")); + }); + + describe("Ignore Patterns and .gitignore", () => { + beforeEach(async () => { + // Reset testDir and create a fresh structure for these specific tests + // This avoids interference from the parent beforeEach structure if it's too general + await rm(testDir, { recursive: true, force: true }); + testDir = await mkdtemp(join(realpathSync(tmpdir()), "test-ignore-")); + + await createStructure(testDir, { + "fileA.txt": "A", + "fileB.log": "B", + "node_modules/some_dep/file.js": "dep file", + "subdir/fileC.txt": "C", + "subdir/fileD.log": "D", + ".env": "secret", + "data.json": "json data", + ".gitignore": "*.log\nnode_modules/\n.env", // Ignore all .log files, node_modules dir, .env file + "subdir/.gitignore": "fileC.txt", // Ignore fileC.txt within subdir + }); + }); + + it("should respect .gitignore by default", async () => { + const { stdout, exitCode } = await runScript([testDir]); + expect(exitCode).toBe(0); + expect(stdout).toContain(join(testDir, "fileA.txt")); // A is not ignored + expect(stdout).not.toContain("fileB.log"); // B is ignored by root .gitignore + expect(stdout).not.toContain("node_modules"); // node_modules ignored + expect(stdout).not.toContain(".env"); // .env ignored + expect(stdout).not.toContain(join(testDir, "subdir/fileC.txt")); // C ignored by subdir .gitignore + expect(stdout).not.toContain(join(testDir, "subdir/fileD.log")); // D is *.log, ignored by root + expect(stdout).toContain(join(testDir, "data.json")); // data.json is not ignored + }); + + it("should ignore .gitignore with --ignoreGitignore", async () => { + const { stdout, exitCode } = await runScript([testDir, "--ignoreGitignore"]); + expect(exitCode).toBe(0); + expect(stdout).toContain(join(testDir, "fileA.txt")); + expect(stdout).toContain(join(testDir, "fileB.log")); // Now included + expect(stdout).toContain(join(testDir, "node_modules/some_dep/file.js")); // Now included + const envPath = join(testDir, ".env"); + expect(stdout).toContain(`${envPath}\n---\nsecret\n---`); // Now included and checking content + expect(stdout).toContain(join(testDir, "subdir/fileC.txt")); // Now included + expect(stdout).toContain(join(testDir, "subdir/fileD.log")); // Now included + // console.log("ignoreGitignore stdout:", stdout); + // console.log("ignoreGitignore stderr:", stderr); + }); + + it("should use custom --ignore patterns", async () => { + const { stdout, exitCode } = await runScript([testDir, "--ignore", "*.txt"]); + expect(exitCode).toBe(0); + // .gitignore is still active, so .log, node_modules, .env are out + // --ignore *.txt removes fileA.txt and subdir/fileC.txt (though C already out by .gitignore) + expect(stdout).not.toContain("fileA.txt"); + expect(stdout).not.toContain("fileB.log"); + expect(stdout).not.toContain(join(testDir, "subdir/fileC.txt")); + expect(stdout).toContain(join(testDir, "data.json")); // json is not txt, not log + }); + + it("should combine --ignoreGitignore and --ignore", async () => { + const { stdout, exitCode } = await runScript([ + testDir, + "--ignoreGitignore", + "--ignore", + "*.log", + "--ignore", + "**/.env", // More specific ignore for .env + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(join(testDir, "fileA.txt")); + expect(stdout).not.toContain("fileB.log"); // Ignored by custom ignore + expect(stdout).toContain(join(testDir, "node_modules/some_dep/file.js")); // Not .log + expect(stdout).not.toContain(".env"); // Ignored by custom ignore + expect(stdout).toContain(join(testDir, "subdir/fileC.txt")); + expect(stdout).not.toContain(join(testDir, "subdir/fileD.log")); // Ignored by custom ignore + }); + + it("should use --ignoreFilesOnly with --ignore to keep directories", async () => { + // Test case: ignore *.js files, but still traverse into node_modules if it wasn't gitignored + const { stdout, exitCode } = await runScript([ + testDir, + "--ignoreGitignore", // So node_modules is considered for traversal + "--ignore", + "*.js", + "--ignoreFilesOnly", + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain(join(testDir, "fileA.txt")); + expect(stdout).not.toContain("dep file"); // node_modules/some_dep/file.js is ignored + // Crucially, other files in node_modules (if any and not .js) would be processed. + // This setup only has one .js file there. If we add a non-js file: + await createStructure(testDir, { "node_modules/another.txt": "another in node_modules" }); + const result = await runScript([testDir, "--ignoreGitignore", "--ignore", "*.js", "--ignoreFilesOnly"]); + expect(result.stdout).toContain("another in node_modules"); + expect(result.stdout).not.toContain("dep file"); + }); + }); + }); + + describe("Stdin Processing", () => { + it("should read paths from stdin", async () => { + await createStructure(testDir, { + "file1.txt": "Stdin Content 1", + "file2.txt": "Stdin Content 2", + }); + const pathsInput = `${join(testDir, "file1.txt")}\n${join(testDir, "file2.txt")}`; + const { stdout, exitCode } = await runScript([], pathsInput); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Stdin Content 1"); + expect(stdout).toContain("Stdin Content 2"); + }); + + it("should read paths from stdin with null separator using -0", async () => { + await createStructure(testDir, { + "file1.txt": "Null Sep Content 1", + "file with space.txt": "Null Sep Content 2", + }); + const pathsInput = `${join(testDir, "file1.txt")}\0${join(testDir, "file with space.txt")}\0`; + const { stdout, exitCode } = await runScript(["-0"], pathsInput); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Null Sep Content 1"); + expect(stdout).toContain("Null Sep Content 2"); + expect(stdout).toContain(join(testDir, "file with space.txt")); + }); + }); + + // Add tests for edge cases and error handling, e.g.: + // - Non-existent input files/dirs (should warn and skip) + // - Empty directories + // - Files with weird names or encodings (basic UTF-8 assumed) + // - Conflicting format options (e.g., -c and -m together - how does the script handle it?) + + it("should warn and skip non-existent files", async () => { + await createStructure(testDir, { "existing.txt": "I exist" }); + const nonExistentFile = join(testDir, "ghost.txt"); + + // Ensure stderr is captured + const { stdout, stderr, exitCode } = await runScript([nonExistentFile, join(testDir, "existing.txt")]); + expect(exitCode).toBe(0); // Script exits 0 if all inputs are processed/skipped without fatal error + + // The warning message "Path does not exist..." is logged via logger.error(), so it goes to stderr. + expect(stderr).toContain(`Path does not exist: ${nonExistentFile}`); + expect(stdout).not.toContain(nonExistentFile); // Ensure it's not on stdout if it was skipped + + // Check that the valid file was still processed and its output is on stdout + expect(stdout).toContain(join(testDir, "existing.txt")); + expect(stdout).toContain("---"); + expect(stdout).toContain("I exist"); + }); + + it("should handle conflicting format options (e.g. -c and -m)", async () => { + // The script's logic for printPath is: if (cxml) else if (markdown) else default. + // So cxml should take precedence over markdown. + await createStructure(testDir, { "file.txt": "format test" }); + const { stdout, exitCode } = await runScript([join(testDir, "file.txt"), "-c", "-m"]); + expect(exitCode).toBe(0); + expect(stdout).toMatch(//); // cxml format + expect(stdout).not.toMatch(/```/); // Not markdown format + }); +}); diff --git a/src/git-last-commits-diff/index.test.ts b/src/git-last-commits-diff/index.test.ts new file mode 100644 index 000000000..7841c3a45 --- /dev/null +++ b/src/git-last-commits-diff/index.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { realpathSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +// Path to the script to be tested +const scriptPath = resolve(__dirname, "./index.ts"); + +interface ExecResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +async function runScript(args: string[], cwd?: string): Promise { + const proc = Bun.spawn({ + cmd: ["bun", "run", scriptPath, ...args], + cwd: cwd || process.cwd(), + env: { ...process.env }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const stdout = await new Response(proc.stdout as ReadableStream).text(); + const stderr = await new Response(proc.stderr as ReadableStream).text(); + const exitCode = await proc.exited; + + return { stdout, stderr, exitCode }; +} + +// Helper to run git commands in a specific directory +async function runGit(args: string[], cwd: string): Promise { + const proc = Bun.spawn({ + cmd: ["git", ...args], + cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout = await new Response(proc.stdout as ReadableStream).text(); + const stderr = await new Response(proc.stderr as ReadableStream).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + console.error(`Git command [git ${args.join(" ")}] failed in ${cwd}:\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`); + } + return { stdout, stderr, exitCode }; +} + +describe("git-last-commits-diff", () => { + let testRepoDir: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + const baseTmpDir = realpathSync(tmpdir()); + testRepoDir = await mkdtemp(join(baseTmpDir, "test-git-diff-")); + process.chdir(testRepoDir); // Change CWD to the repo for script execution context + + // Initialize Git repository + await runGit(["init", "-b", "main"], testRepoDir); + await runGit(["config", "user.name", "Test User"], testRepoDir); + await runGit(["config", "user.email", "test@example.com"], testRepoDir); + await runGit(["config", "commit.gpgsign", "false"], testRepoDir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + if (testRepoDir) { + await rm(testRepoDir, { recursive: true, force: true }); + } + // Ensure env var is cleaned up if a test fails before deleting it + delete process.env.TEST_MODE_CLIPBOARD_OUTPUT_FILE; + }); + + const setupCommits = async (commitDetails: Array<{ files: Record; message: string }>) => { + for (const commit of commitDetails) { + for (const [file, content] of Object.entries(commit.files)) { + const dir = dirname(file); + if (dir !== ".") { + await mkdir(join(testRepoDir, dir), { recursive: true }); + } + await writeFile(join(testRepoDir, file), content); + await runGit(["add", file], testRepoDir); + } + await runGit(["commit", "-m", commit.message], testRepoDir); + } + const { stdout: _log } = await runGit(["log", "--oneline"], testRepoDir); + // console.log("Repo log after setup:\n", log); + }; + + it("should show help with --help flag", async () => { + const { stdout, exitCode } = await runScript(["--help"], originalCwd); // Run from original CWD if script expects repo path as arg + expect(exitCode).toBe(0); + expect(stdout).toContain("Usage: tools git-last-commits-diff "); + }); + + it("should show help and exit with 1 if no directory is provided", async () => { + const { stdout, exitCode } = await runScript([], originalCwd); + expect(exitCode).toBe(1); + expect(stdout).toContain("Usage: tools git-last-commits-diff "); + }); + + it("should exit with error for invalid --commits value", async () => { + await setupCommits([{ files: { "a.txt": "1" }, message: "c1" }]); + let result = await runScript([testRepoDir, "--commits", "0"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Error: --commits value must be a positive integer."); + + result = await runScript([testRepoDir, "--commits", "abc"]); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Error: --commits value must be a positive integer."); + }); + + describe("Diff Generation with --commits", () => { + beforeEach(async () => { + await setupCommits([ + { files: { "file1.txt": "content v1" }, message: "Commit 1" }, + { files: { "file1.txt": "content v2", "file2.txt": "new file" }, message: "Commit 2" }, + { files: { "file1.txt": "content v3", "file2.txt": "new file\nupdated" }, message: "Commit 3" }, + ]); + }); + + it("should output diff for last 1 commit to stdout by default (if --output is empty string)", async () => { + // The script defaults to interactive if no output flags. Forcing stdout via --output "" + const { stdout, exitCode } = await runScript([testRepoDir, "--commits", "1", "--output", ""]); + expect(exitCode).toBe(0); + expect(stdout).toContain("diff --git a/file1.txt b/file1.txt"); + expect(stdout).toContain("-content v2"); + expect(stdout).toContain("+content v3"); + expect(stdout).toContain("diff --git a/file2.txt b/file2.txt"); + expect(stdout).not.toContain("-new file"); + expect(stdout).toContain(" new file\n+updated"); + }); + + it("should output diff for last 2 commits to specified file", async () => { + const outputFile = join(testRepoDir, "diff_output.txt"); + const { stdout, exitCode } = await runScript([testRepoDir, "--commits", "2", "--output", outputFile]); + expect(exitCode).toBe(0); + // Informational messages go to stdout + expect(stdout).toContain("ℹ Will diff the last 2 commit(s)"); + expect(stdout).toContain(`ℹ Output will be written to file: ${outputFile}`); + expect(stdout).toContain(`✔ Diff successfully written to ${outputFile}`); + expect(stdout).toContain("✔ Absolute path "); + expect(stdout).toContain(`"${outputFile}"`); + expect(stdout).toContain(" copied to clipboard."); + + const diffContent = await readFile(outputFile, "utf-8"); + expect(diffContent).toContain("diff --git a/file1.txt b/file1.txt"); + expect(diffContent).toContain("-content v1"); // Diff from C1 to C3 + expect(diffContent).toContain("+content v3"); + + // file2.txt did not exist in C1, created in C2, content "new file\nupdated" in C3. + // So, when diffing C1 vs C3, file2.txt is a new file. + expect(diffContent).toContain("diff --git a/file2.txt b/file2.txt"); + expect(diffContent).toContain("new file mode 100644"); + expect(diffContent).toContain("--- /dev/null"); + expect(diffContent).toContain("+++ b/file2.txt"); + // Content of file2.txt in C3 is "new file\nupdated" + expect(diffContent).toContain("+new file\n+updated"); + // Since "updated" doesn't end with a newline in the setup string: + expect(diffContent).toContain("+updated\n\\ No newline at end of file"); + }); + + it("--output FILE should take precedence over --clipboard", async () => { + const outputFile = join(testRepoDir, "output_prec.txt"); + const testClipboardFile = join(testRepoDir, "clipboard_prec_test_output.txt"); + // Set the env var to ensure that even if clipboard mode was somehow triggered, it would write to a file we can check. + // process.env.TEST_MODE_CLIPBOARD_OUTPUT_FILE = testClipboardFile; // REMOVE, not needed as --output takes precedence + + const { stdout, exitCode, stderr } = await runScript([ + testRepoDir, + "--commits", + "1", + "--output", + outputFile, + "--clipboard", // This should be ignored + // No need to pass --test-mode-clipboard-file here, as clipboard action shouldn't be taken + ]); + + // delete process.env.TEST_MODE_CLIPBOARD_OUTPUT_FILE; // REMOVE + + expect(exitCode).toBe(0); + // Informational messages go to stdout + expect(stdout).toContain("ℹ Will diff the last 1 commit(s)"); + expect(stdout).toContain(`ℹ Output will be written to file: ${outputFile}`); + expect(stdout).toContain(`✔ Diff successfully written to ${outputFile}`); + expect(stdout).toContain("✔ Absolute path "); + expect(stdout).toContain(`"${outputFile}"`); + expect(stdout).toContain(" copied to clipboard."); + + // Ensure the actual output file was written + const fileContent = await readFile(outputFile, "utf-8"); + expect(fileContent).toContain("+content v3"); + + // Ensure clipboard test file was NOT written and no clipboard messages in stderr + try { + await readFile(testClipboardFile, "utf-8"); + // If readFile succeeds, the file was created, which is an error for this test. + throw new Error("Clipboard test file was created, but --output should have taken precedence."); + } catch (error: unknown) { + // Expecting ENOENT (file not found) or similar error + expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); + } + expect(stderr).not.toContain("[TEST MODE] Diff intended for clipboard written to"); + }); + }); + + // describe("Interactive Mode - Commit Selection", () => { + // beforeEach(async () => { + // await setupCommits([ + // { files: { "f1.txt": "a" }, message: "Short Commit A (sha_A)" }, + // { files: { "f1.txt": "b" }, message: "Short Commit B (sha_B)" }, + // ]); + // }); + + // it("should generate diff if a commit is selected interactively", async () => { + // // Need HEAD SHA and the SHA of HEAD~1 + // const headShaRes = await runGit(["rev-parse", "--short", "HEAD"], testRepoDir); + // const prevShaRes = await runGit(["rev-parse", "--short", "HEAD~1"], testRepoDir); + // const headSha = headShaRes.stdout.trim(); + // const prevSha = prevShaRes.stdout.trim(); + + // enquirerPromptSpy.mockImplementation(async (questions: any) => { + // if (questions.name === "selectedCommitValue") { + // // Simulate user selecting the second to last commit (HEAD~1) + // const choice = questions.choices.find((c: any) => c.name === prevSha); + // return { selectedCommitValue: choice ? choice.name : prevSha }; + // } + // if (questions.name === "selectedAction") { + // return { selectedAction: "stdout" }; // Default to stdout for this test + // } + // return {}; + // }); + + // // Run without --commits to trigger interactive commit selection, and without output flags + // const { stdout, stderr, exitCode } = await runScript([testRepoDir, "--output", ""]); // Force stdout + // console.log("Interactive stdout:", stdout); + // console.log("Interactive stderr:", stderr); + + // expect(exitCode).toBe(0); + // expect(enquirerPromptSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'selectedCommitValue' })); + // expect(stdout).toContain(`diff --git a/f1.txt b/f1.txt`); + // expect(stdout).toContain("-a"); // Content from prevSha (Commit A) + // expect(stdout).toContain("+b"); // Content from HEAD (Commit B) + // }); + // }); +}); diff --git a/src/github-release-notes/index.test.ts b/src/github-release-notes/index.test.ts new file mode 100644 index 000000000..193fce070 --- /dev/null +++ b/src/github-release-notes/index.test.ts @@ -0,0 +1,232 @@ +import { spyOn } from "bun:test"; +import { resolve } from "node:path"; +import axios from "axios"; + +// Path to the script to be tested +const scriptPath = resolve(__dirname, "./index.ts"); + +// Mock axios +const _axiosGetSpy = spyOn(axios, "get"); + +interface ExecResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +async function _runScript(args: string[], envVars: Record = {}): Promise { + const proc = Bun.spawn({ + cmd: ["bun", "run", scriptPath, ...args], + cwd: process.cwd(), + env: { ...process.env, ...envVars }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const stdout = await new Response(proc.stdout as ReadableStream).text(); + const stderr = await new Response(proc.stderr as ReadableStream).text(); + const exitCode = await proc.exited; + + return { stdout, stderr, exitCode }; +} + +// Sample GitHub Release Data +const _mockReleases = [ + { + tag_name: "v1.0.0", + name: "Version 1.0.0", + published_at: "2023-01-15T10:00:00Z", + body: "Initial release content.", + html_url: "https://github.com/testowner/testrepo/releases/tag/v1.0.0", + }, + { + tag_name: "v1.1.0", + name: "Version 1.1.0", + published_at: "2023-02-20T12:00:00Z", + body: "Update features for 1.1.0.", + html_url: "https://github.com/testowner/testrepo/releases/tag/v1.1.0", + }, + { + tag_name: "v0.9.0", + name: "Version 0.9.0", + published_at: "2022-12-25T08:00:00Z", + body: "Beta release content.", + html_url: "https://github.com/testowner/testrepo/releases/tag/v0.9.0", + }, +]; +/* +describe("github-release-notes", () => { + let testDir: string; + + beforeEach(async () => { + const baseTmpDir = realpathSync(tmpdir()); + testDir = await mkdtemp(join(baseTmpDir, "test-gh-releases-")); + axiosGetSpy.mockReset(); // Reset spy before each test + }); + + afterEach(async () => { + if (testDir) { + await rm(testDir, { recursive: true, force: true }); + } + }); + + it("should show help with --help flag", async () => { + const { stdout, exitCode } = await runScript(["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Usage: tools github-release-notes /| "); + }); + + it("should show help if no arguments are provided", async () => { + const { stdout, exitCode } = await runScript([]); + expect(exitCode).toBe(0); // Script exits 0 with help + expect(stdout).toContain("Usage: tools github-release-notes /| "); + }); + + it("should exit with error for invalid repo format", async () => { + const outputFile = join(testDir, "out.md"); + const { stdout, stderr, exitCode } = await runScript(["invalid-repo", outputFile]); + expect(exitCode).toBe(1); + // The script's logger outputs to stdout by default for info/error + expect(stdout).toContain("Invalid repository format."); + }); + + it("should fetch and write release notes to file (newest first by default)", async () => { + axiosGetSpy.mockResolvedValue({ data: mockReleases }); + const outputFile = join(testDir, "releases.md"); + + const { exitCode, stdout, stderr } = await runScript(["testowner/testrepo", outputFile]); + + // console.log("STDOUT:", stdout); + // console.log("STDERR:", stderr); + expect(exitCode).toBe(0); + expect(axiosGetSpy).toHaveBeenCalledWith( + "https://api.github.com/repos/testowner/testrepo/releases?per_page=100&page=1", + expect.any(Object) + ); + + const content = await readFile(outputFile, "utf-8"); + expect(content).toContain("# Release Notes: testowner/testrepo"); + expect(content).toMatch(/## \[v1\.1\.0\].*?2023-02-20.*?Update features for 1\.1\.0\./s); + expect(content).toMatch(/## \[v1\.0\.0\].*?2023-01-15.*?Initial release content\./s); + expect(content).toMatch(/## \[v0\.9\.0\].*?2022-12-25.*?Beta release content\./s); + // Check order (newest first) + expect(content.indexOf("v1.1.0")).toBeLessThan(content.indexOf("v1.0.0")); + expect(content.indexOf("v1.0.0")).toBeLessThan(content.indexOf("v0.9.0")); + }); + + it("should fetch and write release notes sorted oldest first with --oldest", async () => { + axiosGetSpy.mockResolvedValue({ data: mockReleases }); + const outputFile = join(testDir, "releases_oldest.md"); + + const { exitCode } = await runScript(["testowner/testrepo", outputFile, "--oldest"]); + expect(exitCode).toBe(0); + + const content = await readFile(outputFile, "utf-8"); + expect(content).toContain("# Release Notes: testowner/testrepo"); + // Check order (oldest first) + expect(content.indexOf("v0.9.0")).toBeLessThan(content.indexOf("v1.0.0")); + expect(content.indexOf("v1.0.0")).toBeLessThan(content.indexOf("v1.1.0")); + }); + + it("should limit releases with --limit", async () => { + axiosGetSpy.mockResolvedValue({ data: mockReleases }); + const outputFile = join(testDir, "releases_limit.md"); + + const { exitCode } = await runScript(["testowner/testrepo", outputFile, "--limit=2"]); + expect(exitCode).toBe(0); + + const content = await readFile(outputFile, "utf-8"); + // Default sort is newest first. With limit 2, we expect v1.1.0 and v1.0.0 + expect(content).toContain("v1.1.0"); + expect(content).toContain("v1.0.0"); + expect(content).not.toContain("v0.9.0"); + }); + + it("should use GITHUB_TOKEN if set", async () => { + axiosGetSpy.mockResolvedValue({ data: [] }); // No releases needed, just check headers + const outputFile = join(testDir, "out.md"); + + await runScript(["testowner/testrepo", outputFile], { GITHUB_TOKEN: "test_token_123" }); + + expect(axiosGetSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "token test_token_123" }) + }) + ); + }); + + it("should handle API error for 404 not found", async () => { + axiosGetSpy.mockRejectedValue({ + isAxiosError: true, + response: { status: 404, data: "Not Found" }, + message: "Request failed with status code 404" + }); + const outputFile = join(testDir, "out.md"); + const { stdout, stderr,exitCode } = await runScript(["testowner/testrepo", outputFile]); + expect(exitCode).toBe(1); + console.error("STDOUT:", stdout+" STDERR:"+stderr); + expect(stdout).toContain("Repository testowner/testrepo not found or no releases available."); + }); + + it("should handle API error for 403 rate limit", async () => { + axiosGetSpy.mockRejectedValue({ + isAxiosError: true, + response: { status: 403, data: "API rate limit exceeded" }, + message: "Request failed with status code 403" + }); + const outputFile = join(testDir, "out.md"); + const { stdout, exitCode } = await runScript(["testowner/testrepo", outputFile]); + expect(exitCode).toBe(1); + console.error("STDOUT:", stdout); + expect(stdout).toContain("Rate limit exceeded."); + }); + + it("should parse full GitHub URL for repo arg", async () => { + axiosGetSpy.mockResolvedValue({ data: [mockReleases[0]] }); + const outputFile = join(testDir, "releases_url.md"); + + const { exitCode } = await runScript([ + "https://github.com/anotherowner/anotherrepo.git", + outputFile + ]); + expect(exitCode).toBe(0); + expect(axiosGetSpy).toHaveBeenCalledWith( + "https://api.github.com/repos/anotherowner/anotherrepo/releases?per_page=100&page=1", + expect.any(Object) + ); + const content = await readFile(outputFile, "utf-8"); + expect(content).toContain("# Release Notes: anotherowner/anotherrepo"); + }); + + it("should correctly paginate if limit > 100 (mocking multiple pages)", async () => { + const manyReleasesPage1 = Array(100).fill(null).map((_, i) => ({ + ...mockReleases[0], tag_name: `v_page1_${i}`, + })); + const manyReleasesPage2 = Array(50).fill(null).map((_, i) => ({ + ...mockReleases[1], tag_name: `v_page2_${i}`, + })); + + axiosGetSpy + .mockResolvedValueOnce({ data: manyReleasesPage1 }) + .mockResolvedValueOnce({ data: manyReleasesPage2 }) + .mockResolvedValueOnce({ data: [] }); // Empty page to stop pagination + + const outputFile = join(testDir, "releases_paged.md"); + const { exitCode, stdout, stderr } = await runScript(["testowner/testrepo", outputFile, "--limit=150"]); + + expect(exitCode).toBe(0); + expect(axiosGetSpy).toHaveBeenCalledTimes(3); // Page 1, Page 2, Page 3 (empty) + expect(axiosGetSpy.mock.calls[0][0]).toContain("page=1"); + expect(axiosGetSpy.mock.calls[1][0]).toContain("page=2"); + expect(axiosGetSpy.mock.calls[2][0]).toContain("page=3"); + + const content = await readFile(outputFile, "utf-8"); + expect(content).toContain("v_page1_0"); + expect(content).toContain("v_page1_99"); + expect(content).toContain("v_page2_0"); + expect(content).toContain("v_page2_49"); + // Count occurrences of "## [v_page" + const releaseHeaders = content.match(/## \[v_page/g); + expect(releaseHeaders?.length).toBe(150); + }); +}); */ diff --git a/src/hold-ai/client.test.ts b/src/hold-ai/client.test.ts new file mode 100644 index 000000000..588d54b42 --- /dev/null +++ b/src/hold-ai/client.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { WebSocket, WebSocketServer } from "ws"; + +const clientScriptPath = resolve(__dirname, "./client.ts"); + +// Helper to run the client script +async function runTestClient(): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { + const clientProcess = spawn("bun", ["run", clientScriptPath], { + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + clientProcess.stdout.on("data", (data) => { + stdout += data.toString(); + }); + clientProcess.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + const exitCode = await new Promise((resolve) => { + clientProcess.on("close", resolve); + clientProcess.on("error", () => resolve(1)); // Treat spawn error as failure + }); + + return { stdout, stderr, exitCode }; +} + +describe("Hold-AI Client", () => { + let mockWSS: WebSocketServer | null = null; + let connectedClientWS: WebSocket | null = null; // The WebSocket instance from the client connection to the mock server + + beforeEach(() => { + // Reset before each test + connectedClientWS = null; + if (mockWSS) { + for (const client of mockWSS.clients) { + client.terminate(); + } + mockWSS.close(); + mockWSS = null; + } + }); + + afterEach(() => { + if (connectedClientWS && connectedClientWS.readyState === WebSocket.OPEN) { + connectedClientWS.terminate(); + } + if (mockWSS) { + for (const client of mockWSS.clients) { + client.terminate(); + } + mockWSS.close(); + mockWSS = null; + } + }); + + const startMockServer = (port = 9091): Promise => { + return new Promise((resolve) => { + const wss = new WebSocketServer({ port }); + wss.on("listening", () => resolve(wss)); + wss.on("connection", (ws) => { + connectedClientWS = ws; // Capture client connection + }); + mockWSS = wss; + }); + }; + + it("should connect to server, receive messages, and resolve on __COMPLETED__", async () => { + mockWSS = await startMockServer(); + + const clientRunPromise = runTestClient(); + + // Wait for the client to connect to our mock server + await new Promise((resolve, reject) => { + const interval = setInterval(() => { + if (connectedClientWS) { + clearInterval(interval); + resolve(); + } + }, 100); + setTimeout(5000, () => { + clearInterval(interval); + reject(new Error("Client did not connect to mock server in time")); + }); + }); + + expect(connectedClientWS).not.toBeNull(); + if (!connectedClientWS) { + throw new Error("Client did not connect"); + } + + // Send messages from mock server to client + connectedClientWS.send(JSON.stringify({ timestamp: new Date().toISOString(), message: "Message 1" })); + await setTimeout(50); // allow client to process + connectedClientWS.send(JSON.stringify({ timestamp: new Date().toISOString(), message: "Message 2" })); + await setTimeout(50); + connectedClientWS.send(JSON.stringify({ timestamp: new Date().toISOString(), message: "__COMPLETED__" })); + + const { stdout, exitCode } = await clientRunPromise; + + // console.log("Client STDOUT:", stdout); + // console.log("Client STDERR:", stderr); + + expect(exitCode).toBe(0); + // The client script logs "Instruction: ..." and then "OK" + expect(stdout).toContain("Instruction: Message 1"); + expect(stdout).toContain("Instruction: Message 2"); + expect(stdout).toContain("OK"); + // It should not log the __COMPLETED__ message itself as an instruction + expect(stdout).not.toContain("Instruction: __COMPLETED__"); + }, 15000); // Increased timeout for async operations + + it("should attempt to reconnect if server is not initially available", async () => { + // Don't start the server immediately + const clientRunPromise = runTestClient(); + + // Client logs "Still processing..." on error/reconnect attempt + // Wait a bit to see if client tries to connect (and fails) + await setTimeout(1000); + + // Now start the mock server + mockWSS = await startMockServer(); + + await new Promise((resolve, reject) => { + const interval = setInterval(() => { + if (connectedClientWS) { + clearInterval(interval); + resolve(); + } + }, 100); + setTimeout(7000, () => { + // Client retries every 3s, give it time + clearInterval(interval); + reject(new Error("Client did not connect to mock server after starting late")); + }); + }); + + expect(connectedClientWS).not.toBeNull(); + if (!connectedClientWS) { + throw new Error("Client did not connect"); + } + + // Send completion to allow client to exit cleanly + connectedClientWS.send(JSON.stringify({ timestamp: new Date().toISOString(), message: "__COMPLETED__" })); + + const { stdout, exitCode } = await clientRunPromise; + expect(exitCode).toBe(0); + expect(stdout).toContain("Still processing..."); // Indicates it likely tried to connect while server was down + expect(stdout).toContain("OK"); // Indicates successful completion + }, 20000); // Longer timeout for reconnection logic +}); diff --git a/src/hold-ai/server.test.ts b/src/hold-ai/server.test.ts new file mode 100644 index 000000000..3c58173d4 --- /dev/null +++ b/src/hold-ai/server.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { type ChildProcess, spawn } from "node:child_process"; // Using node:child_process for more control +import { resolve } from "node:path"; +import { setTimeout } from "node:timers/promises"; // For async delays +import { WebSocket } from "ws"; + +const serverScriptPath = resolve(__dirname, "./server.ts"); + +// Helper to start the server as a child process +async function startTestServer(): Promise { + const serverProcess = spawn("bun", ["run", serverScriptPath], { + stdio: ["pipe", "pipe", "pipe"], // pipe stdin for sending input + detached: false, // if true, it might keep running; manage lifecycle carefully + }); + + // Wait for server to indicate it's ready or timeout + await new Promise((res, rej) => { + let output = ""; + const onData = (data: Buffer) => { + output += data.toString(); + if (output.includes("Hold-AI WebSocket Server started on port 9091")) { + serverProcess.stdout?.off("data", onData); + serverProcess.stderr?.off("data", onData); + res(); + } + }; + serverProcess.stdout?.on("data", onData); + serverProcess.stderr?.on("data", onData); // Server logs info to stdout, but listen to stderr too + serverProcess.on("error", (err) => rej(err)); + setTimeout(5000, () => rej(new Error("Server start timed out"))); + }); + return serverProcess; +} + +// Helper to stop the server +async function stopTestServer(serverProcess: ChildProcess | null): Promise { + if (!serverProcess || serverProcess.killed) { + return; + } + await new Promise((resolve) => { + serverProcess.on("exit", () => resolve()); + // Attempt graceful shutdown by sending 'Ctrl+C' or a known exit command if server handles it. + // For this server, Ctrl+C in the prompt leads to graceful shutdown. + // Sending SIGINT (Ctrl+C) + if (serverProcess.stdin?.writable) { + // serverProcess.stdin.write('\x03'); // Sending Ctrl+C can be unreliable cross-platform or in tests + // serverProcess.stdin.end(); + // For now, just kill, as proper stdin interaction is complex for this test setup. + } + serverProcess.kill("SIGTERM"); // Or SIGINT + setTimeout(2000, () => { + // Timeout for kill + if (!serverProcess.killed) { + serverProcess.kill("SIGKILL"); + } + resolve(); + }); + }); +} + +function connectClient(port = 9091): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://localhost:${port}`); + ws.on("open", () => resolve(ws)); + ws.on("error", (err) => reject(err)); + setTimeout(2000, () => reject(new Error("Client connection timed out"))); + }); +} + +describe("Hold-AI Server", () => { + let serverProcess: ChildProcess | null = null; + + afterEach(async () => { + await stopTestServer(serverProcess); + serverProcess = null; + }); + + it("should start and a client should connect", async () => { + serverProcess = await startTestServer(); + expect(serverProcess).toBeDefined(); + + let client: WebSocket | null = null; + try { + client = await connectClient(); + expect(client.readyState).toBe(WebSocket.OPEN); + } finally { + client?.close(); + } + }, 10000); // Increase timeout for server start and client connect + + it("should send existing messages to a new client", async () => { + serverProcess = await startTestServer(); + + // Simulate server receiving messages via its Enquirer prompt + // This is hard to do directly without complex IPC or refactoring server for testability. + // Instead, we'll test the effect: start server, manually add messages (if server allowed it, it doesn't), then connect client. + // The current server only adds messages via Enquirer. We can't directly inject messages for this test easily. + // Alternative: modify server to accept initial messages via e.g. env var for testing, or mock Enquirer globally. + + // For now, this test is limited. We'll assume if a client connects, and IF there were messages, they'd be sent. + // We can test message broadcasting more directly. + // This specific test for *existing* messages is hard with current server design. + // We will test message broadcasting in another test. + let client: WebSocket | null = null; + try { + client = await connectClient(); + // If we could preload messages on server, we'd check for them here. + // For now, just ensure connection works. + expect(client.readyState).toBe(WebSocket.OPEN); + } finally { + client?.close(); + } + }, 10000); + + it("should broadcast new messages to connected clients", async (done) => { + serverProcess = await startTestServer(); + const client = await connectClient(); + + const testMessage = { timestamp: expect.any(String), message: "Hello Client" }; + + client.on("message", (data) => { + const received = JSON.parse(data.toString()); + expect(received).toEqual(testMessage); + client.close(); + done(); + }); + + // Simulate user typing "Hello Client" into the server's Enquirer prompt + // This requires writing to the server process's stdin. + expect(serverProcess?.stdin).not.toBeNull(); + serverProcess?.stdin?.write("Hello Client\n"); + }, 15000); + + it("should broadcast __COMPLETED__ and clear messages when 'OK' is entered", async (done) => { + serverProcess = await startTestServer(); + const client1 = await connectClient(); + let client1ReceivedCompleted = false; + let _client1Closed = false; + + // Send an initial message to ensure `messages` array is not empty + serverProcess?.stdin?.write("Initial Message\n"); + // Wait for it to be processed by server and broadcast (client will receive it) + await new Promise((resolve) => client1.once("message", () => resolve())); + + client1.on("message", (data) => { + const received = JSON.parse(data.toString()); + if (received.message === "__COMPLETED__") { + client1ReceivedCompleted = true; + } + }); + client1.on("close", () => { + _client1Closed = true; + expect(client1ReceivedCompleted).toBe(true); + // Check if messages are cleared on server (indirectly) + // Connect a new client, it should not receive 'Initial Message' + connectClient() + .then((client2) => { + let receivedInitial = false; + client2.on("message", (data) => { + const msg = JSON.parse(data.toString()); + if (msg.message === "Initial Message") { + receivedInitial = true; + } + }); + // Wait a bit to see if any messages arrive + setTimeout(500).then(() => { + client2.close(); + expect(receivedInitial).toBe(false); // Should not receive the old message + done(); + }); + }) + .catch((err) => done(err)); + }); + + serverProcess?.stdin?.write("OK\n"); + }, 20000); // Increased timeout for multiple client interactions +}); diff --git a/src/t3chat-length/index.test.ts b/src/t3chat-length/index.test.ts new file mode 100644 index 000000000..c61542532 --- /dev/null +++ b/src/t3chat-length/index.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "bun:test"; + +// Assuming the functions and interfaces are exported from index.ts +// If not, we might need to copy them here or adjust the import. +// For this example, let's assume they might not be directly exported from a script that also runs itself. +// So, we'll redefine the necessary parts or use a way to import them if the script is structured for it. + +// --- Definitions (copied or adapted from index.ts if not exportable) --- +interface Message { + content: string; + threadId: string; +} + +interface InputJson { + json: { + messages: Message[]; + }; +} + +interface OutputMessageInfo { + threadLink: string; + contentSizeKB: number; +} + +function processMessages(input: InputJson): OutputMessageInfo[] { + const messages = input.json.messages; + + const messageInfo = messages.map((message) => { + const contentSizeBytes = new TextEncoder().encode(message.content).length; + const contentSizeKB = contentSizeBytes / 1024; + const threadLink = `https://t3.chat/chat/${message.threadId}`; + + return { + threadLink, + contentSizeKB, + }; + }); + + messageInfo.sort((a, b) => b.contentSizeKB - a.contentSizeKB); + + return messageInfo; +} +// --- End Definitions --- + +describe("t3chat-length processor", () => { + it("should process an empty list of messages", () => { + const input: InputJson = { json: { messages: [] } }; + const result = processMessages(input); + expect(result).toEqual([]); + }); + + it("should correctly calculate content size and create thread links", () => { + const messages: Message[] = [ + { content: "Hello", threadId: "thread1" }, // 5 bytes + { content: "A".repeat(1024), threadId: "thread2" }, // 1024 bytes = 1KB + { content: "B".repeat(2048), threadId: "thread3" }, // 2048 bytes = 2KB + ]; + const input: InputJson = { json: { messages } }; + const result = processMessages(input); + + expect(result.length).toBe(3); + + const msg1 = result.find((m) => m.threadLink.includes("thread1")); + const msg2 = result.find((m) => m.threadLink.includes("thread2")); + const msg3 = result.find((m) => m.threadLink.includes("thread3")); + + expect(msg1).toBeDefined(); + expect(msg2).toBeDefined(); + expect(msg3).toBeDefined(); + + if (!msg1 || !msg2 || !msg3) { + throw new Error("Test messages not found in result"); + } + + expect(msg1.threadLink).toBe("https://t3.chat/chat/thread1"); + expect(msg1.contentSizeKB).toBeCloseTo(5 / 1024); + + expect(msg2.threadLink).toBe("https://t3.chat/chat/thread2"); + expect(msg2.contentSizeKB).toBeCloseTo(1); + + expect(msg3.threadLink).toBe("https://t3.chat/chat/thread3"); + expect(msg3.contentSizeKB).toBeCloseTo(2); + }); + + it("should sort messages by contentSizeKB in descending order", () => { + const messages: Message[] = [ + { content: "Short", threadId: "t_short" }, // 5 bytes + { content: "VeryVeryLongContent", threadId: "t_long" }, // 19 bytes + { content: "MediumContent", threadId: "t_medium" }, // 13 bytes + ]; + const input: InputJson = { json: { messages } }; + const result = processMessages(input); + + expect(result.length).toBe(3); + expect(result[0].threadLink).toContain("t_long"); + expect(result[1].threadLink).toContain("t_medium"); + expect(result[2].threadLink).toContain("t_short"); + + expect(result[0].contentSizeKB).toBeCloseTo(19 / 1024); + expect(result[1].contentSizeKB).toBeCloseTo(13 / 1024); + expect(result[2].contentSizeKB).toBeCloseTo(5 / 1024); + }); + + it("should handle messages with empty content", () => { + const messages: Message[] = [ + { content: "", threadId: "empty" }, // 0 bytes + { content: "Not empty", threadId: "not_empty" }, // 9 bytes + ]; + const input: InputJson = { json: { messages } }; + const result = processMessages(input); + + expect(result.length).toBe(2); + + const emptyMsg = result.find((m) => m.threadLink.includes("empty")); + const nonEmptyMsg = result.find((m) => m.threadLink.includes("not_empty")); + + expect(emptyMsg).toBeDefined(); + expect(nonEmptyMsg).toBeDefined(); + + if (!emptyMsg || !nonEmptyMsg) { + throw new Error("Messages not found"); + } + + expect(emptyMsg.contentSizeKB).toBe(0); + expect(nonEmptyMsg.contentSizeKB).toBeCloseTo(9 / 1024); + + // Check sort order + expect(result[0].threadLink).toContain("not_empty"); + expect(result[1].threadLink).toContain("empty"); + }); + + it("should handle unicode characters correctly for byte length", () => { + // Different unicode characters can have different byte lengths + const messages: Message[] = [ + { content: "Hello", threadId: "ascii" }, // 5 bytes + { content: "你好世界", threadId: "chinese" }, // Ni Hao Shi Jie - 4 chars, typically 12 bytes in UTF-8 + { content: "😊", threadId: "emoji" }, // Emoji - typically 4 bytes in UTF-8 + ]; + const input: InputJson = { json: { messages } }; + const result = processMessages(input); + + const asciiMsg = result.find((m) => m.threadLink.includes("ascii")); + const chineseMsg = result.find((m) => m.threadLink.includes("chinese")); + const emojiMsg = result.find((m) => m.threadLink.includes("emoji")); + + expect(asciiMsg).toBeDefined(); + expect(chineseMsg).toBeDefined(); + expect(emojiMsg).toBeDefined(); + + if (!asciiMsg || !chineseMsg || !emojiMsg) { + throw new Error("Messages not found"); + } + + expect(asciiMsg.contentSizeKB).toBeCloseTo(5 / 1024); + expect(chineseMsg.contentSizeKB).toBeCloseTo(new TextEncoder().encode("你好世界").length / 1024); + expect(emojiMsg.contentSizeKB).toBeCloseTo(new TextEncoder().encode("😊").length / 1024); + + // Verify sorting based on actual byte lengths + const expectedOrder = [chineseMsg, asciiMsg, emojiMsg].sort((a, b) => b.contentSizeKB - a.contentSizeKB); + expect(result.map((r) => r.threadLink)).toEqual(expectedOrder.map((r) => r.threadLink)); + }); +}); diff --git a/src/watch/index.test.ts b/src/watch/index.test.ts new file mode 100644 index 000000000..3c4737154 --- /dev/null +++ b/src/watch/index.test.ts @@ -0,0 +1,249 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { type ChildProcess, spawn } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { appendFile, mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; + +const scriptPath = resolve(__dirname, "./index.ts"); + +interface WatchResult { + stdout: string; + stderr: string; + exitCode: number | null; + process: ChildProcess; +} + +// Helper to run the watch script +async function runWatchScript(args: string[], testDir: string, timeoutMs = 5000): Promise { + const output = { stdout: "", stderr: "" }; + + const scriptProcess = spawn("bun", ["run", scriptPath, ...args], { + cwd: testDir, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + scriptProcess.stdout.on("data", (data) => { + output.stdout += data.toString(); + // console.log("[SCRIPT STDOUT]:", data.toString()); + }); + scriptProcess.stderr.on("data", (data) => { + output.stderr += data.toString(); + // console.error("[SCRIPT STDERR]:", data.toString()); + }); + + const exitPromise = new Promise((res) => { + scriptProcess.on("close", res); + scriptProcess.on("error", () => res(1)); + }); + + let exitCode: number | null = null; + if (!args.includes("-f") && !args.includes("--follow")) { + exitCode = await Promise.race([ + exitPromise, + sleep(timeoutMs, null).then(() => { + if (!scriptProcess.killed) { + scriptProcess.kill(); + } + return -1; + }), + ]); + if (exitCode === -1) { + console.warn("Script did not exit as expected in non-follow mode."); + } + } else { + await sleep(500); + } + + return { ...output, exitCode, process: scriptProcess }; +} + +async function stopWatchScript(proc: ChildProcess): Promise { + if (!proc || proc.killed) { + return; + } + return new Promise((resolve) => { + let killTimer: NodeJS.Timeout | null = null; + const onExit = () => { + if (killTimer) { + clearTimeout(killTimer); + } + resolve(); + }; + proc.on("exit", onExit); + proc.kill("SIGTERM"); + killTimer = setTimeout(() => { + killTimer = null; + if (!proc.killed) { + proc.kill("SIGKILL"); + } + proc.removeListener("exit", onExit); + resolve(); + }, 1000) as ReturnType; + }); +} + +// Helper to create a directory structure +async function createStructure(basePath: string, structure: Record) { + for (const [path, content] of Object.entries(structure)) { + const fullPath = join(basePath, path); + await mkdir(dirname(fullPath), { recursive: true }); + if (content !== null) { + await writeFile(fullPath, content); + } + } +} + +describe("watch tool", () => { + let testDir: string; + let currentProcess: ChildProcess | null = null; + + beforeEach(async () => { + const baseTmpDir = realpathSync(tmpdir()); + testDir = await mkdtemp(join(baseTmpDir, "test-watch-")); + }); + + afterEach(async () => { + if (currentProcess) { + await stopWatchScript(currentProcess); + currentProcess = null; + } + if (testDir) { + await rm(testDir, { recursive: true, force: true }); + } + }); + + it("should show help with --help flag", async () => { + const result = await runWatchScript(["--help"], testDir); + currentProcess = result.process; + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Usage:\n tools watch [glob-pattern] [options]"); + }); + + it("should error if no glob pattern is provided", async () => { + const result = await runWatchScript([], testDir); + currentProcess = result.process; + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Error: No glob pattern provided"); + expect(result.stdout).toContain("Use --help for usage information"); + }); + + it("should warn if glob pattern is likely shell-expanded (single non-glob arg)", async () => { + await createStructure(testDir, { "testfile.txt": "content" }); + const result = await runWatchScript(["testfile.txt"], testDir); + currentProcess = result.process; + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Error: It appears your glob patterns may have been expanded by the shell"); + expect(result.stdout).toContain("To prevent this, please wrap each pattern in quotes:"); + expect(result.stdout).toContain( + "Without quotes, the shell expands wildcards before passing arguments to the script." + ); + }); + + it("should not warn for shell expansion if glob pattern is quoted (contains glob chars)", async () => { + const result = await runWatchScript(["*.nothing"], testDir); + currentProcess = result.process; + expect(result.stdout).not.toContain("Error: It appears your glob patterns may have been expanded by the shell"); + expect([1, null].includes(result.exitCode)).toBe(true); + }); + + describe("Non-Follow Mode (Snapshot of files)", () => { + it("should display initial content of matched files and exit", async () => { + await createStructure(testDir, { + "file1.txt": "Line1\nLine2", + "sub/file2.log": "Log Data 1\nLog Data 2\nLog Data 3", + "another.txt": "Single line", + }); + await sleep(50); + await writeFile(join(testDir, "file1.txt"), "Line1\nLine2\nUpdatedL1"); + + const result = await runWatchScript(["*.txt", "sub/*.log", "-n", "2"], testDir); + currentProcess = result.process; + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("EXISTING FILE:"); + expect(result.stdout).toContain("another.txt"); + expect(result.stdout).toContain("Single line"); + expect(result.stdout).toContain("file1.txt"); + expect(result.stdout).toContain("UpdatedL1"); + expect(result.stdout).toContain("sub/file2.log"); + expect(result.stdout).toContain("Log Data 3"); + expect(result.stdout).toContain("Log Data 2"); + }, 10000); + }); + + describe("Follow Mode (-f)", () => { + async function waitForOutput(proc: ChildProcess, text: string | RegExp, timeout = 5000): Promise { + return new Promise((resolve) => { + let accumulatedStdout = ""; + let timer: NodeJS.Timeout | null = null; + const listener = (data: Buffer) => { + const chunk = data.toString(); + accumulatedStdout += chunk; + if (typeof text === "string" ? accumulatedStdout.includes(text) : text.test(accumulatedStdout)) { + if (timer) { + clearTimeout(timer); + } + proc.stdout?.removeListener("data", listener); + resolve(true); + } + }; + proc.stdout?.on("data", listener); + timer = setTimeout(() => { + timer = null; + proc.stdout?.removeListener("data", listener); + resolve(false); + }, timeout) as ReturnType; + }); + } + + it("should display initial files and then new content for appends", async () => { + await createStructure(testDir, { "follow.txt": "Initial content." }); + const result = await runWatchScript(["*.txt", "-f", "-n", "10"], testDir, 15000); + currentProcess = result.process; + + const initialOutputFound = await waitForOutput( + result.process, + /EXISTING FILE: .*follow.txt.*Initial content\./s + ); + expect(initialOutputFound).toBe(true); + + await appendFile(join(testDir, "follow.txt"), "\nAppended line 1."); + const update1Found = await waitForOutput( + result.process, + /UPDATED: follow.txt.*Initial content\.\nAppended line 1\./s + ); + expect(update1Found).toBe(true); + + await appendFile(join(testDir, "follow.txt"), "\nAppended line 2."); + const update2Found = await waitForOutput( + result.process, + /UPDATED: follow.txt.*Appended line 1\.\nAppended line 2\./s + ); + expect(update2Found).toBe(true); + }, 5000); + + it("should detect and display content of new files", async () => { + const result = await runWatchScript(["*.new", "-f"], testDir, 15000); + currentProcess = result.process; + + await sleep(1000); + + await writeFile(join(testDir, "newfile.new"), "Content of new file."); + const newFileFound = await waitForOutput(result.process, /NEW FILE: .*newfile.new.*Content of new file\./s); + expect(newFileFound).toBe(true); + }, 5000); + + it("should report removed files", async () => { + await createStructure(testDir, { "todelete.del": "delete me" }); + const result = await runWatchScript(["*.del", "-f"], testDir, 15000); + currentProcess = result.process; + + await unlink(join(testDir, "todelete.del")); + const removedFileFound = await waitForOutput(result.process, /REMOVED: .*todelete.del/s); + expect(removedFileFound).toBe(true); + }, 5000); + }); +});