diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx
new file mode 100644
index 0000000..1e304df
--- /dev/null
+++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx
@@ -0,0 +1,164 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+
+import { OrchestratorPage } from "./OrchestratorPage";
+import type { Job } from "../../shared/types";
+
+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",
+ suitabilityScore: 90,
+ suitabilityReason: null,
+ tailoredSummary: null,
+ tailoredHeadline: null,
+ tailoredSkills: null,
+ selectedProjectIds: null,
+ pdfPath: null,
+ notionPageId: 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 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],
+ stats: {
+ discovered: 0,
+ 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("./orchestrator/OrchestratorHeader", () => ({
+ OrchestratorHeader: () =>
,
+}));
+
+vi.mock("./orchestrator/OrchestratorSummary", () => ({
+ OrchestratorSummary: () => ,
+}));
+
+vi.mock("./orchestrator/OrchestratorFilters", () => ({
+ OrchestratorFilters: () => ,
+}));
+
+vi.mock("./orchestrator/JobDetailPanel", () => ({
+ JobDetailPanel: () => ,
+}));
+
+vi.mock("./orchestrator/JobListPanel", () => ({
+ JobListPanel: ({ onSelectJob, selectedJobId }: { onSelectJob: (id: string) => void; selectedJobId: string | null }) => (
+
+
{selectedJobId ?? "none"}
+
+
+ ),
+}));
+
+vi.mock("../components", () => ({
+ ManualImportSheet: () => ,
+}));
+
+describe("OrchestratorPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("opens the detail drawer on mobile when a job is selected", () => {
+ window.matchMedia = createMatchMedia(false) as unknown as typeof window.matchMedia;
+
+ render(
+
+
+
+ );
+
+ expect(screen.queryByTestId("detail-panel")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: /select job/i }));
+
+ 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(
+
+
+
+ );
+
+ expect(screen.getByTestId("detail-panel")).toBeInTheDocument();
+ });
+});
diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx
new file mode 100644
index 0000000..9670b20
--- /dev/null
+++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx
@@ -0,0 +1,274 @@
+import React from "react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+
+import { JobDetailPanel } from "./JobDetailPanel";
+import type { Job } from "../../../shared/types";
+import * as api from "../../api";
+
+vi.mock("@/components/ui/dropdown-menu", () => {
+ return {
+ DropdownMenu: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuItem: ({
+ children,
+ onSelect,
+ ...props
+ }: {
+ children: React.ReactNode;
+ onSelect?: () => void;
+ }) => (
+
+ ),
+ DropdownMenuSeparator: () => ,
+ };
+});
+
+vi.mock("../../components", () => ({
+ DiscoveredPanel: ({ job }: { job: Job | null }) => (
+ {job?.id ?? "no-job"}
+ ),
+}));
+
+vi.mock("../../components/ReadyPanel", () => ({
+ ReadyPanel: ({ onEditDescription }: { onEditDescription?: () => void }) => (
+
+
+
+
+ ),
+}));
+
+vi.mock("../../components/TailoringEditor", () => ({
+ TailoringEditor: () => ,
+}));
+
+vi.mock("@client/lib/jobCopy", () => ({
+ copyTextToClipboard: vi.fn().mockResolvedValue(undefined),
+ formatJobForWebhook: vi.fn(() => "payload"),
+}));
+
+vi.mock("../../api", () => ({
+ updateJob: vi.fn(),
+ processJob: vi.fn(),
+ generateJobPdf: vi.fn(),
+ markAsApplied: vi.fn(),
+ skipJob: vi.fn(),
+}));
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ message: vi.fn(),
+ },
+}));
+
+const createJob = (overrides: Partial = {}): 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: "https://example.com/apply",
+ disciplines: null,
+ deadline: "2025-02-01",
+ salary: "GBP 50k",
+ location: "London",
+ degreeRequired: null,
+ starting: null,
+ jobDescription: "Build APIs",
+ status: "ready",
+ suitabilityScore: 82,
+ suitabilityReason: "Strong fit",
+ tailoredSummary: null,
+ tailoredHeadline: null,
+ tailoredSkills: null,
+ selectedProjectIds: null,
+ pdfPath: null,
+ notionPageId: 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",
+ ...overrides,
+});
+
+describe("JobDetailPanel", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders the discovered panel when active tab is discovered", () => {
+ const job = createJob({ id: "job-99", status: "discovered" });
+
+ render(
+
+ );
+
+ expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99");
+ });
+
+ it("wires ready panel edit actions back to the page", () => {
+ const onSetActiveTab = vi.fn();
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /edit description/i }));
+ expect(onSetActiveTab).toHaveBeenCalledWith("discovered");
+ });
+
+ it("shows an empty state when no job is selected", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("No job selected")).toBeInTheDocument();
+ });
+
+ it("renders a stripped description preview for html content", () => {
+ render(
+ Hello world" })}
+ onSelectJobId={vi.fn()}
+ onJobUpdated={vi.fn().mockResolvedValue(undefined)}
+ onSetActiveTab={vi.fn()}
+ />
+ );
+
+ expect(screen.getByText("Hello world")).toBeInTheDocument();
+ });
+
+ it("saves an edited description", async () => {
+ const onJobUpdated = vi.fn().mockResolvedValue(undefined);
+ vi.mocked(api.updateJob).mockResolvedValue(undefined as any);
+
+ render(
+
+ );
+
+ fireEvent.mouseDown(screen.getByRole("tab", { name: /description/i }));
+ fireEvent.click(await screen.findByRole("button", { name: /^edit$/i }));
+
+ fireEvent.change(screen.getByPlaceholderText("Enter job description..."), {
+ target: { value: "Updated description" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: /save changes/i }));
+
+ await waitFor(() =>
+ expect(api.updateJob).toHaveBeenCalledWith("job-1", { jobDescription: "Updated description" })
+ );
+ expect(onJobUpdated).toHaveBeenCalled();
+ });
+
+ it("marks a job as applied from the action button", async () => {
+ const onJobUpdated = vi.fn().mockResolvedValue(undefined);
+ vi.mocked(api.markAsApplied).mockResolvedValue(undefined as any);
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /applied/i }));
+
+ await waitFor(() => expect(api.markAsApplied).toHaveBeenCalledWith("job-1"));
+ expect(onJobUpdated).toHaveBeenCalled();
+ });
+
+ it("skips a job from the menu", async () => {
+ const onJobUpdated = vi.fn().mockResolvedValue(undefined);
+ vi.mocked(api.skipJob).mockResolvedValue(undefined as any);
+
+ render(
+
+ );
+
+ fireEvent.pointerDown(screen.getByRole("button", { name: /more actions/i }));
+ const skipItem = await screen.findByRole("menuitem", { name: /skip job/i });
+ fireEvent.click(skipItem);
+
+ await waitFor(() => expect(api.skipJob).toHaveBeenCalledWith("job-1"));
+ expect(onJobUpdated).toHaveBeenCalled();
+ });
+});
diff --git a/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx
new file mode 100644
index 0000000..76a0ac2
--- /dev/null
+++ b/orchestrator/src/client/pages/orchestrator/JobListPanel.test.tsx
@@ -0,0 +1,140 @@
+import { describe, it, expect, vi } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import { JobListPanel } from "./JobListPanel";
+import type { Job } from "../../../shared/types";
+
+const createJob = (overrides: Partial = {}): 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",
+ suitabilityScore: 72,
+ suitabilityReason: null,
+ tailoredSummary: null,
+ tailoredHeadline: null,
+ tailoredSkills: null,
+ selectedProjectIds: null,
+ pdfPath: null,
+ notionPageId: 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",
+ ...overrides,
+});
+
+describe("JobListPanel", () => {
+ it("shows a loading state when fetching jobs", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("Loading jobs...")).toBeInTheDocument();
+ });
+
+ it("shows the tab empty state copy when no jobs exist", () => {
+ render(
+
+ );
+
+ expect(screen.getByText("No jobs found")).toBeInTheDocument();
+ expect(screen.getByText("Run the pipeline to discover and process new jobs.")).toBeInTheDocument();
+ });
+
+ it("shows the query-specific empty state when searching", () => {
+ render(
+
+ );
+
+ expect(screen.getByText('No jobs match "iOS".')).toBeInTheDocument();
+ });
+
+ it("renders jobs and notifies when a job is selected", () => {
+ const onSelectJob = vi.fn();
+ const jobs = [
+ createJob({ id: "job-1", title: "Backend Engineer" }),
+ createJob({ id: "job-2", title: "Frontend Engineer", employer: "Globex" }),
+ ];
+
+ render(
+
+ );
+
+ expect(screen.getByRole("button", { name: /Backend Engineer/i })).toHaveAttribute("aria-pressed", "true");
+
+ fireEvent.click(screen.getByRole("button", { name: /Frontend Engineer/i }));
+ expect(onSelectJob).toHaveBeenCalledWith("job-2");
+ });
+});
diff --git a/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx
new file mode 100644
index 0000000..92f8bef
--- /dev/null
+++ b/orchestrator/src/client/pages/orchestrator/OrchestratorFilters.test.tsx
@@ -0,0 +1,107 @@
+import React from "react";
+import { describe, it, expect, vi } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import type { ComponentProps } from "react";
+
+import { OrchestratorFilters } from "./OrchestratorFilters";
+import type { FilterTab, JobSort } from "./constants";
+
+vi.mock("@/components/ui/dropdown-menu", () => {
+ const React = require("react") as typeof import("react");
+ const RadioGroupContext = React.createContext<((value: string) => void) | null>(null);
+
+ return {
+ DropdownMenu: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuItem: ({
+ children,
+ onSelect,
+ ...props
+ }: {
+ children: React.ReactNode;
+ onSelect?: () => void;
+ }) => (
+
+ ),
+ DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuSeparator: () => ,
+ DropdownMenuRadioGroup: ({
+ children,
+ onValueChange,
+ }: {
+ children: React.ReactNode;
+ onValueChange?: (value: string) => void;
+ }) => (
+
+ {children}
+
+ ),
+ DropdownMenuRadioItem: ({
+ children,
+ value,
+ }: {
+ children: React.ReactNode;
+ value: string;
+ }) => {
+ const onValueChange = React.useContext(RadioGroupContext);
+ return (
+
+ );
+ },
+ };
+});
+
+const renderFilters = (overrides?: Partial>) => {
+ const props = {
+ activeTab: "ready" as FilterTab,
+ onTabChange: vi.fn(),
+ counts: {
+ ready: 2,
+ discovered: 1,
+ applied: 3,
+ all: 6,
+ },
+ searchQuery: "",
+ onSearchQueryChange: vi.fn(),
+ sourceFilter: "all" as const,
+ onSourceFilterChange: vi.fn(),
+ sort: { key: "score", direction: "desc" } as JobSort,
+ onSortChange: vi.fn(),
+ ...overrides,
+ };
+
+ return {
+ props,
+ ...render(),
+ };
+};
+
+describe("OrchestratorFilters", () => {
+ it("notifies when tabs and search are updated", () => {
+ const { props } = renderFilters();
+
+ fireEvent.mouseDown(screen.getByRole("tab", { name: /applied/i }));
+ expect(props.onTabChange).toHaveBeenCalledWith("applied");
+
+ fireEvent.change(screen.getByPlaceholderText("Search..."), { target: { value: "Design" } });
+ expect(props.onSearchQueryChange).toHaveBeenCalledWith("Design");
+ });
+
+ it("updates source and sort selections", async () => {
+ const { props } = renderFilters();
+
+ fireEvent.pointerDown(screen.getByRole("button", { name: /all sources/i }));
+ fireEvent.click(await screen.findByRole("menuitemradio", { name: /LinkedIn/i }));
+ expect(props.onSourceFilterChange).toHaveBeenCalledWith("linkedin");
+
+ fireEvent.pointerDown(screen.getByRole("button", { name: /score/i }));
+ fireEvent.click(await screen.findByRole("menuitem", { name: /Direction:/i }));
+ expect(props.onSortChange).toHaveBeenCalledWith({ key: "score", direction: "asc" });
+ });
+});
diff --git a/orchestrator/src/setupTests.ts b/orchestrator/src/setupTests.ts
index 8f2609b..d6f6f9c 100644
--- a/orchestrator/src/setupTests.ts
+++ b/orchestrator/src/setupTests.ts
@@ -3,3 +3,13 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
+
+if (typeof globalThis.ResizeObserver === "undefined") {
+ class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ }
+
+ globalThis.ResizeObserver = ResizeObserver;
+}