diff --git a/packages/api/test/api-contract.test.ts b/packages/api/test/api-contract.test.ts index d1012f6c..1396b76e 100644 --- a/packages/api/test/api-contract.test.ts +++ b/packages/api/test/api-contract.test.ts @@ -112,7 +112,7 @@ describe("versioned API contract", () => { }); } } - }); + }, 15_000); it("keeps the exact committed REST artifact available in the Reference group", () => { const docs = JSON.parse( diff --git a/packages/api/test/helpers/loading-fixture.ts b/packages/api/test/helpers/loading-fixture.ts index 863292c4..dfb30d3a 100644 --- a/packages/api/test/helpers/loading-fixture.ts +++ b/packages/api/test/helpers/loading-fixture.ts @@ -10,7 +10,7 @@ import type { Env, OAuthProps } from "../../src/types"; export async function loadingFixture(url: string) { let admin!: Database, db!: Database, env!: Env, auth!: OAuthProps, cookie!: string; const name = `loading_test_${crypto.randomUUID().replaceAll("-", "")}`; - const tenantId = crypto.randomUUID(), userId = crypto.randomUUID(), otherTenant = crypto.randomUUID(); + const tenantId = crypto.randomUUID(), userId = crypto.randomUUID(), otherTenant = crypto.randomUUID(), projectId = crypto.randomUUID(); const jobIds = Array.from({ length: 10 }, () => crypto.randomUUID()); const runId = crypto.randomUUID(); async function setup() { @@ -28,13 +28,13 @@ export async function loadingFixture(url: string) { } } as unknown as Fetcher, DATABASE: db, APP_ORIGIN: "https://factorize.test", SESSION_SIGNING_SECRET: "local-test-signing", OAUTH_PROVIDER: { listUserGrants: async () => ({ items: [] }) } as any, CREDENTIAL_ENCRYPTION_KEY: btoa("a".repeat(32)) } as Env; await new IdentityRepository(db, tenantId).upsertOwner(userId, "owner@example.test"); await db.pool.query("INSERT INTO app.tenants(id) VALUES ($1)", [otherTenant]); - await db.pool.query("INSERT INTO app.projects(tenant_id,id,name) VALUES ($1,md5(($1::uuid)::text || ':default')::uuid,'Default'),($2,md5(($2::uuid)::text || ':default')::uuid,'Default')", [tenantId, otherTenant]); + await db.pool.query("INSERT INTO app.projects(tenant_id,id,name) VALUES ($1,$2,'Default'),($1,md5(($1::uuid)::text || ':legacy')::uuid,'Legacy'),($3,md5(($3::uuid)::text || ':default')::uuid,'Default')", [tenantId, projectId, otherTenant]); auth = { tenantId, userId, sessionVersion: 0, scopes: ["flows:read", "flows:write", "runs:read", "runs:write"], authMethod: "session" }; const member = await new IdentityRepository(db, tenantId).member(userId); auth.sessionVersion = member!.sessionVersion; cookie = `factorize_session=${await signSession({ ...auth, email: "owner@example.test", exp: Math.floor(Date.now()/1000)+3600 }, env.SESSION_SIGNING_SECRET)}`; const prompt = await encrypt("Prompt", env.CREDENTIAL_ENCRYPTION_KEY); for (const [i, id] of jobIds.entries()) { - await db.pool.query("INSERT INTO app.jobs(tenant_id,id,project_id,name,slug,encrypted_prompt_template,execution_target,concurrency_limit) VALUES ($1, $2, md5(($1::uuid)::text || ':default')::uuid, $3, $3, $4, $5, 2)", [tenantId, id, `Job-${i}`, prompt, { agentKind: "codex", connectionId: "local" }]); + await db.pool.query("INSERT INTO app.jobs(tenant_id,id,project_id,name,slug,encrypted_prompt_template,execution_target,concurrency_limit) VALUES ($1, $2, $3, $4, $4, $5, $6, 2)", [tenantId, id, projectId, `Job-${i}`, prompt, { agentKind: "codex", connectionId: "local" }]); for (const [position, kind] of ["manual", "schedule"].entries()) await db.pool.query("INSERT INTO app.triggers(tenant_id,id,job_id,kind,slug,position,config) VALUES ($1,$2,$3,$4,$4,$5,$6)", [tenantId, crypto.randomUUID(), id, kind, position, kind === "schedule" ? { cron: "0 * * * *", timezone: "UTC" } : {}]); } // Same job ID in another tenant must never contribute triggers or statistics. @@ -55,5 +55,5 @@ export async function loadingFixture(url: string) { const request = (path: string, value = cookie) => worker.fetch(new Request(`https://factorize.test${path}`, { headers: { Cookie: value } }), env, { waitUntil() {} } as any); try { await setup(); } catch (error) { await cleanup(); throw error; } - return { db, env, auth, cookie, jobIds, runId, tenantId, userId, otherTenant, request, cleanup }; + return { db, env, auth, cookie, jobIds, runId, tenantId, userId, otherTenant, projectId, request, cleanup }; } diff --git a/packages/api/test/static-slice.e2e.test.ts b/packages/api/test/static-slice.e2e.test.ts index 533919f8..2d3cc1f3 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 jobs, invokes once, refreshes the run and resets a projected trace", async () => { + it("authenticates, navigates a project's jobs, 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 { @@ -62,7 +62,7 @@ describe.skipIf(!url)("compiled React + actual Worker API + PostgreSQL", () => { await page.getByRole("img", { name: "Status: Running", exact: true }).waitFor(); await page.getByRole("img", { name: "Status: Succeeded", exact: true }).waitFor(); } - await page.goto("https://factorize.test/jobs"); + await page.goto(`https://factorize.test/projects/${f.projectId}`); await page.locator("header img").waitFor(); expect(await page.locator("header img").evaluate(async (image: any) => { await image.decode(); return image.complete && image.naturalWidth > 0; })).toBe(true); expect(await page.locator("html").getAttribute("data-theme")).toBe("light"); diff --git a/packages/app/src/features/jobs/detail.tsx b/packages/app/src/features/jobs/detail.tsx index 1f5ef4be..ed8e4853 100644 --- a/packages/app/src/features/jobs/detail.tsx +++ b/packages/app/src/features/jobs/detail.tsx @@ -10,7 +10,7 @@ export function JobDetail() { const query = useQuery(jobQuery(jobId!)); const job = query.data; const project = useQuery({ ...projectQuery(job?.projectId ?? ""), enabled: !!job?.projectId }); - return + return {query.isPending &&

Loading job…

}{query.error &&

{query.error.message}

} {job && <>
diff --git a/packages/app/src/features/jobs/editor.tsx b/packages/app/src/features/jobs/editor.tsx index ff7cb778..80f0fede 100644 --- a/packages/app/src/features/jobs/editor.tsx +++ b/packages/app/src/features/jobs/editor.tsx @@ -59,7 +59,7 @@ function EditorForm({ initial }: { initial?: Job }) { }; const [localConflictDraft, setLocalConflictDraft] = useState(draft); const showJobBreadcrumb = Boolean(initial && !location.pathname.endsWith("/settings")); - return {initial && } + return {initial && } {(remote || reconciling || refreshConflict.error) &&

This job changed while you were editing

Your unsaved draft is retained. Review the latest configuration before retrying.

{refreshConflict.isPending &&

Loading latest configuration…

}{refreshConflict.error && <>

{refreshConflict.error.message}

} {remote && !conflicts.length && <>
{JSON.stringify(editableJob(remote), null, 2)}
} diff --git a/packages/app/src/features/jobs/list.tsx b/packages/app/src/features/jobs/list.tsx index 182ff346..65c44282 100644 --- a/packages/app/src/features/jobs/list.tsx +++ b/packages/app/src/features/jobs/list.tsx @@ -1,36 +1,34 @@ -import { Card, buttonClasses, Page, PageHeader, SortableHeader, Table, tableCellClasses, tableHeadClasses, tableRowClasses } from "../../shared/ui"; +import { Card, SortableHeader, Table, tableCellClasses, tableHeadClasses, tableRowClasses } from "../../shared/ui"; import { Link, useNavigate, useSearch } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { jobsQuery, type JobsOrder, type JobsSearch, type JobsSort } from "./queries"; -export function JobsList() { + +export function JobsTable({ projectId }: { projectId: string }) { const search = useSearch({ strict: false }) as JobsSearch; - const navigate = useNavigate({ from: "/jobs" }); - const query = useQuery(jobsQuery(search)); + const navigate = useNavigate({ from: "/projects/$projectId" }); + const query = useQuery(jobsQuery({ ...search, projectId })); const sortLabel = (sort: JobsSort) => sort === "running" ? "Running" : "Title"; const sortOrder = (sort: JobsSort): JobsOrder => search.sort === sort && search.order === "asc" ? "desc" : "asc"; const chooseSort = (sort: JobsSort) => void navigate({ search: { ...search, cursor: undefined, sort, order: sortOrder(sort) } }); - return - - Create job - + return <> {query.isPending &&

Loading jobs…

} {query.error &&

{query.error.message}

} {query.data && <> - {!query.data.items.length &&

No jobs found

Create your first job or adjust your search.

} + {!query.data.items.length &&

No jobs in this project yet.

Create your first job to get started.

} {!!query.data.items.length && - - chooseSort("running")} /> - chooseSort("title")} /> - - {query.data.items.map(job => - - - )} + + chooseSort("running")} /> + chooseSort("title")} /> + + {query.data.items.map(job => + + + )}
0 ? "font-semibold text-factorize-700 dark:text-factorize-500" : job.enabled ? "text-slate-600 dark:text-slate-400" : ""}`}>{job.runningCount}/{job.concurrencyLimit}
{job.name}{!job.enabled && disabled}
0 ? "font-semibold text-factorize-700 dark:text-factorize-500" : job.enabled ? "text-slate-600 dark:text-slate-400" : ""}`}>{job.runningCount}/{job.concurrencyLimit}
{job.name}{!job.enabled && disabled}
} } -
; + ; } diff --git a/packages/app/src/features/jobs/routes.tsx b/packages/app/src/features/jobs/routes.tsx index d50aa4e5..449a219d 100644 --- a/packages/app/src/features/jobs/routes.tsx +++ b/packages/app/src/features/jobs/routes.tsx @@ -1,7 +1,7 @@ import { createRoute, lazyRouteComponent, redirect, type AnyRootRoute } from "@tanstack/react-router"; import { sessionQuery } from "../auth/session"; import { queryClient } from "../../shared/query-client"; -import { jobsQuery, jobQuery, type JobsSearch } from "./queries"; +import { jobQuery } from "./queries"; import { runStatuses } from "../../shared/ui"; const runStates = runStatuses; const parseRunStates = (value: unknown) => { @@ -12,12 +12,7 @@ export function jobsRoutes(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 jobs = createRoute({ getParentRoute: () => root, path: "/jobs", beforeLoad: guard, - validateSearch: (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 === "false" ? s.enabled : undefined, sort: s.sort === "running" || s.sort === "title" ? s.sort : undefined, order: s.order === "asc" || s.order === "desc" ? s.order : undefined }), - loaderDeps: ({ search }) => search, - loader: ({ deps }) => queryClient.ensureQueryData(jobsQuery(deps)), - component: lazyRouteComponent(() => import("./list"), "JobsList"), - }); + const jobs = createRoute({ getParentRoute: () => root, path: "/jobs", beforeLoad: () => { throw redirect({ to: "/projects" }); } }); const job = createRoute({ getParentRoute: () => root, path: "/jobs/$jobId", beforeLoad: guard, validateSearch: (s: Record) => ({ cursor: typeof s.cursor === "string" ? s.cursor : undefined, previous: typeof s.previous === "string" ? s.previous.slice(0, 20_000) : undefined, states: parseRunStates(s.states) as any, sort: ["run", "status", "created", "agent"].includes(String(s.sort)) ? s.sort as "run" | "status" | "created" | "agent" : undefined, direction: s.direction === "asc" || s.direction === "desc" ? s.direction : undefined }), loader: ({ params }) => queryClient.ensureQueryData(jobQuery(params.jobId)), diff --git a/packages/app/src/features/projects/detail.tsx b/packages/app/src/features/projects/detail.tsx index 6f8db21d..c7efaea5 100644 --- a/packages/app/src/features/projects/detail.tsx +++ b/packages/app/src/features/projects/detail.tsx @@ -1,6 +1,6 @@ import { Link, useParams } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; -import { Button, Card, Page } from "../../shared/ui"; +import { Button, Page, PageHeader } from "../../shared/ui"; import { projectQuery } from "./queries"; -import { jobsQuery } from "../jobs/queries"; -export function ProjectDetail() { const { projectId } = useParams({ from: "/projects/$projectId" }); const project=useQuery(projectQuery(projectId)); const jobs=useQuery(jobsQuery({q:"",projectId})); return Projects{project.data&&<>

{project.data.name}

{project.data.description}

{jobs.isPending&&

Loading jobs…

}{jobs.error&&

{jobs.error.message}

}{jobs.data?.items.map(job=>
{job.name}{job.enabled?"Enabled":"Disabled"}
)}{jobs.data?.items.length===0&&

No jobs in this project yet.

}
}
; } +import { JobsTable } from "../jobs/list"; +export function ProjectDetail() { const { projectId } = useParams({ from: "/projects/$projectId" }); const project=useQuery(projectQuery(projectId)); return Projects{project.data&&<>}; } diff --git a/packages/app/src/features/projects/routes.tsx b/packages/app/src/features/projects/routes.tsx index 1bb8f499..70e379b8 100644 --- a/packages/app/src/features/projects/routes.tsx +++ b/packages/app/src/features/projects/routes.tsx @@ -1,3 +1,4 @@ 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"; -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 detail=createRoute({getParentRoute:()=>root,path:"/projects/$projectId",beforeLoad:guard,loader:({params})=>queryClient.ensureQueryData(projectQuery(params.projectId)),component:lazyRouteComponent(()=>import("./detail"),"ProjectDetail")}); return [list,create,detail]; } +import 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 detail=createRoute({getParentRoute:()=>root,path:"/projects/$projectId",beforeLoad:guard,validateSearch:(s: Record): JobsSearch => ({ q: typeof s.q === "string" ? s.q.slice(0, 120) : "", cursor: typeof s.cursor === "string" ? s.cursor : undefined, sort: s.sort === "running" || s.sort === "title" ? s.sort : undefined, order: s.order === "asc" || s.order === "desc" ? s.order : undefined }),loader:({params})=>queryClient.ensureQueryData(projectQuery(params.projectId)),component:lazyRouteComponent(()=>import("./detail"),"ProjectDetail")}); return [list,create,detail]; } diff --git a/packages/app/src/features/runs/detail.tsx b/packages/app/src/features/runs/detail.tsx index 081de72c..bd56177c 100644 --- a/packages/app/src/features/runs/detail.tsx +++ b/packages/app/src/features/runs/detail.tsx @@ -42,7 +42,7 @@ export function RunDetail() { }, onSuccess: () => cache.invalidateQueries({ queryKey: ["runs", runId] }) }); return {status.isPending &&

Loading run…

}{status.error &&

{status.error.message}

} - + {status.data && <>

{status.data.run_name || "Run"}

diff --git a/packages/app/src/routes/index.tsx b/packages/app/src/routes/index.tsx index 5e31fe65..9e539fa5 100644 --- a/packages/app/src/routes/index.tsx +++ b/packages/app/src/routes/index.tsx @@ -6,7 +6,7 @@ export function MigrationIndex() { const session = useQuery(sessionQuery); return

Your software factory

Welcome to Factorize

Connect your tools, configure your jobs, and follow every run.

{session.isPending &&

Loading your account…

}{session.error &&

{session.error.message}

} - {session.data?.authenticated ?

Jobs · Settings

:

Sign in · Create an account

} + {session.data?.authenticated ?

Projects · Settings

:

Sign in · Create an account

}

About Factorize

; } diff --git a/packages/app/test/editor.browser.test.mjs b/packages/app/test/editor.browser.test.mjs index f456f5f0..b3306cf9 100644 --- a/packages/app/test/editor.browser.test.mjs +++ b/packages/app/test/editor.browser.test.mjs @@ -53,7 +53,7 @@ test("template insertion escapes values and navigation protects unsaved work", a const { context, page } = await setup(); await page.route(`**/api/v1/jobs/${id}`, route => route.fulfill({ json: initial })); await page.goto(origin + `/jobs/${id}/edit`); await page.getByText("Insert a prompt template variable", { exact: true }).click(); await page.getByLabel("Prompt template", { exact: true }).focus(); await page.getByLabel("Prompt template", { exact: true }).press("End"); await page.getByRole("button", { name: "trigger-1.prompt", exact: true }).click(); assert.equal(await page.getByLabel("Prompt template", { exact: true }).inputValue(), "Original prompt{{trigger-1.prompt}}"); - page.once("dialog", dialog => dialog.dismiss()); await page.locator("main").getByRole("link", { name: "Jobs", exact: true }).click(); assert.ok(page.url().includes("/settings")); await context.close(); + page.once("dialog", dialog => dialog.dismiss()); await page.locator("main").getByRole("link", { name: "Projects", exact: true }).click(); assert.ok(page.url().includes("/settings")); await context.close(); }); test("provider editor fetches only the selected provider and submits project/matching/conditions configuration explicitly", async () => { const { context, page } = await setup(); const reads = [], writes = []; let current = structuredClone(initial); diff --git a/packages/app/test/functional/critical.spec.mjs b/packages/app/test/functional/critical.spec.mjs index af4f09ad..43578efd 100644 --- a/packages/app/test/functional/critical.spec.mjs +++ b/packages/app/test/functional/critical.spec.mjs @@ -2,7 +2,7 @@ import AxeBuilder from "@axe-core/playwright"; import { test, expect } from "@playwright/test"; import { createServer } from "node:http"; import { readFile } from "node:fs/promises"; -import { mockApi, screens, jobId } from "../visual/fixtures.mjs"; +import { mockApi, screens, jobId, projectId } from "../visual/fixtures.mjs"; let server; let origin; @@ -64,8 +64,8 @@ test("loading, search, error and no-result states are actionable", async ({ 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.goto(origin + "/jobs"); - await expect(page.getByRole("status").filter({ hasText: "Loading…" })).toBeVisible(); + await page.goto(origin + "/projects/" + projectId); + await expect(page.getByRole("status").filter({ hasText: "Loading jobs…" })).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."); diff --git a/packages/app/test/jobs.browser.test.mjs b/packages/app/test/jobs.browser.test.mjs index f3ac2953..fdd4d408 100644 --- a/packages/app/test/jobs.browser.test.mjs +++ b/packages/app/test/jobs.browser.test.mjs @@ -4,8 +4,8 @@ import { createServer } from "node:http"; import { readFile, writeFile } from "node:fs/promises"; import { chromium } from "playwright"; let server, origin, browser; -const jobId = "00000000-0000-4000-8000-000000000001", runId = "00000000-0000-4000-8000-000000000002"; -const summary = { id: jobId, name: "Review builds", slug: "review-builds", enabled: true, runningCount: 0, concurrencyLimit: 2, lastRunState: null, model: "", effort: "", agentKind: "codex", createdAt: "2026-10-09T12:00:00Z", updatedAt: "2026-10-09T12:00:00Z" }; +const jobId = "00000000-0000-4000-8000-000000000001", projectId = "00000000-0000-4000-8000-000000000010", runId = "00000000-0000-4000-8000-000000000002"; +const summary = { id: jobId, projectId, name: "Review builds", slug: "review-builds", enabled: true, runningCount: 0, concurrencyLimit: 2, lastRunState: null, model: "", effort: "", agentKind: "codex", createdAt: "2026-10-09T12:00:00Z", updatedAt: "2026-10-09T12:00:00Z" }; before(async () => { server = createServer(async (req, res) => { const path = new URL(req.url, "http://local").pathname; @@ -22,6 +22,7 @@ async function contextFor(options) { await page.route("**/api/v1/runs?**", route => route.fulfill({ json: { items: [], nextCursor: null } })); page.setDefaultTimeout(10000); await page.route("**/api/v1/session", route => route.fulfill({ json: { authenticated: true, user: { id: "owner", email: "owner@example.test" }, workspace: { id: "tenant", name: "Workspace" }, capabilities: [], expiresAt: "2026-10-10T00:00:00Z" } })); + await page.route(`**/api/v1/projects/${projectId}`, route => route.fulfill({ json: { id: projectId, name: "Release project", description: "Project jobs", jobCount: 31 } })); return { context, page }; } 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 () => { @@ -36,7 +37,7 @@ for (const width of [1280, 390, 320]) for (const theme of ["light", "dark"]) tes return route.fulfill({ json: { items: url.searchParams.has("cursor") ? fixtures.slice(30) : fixtures.slice(0, 30), nextCursor: url.searchParams.has("cursor") ? null : fixtures[29].id } }); }); await page.route(`**/api/v1/jobs/${fixtures[30].id}`, route => route.fulfill({ json: { ...fixtures[30], promptTemplate: "Template", runNameTemplate: "", executionTargetId: "local", executionTarget: { connectionId: "local", workspace: "ephemeral", cwd: "/workspace", agentKind: "codex" }, triggers: [], currentRuns: 2, maxConcurrency: 10 } })); - await page.goto(origin + "/jobs?q=Review"); + 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"]); @@ -50,8 +51,8 @@ for (const width of [1280, 390, 320]) for (const theme of ["light", "dark"]) tes ); 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 heading = await page.getByRole("heading", { name: "Jobs", exact: true }).boundingBox(), create = await page.getByRole("link", { name: "Create job", exact: true }).boundingBox(); - assert.ok(create.x > heading.x + heading.width && Math.abs(create.y - heading.y) < 12); + const heading = await page.getByRole("heading", { name: "Release project", exact: true }).boundingBox(), create = await page.getByRole("link", { name: "Create job", exact: true }).boundingBox(); + if (width >= 1280) assert.ok(create.x > heading.x + heading.width && Math.abs(create.y - heading.y) < 12); 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); @@ -77,13 +78,13 @@ test("mobile jobs list only navigates when a job title is tapped", async () => { { ...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 + "/jobs"); + 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 page.getByRole("heading", { name: "Jobs", exact: true }).click(); + await page.getByRole("heading", { name: "Release project", exact: true }).click(); await table.locator("tbody tr").nth(1).locator("td").first().click(); - assert.match(page.url(), /\/jobs(?:\?|$)/); + assert.match(page.url(), /\/projects\/[^/]+(?:\?|$)/); await table.getByRole("link", { name: "Release build", exact: true }).click(); await page.waitForURL("**/jobs/release-build"); @@ -99,7 +100,7 @@ test("jobs can sort running and title in both directions with accessible state", { ...summary, id: "job-i", name: "Idle", runningCount: 0 }, ]; await page.route("**/api/v1/job-summaries?**", route => route.fulfill({ json: { items: fixtures, nextCursor: null } })); - await page.goto(origin + "/jobs"); + await page.goto(origin + `/projects/${projectId}`); 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"]); diff --git a/packages/app/test/runs.browser.test.mjs b/packages/app/test/runs.browser.test.mjs index 9a03d980..0206aad1 100644 --- a/packages/app/test/runs.browser.test.mjs +++ b/packages/app/test/runs.browser.test.mjs @@ -90,7 +90,7 @@ test("prompt/provenance and diagnostics are lazy, terminal replay retries reuse assert.equal(await page.locator('a[href^="javascript:"]').count(), 0); assert.equal(await page.locator("main script").count(), 0); assert.equal(await page.getByRole("tab", { name: "Trace", exact: true }).getAttribute("aria-selected"), "true"); const breadcrumbs = page.getByRole("navigation", { name: "Breadcrumb" }); - assert.equal(await breadcrumbs.getByRole("link", { name: "Jobs", exact: true }).getAttribute("href"), "/jobs?q="); + assert.equal(await breadcrumbs.getByRole("link", { name: "Projects", exact: true }).getAttribute("href"), "/projects"); assert.equal(await breadcrumbs.getByRole("link", { name: "Job", exact: true }).getAttribute("href"), "/jobs/" + jobId); await page.getByRole("tab", { name: "Info", exact: true }).click(); await page.getByText("Created", { exact: true }).waitFor(); await page.getByText("Started", { exact: true }).waitFor(); await page.getByText("Queue time", { exact: true }).waitFor(); await page.getByText("Runtime", { exact: true }).waitFor(); await page.getByText("Execution destination", { exact: true }).waitFor(); diff --git a/packages/app/test/visual/fixtures.mjs b/packages/app/test/visual/fixtures.mjs index 18ccd6a0..bc8023ae 100644 --- a/packages/app/test/visual/fixtures.mjs +++ b/packages/app/test/visual/fixtures.mjs @@ -1,8 +1,9 @@ export const jobId = "00000000-0000-4000-8000-000000000001"; +export const projectId = "00000000-0000-4000-8000-000000000010"; export const runId = "00000000-0000-4000-8000-000000000002"; export const time = "2026-10-09T12:00:00.000Z"; export const job = { - id: jobId, name: "Review release builds", slug: "review-builds", enabled: true, + id: jobId, projectId, name: "Review release builds", slug: "review-builds", enabled: true, promptTemplate: "Review {{trigger-1.prompt}}.\nCheck the release notes and report any regressions.", runNameTemplate: "Release review", executionTargetId: "vm", executionTarget: { connectionId: "vm", workspace: "ephemeral", cwd: "/workspace", agentKind: "codex" }, @@ -26,6 +27,7 @@ export const events = [ { id: "four", sequence: 4, type: "assistant_message", title: "Assistant", preview: "### Release ready\n\nThe interfaces remain compatible.\n\n- Contract checks passed\n- No regressions found\n\n```typescript\nconst release = { ready: true };\n```", display: {} }, ]; export const integrations = { linear: { organizationName: "Release workspace" }, clickup: null, github: { installations: [] }, exeConnections: [], ampConnections: [], cloudflareTail: { installations: [] } }; +export const project = { id: projectId, name: "Release projects", description: "Projects for release automation.", jobCount: 2 }; export const targets = [{ id: "vm", kind: "exe-vm", name: "Release VM", workspace: "ephemeral", cwd: "/workspace", agentKind: "codex", models: ["gpt-6"], efforts: ["high"], capabilities: ["stop"] }]; export async function mockApi(page, { legacy = false, authenticated = true, empty = false, state = "succeeded", long = false } = {}) { const configuredJob = { ...job, ...(long ? { name: "Long release review " + "abcdefghij".repeat(14), promptTemplate: "A long prompt " + "word ".repeat(500) } : {}) }; @@ -33,6 +35,8 @@ export async function mockApi(page, { legacy = false, authenticated = true, empt const url = new URL(route.request().url()), path = url.pathname; let json; if (path === "/api/v1/session") json = authenticated ? { authenticated: true, user: { id: "owner", email: "owner@example.test" }, workspace: { id: "tenant", name: "Release" }, capabilities: [], expiresAt: "2026-10-10T00:00:00Z" } : { authenticated: false }; + else if (path === "/api/v1/projects") json = [project]; + else if (path === "/api/v1/projects/" + projectId) json = project; else if (path === "/api/v1/job-summaries") json = { items: empty ? [] : [configuredJob, { ...configuredJob, id: "disabled", name: "Archive release artifacts", enabled: false, runningCount: 0, lastRunState: "failed" }], nextCursor: null }; else if (path === "/api/v1/jobs") json = empty ? [] : [configuredJob, { ...configuredJob, id: "disabled", name: "Archive release artifacts", enabled: false, runningCount: 0, lastRunState: "failed" }]; else if (path === "/api/v1/jobs/" + jobId) json = configuredJob;