diff --git a/packages/api/test/static-slice.e2e.test.ts b/packages/api/test/static-slice.e2e.test.ts index 403d7c7b..344e4e57 100644 --- a/packages/api/test/static-slice.e2e.test.ts +++ b/packages/api/test/static-slice.e2e.test.ts @@ -9,7 +9,7 @@ vi.mock("@cloudflare/workers-oauth-provider", () => ({ OAuthProvider: class {}, vi.mock("cloudflare:workers", () => ({ WorkerEntrypoint: class {}, DurableObject: class {} })); const url = process.env.AUTH_TEST_DATABASE_URL; describe.skipIf(!url)("compiled React + actual Worker API + PostgreSQL", () => { - it("authenticates, navigates a project's jobs, invokes once, refreshes the run and resets a projected trace", async () => { + it("authenticates, navigates a project's runs, invokes once, refreshes the run and resets a projected trace", async () => { const f = await loadingFixture(url!), browser = await chromium.launch({ headless: true }); const failures: string[] = [], calls: string[] = []; try { @@ -71,7 +71,9 @@ describe.skipIf(!url)("compiled React + actual Worker API + PostgreSQL", () => { expect(await page.getByRole("link", { name: "Factorize", exact: true }).count()).toBe(0); expect(await page.locator("html").getAttribute("data-theme")).toBe("light"); expect(await page.locator("body").evaluate((element: any) => (globalThis as any).getComputedStyle(element).backgroundColor)).toBe("rgb(252, 251, 246)"); - await page.getByRole("link", { name: "Job-1", exact: true }).click(); + expect(await page.getByRole("navigation", { name: "Project navigation" }).getByRole("link", { name: "Runs", exact: true }).count()).toBe(0); + await page.getByRole("link", { name: "Job-0", exact: true }).first().waitFor(); + await page.goto(`https://factorize.test/jobs/${f.jobIds[1]}`); await page.getByRole("button", { name: "More run options", exact: true }).click(); await page.getByRole("menuitem", { name: "Run with prompt", exact: true }).click(); await page.getByLabel("Prompt", { exact: true }).fill("Real invocation"); diff --git a/packages/app/src/features/projects/detail.tsx b/packages/app/src/features/projects/detail.tsx index d9c96865..3715fd81 100644 --- a/packages/app/src/features/projects/detail.tsx +++ b/packages/app/src/features/projects/detail.tsx @@ -1,7 +1,7 @@ import { Link, useParams } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { ActionBar, Button } from "../../shared/ui"; -import { JobsTable } from "../jobs/list"; +import { RunList } from "../runs/list"; import { ProjectGraph } from "./graph"; import { projectQuery } from "./queries"; import { ProjectNav } from "./project-nav"; @@ -12,10 +12,10 @@ export function ProjectDetail() { const project = useQuery(projectQuery(projectId)); const [view, setView] = useState<"list" | "graph">("list"); return {project.data && -
+
- }{view === "list" ? : }; + }{view === "list" ? : }; } diff --git a/packages/app/src/features/projects/project-nav.tsx b/packages/app/src/features/projects/project-nav.tsx index 0ac99360..9e711366 100644 --- a/packages/app/src/features/projects/project-nav.tsx +++ b/packages/app/src/features/projects/project-nav.tsx @@ -4,11 +4,10 @@ import { Page } from "../../shared/ui"; import { NavIcon } from "../../shared/nav-icon"; import { projectQuery } from "./queries"; -type ProjectTab = "jobs" | "runs" | "settings"; +type ProjectTab = "jobs" | "settings"; -const tabs: Array<{ id: ProjectTab; label: string; to: "/projects/$projectId" | "/projects/$projectId/runs" | "/projects/$projectId/settings"; icon: React.ReactNode }> = [ +const tabs: Array<{ id: ProjectTab; label: string; to: "/projects/$projectId" | "/projects/$projectId/settings"; icon: React.ReactNode }> = [ { id: "jobs", label: "Jobs", to: "/projects/$projectId", icon: }, - { id: "runs", label: "Runs", to: "/projects/$projectId/runs", icon: }, { id: "settings", label: "Settings", to: "/projects/$projectId/settings", icon: }, ]; @@ -24,7 +23,7 @@ export function ProjectNav({ tab, projectId: projectIdProp, children }: { tab: P
-

{project.data?.name ?? "Project"} {tab === "jobs" ? "jobs" : tab === "runs" ? "runs" : "settings"}

+

{project.data?.name ?? "Project"} {tab === "jobs" ? "runs" : "settings"}

{project.isPending &&

Loading project…

}{project.error &&

{project.error.message}

} {children}
diff --git a/packages/app/src/features/projects/routes.tsx b/packages/app/src/features/projects/routes.tsx index 0c6f32e2..9a3c9142 100644 --- a/packages/app/src/features/projects/routes.tsx +++ b/packages/app/src/features/projects/routes.tsx @@ -1,4 +1,6 @@ import { createRoute, lazyRouteComponent, redirect, type AnyRootRoute } from "@tanstack/react-router"; import { queryClient } from "../../shared/query-client"; import { sessionQuery } from "../auth/session"; import { projectsQuery, projectQuery } from "./queries"; -import { jobsQuery, type JobsSearch } from "../jobs/queries"; -export function projectsRoutes(root: AnyRootRoute) { const guard=async({location}:{location:{href:string}})=>{if(!(await queryClient.ensureQueryData(sessionQuery)).authenticated)throw redirect({to:"/auth/login",search:{returnTo:location.href,token:undefined}});}; const list=createRoute({getParentRoute:()=>root,path:"/projects",beforeLoad:guard,loader:()=>queryClient.ensureQueryData(projectsQuery),component:lazyRouteComponent(()=>import("./list"),"ProjectsList")}); const create=createRoute({getParentRoute:()=>root,path:"/projects/new",beforeLoad:guard,component:lazyRouteComponent(()=>import("./create"),"ProjectCreate")}); const validateJobsSearch = (s: Record): JobsSearch => ({ q: typeof s.q === "string" ? s.q.slice(0, 120) : "", cursor: typeof s.cursor === "string" ? s.cursor : undefined, enabled: s.enabled === "true" || s.enabled === true ? "true" : s.enabled === "false" || s.enabled === false ? "false" : undefined, sort: s.sort === "running" || s.sort === "title" ? s.sort : undefined, order: s.order === "asc" || s.order === "desc" ? s.order : undefined }); const detail=createRoute({getParentRoute:()=>root,path:"/projects/$projectId",beforeLoad:guard,validateSearch:validateJobsSearch,loaderDeps:({search})=>({q:search.q,cursor:search.cursor,enabled:search.enabled,sort:search.sort,order:search.order}),loader:({params,deps})=>{void queryClient.prefetchQuery(jobsQuery({...deps,projectId:params.projectId})).catch(()=>undefined);return queryClient.ensureQueryData(projectQuery(params.projectId));},component:lazyRouteComponent(()=>import("./detail"),"ProjectDetail")}); const projectRuns=createRoute({getParentRoute:()=>root,path:"/projects/$projectId/runs",beforeLoad:guard,validateSearch:(s: Record) => ({cursor:typeof s.cursor === "string" ? s.cursor : undefined, previous:typeof s.previous === "string" ? s.previous : undefined, projects:undefined, jobs:Array.isArray(s.jobs) ? s.jobs.filter((job): job is string => typeof job === "string") : typeof s.jobs === "string" ? [s.jobs] : undefined, states:Array.isArray(s.states) ? s.states : undefined, sort:typeof s.sort === "string" ? s.sort : undefined, direction:s.direction === "asc" || s.direction === "desc" ? s.direction : undefined}),loader:({params})=>queryClient.ensureQueryData(projectQuery(params.projectId)),component:lazyRouteComponent(()=>import("./runs"),"ProjectRuns")}); const settings=createRoute({getParentRoute:()=>root,path:"/projects/$projectId/settings",beforeLoad:guard,loader:({params})=>queryClient.ensureQueryData(projectQuery(params.projectId)),component:lazyRouteComponent(()=>import("./settings"),"ProjectSettings")}); return [list,create,detail,projectRuns,settings]; } +import { jobsQuery } from "../jobs/queries"; +import { runPageQuery } from "../runs/queries"; +import type { RunListSearch } from "../runs/list"; +export function projectsRoutes(root: AnyRootRoute) { const guard=async({location}:{location:{href:string}})=>{if(!(await queryClient.ensureQueryData(sessionQuery)).authenticated)throw redirect({to:"/auth/login",search:{returnTo:location.href,token:undefined}});}; const list=createRoute({getParentRoute:()=>root,path:"/projects",beforeLoad:guard,loader:()=>queryClient.ensureQueryData(projectsQuery),component:lazyRouteComponent(()=>import("./list"),"ProjectsList")}); const create=createRoute({getParentRoute:()=>root,path:"/projects/new",beforeLoad:guard,component:lazyRouteComponent(()=>import("./create"),"ProjectCreate")}); const validateProjectRunsSearch = (s: Record): RunListSearch => ({ cursor:typeof s.cursor === "string" ? s.cursor : undefined, previous:typeof s.previous === "string" ? s.previous : undefined, jobs:Array.isArray(s.jobs) ? s.jobs.filter((job): job is string => typeof job === "string") : typeof s.jobs === "string" ? [s.jobs] : undefined, states:Array.isArray(s.states) ? s.states as RunListSearch["states"] : typeof s.states === "string" ? [s.states as NonNullable[number]] : undefined, sort:s.sort === "job" || s.sort === "run" || s.sort === "status" || s.sort === "created" || s.sort === "agent" ? s.sort : undefined, direction:s.direction === "asc" || s.direction === "desc" ? s.direction : undefined }); const loadProject = ({params,deps}:{params:{projectId:string};deps:RunListSearch}) => { void queryClient.prefetchQuery(jobsQuery({q:"",projectId:params.projectId})).catch(()=>undefined); void queryClient.prefetchQuery(runPageQuery({projectIds:[params.projectId],jobIds:deps.jobs,cursor:deps.cursor,limit:20,sort:deps.sort,direction:deps.direction,states:deps.states})).catch(()=>undefined); return queryClient.ensureQueryData(projectQuery(params.projectId)); }; const detail=createRoute({getParentRoute:()=>root,path:"/projects/$projectId",beforeLoad:guard,validateSearch:validateProjectRunsSearch,loaderDeps:({search})=>search,loader:loadProject,component:lazyRouteComponent(()=>import("./detail"),"ProjectDetail")}); const projectRuns=createRoute({getParentRoute:()=>root,path:"/projects/$projectId/runs",beforeLoad:guard,validateSearch:validateProjectRunsSearch,loaderDeps:({search})=>search,loader:loadProject,component:lazyRouteComponent(()=>import("./runs"),"ProjectRuns")}); const settings=createRoute({getParentRoute:()=>root,path:"/projects/$projectId/settings",beforeLoad:guard,loader:({params})=>queryClient.ensureQueryData(projectQuery(params.projectId)),component:lazyRouteComponent(()=>import("./settings"),"ProjectSettings")}); return [list,create,detail,projectRuns,settings]; } diff --git a/packages/app/src/features/projects/runs.tsx b/packages/app/src/features/projects/runs.tsx index 25f18652..8468e64b 100644 --- a/packages/app/src/features/projects/runs.tsx +++ b/packages/app/src/features/projects/runs.tsx @@ -1,4 +1,4 @@ import { useParams } from "@tanstack/react-router"; import { RunList } from "../runs/list"; import { ProjectNav } from "./project-nav"; -export function ProjectRuns() { const { projectId } = useParams({ from: "/projects/$projectId/runs" }); return

Project runs

; } +export function ProjectRuns() { const { projectId } = useParams({ from: "/projects/$projectId/runs" }); return

Project runs

; } diff --git a/packages/app/src/features/runs/list.tsx b/packages/app/src/features/runs/list.tsx index ebcf063f..b84d9d81 100644 --- a/packages/app/src/features/runs/list.tsx +++ b/packages/app/src/features/runs/list.tsx @@ -12,7 +12,7 @@ function FunnelButton({ kind, name, selected, onToggle }: { kind: "project" | "j export function RunList({ jobId, projectId, limit = 30 }: { jobId?: string; projectId?: string; limit?: number }) { const search = useSearch({ strict: false }) as RunListSearch, navigate = useNavigate(); - const updateSearch = (next: RunListSearch) => void navigate((projectId ? { to: "/projects/$projectId/runs", params: { projectId }, search: next } : jobId ? { to: "/jobs/$jobId", params: { jobId }, search: next } : { to: "/job-runs", search: next }) as any); + const updateSearch = (next: RunListSearch) => void navigate((projectId ? { to: "/projects/$projectId", params: { projectId }, search: next } : jobId ? { to: "/jobs/$jobId", params: { jobId }, search: next } : { to: "/job-runs", search: next }) as any); const scoped = !!jobId || !!projectId; const runs = useQuery(runPageQuery({ jobId, projectIds: projectId ? [projectId] : jobId ? undefined : search.projects, jobIds: jobId ? undefined : search.jobs, cursor: search.cursor, limit, sort: search.sort, direction: search.direction, states: search.states })); const projects = useQuery({ ...projectsQuery, enabled: !scoped }), jobs = useQuery({ ...jobsQuery({ q: "", projectId }), enabled: !jobId && (!!projectId || !scoped) }); @@ -26,7 +26,7 @@ export function RunList({ jobId, projectId, limit = 30 }: { jobId?: string; proj const statusHeader = ; const previous = search.previous?.split(",") ?? [], pageClasses = `inline-flex min-h-10 items-center rounded-lg border px-3 py-2 text-sm font-semibold ${buttonClasses.secondary}`; const pageSearch = (cursor?: string, previousValue?: string) => ({ ...search, cursor, previous: previousValue }); - const pagination = jobId ? : projectId ? : ; + const pagination = jobId ? : projectId ? : ; const projectOptions = (projects.data ?? []).map(project => ({ id: project.id, label: project.name })), jobOptions = (jobs.data?.items ?? []).map(job => ({ id: job.id, label: job.name })); const emptyMessage = search.states?.length && !search.projects?.length && !search.jobs?.length ? "No runs match the selected statuses." : "No runs match the selected filters."; return
{runs.isPending &&

Loading runs…

}{runs.error &&

{runs.error.message}

}{runs.data && <>{!projectId && }{statusHeader}{!projectId && (!scoped ? changeSelection("projects", id, checked)} onClear={() => clearFilters("projects")} /> : )}{!jobId ? changeSelection("jobs", id, checked)} onClear={() => clearFilters("jobs")} /> : }{sortableHeader("run", "Run")}{sortableHeader("created", "Created")}{runs.data.items.map(run => {!projectId && })}{runs.data.items.length === 0 && }
Job runs{search.sort ? "" : ", queued and starting first, running second, then other states; newest first within each group"}
ProjectJobElapsed
{run.project_id && run.project_name ?
{!scoped&& changeSelection("projects", run.project_id!, !(search.projects ?? []).includes(run.project_id!))}/>}{run.project_name}
: —}
{!scoped&& changeSelection("jobs", run.job_id, !(search.jobs ?? []).includes(run.job_id))}/>}{run.job_name}
{run.run_name || "Run"}{(run.failure?.reason || (run.agent_error as any)?.reason) &&

{run.failure?.reason || (run.agent_error as any).reason}

}
{run.elapsed_time ? formatElapsedTime(run.elapsed_time) : "—"}
{search.states?.length || search.projects?.length || search.jobs?.length ? <>

{emptyMessage}

: jobId || projectId ? "No runs found." : "No runs yet"}
}{pagination}
; diff --git a/packages/app/test/functional/critical.spec.mjs b/packages/app/test/functional/critical.spec.mjs index c75282f2..e23bce2c 100644 --- a/packages/app/test/functional/critical.spec.mjs +++ b/packages/app/test/functional/critical.spec.mjs @@ -63,12 +63,12 @@ test("loading, search, error and no-result states are actionable", async ({ page await mockApi(page); let pending; let mode = "pending"; - await page.route("**/api/v1/job-summaries?**", route => mode === "pending" ? (pending = route) : route.fulfill({ status: 403, json: { error: { code: "forbidden", message: "This workspace is unavailable." } } })); + await page.route("**/api/v1/runs?**", route => mode === "pending" ? (pending = route) : route.fulfill({ status: 403, json: { error: { code: "forbidden", message: "This workspace is unavailable." } } })); await page.goto(origin + "/projects/" + projectId); - await expect(page.getByRole("status").filter({ hasText: "Loading jobs…" })).toBeVisible(); + await expect(page.getByRole("status").filter({ hasText: "Loading runs…" })).toBeVisible(); mode = "error"; await pending.fulfill({ status: 403, json: { error: { code: "forbidden", message: "This workspace is unavailable." } } }); - await expect(page.getByRole("alert")).toHaveText("This workspace is unavailable."); + await expect(page.getByRole("alert")).toContainText("This workspace is unavailable."); await page.getByRole("button", { name: "Search jobs, runs, and messages", exact: true }).click(); const input = page.getByRole("combobox", { name: "Search jobs and runs by name, title, or issue identifier", exact: true }); await expect(input).toBeFocused(); @@ -80,9 +80,9 @@ test("loading, search, error and no-result states are actionable", async ({ page test("project and job child requests start after session but do not wait for their detail requests", async ({ page }) => { const requests = []; - let releaseProject, releaseJobs, releaseJob, releaseHistory; + let releaseProject, releaseRuns, releaseJob, releaseHistory; const projectGate = new Promise(resolve => { releaseProject = resolve; }); - const jobsGate = new Promise(resolve => { releaseJobs = resolve; }); + const runsGate = new Promise(resolve => { releaseRuns = resolve; }); const jobGate = new Promise(resolve => { releaseJob = resolve; }); const historyGate = new Promise(resolve => { releaseHistory = resolve; }); await page.route("**/api/v1/**", async route => { @@ -90,29 +90,30 @@ test("project and job child requests start after session but do not wait for the requests.push({ path, search: url.search }); if (path === "/api/v1/session") return route.fulfill({ json: { authenticated: true, user: { id: "owner" }, workspace: { id: "tenant", name: "Release" }, capabilities: [] } }); if (path === `/api/v1/projects/${projectId}`) { await projectGate; return route.fulfill({ json: project }); } - if (path === "/api/v1/job-summaries") { await jobsGate; return route.fulfill({ json: { items: [], nextCursor: null } }); } + if (path === "/api/v1/runs") { await runsGate; return route.fulfill({ json: { items: [], nextCursor: null } }); } if (path === `/api/v1/jobs/${jobId}`) { await jobGate; return route.fulfill({ json: job }); } if (path === "/api/v1/runs") { await historyGate; return route.fulfill({ json: { items: [], nextCursor: null } }); } return route.fulfill({ json: [] }); }); - const projectNavigation = page.goto(`${origin}/projects/${projectId}?q=release&sort=title&order=desc`); + const projectNavigation = page.goto(`${origin}/projects/${projectId}?sort=created&direction=desc&states=failed`); await expect.poll(() => requests.map(request => request.path)).toContain("/api/v1/session"); await expect.poll(() => requests.map(request => request.path)).toContain(`/api/v1/projects/${projectId}`); - await expect.poll(() => requests.map(request => request.path)).toContain("/api/v1/job-summaries"); - expect(requests.find(request => request.path === "/api/v1/job-summaries").search).toContain(`projectId=${projectId}`); - expect(requests.find(request => request.path === "/api/v1/job-summaries").search).toContain("q=release"); + await expect.poll(() => requests.map(request => request.path)).toContain("/api/v1/runs"); + expect(requests.find(request => request.path === "/api/v1/runs").search).toContain(`projectId=${projectId}`); + expect(requests.find(request => request.path === "/api/v1/runs").search).toContain("sort=created"); + expect(requests.find(request => request.path === "/api/v1/runs").search).toContain("state=failed"); releaseProject(); await projectNavigation; await expect(page.getByRole("heading", { name: project.name })).toBeVisible(); - expect(await page.getByRole("status").filter({ hasText: "Loading jobs…" }).isVisible()).toBe(true); - releaseJobs(); - await expect(page.getByRole("status").filter({ hasText: "Loading jobs…" })).toHaveCount(0); + expect(await page.getByRole("status").filter({ hasText: "Loading runs…" }).isVisible()).toBe(true); + releaseRuns(); + await expect(page.getByRole("status").filter({ hasText: "Loading runs…" })).toHaveCount(0); const jobNavigation = page.goto(`${origin}/jobs/${jobId}?cursor=cursor-1&sort=created&direction=desc&states=failed`); await expect.poll(() => requests.map(request => request.path)).toContain(`/api/v1/jobs/${jobId}`); - await expect.poll(() => requests.filter(request => request.path === "/api/v1/runs").length).toBe(1); - const historySearch = requests.find(request => request.path === "/api/v1/runs").search; + await expect.poll(() => requests.filter(request => request.path === "/api/v1/runs" && request.search.includes("jobId=")).length).toBe(1); + const historySearch = requests.find(request => request.path === "/api/v1/runs" && request.search.includes("jobId=")).search; expect(historySearch).toContain("jobId="); expect(historySearch).toContain("cursor=cursor-1"); expect(historySearch).toContain("sort=created"); @@ -122,12 +123,10 @@ test("project and job child requests start after session but do not wait for the await jobNavigation; await expect(page.getByRole("heading", { name: job.name, exact: true })).toHaveCount(0); await expect(page.getByRole("group", { name: "Job actions" }).getByRole("button", { name: "Run job", exact: true })).toBeVisible(); - expect(await page.getByRole("status").filter({ hasText: "Loading run history…" }).isVisible()).toBe(true); releaseHistory(); - await expect(page.getByRole("status").filter({ hasText: "Loading run history…" })).toHaveCount(0); }); -test("intent preloading reuses project detail and jobs queries on click", async ({ page }) => { +test("intent preloading reuses project detail and loads runs on click", async ({ page }) => { const requestCounts = new Map(); await page.route("**/api/v1/**", async route => { const path = new URL(route.request().url()).pathname; @@ -144,22 +143,23 @@ test("intent preloading reuses project detail and jobs queries on click", async const projectLink = page.getByRole("link", { name: new RegExp(project.name) }); await projectLink.hover(); await expect.poll(() => requestCounts.get(`/api/v1/projects/${projectId}`) ?? 0).toBe(1); - await expect.poll(() => requestCounts.get("/api/v1/job-summaries") ?? 0).toBe(1); + await expect.poll(() => requestCounts.get("/api/v1/runs") ?? 0).toBe(1); await projectLink.click(); await expect(page.getByRole("heading", { name: project.name })).toBeVisible(); expect(requestCounts.get(`/api/v1/projects/${projectId}`)).toBe(1); - expect(requestCounts.get("/api/v1/job-summaries")).toBe(1); + expect(requestCounts.get("/api/v1/runs")).toBe(1); await page.goto(`${origin}/jobs`); const jobLink = page.getByRole("link", { name: job.name, exact: true }); + const runsBeforeJobHover = requestCounts.get("/api/v1/runs") ?? 0; await jobLink.hover(); await expect.poll(() => requestCounts.get(`/api/v1/jobs/${jobId}`) ?? 0).toBe(1); - await expect.poll(() => requestCounts.get("/api/v1/runs") ?? 0).toBe(1); + await expect.poll(() => requestCounts.get("/api/v1/runs") ?? 0).toBe(runsBeforeJobHover + 1); await jobLink.click(); await expect(page.getByRole("heading", { name: job.name, exact: true })).toHaveCount(0); await expect(page.getByRole("group", { name: "Job actions" }).getByRole("button", { name: "Run job", exact: true })).toBeVisible(); expect(requestCounts.get(`/api/v1/jobs/${jobId}`)).toBe(1); - expect(requestCounts.get("/api/v1/runs")).toBe(1); + expect(requestCounts.get("/api/v1/runs")).toBe(runsBeforeJobHover + 1); }); test("conversation trace renders assistant messages and uses document scrolling", async ({ page }) => { diff --git a/packages/app/test/jobs.browser.test.mjs b/packages/app/test/jobs.browser.test.mjs index e3e1c037..1bdc93e2 100644 --- a/packages/app/test/jobs.browser.test.mjs +++ b/packages/app/test/jobs.browser.test.mjs @@ -112,7 +112,7 @@ test("project settings can rename and describe a project", async () => { }); await page.route("**/api/v1/job-summaries?**", route => route.fulfill({ json: { items: [], nextCursor: null } })); await page.goto(origin + `/projects/${projectId}`); - await page.getByRole("heading", { name: "Release project jobs", exact: true }).waitFor(); + await page.getByRole("heading", { name: "Release project runs", exact: true }).waitFor(); await page.getByRole("link", { name: "Settings", exact: true }).click(); assert.equal(await page.getByRole("navigation", { name: "Project navigation" }).getByRole("link", { name: "Settings", exact: true }).getAttribute("aria-current"), "page"); await page.getByLabel("Name", { exact: true }).fill("Renamed project"); @@ -301,7 +301,7 @@ test("project graph keeps opaque webhook resource IDs out of labels and availabl await page.getByText(`Linear · ${projectUuid}`, { exact: true }).waitFor(); await context.close(); }); -test("project tabs stay on projects while job detail gets Runs and Settings navigation", async () => { +test("project tabs omit Runs while job detail keeps Runs and Settings navigation", async () => { const { page, context } = await contextFor({ viewport: { width: 1280, height: 844 } }); await page.route("**/api/v1/projects", route => route.fulfill({ json: [{ id: projectId, name: "Release project", description: "Project jobs", jobCount: 1 }] })); await page.route(`**/api/v1/jobs/${jobId}`, route => route.fulfill({ json: { ...summary, promptTemplate: "Template", executionConfigId: "local", triggers: [] } })); @@ -309,11 +309,12 @@ test("project tabs stay on projects while job detail gets Runs and Settings navi await page.getByRole("heading", { name: "Projects", exact: true }).waitFor(); assert.equal(await page.getByRole("navigation", { name: "Breadcrumb" }).count(), 1); await page.goto(origin + `/projects/${projectId}`); - await page.getByRole("heading", { name: "Release project jobs", exact: true }).waitFor(); + await page.getByRole("heading", { name: "Release project runs", exact: true }).waitFor(); assert.equal(await page.getByRole("navigation", { name: "Breadcrumb" }).count(), 0); assert.equal(await page.getByRole("navigation", { name: "Main navigation" }).getByRole("link", { name: "Release project", exact: true }).count(), 1); const projectNavigation = page.getByRole("navigation", { name: "Project navigation" }); assert.equal(await projectNavigation.getByRole("link", { name: "Jobs", exact: true }).getAttribute("aria-current"), "page"); + assert.equal(await projectNavigation.getByRole("link", { name: "Runs", exact: true }).count(), 0); await page.goto(origin + `/jobs/${jobId}`); assert.equal(await page.getByRole("navigation", { name: "Breadcrumb" }).count(), 0); assert.equal(await page.getByRole("heading", { name: "Review builds", exact: true }).count(), 0); @@ -362,7 +363,7 @@ test("dashboard Projects subnav completes keyboard navigation into project Jobs" const project = page.getByRole("link", { name: /Release project/ }); await project.focus(); await page.keyboard.press("Enter"); - await page.getByRole("heading", { name: "Release project jobs", exact: true }).waitFor(); + await page.getByRole("heading", { name: "Release project runs", exact: true }).waitFor(); assert.equal(await page.getByRole("navigation", { name: "Project navigation" }).getByRole("link", { name: "Jobs", exact: true }).getAttribute("aria-current"), "page"); await context.close(); }); @@ -424,75 +425,6 @@ test("mobile main navigation truncates the account and project names without the assert.equal(await mainNav.getByRole("button", { name: "Switch project" }).evaluate(element => getComputedStyle(element).marginLeft), "0px"); await context.close(); }); -for (const width of [1280, 390, 320]) for (const theme of ["light", "dark"]) test(`compact jobs grouping, pagination, focus and long titles at ${width} ${theme}`, async () => { - const { page, context } = await contextFor({ viewport: { width, height: 844 }, colorScheme: theme }); - const longTitle = "Zulu " + "long title ".repeat(30); - const fixtures = Array.from({ length: 31 }, (_, index) => ({ ...summary, id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`, name: `Idle ${String(index).padStart(2, "0")}`, enabled: index % 2 === 0 })); - fixtures[0] = { ...fixtures[0], name: longTitle, enabled: true, runningCount: 1, concurrencyLimit: 10 }; - fixtures[30] = { ...fixtures[30], name: "Alpha running", enabled: false, runningCount: 2, concurrencyLimit: 10 }; - const requests = []; - await page.route("**/api/v1/job-summaries?**", route => { - const url = new URL(route.request().url()); requests.push(url); - const serverOrder = [...fixtures].sort((a, b) => Number(b.runningCount > 0) - Number(a.runningCount > 0) || a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); - const offset = url.searchParams.has("cursor") ? 30 : 0; - return route.fulfill({ json: { items: serverOrder.slice(offset, offset + 30), nextCursor: offset + 30 < serverOrder.length ? "server-cursor-30" : null } }); - }); - await page.route(`**/api/v1/jobs/${fixtures[30].id}`, route => route.fulfill({ json: { ...fixtures[30], promptTemplate: "Template", runNameTemplate: "", executionConfigId: "local", executionConfig: { connectionId: "local", workspace: "ephemeral", cwd: "/workspace", agentKind: "codex" }, triggers: [], currentRuns: 2, maxConcurrency: 10 } })); - await page.goto(origin + `/projects/${projectId}?q=Review`); - const table = page.getByRole("table", { name: "Jobs", exact: true }); - await table.getByRole("link", { name: "Alpha running", exact: true }).waitFor(); - assert.deepEqual(await table.locator("thead th").allTextContents(), ["Running", "Title"]); - assert.deepEqual(await table.locator("tbody td:last-child a").allTextContents(), ["Alpha running", longTitle, ...fixtures.slice(1, 29).map(job => job.name)]); - assert.deepEqual(await table.locator("tbody tr").first().locator("td").allTextContents(), ["2/10", "Alpha runningcodexdisabled"]); - assert.deepEqual(await table.locator("tbody tr").nth(2).locator("td").allTextContents(), ["0/2", "Idle 01codexdisabled"]); - assert.equal(await table.locator("tbody tr").first().getByText("disabled", { exact: true }).count(), 1); - assert.notEqual( - await table.locator("tbody tr").first().evaluate(el => getComputedStyle(el).color), - await table.locator("tbody tr").nth(1).evaluate(el => getComputedStyle(el).color), - ); - assert.equal(await page.getByLabel("Search jobs", { exact: true }).count(), 0); - assert.equal(await page.getByText("Your software factory. Configure prompts, connect triggers, and follow every run.").count(), 0); - const controls = await page.getByRole("group", { name: "Project jobs view" }).boundingBox(), create = await page.getByRole("link", { name: "Create job", exact: true }).boundingBox(), tableBox = await table.boundingBox(); - assert.ok(create.x > controls.x + controls.width && Math.abs(create.y - controls.y) < 12); - assert.ok(Math.abs((create.x + create.width) - (tableBox.x + tableBox.width)) < 2); - const longLink = table.getByRole("link", { name: longTitle, exact: true }); - assert.equal(await longLink.evaluate(el => el.scrollWidth > el.clientWidth && getComputedStyle(el).whiteSpace === "nowrap"), true); - const first = table.locator("tbody tr").first(), idleColor = await first.evaluate(el => getComputedStyle(el).backgroundColor); - await first.hover(); await page.waitForFunction(() => getComputedStyle(document.querySelector("tbody tr")).backgroundColor !== "rgba(0, 0, 0, 0)"); assert.notEqual(await first.evaluate(el => getComputedStyle(el).backgroundColor), idleColor); - await page.mouse.move(0, 0); const link = first.getByRole("link"); await link.focus(); - assert.equal(await link.evaluate(el => el === document.activeElement), true); - await page.waitForFunction(() => getComputedStyle(document.querySelector("tbody tr")).backgroundColor !== "rgba(0, 0, 0, 0)"); - assert.notEqual(await first.evaluate(el => getComputedStyle(el).backgroundColor), idleColor); - assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true); - await page.getByRole("link", { name: "Next page" }).click(); await table.getByRole("link", { name: "Idle 29", exact: true }).waitFor(); - await page.reload(); await table.getByRole("link", { name: "Idle 29", exact: true }).waitFor(); - await page.getByRole("link", { name: "First page" }).click(); await table.getByRole("link", { name: "Alpha running", exact: true }).waitFor(); - assert.ok(requests.every(url => url.searchParams.get("limit") === "30" && url.searchParams.get("q") === "Review" && url.searchParams.get("sort") === "active")); - await table.getByRole("link", { name: "Alpha running", exact: true }).focus(); await page.keyboard.press("Enter"); - await page.waitForURL(`**/jobs/${fixtures[30].id}`); - assert.equal(await page.getByRole("heading", { name: "Alpha running", exact: true }).count(), 0); - await page.getByRole("group", { name: "Job actions" }).getByRole("button", { name: "Run job", exact: true }).waitFor(); - await context.close(); -}); -test("mobile jobs list only navigates when a job title is tapped", async () => { - const { page, context } = await contextFor({ viewport: { width: 390, height: 844 } }); - const jobs = [ - { ...summary, id: "security-checker", name: "Security Checker Dispatcher" }, - { ...summary, id: "release-build", name: "Release build" }, - ]; - await page.route("**/api/v1/job-summaries?**", route => route.fulfill({ json: { items: jobs, nextCursor: null } })); - await page.goto(origin + `/projects/${projectId}`); - const table = page.getByRole("table", { name: "Jobs", exact: true }); - await table.getByRole("link", { name: "Security Checker Dispatcher", exact: true }).waitFor(); - - await table.locator("tbody tr").nth(1).locator("td").first().click(); - assert.match(page.url(), /\/projects\/[^/]+(?:\?|$)/); - - await table.getByRole("link", { name: "Release build", exact: true }).click(); - await page.waitForURL("**/jobs/release-build"); - assert.equal(page.url().endsWith("/jobs/release-build"), true); - await context.close(); -}); test("jobs can sort running and title in both directions with accessible state", async () => { const { page, context } = await contextFor(); const fixtures = [ @@ -511,7 +443,7 @@ test("jobs can sort running and title in both directions with accessible state", }); return route.fulfill({ json: { items: rows, nextCursor: null } }); }); - await page.goto(origin + `/projects/${projectId}`); + await page.goto(origin + "/jobs"); const table = page.getByRole("table", { name: "Jobs", exact: true }); await table.getByRole("link", { name: "Zulu", exact: true }).waitFor(); assert.deepEqual(await table.locator("tbody a").allTextContents(), ["Alpha", "Beta", "Zulu", "Idle"]); @@ -526,14 +458,14 @@ test("jobs can sort running and title in both directions with accessible state", assert.deepEqual(await table.locator("tbody a").allTextContents(), ["Alpha", "Beta", "Zulu", "Idle"]); const title = table.getByRole("button", { name: /Title sort/ }); await title.click(); - await page.waitForFunction(() => document.querySelectorAll("thead th")[1]?.getAttribute("aria-sort") === "ascending"); - assert.equal(await table.locator("th").nth(1).getAttribute("aria-sort"), "ascending"); + await page.waitForFunction(() => document.querySelectorAll("thead th")[2]?.getAttribute("aria-sort") === "ascending"); + assert.equal(await table.locator("th").nth(2).getAttribute("aria-sort"), "ascending"); assert.deepEqual(await table.locator("tbody a").allTextContents(), ["Alpha", "Beta", "Idle", "Zulu"]); await page.reload(); await table.getByRole("link", { name: "Alpha", exact: true }).waitFor(); - assert.equal(await table.locator("th").nth(1).getAttribute("aria-sort"), "ascending"); + assert.equal(await table.locator("th").nth(2).getAttribute("aria-sort"), "ascending"); await title.click(); - await page.waitForFunction(() => document.querySelectorAll("thead th")[1]?.getAttribute("aria-sort") === "descending"); - assert.equal(await table.locator("th").nth(1).getAttribute("aria-sort"), "descending"); + await page.waitForFunction(() => document.querySelectorAll("thead th")[2]?.getAttribute("aria-sort") === "descending"); + assert.equal(await table.locator("th").nth(2).getAttribute("aria-sort"), "descending"); assert.deepEqual(await table.locator("tbody a").allTextContents(), ["Zulu", "Idle", "Beta", "Alpha"]); await context.close(); }); diff --git a/packages/app/test/runs.browser.test.mjs b/packages/app/test/runs.browser.test.mjs index fb02f915..10beb870 100644 --- a/packages/app/test/runs.browser.test.mjs +++ b/packages/app/test/runs.browser.test.mjs @@ -95,14 +95,15 @@ const runs = Array.from({ length: 21 }, (_, index) => ({ id: `00000000-0000-4000 await page.getByRole("link", { name: "Next page" }).click(); await page.getByRole("link", { name: "Run 20", exact: true }).waitFor(); await page.reload(); await page.getByRole("link", { name: "Run 20", exact: true }).waitFor(); assert.ok(refreshes >= 2); await context.close(); }); -test("project Runs tab filters only project jobs and preserves selection through clearing, sorting, paging, and reload", async () => { +test("project page lists runs without a Runs subnav and preserves filters through clearing, sorting, paging, and reload", async () => { const { context, page } = await setup(); const requests = [], jobRequests = []; const captureEvidence = process.env.GEN_2823_CAPTURE_EVIDENCE === "1", evidenceDir = "/home/exedev/.factorize-job/GEN-2823"; if (captureEvidence) await mkdir(evidenceDir, { recursive: true }); await page.setViewportSize({ width: 1721, height: 914 }); const otherJobId = "00000000-0000-4000-8000-000000000009"; await page.route(`**/api/v1/projects/${projectId}`, route => route.fulfill({ json: { id: projectId, name: "Project Alpha", description: "Project details", jobCount: 1 } })); await page.route("**/api/v1/job-summaries?**", route => { const url = new URL(route.request().url()); jobRequests.push(url); return route.fulfill({ json: { items: [{ id: jobId, name: "Review builds", projectId, runningCount: 0 }], nextCursor: null } }); }); await page.route("**/api/v1/runs?**", route => { const url = new URL(route.request().url()); requests.push(url); const selected = url.searchParams.getAll("jobId").includes(jobId); return route.fulfill({ json: { items: [{ id: selected ? runId : otherJobId, project_id: projectId, project_name: "Project Alpha", job_id: selected ? jobId : otherJobId, job_name: selected ? "Review builds" : "Another job", run_name: selected ? "Filtered project run" : "Unfiltered project run", state: "succeeded", created_at: time, agent_kind: "codex" }], nextCursor: "next" } }); }); - await page.goto(origin + `/projects/${projectId}/runs`); await page.getByRole("link", { name: "Unfiltered project run", exact: true }).waitFor(); + await page.goto(origin + `/projects/${projectId}`); await page.getByRole("link", { name: "Unfiltered project run", exact: true }).waitFor(); + assert.deepEqual(await page.getByRole("navigation", { name: "Project navigation" }).getByRole("link").allTextContents(), ["Jobs", "Settings"]); const projectRunsTable = page.getByRole("table"); assert.equal(await projectRunsTable.getByRole("columnheader", { name: "Project", exact: true }).count(), 0); assert.equal(await projectRunsTable.locator("thead th").count(), 5); assert.equal(await projectRunsTable.locator("tbody tr").first().locator("td").count(), 5); assert.ok(jobRequests.every(url => url.searchParams.get("projectId") === projectId)); const jobFilter = page.getByRole("button", { name: /Job, all jobs/ }); await jobFilter.click(); @@ -119,7 +120,7 @@ test("project Runs tab filters only project jobs and preserves selection through assert.ok(requests.every(url => url.searchParams.getAll("projectId").every(id => id === projectId))); await page.goto(origin + "/job-runs"); await page.getByRole("button", { name: /Job, all jobs/ }).click(); await page.getByRole("dialog", { name: "Job filter" }).getByLabel("Review builds").waitFor(); if (captureEvidence) await page.screenshot({ path: `${evidenceDir}/global-runs-job-filter-open-desktop.png`, fullPage: true }); await context.close(); - const mobile = await setup(); await mobile.page.setViewportSize({ width: 390, height: 844 }); await mobile.page.route(`**/api/v1/projects/${projectId}`, route => route.fulfill({ json: { id: projectId, name: "Project Alpha", description: "Project details", jobCount: 1 } })); await mobile.page.route("**/api/v1/job-summaries?**", route => route.fulfill({ json: { items: [{ id: jobId, name: "Review builds", projectId, runningCount: 0 }], nextCursor: null } })); await mobile.page.route("**/api/v1/runs?**", route => route.fulfill({ json: { items: [{ id: runId, project_id: projectId, project_name: "Project Alpha", job_id: jobId, job_name: "Review builds", run_name: "Filtered project run", state: "succeeded", created_at: time, agent_kind: "codex" }], nextCursor: null } })); await mobile.page.goto(origin + `/projects/${projectId}/runs?jobs=${jobId}`); await mobile.page.getByRole("link", { name: "Filtered project run", exact: true }).waitFor(); if (captureEvidence) await mobile.page.screenshot({ path: `${evidenceDir}/project-runs-selected-mobile.png`, fullPage: true }); await mobile.context.close(); + const mobile = await setup(); await mobile.page.setViewportSize({ width: 390, height: 844 }); await mobile.page.route(`**/api/v1/projects/${projectId}`, route => route.fulfill({ json: { id: projectId, name: "Project Alpha", description: "Project details", jobCount: 1 } })); await mobile.page.route("**/api/v1/job-summaries?**", route => route.fulfill({ json: { items: [{ id: jobId, name: "Review builds", projectId, runningCount: 0 }], nextCursor: null } })); await mobile.page.route("**/api/v1/runs?**", route => route.fulfill({ json: { items: [{ id: runId, project_id: projectId, project_name: "Project Alpha", job_id: jobId, job_name: "Review builds", run_name: "Filtered project run", state: "succeeded", created_at: time, agent_kind: "codex" }], nextCursor: null } })); await mobile.page.goto(origin + `/projects/${projectId}?jobs=${jobId}`); await mobile.page.getByRole("link", { name: "Filtered project run", exact: true }).waitFor(); assert.equal(await mobile.page.getByRole("navigation", { name: "Project navigation" }).getByRole("link", { name: "Runs", exact: true }).count(), 0); if (captureEvidence) await mobile.page.screenshot({ path: `${evidenceDir}/project-runs-selected-mobile.png`, fullPage: true }); await mobile.context.close(); }); test("all-runs table sorts its data columns with accessible headers", async () => {