8b8582f231
`dashboard_runs.mode` is NOT NULL and `recordRun` swallows insert errors by design, so pty-run.js passing `mode: null` meant NO tmux-backed run was ever persisted — the newest row in a live database dated from before pty-run.js replaced run-spawner.js. `publicRun` reads cwd, model, prompt preview, started_at and session_id off that row and the handle itself is recomputed from tmux, so the runs modal rendered a live row with no cwd, no model, no prompt and a start date of Jan 1 1970. The same missing row is why a resumed session kept its separate `external` badge: the client dedupes an external session against the session ids carried by run rows, and no run row ever carried one. - pty-run.js records `mode: "interactive"` — every run here is one pane. - dashboard-runs.js still never fails a live run on a db error, but warns instead of swallowing silently; that silence hid this for six weeks. - RunHistory renders `startedAt: 0` as "-" rather than dating a row with no known start time to the epoch. Also closes the other half of the same lie: adopting a pane that is already running an agent now throws ERUNLIVE (409 from both the lane start action and POST /api/run) when the request carried a prompt or a resumeSessionId. That argv cannot be typed over a live agent, so answering 200 reported a resume that never spawned. A start asking for nothing but a pane still adopts.
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
/**
|
|
* @file RunHistory.test.tsx
|
|
* @description Pins the props-only boundary of `ActiveRunsSwitcher` / `RunsModal`
|
|
* after their move out of `pages/Run.tsx`: the switcher counts live runs and
|
|
* opens the list, the list merges live in-memory handles with persistent history
|
|
* (live entries winning on a shared id) newest first, marks the live and current
|
|
* rows, filters by status / mode / free text, and fires attach / resume / view
|
|
* with the right run — attach by run id, resume and view with the matching
|
|
* history item.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { fireEvent, render, screen } from "@testing-library/react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import i18n from "i18next";
|
|
import { ActiveRunsSwitcher, RunsModal, type UnifiedRunRow } from "../RunHistory";
|
|
import type { DashboardRunHistoryItem, RunListResponse } from "../../../lib/api";
|
|
import type { Session } from "../../../lib/types";
|
|
|
|
const LIVE_ID = "run-live";
|
|
const PAST_ID = "run-past";
|
|
const HEADLESS_ID = "run-headless";
|
|
|
|
const activeRuns = {
|
|
activeCount: 1,
|
|
maxConcurrent: 2,
|
|
items: [
|
|
{
|
|
id: LIVE_ID,
|
|
sessionId: "sess-live",
|
|
cwd: "/tmp/live",
|
|
model: "claude-opus-5",
|
|
status: "running",
|
|
startedAt: "2000-01-01T00:50:00Z",
|
|
promptPreview: "the live prompt",
|
|
endedAt: null,
|
|
},
|
|
],
|
|
} as unknown as RunListResponse;
|
|
|
|
function historyItem(over: Partial<DashboardRunHistoryItem>): DashboardRunHistoryItem {
|
|
return {
|
|
id: PAST_ID,
|
|
session_id: "sess-past",
|
|
cwd: "/tmp/past",
|
|
model: "sonnet",
|
|
status: "completed",
|
|
prompt_preview: "the past prompt",
|
|
started_at: new Date(2000).toISOString(),
|
|
ended_at: new Date(2500).toISOString(),
|
|
exit_code: 0,
|
|
permission_mode: "acceptEdits",
|
|
effort: "",
|
|
resume_session_id: null,
|
|
isLive: false,
|
|
...over,
|
|
} as DashboardRunHistoryItem;
|
|
}
|
|
|
|
const PAST = historyItem({});
|
|
const HEADLESS = historyItem({
|
|
id: HEADLESS_ID,
|
|
session_id: "sess-headless",
|
|
cwd: "/tmp/headless",
|
|
prompt_preview: "the headless prompt",
|
|
started_at: new Date(1000).toISOString(),
|
|
});
|
|
|
|
function externalSession(over: Partial<Session> = {}): Session {
|
|
return {
|
|
id: "sess-external",
|
|
name: "the external prompt",
|
|
status: "active",
|
|
cwd: "/tmp/external",
|
|
model: "claude-opus-5",
|
|
started_at: new Date(3000).toISOString(),
|
|
ended_at: null,
|
|
updated_at: new Date(3000).toISOString(),
|
|
source: "local",
|
|
...over,
|
|
} as unknown as Session;
|
|
}
|
|
|
|
function renderSwitcher(overrides: Partial<React.ComponentProps<typeof ActiveRunsSwitcher>> = {}) {
|
|
const spies = {
|
|
onAttach: vi.fn(),
|
|
onResumeFromHistory: vi.fn(),
|
|
onViewFromHistory: vi.fn(),
|
|
onRefresh: vi.fn(),
|
|
};
|
|
const utils = render(
|
|
<MemoryRouter>
|
|
<ActiveRunsSwitcher
|
|
activeRuns={activeRuns}
|
|
currentHandleId={null}
|
|
runHistory={[PAST, HEADLESS]}
|
|
{...spies}
|
|
{...overrides}
|
|
/>
|
|
</MemoryRouter>
|
|
);
|
|
return { ...utils, spies };
|
|
}
|
|
|
|
function row(id: string, over: Partial<UnifiedRunRow> = {}): UnifiedRunRow {
|
|
return {
|
|
id,
|
|
sessionId: `sess-${id}`,
|
|
cwd: `/tmp/${id}`,
|
|
model: "sonnet",
|
|
status: "abandoned",
|
|
promptPreview: `prompt of ${id}`,
|
|
startedAt: 1000,
|
|
endedAt: 2000,
|
|
isLive: false,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function renderModal(
|
|
rows: UnifiedRunRow[],
|
|
overrides: Partial<React.ComponentProps<typeof RunsModal>> = {}
|
|
) {
|
|
const spies = {
|
|
onAttach: vi.fn(),
|
|
onResume: vi.fn(),
|
|
onView: vi.fn(),
|
|
onClose: vi.fn(),
|
|
onRefresh: vi.fn(),
|
|
};
|
|
const utils = render(
|
|
<MemoryRouter>
|
|
<RunsModal
|
|
rows={rows}
|
|
currentHandleId={null}
|
|
runHistory={[PAST, HEADLESS]}
|
|
{...spies}
|
|
{...overrides}
|
|
/>
|
|
</MemoryRouter>
|
|
);
|
|
return { ...utils, spies };
|
|
}
|
|
|
|
/** The nth "All" chip — index 0 is the status group, 1 is the mode group. */
|
|
function allChip(nth: number): HTMLElement {
|
|
const hits = screen.getAllByText(i18n.t("run:runs.allLabel", "All"));
|
|
const hit = hits[nth];
|
|
if (!hit) throw new Error(`no "All" chip at index ${nth}`);
|
|
return hit;
|
|
}
|
|
|
|
/** A filter chip, told apart from the same word appearing in a row's status
|
|
* pill or mode badge by being a `<button>`. */
|
|
function chip(label: string): HTMLElement {
|
|
const hit = screen.getAllByText(label).find((el) => el.tagName === "BUTTON");
|
|
if (!hit) throw new Error(`no filter chip labelled ${label}`);
|
|
return hit;
|
|
}
|
|
|
|
const openModal = () =>
|
|
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
|
|
|
beforeEach(() => {
|
|
i18n.changeLanguage("en");
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("ActiveRunsSwitcher", () => {
|
|
it("labels the button with the live count and opens the list", () => {
|
|
renderSwitcher();
|
|
openModal();
|
|
expect(screen.getByText("the live prompt")).toBeTruthy();
|
|
expect(screen.getByText("the past prompt")).toBeTruthy();
|
|
});
|
|
|
|
it("falls back to the total count when nothing is live, and disables at zero", () => {
|
|
const { unmount } = renderSwitcher({ activeRuns: null });
|
|
expect(screen.getByText(i18n.t("run:runs.switcher"))).toBeTruthy();
|
|
expect(screen.getByText("2")).toBeTruthy();
|
|
unmount();
|
|
|
|
renderSwitcher({ activeRuns: null, runHistory: [] });
|
|
const button = screen.getByText(i18n.t("run:runs.switcher")).closest("button");
|
|
expect((button as HTMLButtonElement).disabled).toBe(true);
|
|
});
|
|
|
|
it("lists live runs first and marks the live one", () => {
|
|
renderSwitcher();
|
|
openModal();
|
|
const prompts = screen
|
|
.getAllByText(/the (live|past|headless) prompt/)
|
|
.map((el) => el.textContent);
|
|
expect(prompts).toEqual(["the live prompt", "the past prompt", "the headless prompt"]);
|
|
expect(screen.getAllByText("live")).toHaveLength(1);
|
|
});
|
|
|
|
it("prefers the live handle over a history row with the same id", () => {
|
|
renderSwitcher({ runHistory: [historyItem({ id: LIVE_ID, prompt_preview: "stale copy" })] });
|
|
openModal();
|
|
expect(screen.getByText("the live prompt")).toBeTruthy();
|
|
expect(screen.queryByText("stale copy")).toBeNull();
|
|
});
|
|
|
|
it("counts and lists a session started outside the dashboard, resumable not attachable", () => {
|
|
const { spies } = renderSwitcher({
|
|
activeRuns: null,
|
|
runHistory: [],
|
|
externalSessions: [externalSession()],
|
|
});
|
|
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
|
expect(screen.getByText("the external prompt")).toBeTruthy();
|
|
expect(screen.getByText(i18n.t("run:runs.externalBadge"))).toBeTruthy();
|
|
// No tmux session of ours to attach to — Resume is the only action.
|
|
expect(screen.queryByText(i18n.t("run:runs.attachLabel", "Attach"))).toBeNull();
|
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
|
expect(spies.onResumeFromHistory).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
id: "session:sess-external",
|
|
session_id: "sess-external",
|
|
cwd: "/tmp/external",
|
|
status: "running",
|
|
isLive: true,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("skips external sessions already covered by a run, remote ones, and cwd-less ones", () => {
|
|
renderSwitcher({
|
|
runHistory: [],
|
|
externalSessions: [
|
|
externalSession({ id: "sess-live" }), // same session as the live run
|
|
externalSession({ id: "sess-remote", source: "remote-1" }),
|
|
externalSession({ id: "sess-nocwd", cwd: null }),
|
|
],
|
|
});
|
|
fireEvent.click(screen.getByText(i18n.t("run:runs.viewActive_other", { count: 1 })));
|
|
expect(screen.getByText("the live prompt")).toBeTruthy();
|
|
expect(screen.queryByText("the external prompt")).toBeNull();
|
|
expect(screen.queryByText(i18n.t("run:runs.externalBadge"))).toBeNull();
|
|
});
|
|
|
|
it("shows a dash, not the epoch, for a row with no start time", () => {
|
|
// A run whose persisted row is missing arrives with startedAt 0. Rendering
|
|
// that as "Jan 1, 08:00 AM" presents a gap in the data as real data.
|
|
renderModal([row("no-start", { startedAt: 0 })]);
|
|
expect(screen.getByText("-")).toBeTruthy();
|
|
expect(screen.queryByText(/Jan 1,/)).toBeNull();
|
|
});
|
|
|
|
it("fires attach with the run id of the row that was clicked", () => {
|
|
const { spies } = renderSwitcher();
|
|
openModal();
|
|
fireEvent.click(screen.getByText(i18n.t("run:runs.attachLabel", "Attach")));
|
|
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
|
// Attaching closes the list, which is what the page relies on.
|
|
expect(screen.queryByText("the past prompt")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("RunsModal", () => {
|
|
it("offers Attach only for a live row that is not the current one", () => {
|
|
const { unmount } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })], {
|
|
currentHandleId: LIVE_ID,
|
|
});
|
|
expect(screen.queryByText(i18n.t("run:runs.attachLabel", "Attach"))).toBeNull();
|
|
expect(screen.getByText(i18n.t("run:runs.currentBadge", "current"))).toBeTruthy();
|
|
unmount();
|
|
|
|
const { spies } = renderModal([row(LIVE_ID, { isLive: true, status: "running" })]);
|
|
fireEvent.click(screen.getByText(i18n.t("run:runs.attachLabel", "Attach")));
|
|
expect(spies.onAttach).toHaveBeenCalledWith(LIVE_ID);
|
|
});
|
|
|
|
it("fires resume with the history item behind a finished row", () => {
|
|
const { spies } = renderModal([row(PAST_ID)]);
|
|
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
|
expect(spies.onResume).toHaveBeenCalledWith(PAST);
|
|
});
|
|
|
|
it("fires view for a finished row", () => {
|
|
const { spies } = renderModal([row(HEADLESS_ID)]);
|
|
fireEvent.click(screen.getByText(i18n.t("run:runs.viewLabel")));
|
|
expect(spies.onView).toHaveBeenCalledWith(HEADLESS);
|
|
});
|
|
|
|
it("filters by status and by free text", () => {
|
|
const rows = [
|
|
row("a", { status: "running", isLive: true, promptPreview: "alpha" }),
|
|
row("b", { status: "killed", promptPreview: "bravo" }),
|
|
row("c", { status: "abandoned", promptPreview: "charlie" }),
|
|
];
|
|
renderModal(rows);
|
|
|
|
fireEvent.click(chip(i18n.t("run:status.killed")));
|
|
expect(screen.getByText("bravo")).toBeTruthy();
|
|
expect(screen.queryByText("alpha")).toBeNull();
|
|
|
|
fireEvent.click(allChip(0));
|
|
fireEvent.change(
|
|
screen.getByPlaceholderText(
|
|
i18n.t("run:runs.searchPlaceholder", "Search prompt, cwd, model, or session id…")
|
|
),
|
|
{ target: { value: "alpha" } }
|
|
);
|
|
expect(screen.getByText("alpha")).toBeTruthy();
|
|
expect(screen.queryByText("charlie")).toBeNull();
|
|
});
|
|
|
|
it("polls onRefresh while it is the foreground UI", () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const { spies } = renderModal([row("a")]);
|
|
expect(spies.onRefresh).toHaveBeenCalledTimes(1);
|
|
vi.advanceTimersByTime(4000);
|
|
expect(spies.onRefresh).toHaveBeenCalledTimes(3);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|