efa417488a
- A resume of a session a live pane already holds is refused (409 ESESSIONLIVE with error.run_id) and the client opens that pane, pasting any typed message there; catalog tools refuse resumeSessionId (EUNSUPPORTED) instead of silently starting fresh. - POST /api/lanes/:id/start takes slot: "auto": the server picks the free tab under the lane lock (components/lanes/startSession.ts on the client, kept out of WorkspaceHome so the sidebar does not pull react-markdown into the main bundle). - Unsaved whole-file edits are guarded on every exit (Back, another file, tab switch, close, lane/session change, beforeunload). - Closing an AI tab asks first; hiding a pane has its own icon. Tabs and pane headers show the session name, a state dot, rename by double-click. - Terminal: Ctrl+M toggles Tab-moves-focus (no keyboard trap), Cmd+F / Ctrl+Shift+F find bar, clickable links (noopener), a write-only OSC 52 clipboard, a Reconnecting overlay, debounced resizes, optional screen reader mode. Alt+1-4 / Alt+\ / Alt+W focus, split and hide panes. - Changes panel: draggable splitter, wrapping git bar, history paging, disarming discard/undo/abort, inline diff comments pasted only into a visible pane; drag MIME guard on sidebar rows; IME-safe Enter; Show more in the sidebar and Recents; idle dots readable with sr-only text. - Adds @xterm/addon-clipboard, @xterm/addon-search, @xterm/addon-web-links.
407 lines
17 KiB
TypeScript
407 lines
17 KiB
TypeScript
/**
|
|
* @file LaneGitBar.test.tsx
|
|
* @description Pins the lane git action row: the ahead/behind readout, which
|
|
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
|
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
|
|
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
|
* refuses it too), and that a failed action shows git's own words rather than
|
|
* a generic sentence.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { LaneGitBar } from "../LaneGitBar";
|
|
import { api } from "../../../lib/api";
|
|
import type { LaneGitFacts } from "../../../lib/types";
|
|
|
|
// A merge asks through the themed confirm (its host lives in App): yes.
|
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
|
vi.mock("../../../lib/api", () => ({
|
|
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
|
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
|
}));
|
|
|
|
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
|
|
return {
|
|
available: true,
|
|
branch: "feat/thing",
|
|
head: "9b3e74a",
|
|
unborn: false,
|
|
subject: "wip",
|
|
dirty: 2,
|
|
untracked: 1,
|
|
files: [],
|
|
hasRemote: true,
|
|
upstream: "origin/feat/thing",
|
|
ahead: 2,
|
|
behind: 1,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(api.lanes.gitAction).mockReset().mockResolvedValue({
|
|
action: "fetch",
|
|
ok: true,
|
|
output: "",
|
|
});
|
|
vi.mocked(api.lanes.gitBranches)
|
|
.mockReset()
|
|
.mockResolvedValue({
|
|
available: true,
|
|
branches: ["development", "feat/thing"],
|
|
current: "feat/thing",
|
|
worktrees: [],
|
|
});
|
|
});
|
|
|
|
describe("LaneGitBar", () => {
|
|
it("shows both sides of the upstream comparison", () => {
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
const tracking = screen.getByTestId("lane-git-tracking");
|
|
expect(tracking).toHaveTextContent("2");
|
|
expect(tracking).toHaveTextContent("1");
|
|
});
|
|
|
|
it("disables the network actions when the repo has no remote", () => {
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ hasRemote: false, upstream: null })}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled();
|
|
// Local actions stay available — a repo with no remote still branches.
|
|
expect(screen.getByRole("button", { name: /Branch/ })).toBeEnabled();
|
|
});
|
|
|
|
it("offers no commit at all — that is the Changes panel's staged-only box", () => {
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
|
|
});
|
|
|
|
it("shows an unborn repo's branch and asks for a first commit instead of a push", () => {
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({
|
|
branch: "main",
|
|
head: null,
|
|
unborn: true,
|
|
subject: "",
|
|
hasRemote: false,
|
|
upstream: null,
|
|
ahead: null,
|
|
behind: null,
|
|
})}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
expect(screen.getByTestId("lane-git-bar")).toHaveTextContent("no commits yet");
|
|
expect(screen.getByText(/Commit first, then publish/)).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: /Fetch|Pull|Push|Publish/ })).toBeNull();
|
|
});
|
|
|
|
it("publishes a repo with no remote to a typed URL", async () => {
|
|
const user = userEvent.setup();
|
|
const onChanged = vi.fn();
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ hasRemote: false, upstream: null, ahead: null, behind: null })}
|
|
onChanged={onChanged}
|
|
/>
|
|
);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Publish…" }));
|
|
await user.type(screen.getByLabelText(/Remote URL/), "git@host:owner/repo.git");
|
|
await user.click(screen.getByRole("button", { name: "Publish" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "publish", {
|
|
url: "git@host:owner/repo.git",
|
|
})
|
|
);
|
|
expect(onChanged).toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ upstream: null, ahead: null, behind: null })}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
expect(screen.queryByRole("button", { name: /^Push$/ })).toBeNull();
|
|
await user.click(screen.getByRole("button", { name: "Publish branch" }));
|
|
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
|
|
});
|
|
|
|
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(3));
|
|
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
|
// Picking is harmless; applying is what a dirty tree blocks.
|
|
expect(screen.getByRole("button", { name: "Switch branch" })).toBeDisabled();
|
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("switches branch on a clean tree", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
const select = await screen.findByLabelText("Switch branch…");
|
|
await user.selectOptions(select, "development");
|
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "switch", { branch: "development" })
|
|
);
|
|
});
|
|
|
|
it("re-reads the branch list after a switch, so the branch just left is not held", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
|
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
|
const user = userEvent.setup();
|
|
const onNewLane = vi.fn();
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ dirty: 0, untracked: 0 })}
|
|
onChanged={vi.fn()}
|
|
onNewLane={onNewLane}
|
|
/>
|
|
);
|
|
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await user.type(await screen.findByLabelText("New branch name…"), "feat/sibling");
|
|
await user.click(screen.getByRole("button", { name: "Create" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "branch-create", {
|
|
branch: "feat/sibling",
|
|
})
|
|
);
|
|
|
|
await user.selectOptions(screen.getByLabelText("Switch branch…"), "development");
|
|
await user.click(screen.getByRole("button", { name: "New workspace here" }));
|
|
expect(onNewLane).toHaveBeenCalledWith("development");
|
|
});
|
|
|
|
it("offers a sibling lane only on a branch no worktree holds — the server refuses the rest", async () => {
|
|
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
|
available: true,
|
|
branches: ["development", "feat/thing", "feat/free"],
|
|
current: "feat/thing",
|
|
worktrees: [
|
|
{ path: "/repo/lane3", branch: "feat/thing" },
|
|
{ path: "/repo/dev", branch: "development" },
|
|
],
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
const select = await screen.findByLabelText("Switch branch…");
|
|
const newLane = () => screen.getByRole("button", { name: "New workspace here" });
|
|
|
|
// This lane's own branch: its worktree is this lane.
|
|
await user.selectOptions(select, "feat/thing");
|
|
expect(newLane()).toBeDisabled();
|
|
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
|
|
await user.selectOptions(select, "development");
|
|
expect(newLane()).toBeDisabled();
|
|
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
|
|
|
await user.selectOptions(select, "feat/free");
|
|
expect(newLane()).toBeEnabled();
|
|
});
|
|
|
|
it("prints git's own rejection text when an action fails", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.lanes.gitAction).mockRejectedValue(
|
|
new Error("git push failed\n! [rejected] main -> main (non-fast-forward)")
|
|
);
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /Push/ }));
|
|
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
|
|
});
|
|
|
|
it("re-reads the branch list for the new lane instead of showing the old one's", async () => {
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
|
|
|
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
|
|
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
|
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
|
|
});
|
|
|
|
it("sends one branch-create for a double Enter", async () => {
|
|
vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {}));
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await user.type(await screen.findByLabelText(/New branch/), "feat/once{Enter}{Enter}");
|
|
expect(api.lanes.gitAction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("forgets which branches the previous lane's worktrees held once the lane changes", async () => {
|
|
let resolveOld: (v: Awaited<ReturnType<typeof api.lanes.gitBranches>>) => void = () => {};
|
|
vi.mocked(api.lanes.gitBranches).mockReturnValueOnce(
|
|
new Promise((r) => {
|
|
resolveOld = r;
|
|
})
|
|
);
|
|
const user = userEvent.setup();
|
|
const { rerender } = render(
|
|
<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />
|
|
);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
|
|
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
const select = await screen.findByLabelText("Switch branch…");
|
|
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
|
// Lane 3's answer lands late: its worktree held `development`.
|
|
resolveOld({
|
|
available: true,
|
|
branches: ["development", "feat/thing"],
|
|
current: "feat/thing",
|
|
worktrees: [{ path: "/repo/dev", branch: "development" }],
|
|
});
|
|
await user.selectOptions(select, "development");
|
|
const newLane = screen.getByRole("button", { name: "New workspace here" });
|
|
expect(newLane).toBeEnabled();
|
|
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
|
});
|
|
|
|
it("claims no sync state when the upstream comparison could not be counted", () => {
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={facts({ upstream: "origin/gone", ahead: null, behind: null })}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
expect(screen.queryByText("in sync")).toBeNull();
|
|
expect(screen.queryByTestId("lane-git-tracking")).toBeNull();
|
|
});
|
|
|
|
it("explains a push with nowhere to go instead of the server's generic text", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.lanes.gitAction).mockRejectedValue(
|
|
Object.assign(new Error("this repository has no remote to push to"), { code: "ENOREMOTE" })
|
|
);
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Push/ }));
|
|
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent(
|
|
"No remote to push to or pull from"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("git login (issue #45)", () => {
|
|
const authErr = (rejected = false) =>
|
|
Object.assign(new Error("git.smartgift.io.vn needs a username and password or access token"), {
|
|
code: "EGITAUTH",
|
|
auth: { protocol: "https", host: "git.smartgift.io.vn", username: "vi", rejected },
|
|
});
|
|
|
|
it("asks for a login when git needs one, then resends the same action with it", async () => {
|
|
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(authErr());
|
|
const user = userEvent.setup();
|
|
const onChanged = vi.fn();
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
|
|
|
|
await user.click(screen.getByRole("button", { name: /Push/ }));
|
|
const form = await screen.findByTestId("lane-git-login");
|
|
expect(form).toHaveTextContent("git.smartgift.io.vn");
|
|
expect(screen.getByLabelText("Username")).toHaveValue("vi");
|
|
expect(screen.getByRole("checkbox", { name: /Remember for everyone/ })).toBeChecked();
|
|
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
|
|
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
|
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "push", {
|
|
credentials: {
|
|
protocol: "https",
|
|
host: "git.smartgift.io.vn",
|
|
username: "vi",
|
|
password: "s3cret",
|
|
remember: true,
|
|
},
|
|
})
|
|
);
|
|
await waitFor(() => expect(screen.queryByTestId("lane-git-login")).toBeNull());
|
|
expect(onChanged).toHaveBeenCalled();
|
|
expect(document.body.textContent).not.toContain("s3cret");
|
|
});
|
|
|
|
it("says the login was wrong when the host rejected it, and keeps the form", async () => {
|
|
vi.mocked(api.lanes.gitAction)
|
|
.mockRejectedValueOnce(authErr())
|
|
.mockRejectedValueOnce(authErr(true));
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Fetch/ }));
|
|
await user.type(await screen.findByLabelText("Password or access token"), "bad");
|
|
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
|
expect(await screen.findByTestId("lane-git-login")).toHaveTextContent(/rejected/i);
|
|
expect(screen.getByLabelText("Password or access token")).toHaveValue("");
|
|
});
|
|
});
|
|
|
|
describe("merge (issue #45)", () => {
|
|
it("merges the picked branch into the current one after a confirm", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
|
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
|
await user.click(screen.getByRole("button", { name: "Merge into current" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
|
);
|
|
});
|
|
|
|
it("offers to merge the upstream when the branch has diverged", async () => {
|
|
const user = userEvent.setup();
|
|
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
|
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
|
|
await waitFor(() =>
|
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "origin/feat/thing" })
|
|
);
|
|
});
|
|
|
|
it("disables pull and switch while a merge is in progress", () => {
|
|
render(
|
|
<LaneGitBar
|
|
laneId={3}
|
|
git={{ ...facts(), merging: true, conflicts: 1 } as LaneGitFacts}
|
|
onChanged={vi.fn()}
|
|
/>
|
|
);
|
|
expect(screen.getByRole("button", { name: /Pull/ })).toBeDisabled();
|
|
expect(screen.getByRole("button", { name: /Branch/ })).toBeDisabled();
|
|
});
|
|
});
|