fix: use dependency injection for image-downloader fs mocks
mock.module("fs/promises") on Linux Bun poisons the entire mock
tracking system — not just fs mocks, but ALL mock/spy assertions
(octokit mocks, spyOn(global, "fetch"), etc.) stop recording calls.
Replace with dependency injection: downloadCommentImages() now accepts
an optional fsDeps parameter. Tests pass mock mkdir/writeFile directly.
Production callers omit it and get real fs. No mock.module anywhere.
Fixes 18 test failures blocking CI on Forgejo Linux runners.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6113a4ef2d
commit
a6b3c94004
3 files changed
+54
-16
No files matched your search
@@ -31,6 +31,18 @@ Systematically exercise each untested scenario on the live Forgejo instance (git
|
|||||||
- Ensure the workflow has the correct permissions for each trigger type
|
- Ensure the workflow has the correct permissions for each trigger type
|
||||||
- **Validation**: Workflow YAML includes all required `on:` triggers
|
- **Validation**: Workflow YAML includes all required `on:` triggers
|
||||||
|
|
||||||
|
- [x] **M1.5: Fix image-downloader test failures blocking CI**
|
||||||
|
|
||||||
|
- **Problem**: All 18 `downloadCommentImages` tests failed on Forgejo CI (Linux Bun 1.2.21) while passing locally on macOS. This blocked all PRs.
|
||||||
|
- **Root cause**: `mock.module("fs/promises")` on Linux Bun poisons the entire mock tracking system — not just fs mocks, but ALL mock/spy assertions in the file (octokit parameter mocks, `spyOn(global, "fetch")`, etc.) stopped recording calls. The only passing test used a **negative** assertion (`not.toHaveBeenCalledWith`) which trivially passes when tracking is broken.
|
||||||
|
- **History of failed attempts**:
|
||||||
|
1. Upstream uses `spyOn(fs, "mkdir")` — works on GitHub CI (Bun 1.2.12/ubuntu) but fails on Forgejo Linux (different `fs/promises` object references between test and source modules)
|
||||||
|
2. `96ec04e` tried `_fsForTest` mutable export — abandoned
|
||||||
|
3. `cff360d` tried dependency injection — replaced
|
||||||
|
4. `dab92ef` tried `mock.module("fs/promises")` — works on macOS, poisons mock tracking on Linux
|
||||||
|
- **Fix**: Dependency injection via optional `fsDeps` parameter on `downloadCommentImages()`. Tests pass mock `mkdir`/`writeFile` directly — no module-level mocking at all. Production callers omit the parameter and get real `fs`. Zero `mock.module` usage in the test file.
|
||||||
|
- **Validation**: 797/797 tests pass locally, typecheck clean. Awaiting CI confirmation.
|
||||||
|
|
||||||
- [ ] **M2: Test PR review comment trigger**
|
- [ ] **M2: Test PR review comment trigger**
|
||||||
|
|
||||||
- Create a test PR on the repo
|
- Create a test PR on the repo
|
||||||
@@ -89,6 +101,7 @@ Systematically exercise each untested scenario on the live Forgejo instance (git
|
|||||||
| Forgejo webhook payloads differ from expected | Log raw payloads, compare with GitHub format, adapt parsing |
|
| Forgejo webhook payloads differ from expected | Log raw payloads, compare with GitHub format, adapt parsing |
|
||||||
| Trigger type not supported by Forgejo Actions | Document as known limitation, skip gracefully |
|
| Trigger type not supported by Forgejo Actions | Document as known limitation, skip gracefully |
|
||||||
| Action creates unwanted branches/comments during testing | Use a dedicated test repo or clean up after each test |
|
| Action creates unwanted branches/comments during testing | Use a dedicated test repo or clean up after each test |
|
||||||
|
| Bun mock.module breaks mock tracking on Linux | Use dependency injection for fs mocks, never mock.module |
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ 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";
|
||||||
|
|
||||||
|
/** Injectable fs operations for cross-platform test compatibility.
|
||||||
|
* On Linux Bun, mock.module("fs/promises") poisons the entire mock tracking
|
||||||
|
* system, and spyOn(fs, "mkdir") gives test/source different object refs.
|
||||||
|
* Dependency injection sidesteps all of this. */
|
||||||
|
export interface FsDeps {
|
||||||
|
mkdir: typeof fs.mkdir;
|
||||||
|
writeFile: typeof fs.writeFile;
|
||||||
|
}
|
||||||
|
|
||||||
// Build regexes at call time, reading GITHUB_SERVER_URL from process.env
|
// Build regexes at call time, reading GITHUB_SERVER_URL from process.env
|
||||||
// directly. The config.ts export freezes the value at module load time,
|
// directly. The config.ts export freezes the value at module load time,
|
||||||
// which can be wrong when the Forgejo CI runner injects its own
|
// which can be wrong when the Forgejo CI runner injects its own
|
||||||
@@ -63,11 +72,13 @@ export async function downloadCommentImages(
|
|||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
comments: CommentWithImages[],
|
comments: CommentWithImages[],
|
||||||
|
fsDeps?: FsDeps,
|
||||||
): Promise<Map<string, string>> {
|
): Promise<Map<string, string>> {
|
||||||
|
const { mkdir, writeFile } = fsDeps ?? fs;
|
||||||
const urlToPathMap = new Map<string, string>();
|
const urlToPathMap = new Map<string, string>();
|
||||||
const downloadsDir = "/tmp/github-images";
|
const downloadsDir = "/tmp/github-images";
|
||||||
|
|
||||||
await fs.mkdir(downloadsDir, { recursive: true });
|
await mkdir(downloadsDir, { recursive: true });
|
||||||
|
|
||||||
const { imageRegex, htmlImgRegex } = buildImageRegexes();
|
const { imageRegex, htmlImgRegex } = buildImageRegexes();
|
||||||
|
|
||||||
@@ -217,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 fs.writeFile(localPath, buffer);
|
await writeFile(localPath, buffer);
|
||||||
console.log(`✓ Saved: ${localPath}`);
|
console.log(`✓ Saved: ${localPath}`);
|
||||||
|
|
||||||
urlToPathMap.set(originalUrl, localPath);
|
urlToPathMap.set(originalUrl, localPath);
|
||||||
|
|||||||
@@ -9,22 +9,10 @@ import {
|
|||||||
setSystemTime,
|
setSystemTime,
|
||||||
} from "bun:test";
|
} from "bun:test";
|
||||||
|
|
||||||
// Mock fs/promises at the module loader level. Bun hoists mock.module calls
|
|
||||||
// before imports, so the source module's `import fs from "fs/promises"` gets
|
|
||||||
// these mocks. This avoids cross-module jest.fn() tracking bugs on Linux Bun.
|
|
||||||
const mockMkdir = mock(() => Promise.resolve(undefined as any));
|
|
||||||
const mockWriteFile = mock(() => Promise.resolve(undefined as any));
|
|
||||||
|
|
||||||
mock.module("fs/promises", () => ({
|
|
||||||
default: {
|
|
||||||
mkdir: mockMkdir,
|
|
||||||
writeFile: mockWriteFile,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
downloadCommentImages,
|
downloadCommentImages,
|
||||||
type CommentWithImages,
|
type CommentWithImages,
|
||||||
|
type FsDeps,
|
||||||
} 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";
|
||||||
|
|
||||||
@@ -35,6 +23,14 @@ describe("downloadCommentImages", () => {
|
|||||||
let fetchSpy: any;
|
let fetchSpy: any;
|
||||||
let savedServerUrl: string | undefined;
|
let savedServerUrl: string | undefined;
|
||||||
|
|
||||||
|
// DI mocks for fs — avoids mock.module which poisons mock tracking on Linux Bun
|
||||||
|
const mockMkdir = mock(() => Promise.resolve(undefined as any));
|
||||||
|
const mockWriteFile = mock(() => Promise.resolve(undefined as any));
|
||||||
|
const fsDeps: FsDeps = {
|
||||||
|
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.
|
||||||
// The image-downloader reads process.env directly at call time.
|
// The image-downloader reads process.env directly at call time.
|
||||||
@@ -88,7 +84,7 @@ 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, fsDeps);
|
||||||
|
|
||||||
expect(mockMkdir).toHaveBeenCalledWith("/tmp/github-images", {
|
expect(mockMkdir).toHaveBeenCalledWith("/tmp/github-images", {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
@@ -110,6 +106,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.size).toBe(0);
|
expect(result.size).toBe(0);
|
||||||
@@ -155,6 +152,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({
|
expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({
|
||||||
@@ -217,6 +215,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockOctokit.rest.pulls.getReviewComment).toHaveBeenCalledWith({
|
expect(mockOctokit.rest.pulls.getReviewComment).toHaveBeenCalledWith({
|
||||||
@@ -266,6 +265,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockOctokit.rest.pulls.getReview).toHaveBeenCalledWith({
|
expect(mockOctokit.rest.pulls.getReview).toHaveBeenCalledWith({
|
||||||
@@ -315,6 +315,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockOctokit.rest.issues.get).toHaveBeenCalledWith({
|
expect(mockOctokit.rest.issues.get).toHaveBeenCalledWith({
|
||||||
@@ -365,6 +366,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockOctokit.rest.pulls.get).toHaveBeenCalledWith({
|
expect(mockOctokit.rest.pulls.get).toHaveBeenCalledWith({
|
||||||
@@ -418,6 +420,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
@@ -471,6 +474,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once
|
expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once
|
||||||
@@ -506,6 +510,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.size).toBe(0);
|
expect(result.size).toBe(0);
|
||||||
@@ -548,6 +553,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.size).toBe(0);
|
expect(result.size).toBe(0);
|
||||||
@@ -579,6 +585,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.size).toBe(0);
|
expect(result.size).toBe(0);
|
||||||
@@ -652,6 +659,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
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}`,
|
||||||
@@ -698,6 +706,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
@@ -745,6 +754,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({
|
expect(mockOctokit.rest.issues.getComment).toHaveBeenCalledWith({
|
||||||
@@ -811,6 +821,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
@@ -863,6 +874,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
@@ -911,6 +923,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once
|
expect(fetchSpy).toHaveBeenCalledTimes(1); // Only downloaded once
|
||||||
@@ -957,6 +970,7 @@ describe("downloadCommentImages", () => {
|
|||||||
"owner",
|
"owner",
|
||||||
"repo",
|
"repo",
|
||||||
comments,
|
comments,
|
||||||
|
fsDeps,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
Reference in new issue
Block a user