Files
paseo/packages/app/src/git/use-status-query.test.ts
T
Mohamed BoudraandGitHub ed07452c80 feat(checkout): merge PR action with real-GitHub e2e test (#814)
* feat(checkout): merge PR action with real-GitHub e2e test

Adds a `checkoutPrMerge` server RPC backed by `gh pr merge` (squash, merge,
or rebase) and surfaces three new actions in the git-actions menu —
"Squash and merge", "Create a merge commit", "Rebase and merge" — with
squash taking the primary slot when a PR is open and mergeable. Also
renames the existing local merge action to "Merge locally" to disambiguate
it from the new GitHub-side merge.

Adds an end-to-end test that creates a real temporary GitHub repo, opens a
PR via the daemon, polls until mergeable, calls `checkoutPrMerge`, and
verifies the merge landed on `main` — no mocks. The test pre-flights the
`delete_repo` scope so it skips cleanly on tokens that can't tear down,
and fails loud if cleanup ever errors so we don't leak repos.

PR status gains an optional `mergeable` field (`MERGEABLE | CONFLICTING |
UNKNOWN`) populated from `gh pr view --json mergeable`. The schema is
backward compatible — old payloads without the field still parse and
default to `UNKNOWN` via `z.catch`.

* Update PR status snapshot expectations

* refactor: collect git/checkout/PR feature into packages/app/src/git/

Move 21 git/checkout/PR files out of components/, hooks/, stores/, screens/,
utils/ flat-peer slop into a single packages/app/src/git/ module with
shrunk filenames (git-diff-pane.tsx -> diff-pane.tsx, use-git-actions.ts ->
use-actions.ts, etc).

Dedupe: drop the duplicate useGitActionHandlers from diff-pane.tsx — the
shared useGitActions hook is the single source. Drop the
buildGitActionsForPane wrapper. Collapse the merge-PR triple fan-out
(mergePrSquash/Merge/RebaseStatus) into one record keyed by
CheckoutPrMergeMethod. Streamline server-side single-use helpers
(dispatchStash/PullRequestMessage adapters, GitHubPullRequestMergeError
wrapper, legacy worktree test helpers).

Schema-additive only: messages.ts exports CheckoutPrMergeMethod and
PullRequestMergeable types; daemon-client.ts uses the typed alias.
Backward-compatible.

Net delta: 43 files, +219/-1212.

* fix(app): use GitHub icon for all merge-PR menu actions

* test: namespace temp GitHub repos under `paseotmp-` and centralize naming

Old prefixes (`paseo-checkout-ship-`, `paseo-e2e-`, `paseo-checkout-pr-merge-`)
collided with the `paseo-*` namespace of real repos, making bulk cleanup
unsafe. Switch every test that creates real GitHub repos to a single
unmistakable `paseotmp-` prefix, owned by one shared helper per test
boundary so no caller can name a repo outside the namespace.

- New `packages/server/src/server/test-utils/temp-github-repo.ts` exports
  `TEMP_GITHUB_REPO_PREFIX` + `createTempGithubRepoName(category)`; both
  server e2e tests (checkout-ship, checkout-pr-merge) consume it.
- `packages/app/e2e/helpers/github-fixtures.ts` locks the prefix internally
  and now requires callers to pass a `category` instead of an arbitrary
  `prefix`. Updated pr-pane and composer-attachments specs accordingly.
2026-05-09 15:39:26 +08:00

318 lines
9.0 KiB
TypeScript

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import {
QueryClient,
QueryClientProvider,
focusManager,
onlineManager,
} from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusResponse } from "@server/shared/messages";
import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-status-query";
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => {
const hoistedHandlers = new Set<(message: unknown) => void>();
const hoistedClient = {
getCheckoutStatus: vi.fn(),
on: vi.fn((type: string, handler: (message: unknown) => void) => {
if (type !== "checkout_status_update") {
return () => {};
}
hoistedHandlers.add(handler);
return () => {
hoistedHandlers.delete(handler);
};
}),
};
return {
mockClient: hoistedClient,
checkoutStatusUpdateHandlers: hoistedHandlers,
mockRuntime: {
client: hoistedClient,
isConnected: true,
},
};
});
vi.mock("@/runtime/host-runtime", () => ({
useHostRuntimeClient: () => mockRuntime.client,
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
}));
const serverId = "server-1";
const cwd = "/repo";
function checkoutStatus(overrides: Partial<CheckoutStatusPayload> = {}): CheckoutStatusPayload {
return {
cwd,
error: null,
requestId: "checkout-status-1",
isGit: true,
isPaseoOwnedWorktree: false,
repoRoot: cwd,
currentBranch: "main",
isDirty: false,
baseRef: "origin/main",
aheadBehind: { ahead: 0, behind: 0 },
aheadOfOrigin: 0,
behindOfOrigin: 0,
hasRemote: true,
remoteUrl: "git@github.com:getpaseo/paseo.git",
...overrides,
} as CheckoutStatusPayload;
}
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
}
function renderCheckoutStatusHook({
queryClient = createTestQueryClient(),
hookCwd = cwd,
}: {
queryClient?: QueryClient;
hookCwd?: string;
} = {}) {
let latest: ReturnType<typeof useCheckoutStatusQuery> | null = null;
function Probe() {
latest = useCheckoutStatusQuery({ serverId, cwd: hookCwd });
return null;
}
const container = document.getElementById("root");
if (!container) {
throw new Error("Missing root container");
}
const root = createRoot(container);
return {
get latest() {
if (!latest) {
throw new Error("Expected hook result");
}
return latest;
},
async mount() {
await act(async () => {
root.render(
React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(Probe),
),
);
});
},
async unmount() {
await act(async () => {
root.unmount();
});
},
};
}
async function waitForExpectation(assertion: () => void): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await act(async () => {
vi.advanceTimersByTime(10);
await Promise.resolve();
});
}
}
throw lastError;
}
async function emitCheckoutStatusUpdate(payload: CheckoutStatusPayload): Promise<void> {
await act(async () => {
for (const handler of checkoutStatusUpdateHandlers) {
handler({
type: "checkout_status_update",
payload,
});
}
await Promise.resolve();
});
}
describe("useCheckoutStatusQuery", () => {
beforeEach(() => {
vi.useFakeTimers();
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
url: "http://localhost",
});
Object.defineProperty(globalThis, "document", {
value: dom.window.document,
configurable: true,
});
Object.defineProperty(globalThis, "window", {
value: dom.window,
configurable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
configurable: true,
});
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
});
mockRuntime.client = mockClient;
mockRuntime.isConnected = true;
mockClient.getCheckoutStatus.mockReset();
mockClient.on.mockClear();
checkoutStatusUpdateHandlers.clear();
});
afterEach(() => {
focusManager.setFocused(undefined);
onlineManager.setOnline(true);
vi.useRealTimers();
});
it("does not schedule polling after the cold read", async () => {
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
const hook = renderCheckoutStatusHook();
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.status?.currentBranch).toBe("main");
});
await act(async () => {
vi.advanceTimersByTime(60_000);
});
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
});
it("returns the cached snapshot without calling the daemon", async () => {
const queryClient = createTestQueryClient();
const cached = checkoutStatus({ requestId: "cached" });
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached);
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "uncached" }));
const hook = renderCheckoutStatusHook({ queryClient });
await hook.mount();
expect(hook.latest.status).toEqual(cached);
expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
});
it("fetches once on a cold read and uses cache on remount", async () => {
const queryClient = createTestQueryClient();
const fetched = checkoutStatus({ requestId: "cold-read" });
mockClient.getCheckoutStatus.mockResolvedValue(fetched);
const firstHook = renderCheckoutStatusHook({ queryClient });
await firstHook.mount();
await waitForExpectation(() => {
expect(firstHook.latest.status).toEqual(fetched);
});
await firstHook.unmount();
const secondHook = renderCheckoutStatusHook({ queryClient });
await secondHook.mount();
expect(secondHook.latest.status).toEqual(fetched);
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
});
it("does not refetch checkout status on focus or reconnect", async () => {
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
const hook = renderCheckoutStatusHook();
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.status?.currentBranch).toBe("main");
});
await act(async () => {
focusManager.setFocused(false);
focusManager.setFocused(true);
onlineManager.setOnline(false);
onlineManager.setOnline(true);
await Promise.resolve();
});
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
});
it("server-pushed checkout status update writes into the React Query cache at checkoutStatusQueryKey(serverId, cwd)", async () => {
const queryClient = createTestQueryClient();
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
const hook = renderCheckoutStatusHook({ queryClient });
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.status?.requestId).toBe("initial");
});
mockClient.getCheckoutStatus.mockClear();
const pushed = checkoutStatus({
requestId: "server-push",
currentBranch: "pushed-branch",
isDirty: true,
aheadBehind: { ahead: 2, behind: 1 },
aheadOfOrigin: 2,
behindOfOrigin: 1,
});
await emitCheckoutStatusUpdate(pushed);
expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
});
it("server push only updates the matching cwd's cache key, not other workspaces", async () => {
const queryClient = createTestQueryClient();
const otherCwd = "/other-repo";
const otherCached = checkoutStatus({
cwd: otherCwd,
repoRoot: otherCwd,
requestId: "other-cached",
currentBranch: "other-main",
});
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached);
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
const hook = renderCheckoutStatusHook({ queryClient });
await hook.mount();
await waitForExpectation(() => {
expect(hook.latest.status?.requestId).toBe("initial");
});
const pushed = checkoutStatus({
requestId: "server-push",
currentBranch: "pushed-branch",
});
await emitCheckoutStatusUpdate(pushed);
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual(
otherCached,
);
});
});