Jobber/orchestrator/src/client/pages/OrchestratorPage.test.tsx
Shaheer Sarfaraz 60788b0f6a
modal to configure pipeline settings on pipeline runs (#99)
* feat(orchestrator): add unified run modal shell with Automatic/Manual tabs

* feat(orchestrator): implement Automatic tab presets, estimate, and save+run flow

* refactor(manual-import): reuse manual import flow inside unified run modal

* refactor(settings): move pipeline tuning out of settings page into run modal

* stage 5

* jobs per term simplified

* copy improvement

* pill input

* better UI

* style(orchestrator): align run settings inputs on one row

* style(orchestrator): remove hover and pointer affordance from term pills

* style(orchestrator): restore hover and pointer affordance for term pills

* style(orchestrator): make search term pill hover more prominent

* better hover

* refactor(orchestrator): use react-hook-form in automatic run panel

* formatting

* fix(orchestrator): resolve biome issues in automatic run modal

* better copy

* feat(orchestrator): auto-select custom preset on manual config changes

* remove badge

* feat(orchestrator): redesign automatic run panel with collapsible advanced settings

* refactor(orchestrator): move estimate summary to footer and dedupe sources

* style(orchestrator): separate search term input from term pills

* style(orchestrator): remove save preset action from automatic footer

* ux(orchestrator): make entire search term pill tap-to-remove

* remove badge

* remove badge

* fix(orchestrator): return zero estimate when search terms are empty
2026-02-07 21:48:44 +00:00

425 lines
11 KiB
TypeScript

import type { Job } from "@shared/types.js";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as api from "../api";
import { OrchestratorPage } from "./OrchestratorPage";
import type { FilterTab } from "./orchestrator/constants";
vi.mock("../api", () => ({
updateSettings: vi.fn().mockResolvedValue({}),
runPipeline: vi.fn().mockResolvedValue({ message: "ok" }),
getPipelineStatus: vi.fn().mockResolvedValue({
isRunning: false,
lastRun: null,
nextScheduledRun: null,
}),
}));
const jobFixture: Job = {
id: "job-1",
source: "linkedin",
sourceJobId: null,
jobUrlDirect: null,
datePosted: null,
title: "Backend Engineer",
employer: "Acme",
employerUrl: null,
jobUrl: "https://example.com/job",
applicationLink: null,
disciplines: null,
deadline: null,
salary: null,
location: "London",
degreeRequired: null,
starting: null,
jobDescription: "Build APIs",
status: "ready",
outcome: null,
closedAt: null,
suitabilityScore: 90,
suitabilityReason: null,
tailoredSummary: null,
tailoredHeadline: null,
tailoredSkills: null,
selectedProjectIds: null,
pdfPath: null,
notionPageId: null,
sponsorMatchScore: null,
sponsorMatchNames: null,
jobType: null,
salarySource: null,
salaryInterval: null,
salaryMinAmount: null,
salaryMaxAmount: null,
salaryCurrency: null,
isRemote: null,
jobLevel: null,
jobFunction: null,
listingType: null,
emails: null,
companyIndustry: null,
companyLogo: null,
companyUrlDirect: null,
companyAddresses: null,
companyNumEmployees: null,
companyRevenue: null,
companyDescription: null,
skills: null,
experienceRange: null,
companyRating: null,
companyReviewsCount: null,
vacancyCount: null,
workFromHomeType: null,
discoveredAt: "2025-01-01T00:00:00Z",
processedAt: null,
appliedAt: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
};
const job2: Job = { ...jobFixture, id: "job-2", status: "discovered" };
const createMatchMedia = (matches: boolean) =>
vi.fn().mockImplementation((query: string) => ({
matches,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.mock("./orchestrator/useOrchestratorData", () => ({
useOrchestratorData: () => ({
jobs: [jobFixture, job2],
stats: {
discovered: 1,
processing: 0,
ready: 1,
applied: 0,
skipped: 0,
expired: 0,
},
isLoading: false,
isPipelineRunning: false,
setIsPipelineRunning: vi.fn(),
loadJobs: vi.fn(),
}),
}));
vi.mock("./orchestrator/usePipelineSources", () => ({
usePipelineSources: () => ({
pipelineSources: ["linkedin"],
setPipelineSources: vi.fn(),
toggleSource: vi.fn(),
}),
}));
vi.mock("../hooks/useSettings", () => ({
useSettings: () => ({
settings: {
jobspySites: ["indeed", "linkedin"],
ukvisajobsEmail: null,
ukvisajobsPasswordHint: null,
},
refreshSettings: vi.fn(),
}),
}));
vi.mock("./orchestrator/OrchestratorHeader", () => ({
OrchestratorHeader: () => <div data-testid="header" />,
}));
vi.mock("./orchestrator/OrchestratorSummary", () => ({
OrchestratorSummary: () => <div data-testid="summary" />,
}));
vi.mock("./orchestrator/OrchestratorFilters", () => ({
OrchestratorFilters: ({
onTabChange,
onSearchQueryChange,
onSortChange,
sourcesWithJobs,
}: {
onTabChange: (t: FilterTab) => void;
onSearchQueryChange: (q: string) => void;
onSortChange: (s: any) => void;
sourcesWithJobs: string[];
}) => (
<div data-testid="filters">
<div data-testid="sources-with-jobs">{sourcesWithJobs.join(",")}</div>
<button type="button" onClick={() => onTabChange("discovered")}>
To Discovered
</button>
<button type="button" onClick={() => onSearchQueryChange("test search")}>
Set Search
</button>
<button
type="button"
onClick={() => onSortChange({ key: "title", direction: "asc" })}
>
Set Sort
</button>
</div>
),
}));
vi.mock("./orchestrator/JobDetailPanel", () => ({
JobDetailPanel: () => <div data-testid="detail-panel" />,
}));
vi.mock("./orchestrator/JobListPanel", () => ({
JobListPanel: ({
onSelectJob,
selectedJobId,
}: {
onSelectJob: (id: string) => void;
selectedJobId: string | null;
}) => (
<div>
<div data-testid="selected-job">{selectedJobId ?? "none"}</div>
<button
data-testid="select-job-1"
type="button"
onClick={() => onSelectJob("job-1")}
>
Select job 1
</button>
<button
data-testid="select-job-2"
type="button"
onClick={() => onSelectJob("job-2")}
>
Select job 2
</button>
</div>
),
}));
vi.mock("./orchestrator/RunModeModal", () => ({
RunModeModal: ({
onSaveAndRunAutomatic,
}: {
onSaveAndRunAutomatic: (values: {
topN: number;
minSuitabilityScore: number;
searchTerms: string[];
runBudget: number;
}) => Promise<void>;
}) => (
<button
type="button"
data-testid="run-automatic"
onClick={() =>
void onSaveAndRunAutomatic({
topN: 12,
minSuitabilityScore: 55,
searchTerms: ["backend"],
runBudget: 150,
})
}
>
Run automatic
</button>
),
}));
vi.mock("../components", () => ({
ManualImportSheet: () => <div data-testid="manual-import" />,
}));
const LocationWatcher = () => {
const location = useLocation();
return (
<div data-testid="location">{location.pathname + location.search}</div>
);
};
describe("OrchestratorPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("syncs tab selection to the URL", () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("To Discovered"));
expect(screen.getByTestId("location").textContent).toContain("/discovered");
});
it("syncs job selection to the URL", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/all"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
// Initial load will auto-select the first matching job (job-1 for all tab)
const locationText = () => screen.getByTestId("location").textContent;
expect(locationText()).toContain("/all/job-1");
// Clicking job-2 should update URL
const job2Button = screen.getByTestId("select-job-2");
fireEvent.click(job2Button);
// Wait for URL to update
await waitFor(() => {
expect(locationText()).toContain("/all/job-2");
});
});
it("syncs search query to URL as a parameter", () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("Set Search"));
expect(screen.getByTestId("location").textContent).toContain(
"q=test+search",
);
});
it("syncs sorting to URL and removes it when default", () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByText("Set Sort"));
expect(screen.getByTestId("location").textContent).toContain(
"sort=title-asc",
);
});
it("opens the detail drawer on mobile when a job is selected", () => {
window.matchMedia = createMatchMedia(
false,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
expect(screen.queryByTestId("detail-panel")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("select-job-1"));
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
});
it("renders the detail panel inline on desktop", () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
});
it("clears source filter when no jobs exist for it", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
render(
<MemoryRouter initialEntries={["/ready?source=ukvisajobs"]}>
<LocationWatcher />
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByTestId("location").textContent).not.toContain(
"source=ukvisajobs",
);
});
});
it("saves automatic settings from modal", async () => {
window.matchMedia = createMatchMedia(
true,
) as unknown as typeof window.matchMedia;
const setIntervalSpy = vi
.spyOn(globalThis, "setInterval")
.mockReturnValue(0 as unknown as NodeJS.Timeout);
render(
<MemoryRouter initialEntries={["/ready"]}>
<Routes>
<Route path="/:tab" element={<OrchestratorPage />} />
<Route path="/:tab/:jobId" element={<OrchestratorPage />} />
</Routes>
</MemoryRouter>,
);
fireEvent.click(screen.getByTestId("run-automatic"));
await waitFor(() => {
expect(api.updateSettings).toHaveBeenCalledWith({
searchTerms: ["backend"],
jobspyResultsWanted: 150,
gradcrackerMaxJobsPerTerm: 150,
ukvisajobsMaxJobs: 150,
});
});
setIntervalSpy.mockRestore();
});
});