Files
oh-my-pi/packages/coding-agent/test/status-line-vcs-refresh.test.ts
T
can1357 c64e7146e0 refactor(coding-agent): consolidated jujutsu logic into a centralized utility module
- Consolidated Jujutsu (jj) integration logic into a centralized utility module with working-copy and status handling.
- Removed deprecated jj-info helper modules and their corresponding test files.
- Updated status line component and associated tests to consume the new centralized jj utility API.
- Added comprehensive test coverage for working-copy label parsing, status summary mapping, and repository root resolution.
2026-07-23 20:47:42 +02:00

167 lines
6.1 KiB
TypeScript

/**
* Regression: StatusLineComponent's VCS segment was blank on the first (cold)
* paint and only appeared after an unrelated re-render (e.g. flipping a
* statusline setting and back). The async git-status and jj-label fetches
* filled their caches but never called #onBranchChange, so the resolved value
* had no way to reach the screen until something else forced a repaint. Worst
* in a jj workspace, where there is no git branch so the PR / default-branch
* lookups (which do fire #onBranchChange) never run.
*
* Contract: when an async VCS fetch resolves with a value, the component
* requests a repaint via #onBranchChange. (Post-dispose suppression of the
* same callback is covered by status-line-dispose-async-leak.test.ts.)
*/
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { StatusLineSettings } from "@oh-my-pi/pi-coding-agent/modes/components/status-line";
import { StatusLineComponent } from "@oh-my-pi/pi-coding-agent/modes/components/status-line";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { GitRefHead } from "@oh-my-pi/pi-coding-agent/utils/git";
import * as git from "@oh-my-pi/pi-coding-agent/utils/git";
import * as jj from "@oh-my-pi/pi-coding-agent/utils/jj";
import { getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils";
type GitStatus = { staged: number; unstaged: number; untracked: number };
const originalProjectDir = getProjectDir();
beforeAll(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true });
await initTheme();
});
afterAll(() => {
resetSettingsForTest();
setProjectDir(originalProjectDir);
});
afterEach(() => {
vi.restoreAllMocks();
});
function makeSession() {
return {
state: { messages: [], model: undefined },
messages: [],
model: undefined,
systemPrompt: [],
agent: { state: { tools: [] } },
skills: [],
isStreaming: false,
isAutoThinking: false,
autoResolvedThinkingLevel: () => undefined,
isFastModeActive: () => false,
isFastModeEnabled: () => false,
getGoalModeState: () => null,
getAsyncJobSnapshot: () => ({ running: [] }),
modelRegistry: { isUsingOAuth: () => false },
sessionManager: {
getSessionName: () => "vcs-refresh test",
getUsageStatistics: () => ({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
premiumRequests: 0,
cost: 0,
}),
},
} as unknown as ConstructorParameters<typeof StatusLineComponent>[0];
}
const fakeRefHead: GitRefHead = {
kind: "ref",
branchName: "main",
ref: "refs/heads/main",
commit: null,
commonDir: "/fake/.git",
gitDir: "/fake/.git",
gitEntryPath: "/fake/.git",
headPath: "/fake/.git/HEAD",
repoRoot: "/fake",
headContent: "ref: refs/heads/main\n",
};
const gitSegment: StatusLineSettings = {
preset: "custom",
leftSegments: ["git"],
rightSegments: ["session_name"],
separator: "powerline-thin",
sessionAccent: false,
transparent: false,
};
describe("StatusLineComponent repaints when an async VCS fetch resolves", () => {
it("fires #onBranchChange when git status resolves on the cold paint", async () => {
vi.spyOn(git.head, "resolveSync").mockReturnValue(fakeRefHead);
vi.spyOn(git.branch, "default").mockReturnValue(Promise.withResolvers<string | null>().promise);
const status = Promise.withResolvers<GitStatus | null>();
vi.spyOn(git.status, "summary").mockReturnValue(status.promise);
const onBranchChange = vi.fn();
const component = new StatusLineComponent(makeSession());
component.updateSettings(gitSegment);
component.watchBranch(onBranchChange);
component.getTopBorder(80); // cold paint kicks off the git-status fetch
expect(onBranchChange).not.toHaveBeenCalled();
status.resolve({ staged: 1, unstaged: 2, untracked: 3 });
await Promise.resolve();
await Promise.resolve();
expect(onBranchChange).toHaveBeenCalled();
component.dispose();
});
it("fires #onBranchChange when the jj label resolves on the cold paint", async () => {
vi.spyOn(git.head, "resolveSync").mockReturnValue(null); // no git branch -> jj overlay
vi.spyOn(git.branch, "default").mockReturnValue(Promise.withResolvers<string | null>().promise);
vi.spyOn(git.status, "summary").mockReturnValue(Promise.withResolvers<GitStatus | null>().promise); // isolate the jj fire
vi.spyOn(jj.repo, "rootSync").mockReturnValue("/fake/jj/root");
const label = Promise.withResolvers<string | null>();
vi.spyOn(jj.workingCopy, "label").mockReturnValue(label.promise);
const onBranchChange = vi.fn();
const component = new StatusLineComponent(makeSession());
component.updateSettings(gitSegment);
component.watchBranch(onBranchChange);
component.getTopBorder(80); // cold paint kicks off the jj-label fetch
expect(onBranchChange).not.toHaveBeenCalled();
label.resolve("feature-x");
await Promise.resolve();
await Promise.resolve();
expect(onBranchChange).toHaveBeenCalled();
component.dispose();
});
it("fires #onBranchChange when jj status resolves on the cold paint", async () => {
vi.spyOn(git.head, "resolveSync").mockReturnValue(null); // no git -> jj repo
vi.spyOn(git.branch, "default").mockReturnValue(Promise.withResolvers<string | null>().promise);
vi.spyOn(git.status, "summary").mockReturnValue(Promise.withResolvers<GitStatus | null>().promise);
vi.spyOn(jj.repo, "rootSync").mockReturnValue("/fake/jj/root");
vi.spyOn(jj.workingCopy, "label").mockReturnValue(Promise.withResolvers<string | null>().promise); // isolate the status fire
const status = Promise.withResolvers<GitStatus | null>();
vi.spyOn(jj.status, "summary").mockReturnValue(status.promise);
const onBranchChange = vi.fn();
const component = new StatusLineComponent(makeSession());
component.updateSettings(gitSegment);
component.watchBranch(onBranchChange);
component.getTopBorder(80); // cold paint kicks off the jj-status fetch
expect(onBranchChange).not.toHaveBeenCalled();
status.resolve({ staged: 0, unstaged: 4, untracked: 1 });
await Promise.resolve();
await Promise.resolve();
expect(onBranchChange).toHaveBeenCalled();
component.dispose();
});
});