Files
fusion/packages/dashboard/src/__tests__/github-forced-mode.test.ts
gsxdsm d3593065e4 FN-6960: bound PR metadata generation
Keep Create PR metadata generation responsive and ensure fallback PR content remains editable.

- Race metadata collection, prompt execution, and session creation against abort/timeout signals.
- Return fallback PR metadata from the API route when generation exceeds the route budget.
- Seed the Create PR dialog with editable fallback body content after metadata failures and require a non-empty body before submit.
- Validate non-empty PR bodies in both gh CLI and API-backed PR creation paths.
- Add regression coverage for bounded metadata generation, fallback responses, and PR body validation.

Files changed:
 .changeset/fn-6960-pr-metadata-bounded.md          |  7 ++
 .../dashboard/app/components/PrCreateModal.tsx     | 34 ++++++++-
 .../components/__tests__/PrCreateModal.test.tsx    | 41 +++++++++++
 .../src/__tests__/github-create-pr.test.ts         |  6 +-
 .../src/__tests__/github-forced-mode.test.ts       |  2 +-
 packages/dashboard/src/__tests__/github.test.ts    | 21 ++----
 .../src/__tests__/pr-metadata-generator.test.ts    | 55 ++++++++++----
 .../src/__tests__/pr-routes.contract.test.ts       | 10 +++
 .../register-git-github.pr-errors.test.ts          |  2 +-
 ...it-github.pr-options-preflight-metadata.test.ts | 25 +++++++
 .../dashboard/src/__tests__/routes-auth.test.ts    | 12 +--
 .../dashboard/src/__tests__/routes-github.test.ts  |  2 +-
 packages/dashboard/src/github.ts                   | 20 ++++-
 packages/dashboard/src/pr-metadata-generator.ts    | 86 ++++++++++++++--------
 .../dashboard/src/routes/register-git-github.ts    | 67 +++++++++++++----
 15 files changed, 301 insertions(+), 89 deletions(-)

Fusion-Task-Id: FN-6960
Fusion-Task-Lineage: 1ae72062-7180-4de3-999b-98d131f00792
2026-06-24 23:32:51 -07:00

119 lines
4.9 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
runGh: vi.fn(),
runGhAsync: vi.fn(),
runGhJsonAsync: vi.fn(),
getGhErrorMessage: vi.fn((error) => error instanceof Error ? error.message : String(error)),
};
});
import { getGhErrorMessage, isGhAuthenticated, isGhAvailable, runGh, runGhAsync, runGhJsonAsync } from "@fusion/core";
import { GitHubClient } from "../github.js";
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
const mockRunGh = vi.mocked(runGh);
const mockRunGhAsync = vi.mocked(runGhAsync);
const mockRunGhJsonAsync = vi.mocked(runGhJsonAsync);
const mockGetGhErrorMessage = vi.mocked(getGhErrorMessage);
describe("GitHubClient forced mode", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(true);
mockGetGhErrorMessage.mockImplementation((error: unknown) => error instanceof Error ? error.message : String(error));
});
it("forced token mode uses only REST path", async () => {
const fetchSpy = vi.spyOn(global, "fetch" as never).mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ number: 1, html_url: "https://github.com/o/r/issues/1", created_at: "2026-01-01T00:00:00.000Z" }),
} as never);
mockRunGhJsonAsync.mockRejectedValue(new Error("gh should not run"));
const client = new GitHubClient({ token: "token-123", forceMode: "token" });
await client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" });
expect(fetchSpy).toHaveBeenCalled();
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
});
it("forced token mode without token throws before network", async () => {
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
throw new Error("fetch should not run");
});
const client = new GitHubClient({ forceMode: "token" });
await expect(client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" })).rejects.toThrow("forced to token mode");
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
});
it("forced gh-cli mode uses only gh path", async () => {
mockRunGhAsync.mockResolvedValue("https://github.com/o/r/issues/2");
mockRunGhJsonAsync.mockResolvedValue({ url: "https://github.com/o/r/issues/2", number: 2, createdAt: "2026-01-02T00:00:00.000Z" } as never);
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
throw new Error("fetch should not run");
});
const client = new GitHubClient({ forceMode: "gh-cli" });
await client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" });
expect(mockRunGhJsonAsync).toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("forced gh-cli mode without gh throws before network", async () => {
mockIsGhAvailable.mockReturnValue(false);
const fetchSpy = vi.spyOn(global, "fetch" as never).mockImplementation(() => {
throw new Error("fetch should not run");
});
const client = new GitHubClient({ forceMode: "gh-cli" });
await expect(client.createIssue({ owner: "o", repo: "r", title: "t", body: "b" })).rejects.toThrow("gh CLI is not available");
expect(fetchSpy).not.toHaveBeenCalled();
expect(mockRunGhJsonAsync).not.toHaveBeenCalled();
});
it("legacy constructor keeps opportunistic fallback semantics", async () => {
mockRunGh.mockImplementation(() => {
throw new Error("gh failed");
});
const fetchSpy = vi.spyOn(global, "fetch" as never).mockResolvedValue({
ok: true,
status: 201,
json: async () => ({ number: 3, html_url: "https://github.com/o/r/pull/3", title: "t", state: "open", head: { ref: "head" }, base: { ref: "main" }, comments: 0 }),
} as never);
const client = new GitHubClient("token-legacy");
await client.createPr({ owner: "o", repo: "r", title: "t", body: "b", head: "head", base: "main" });
expect(mockRunGh).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("requireToken throws when token missing", () => {
const client = new GitHubClient({ forceMode: "token" });
expect(() => (client as any).requireToken()).toThrow("forced to token mode");
});
it("requireGh throws when gh unavailable or unauthenticated", () => {
const client = new GitHubClient({ forceMode: "gh-cli" });
mockIsGhAvailable.mockReturnValue(false);
expect(() => (client as any).requireGh()).toThrow("gh CLI is not available");
mockIsGhAvailable.mockReturnValue(true);
mockIsGhAuthenticated.mockReturnValue(false);
expect(() => (client as any).requireGh()).toThrow("gh CLI is not authenticated");
});
});