diff --git a/prds/6-e2e-test-forgejo-triggers.md b/prds/6-e2e-test-forgejo-triggers.md index e4bc985..c72564a 100644 --- a/prds/6-e2e-test-forgejo-triggers.md +++ b/prds/6-e2e-test-forgejo-triggers.md @@ -31,17 +31,18 @@ Systematically exercise each untested scenario on the live Forgejo instance (git - Ensure the workflow has the correct permissions for each trigger type - **Validation**: Workflow YAML includes all required `on:` triggers -- [x] **M1.5: Fix image-downloader test failures blocking CI** +- [ ] **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) + - **Problem**: All 18 `downloadCommentImages` tests failed on Forgejo CI (Linux Bun 1.2.21 in `harbor.wxs.ro/wxs/grunt-runner:v1.9.0`) while passing locally on macOS. This blocks all PRs. + - **What we know**: On this Linux Docker image, `mock()` and `spyOn()` call tracking from `bun:test` is broken for this file. Even with DI (no module-level mocking at all), mock functions report "not called" despite being invoked. The ONLY passing test uses a negative assertion. This is NOT a `mock.module` issue — it affects plain `mock()` functions passed as parameters too. + - **History of failed attempts** (5 so far): + 1. Upstream uses `spyOn(fs, "mkdir")` — works on GitHub CI (Bun 1.2.12/ubuntu) but fails on Forgejo Linux 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. + 3. `cff360d` tried dependency injection for fs mocks — still failed on CI + 4. `dab92ef` tried `mock.module("fs/promises")` — works on macOS, fails on Linux + 5. `c6ca8a7` removed `mock.module` entirely, used DI with `fsDeps` param — **still fails on CI** (mock tracking broken at a deeper level) + - **Next step**: Set up local Docker reproduction using `grunt-runner:v1.9.0` to debug interactively + - **Validation**: Pending — need local Docker repro first - [ ] **M2: Test PR review comment trigger** diff --git a/test/debug-mock-tracking.test.ts b/test/debug-mock-tracking.test.ts new file mode 100644 index 0000000..7a7aca8 --- /dev/null +++ b/test/debug-mock-tracking.test.ts @@ -0,0 +1,154 @@ +/** + * Diagnostic test file to debug mock tracking failures on Forgejo CI. + * Tests pass locally (macOS + Docker grunt-runner) but image-downloader + * tests fail on the actual Forgejo runner. This file isolates mock/spy + * tracking behavior to find the exact failure point. + * + * DELETE THIS FILE once the root cause is found and fixed. + */ +import { + describe, + test, + expect, + spyOn, + mock, + beforeEach, + afterEach, +} from "bun:test"; + +describe("DEBUG: mock tracking diagnostics", () => { + test("basic mock() tracks calls", () => { + const fn = mock(() => 42); + fn("hello"); + console.log("[DIAG] mock.calls:", JSON.stringify(fn.mock.calls)); + expect(fn).toHaveBeenCalledWith("hello"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + test("mock() tracks calls through object property", () => { + const obj = { method: mock(() => "ok") }; + obj.method("arg1", "arg2"); + console.log( + "[DIAG] obj.method.mock.calls:", + JSON.stringify(obj.method.mock.calls), + ); + expect(obj.method).toHaveBeenCalledWith("arg1", "arg2"); + }); + + test("mock() tracks calls after destructuring", () => { + const fn = mock(() => Promise.resolve("done")); + const obj = { fn }; + const { fn: extracted } = obj; + extracted("test"); + console.log( + "[DIAG] destructured mock.calls:", + JSON.stringify(fn.mock.calls), + ); + expect(fn).toHaveBeenCalledWith("test"); + }); + + test("mock() tracks calls when passed as parameter", async () => { + const mockFn = mock(() => Promise.resolve(undefined)); + const deps = { action: mockFn as (...args: unknown[]) => Promise }; + + async function doWork(d: { + action: (...args: unknown[]) => Promise; + }) { + const { action } = d; + await action("path", { recursive: true }); + } + + await doWork(deps); + console.log( + "[DIAG] param-passed mock.calls:", + JSON.stringify(mockFn.mock.calls), + ); + expect(mockFn).toHaveBeenCalledWith("path", { recursive: true }); + }); + + test("spyOn(console) tracks calls", () => { + const spy = spyOn(console, "warn").mockImplementation(() => {}); + console.warn("test warning"); + // Check raw calls before expect + console.log( + "[DIAG] console.warn spy.mock.calls:", + JSON.stringify(spy.mock.calls), + ); + expect(spy).toHaveBeenCalledWith("test warning"); + spy.mockRestore(); + }); + + test("spyOn(global, 'fetch') tracks calls", async () => { + const fetchSpy = spyOn(global, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(0), + } as Response); + await fetch("https://example.com"); + console.log( + "[DIAG] fetch spy.mock.calls:", + JSON.stringify(fetchSpy.mock.calls), + ); + expect(fetchSpy).toHaveBeenCalledWith("https://example.com"); + fetchSpy.mockRestore(); + }); + + test("mock() with mockClear preserves tracking", () => { + const fn = mock(() => "first"); + fn("before-clear"); + fn.mockClear(); + fn("after-clear"); + console.log( + "[DIAG] after mockClear, calls:", + JSON.stringify(fn.mock.calls), + ); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith("after-clear"); + }); + + describe("mock created at describe scope", () => { + const describeMock = mock(() => Promise.resolve(undefined as any)); + + beforeEach(() => { + describeMock.mockClear(); + }); + + test("tracks calls from describe-scoped mock", async () => { + await describeMock("test-arg"); + console.log( + "[DIAG] describe-scope mock.calls:", + JSON.stringify(describeMock.mock.calls), + ); + expect(describeMock).toHaveBeenCalledWith("test-arg"); + }); + + test("tracks calls via DI pattern (like image-downloader)", async () => { + const deps = { + mkdir: describeMock as any, + }; + + // Simulate the exact pattern from image-downloader.ts + const { mkdir } = deps; + await mkdir("/tmp/github-images", { recursive: true }); + + console.log( + "[DIAG] DI-pattern mock.calls:", + JSON.stringify(describeMock.mock.calls), + ); + expect(describeMock).toHaveBeenCalledWith("/tmp/github-images", { + recursive: true, + }); + }); + }); + + describe("env var diagnostics", () => { + test("GITHUB_SERVER_URL value", () => { + console.log("[DIAG] GITHUB_SERVER_URL:", process.env.GITHUB_SERVER_URL); + console.log("[DIAG] FORGEJO:", process.env.FORGEJO); + console.log("[DIAG] RUNNER_OS:", process.env.RUNNER_OS); + console.log("[DIAG] Bun.version:", Bun.version); + console.log("[DIAG] process.arch:", process.arch); + console.log("[DIAG] process.platform:", process.platform); + expect(true).toBe(true); + }); + }); +});