fix: use dependency injection for image-downloader fs mocks

The _fsForTest mutable object pattern fails on Linux Bun because
test and source files get different module instances. Switch to
passing fs operations as an optional parameter with defaults.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vlad MocanuandClaude Opus 4.6 committed 2026-02-26 08:54:58 +02:00
1 parent ac8e22e2f8
commit cff360d00a
2 files changed
+40 -21

No files matched your search

+9 -7
View File
@@ -2,11 +2,12 @@ import fs from "fs/promises";
import path from "path"; import path from "path";
import type { Octokits } from "../api/client"; import type { Octokits } from "../api/client";
// Mutable fs reference for cross-platform test compatibility. export type FsOps = {
// On Linux, Bun gives test and source files different fs/promises objects, mkdir: typeof fs.mkdir;
// so spyOn(fs, "mkdir") in a test file won't intercept calls here. writeFile: typeof fs.writeFile;
// Tests override these methods directly instead. };
export const _fsForTest = {
const defaultFsOps: FsOps = {
mkdir: fs.mkdir.bind(fs), mkdir: fs.mkdir.bind(fs),
writeFile: fs.writeFile.bind(fs), writeFile: fs.writeFile.bind(fs),
}; };
@@ -72,11 +73,12 @@ export async function downloadCommentImages(
owner: string, owner: string,
repo: string, repo: string,
comments: CommentWithImages[], comments: CommentWithImages[],
fsOps: FsOps = defaultFsOps,
): Promise<Map<string, string>> { ): Promise<Map<string, string>> {
const urlToPathMap = new Map<string, string>(); const urlToPathMap = new Map<string, string>();
const downloadsDir = "/tmp/github-images"; const downloadsDir = "/tmp/github-images";
await _fsForTest.mkdir(downloadsDir, { recursive: true }); await fsOps.mkdir(downloadsDir, { recursive: true });
const { imageRegex, htmlImgRegex } = buildImageRegexes(); const { imageRegex, htmlImgRegex } = buildImageRegexes();
@@ -226,7 +228,7 @@ export async function downloadCommentImages(
const arrayBuffer = await imageResponse.arrayBuffer(); const arrayBuffer = await imageResponse.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
await _fsForTest.writeFile(localPath, buffer); await fsOps.writeFile(localPath, buffer);
console.log(`✓ Saved: ${localPath}`); console.log(`✓ Saved: ${localPath}`);
urlToPathMap.set(originalUrl, localPath); urlToPathMap.set(originalUrl, localPath);
+31 -14
View File
@@ -10,8 +10,8 @@ import {
} from "bun:test"; } from "bun:test";
import { import {
downloadCommentImages, downloadCommentImages,
_fsForTest,
type CommentWithImages, type CommentWithImages,
type FsOps,
} from "../src/github/utils/image-downloader"; } from "../src/github/utils/image-downloader";
import type { Octokits } from "../src/github/api/client"; import type { Octokits } from "../src/github/api/client";
@@ -22,13 +22,13 @@ describe("downloadCommentImages", () => {
let fetchSpy: any; let fetchSpy: any;
let savedServerUrl: string | undefined; let savedServerUrl: string | undefined;
// Store originals for restoration // Mock functions for fs operations — injected via parameter, no module mutation needed
const origMkdir = _fsForTest.mkdir;
const origWriteFile = _fsForTest.writeFile;
// Mock functions for fs operations
const mockMkdir = jest.fn().mockResolvedValue(undefined); const mockMkdir = jest.fn().mockResolvedValue(undefined);
const mockWriteFile = jest.fn().mockResolvedValue(undefined); const mockWriteFile = jest.fn().mockResolvedValue(undefined);
const mockFsOps: FsOps = {
mkdir: mockMkdir as any,
writeFile: mockWriteFile as any,
};
beforeEach(() => { beforeEach(() => {
// Pin GITHUB_SERVER_URL so tests pass regardless of runner environment. // Pin GITHUB_SERVER_URL so tests pass regardless of runner environment.
@@ -41,11 +41,8 @@ describe("downloadCommentImages", () => {
consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {}); consoleWarnSpy = spyOn(console, "warn").mockImplementation(() => {});
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {}); consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
// Override _fsForTest methods directly — works cross-platform
mockMkdir.mockClear(); mockMkdir.mockClear();
mockWriteFile.mockClear(); mockWriteFile.mockClear();
_fsForTest.mkdir = mockMkdir as any;
_fsForTest.writeFile = mockWriteFile as any;
// Set fake system time for consistent filenames // Set fake system time for consistent filenames
setSystemTime(new Date("2024-01-01T00:00:00.000Z")); // 1704067200000 setSystemTime(new Date("2024-01-01T00:00:00.000Z")); // 1704067200000
@@ -59,10 +56,6 @@ describe("downloadCommentImages", () => {
delete process.env.GITHUB_SERVER_URL; delete process.env.GITHUB_SERVER_URL;
} }
// Restore original fs methods
_fsForTest.mkdir = origMkdir;
_fsForTest.writeFile = origWriteFile;
consoleLogSpy.mockRestore(); consoleLogSpy.mockRestore();
consoleWarnSpy.mockRestore(); consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore(); consoleErrorSpy.mockRestore();
@@ -90,7 +83,13 @@ describe("downloadCommentImages", () => {
const mockOctokit = createMockOctokit(); const mockOctokit = createMockOctokit();
const comments: CommentWithImages[] = []; const comments: CommentWithImages[] = [];
await downloadCommentImages(mockOctokit, "owner", "repo", comments); await downloadCommentImages(
mockOctokit,
"owner",
"repo",
comments,
mockFsOps,
);
expect(mockMkdir).toHaveBeenCalledWith("/tmp/github-images", { expect(mockMkdir).toHaveBeenCalledWith("/tmp/github-images", {
recursive: true, recursive: true,
@@ -112,6 +111,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(result.size).toBe(0); expect(result.size).toBe(0);
@@ -155,6 +155,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({ expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({
@@ -215,6 +216,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(mockOctokit.rest.pulls.getReviewComment).toHaveBeenCalledWith({ expect(mockOctokit.rest.pulls.getReviewComment).toHaveBeenCalledWith({
@@ -262,6 +264,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(mockOctokit.rest.pulls.getReview).toHaveBeenCalledWith({ expect(mockOctokit.rest.pulls.getReview).toHaveBeenCalledWith({
@@ -309,6 +312,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(mockOctokit.rest.issues.get).toHaveBeenCalledWith({ expect(mockOctokit.rest.issues.get).toHaveBeenCalledWith({
@@ -357,6 +361,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(mockOctokit.rest.pulls.get).toHaveBeenCalledWith({ expect(mockOctokit.rest.pulls.get).toHaveBeenCalledWith({
@@ -408,6 +413,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(fetchSpy).toHaveBeenCalledTimes(2); expect(fetchSpy).toHaveBeenCalledTimes(2);
@@ -459,6 +465,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once
@@ -492,6 +499,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(result.size).toBe(0); expect(result.size).toBe(0);
@@ -532,6 +540,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(result.size).toBe(0); expect(result.size).toBe(0);
@@ -563,6 +572,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(result.size).toBe(0); expect(result.size).toBe(0);
@@ -634,6 +644,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(result.get(url)).toBe( expect(result.get(url)).toBe(
`/tmp/github-images/image-${1704067200000 + callIndex}-0${ext}`, `/tmp/github-images/image-${1704067200000 + callIndex}-0${ext}`,
@@ -678,6 +689,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledTimes(1);
@@ -723,6 +735,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({ expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({
@@ -787,6 +800,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(fetchSpy).toHaveBeenCalledTimes(2); expect(fetchSpy).toHaveBeenCalledTimes(2);
@@ -837,6 +851,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(fetchSpy).toHaveBeenCalledTimes(2); expect(fetchSpy).toHaveBeenCalledTimes(2);
@@ -883,6 +898,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once
@@ -927,6 +943,7 @@ describe("downloadCommentImages", () => {
"owner", "owner",
"repo", "repo",
comments, comments,
mockFsOps,
); );
expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledTimes(1);