Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
TestSpeedType,
TestcaseType,
} from "@agenta/web-tests/playwright/config/testTags"
import {test} from "@agenta/web-tests/tests/fixtures/base.fixture"
import {test as baseTest} from "@agenta/web-tests/tests/fixtures/base.fixture"
import {getProjectScopedBasePath} from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers"
import {expect} from "@agenta/web-tests/utils"
import type {Page} from "@playwright/test"
Expand All @@ -19,6 +19,20 @@ import {expectAuthenticatedSession} from "../utils/auth"
import {createScenarios} from "../utils/scenarios"
import {buildAcceptanceTags} from "../utils/tags"

const test = baseTest.extend<{
registerAgentAppForCleanup: (appId: string) => void
}>({
registerAgentAppForCleanup: async ({apiHelpers}, use) => {
let appId: string | undefined
await use((createdAppId) => {
appId = createdAppId
})
if (appId) {
await apiHelpers.archiveApp(appId)
}
},
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const scenarios = createScenarios(test)

const tags = buildAcceptanceTags({
Expand Down Expand Up @@ -89,7 +103,7 @@ const selectBundledFile = async (page: Page, label: string) => {
test(
"uploading a skill package parses the folded description and keeps bundled files isolated",
{tag: tags},
async ({page, uiHelpers}) => {
async ({page, uiHelpers, registerAgentAppForCleanup}) => {
const appName = `e2e-skill-upload-${Date.now()}`
const descriptionField = () =>
skillDrawer(page).getByPlaceholder("When the agent should reach for this skill")
Expand Down Expand Up @@ -139,6 +153,7 @@ test(
const createResponse = await createResponsePromise
expect(createResponse.ok()).toBe(true)
const created = (await createResponse.json()) as {workflow: {id: string}}
registerAgentAppForCleanup(created.workflow.id)

await page.goto(
`${getProjectScopedBasePath(page)}/apps/${created.workflow.id}/playground`,
Expand Down
78 changes: 78 additions & 0 deletions web/oss/tests/playwright/unit/api-helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
appMatchesType,
selectLatestAppRevisions,
} from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers"
import type {
APP_TYPE,
ListAppsItem,
} from "@agenta/web-tests/tests/fixtures/base.fixture/apiHelpers/types"
import {expect, test} from "@playwright/test"

const app = (flags: ListAppsItem["flags"]): ListAppsItem =>
({
id: "app-id",
name: "test-app",
app_type: "completion",
flags,
created_at: null,
}) as ListAppsItem

test("agent revisions are excluded from prompt app matching", () => {
const artifact = app({is_application: true})
const agentRevision = {
flags: {is_agent: true, is_chat: true, is_custom: true},
}

for (const type of ["completion", "chat", "custom"] satisfies APP_TYPE[]) {
expect(appMatchesType(artifact, type, agentRevision)).toBe(false)
}
})

test("latest revision flags classify prompt apps", () => {
const artifact = app({is_application: true})

expect(appMatchesType(artifact, "completion", {flags: {}})).toBe(true)
expect(appMatchesType(artifact, "chat", {flags: {is_chat: true}})).toBe(true)
expect(appMatchesType(artifact, "custom", {flags: {is_custom: true}})).toBe(true)
})

test("latest revision selection ignores v0 records", () => {
const latestByAppId = selectLatestAppRevisions([
{
workflow_id: "app-id",
flags: {is_agent: true},
version: "0",
created_at: "2026-07-31T10:00:00Z",
},
{
workflow_id: "app-id",
flags: {},
version: "1",
created_at: "2026-07-31T09:00:00Z",
},
])

const latest = latestByAppId.get("app-id")
expect(latest?.version).toBe("1")
expect(appMatchesType(app({is_application: true}), "completion", latest)).toBe(true)
})

test("latest revision selection keeps records with a missing version", () => {
const latestByAppId = selectLatestAppRevisions([
{
workflow_id: "app-id",
flags: {is_agent: true},
created_at: "2026-07-31T09:00:00Z",
},
{
workflow_id: "app-id",
flags: {},
version: "0",
created_at: "2026-07-31T10:00:00Z",
},
])

const latest = latestByAppId.get("app-id")
expect(latest?.version).toBeUndefined()
expect(latest?.flags?.is_agent).toBe(true)
})
120 changes: 97 additions & 23 deletions web/tests/tests/fixtures/base.fixture/apiHelpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,17 @@ import {getProjectMetadataPath} from "../../../../playwright/config/runtime.ts"
import {UseFn} from "../../types"
import {FixtureContext} from "../types"

import type {ApiHandlerOptions, ApiHelpers, CreateTestsetInput, CreatedTestset} from "./types"
import type {
ApiHandlerOptions,
ApiHelpers,
APP_TYPE,
CreateTestsetInput,
CreatedTestset,
ListAppsItem,
} from "./types"

type APP_TYPE = "completion" | "chat" | "custom"
type CREATABLE_APP_TYPE = Exclude<APP_TYPE, "custom">

interface ListAppsItem {
id: string
name: string
app_type: APP_TYPE
flags: {
is_chat?: boolean
is_custom?: boolean
is_application?: boolean
is_evaluator?: boolean
} | null
created_at: string | null
[key: string]: any
}

interface ApiVariant {
id: string
name: string | null
Expand All @@ -45,6 +37,14 @@ interface ApiVariant {

const latestRevisionIdByAppId = new Map<string, string>()

export interface AppRevision {
workflow_id?: string | null
flags?: ListAppsItem["flags"]
version?: string | null
created_at?: string | null
updated_at?: string | null
}

interface TestProjectMetadata {
project_id?: string
workspace_id?: string
Expand Down Expand Up @@ -410,6 +410,65 @@ async function createApp(page: Page, type: APP_TYPE): Promise<ListAppsItem> {
} as ListAppsItem
}

const revisionRecencyScore = (revision: AppRevision): number => {
const createdAt = revision.created_at ? Date.parse(revision.created_at) : 0
const updatedAt = revision.updated_at ? Date.parse(revision.updated_at) : 0
return createdAt || updatedAt || Number(revision.version ?? 0)
}

export const selectLatestAppRevisions = (revisions: AppRevision[]): Map<string, AppRevision> => {
const latestByAppId = new Map<string, AppRevision>()

for (const revision of revisions) {
const appId = revision.workflow_id
if (!appId || revision.version === "0") continue

const current = latestByAppId.get(appId)
if (!current || revisionRecencyScore(revision) > revisionRecencyScore(current)) {
latestByAppId.set(appId, revision)
}
}

return latestByAppId
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const getLatestAppRevisions = async (
page: Page,
apps: ListAppsItem[],
): Promise<Map<string, AppRevision>> => {
if (!apps.length) return new Map()

const queryUrl = new URL(`${getApiURL(page)}/workflows/revisions/query`)
queryUrl.searchParams.set("project_id", getProjectId(page))

const response = await page.request.post(queryUrl.toString(), {
data: {workflow_refs: apps.map(({id}) => ({id}))},
})
if (!response.ok()) {
const body = await response.text().catch(() => "<unreadable>")
throw new Error(
`getLatestAppRevisions failed: HTTP ${response.status()} ${response.statusText()}.\n` +
`URL: ${queryUrl.toString()}\n` +
`Response body: ${body}`,
)
}

const data = (await response.json()) as {workflow_revisions?: AppRevision[]}
return selectLatestAppRevisions(data.workflow_revisions ?? [])
}

export const appMatchesType = (
app: ListAppsItem,
type: APP_TYPE,
latestRevision?: {flags?: ListAppsItem["flags"]},
): boolean => {
const flags = {...(app.flags ?? {}), ...(latestRevision?.flags ?? {})}
if (flags.is_agent) return false
if (type === "chat") return !!flags.is_chat
if (type === "custom") return !!flags.is_custom
return !flags.is_chat && !flags.is_custom && !flags.is_evaluator
}

export const getApp = async (page: Page, type: APP_TYPE = "completion") => {
const appsResponse = waitForApiResponse<{workflows: ListAppsItem[]; count: number}>(page, {
route: "/workflows/query",
Expand All @@ -425,18 +484,15 @@ export const getApp = async (page: Page, type: APP_TYPE = "completion") => {

expect(Array.isArray(apps)).toBe(true)

const appMatchesType = (app: ListAppsItem) => {
if (type === "chat") return !!app.flags?.is_chat
if (type === "custom") return !!app.flags?.is_custom
// completion: exclude evaluator apps, which also lack is_chat/is_custom
return !app.flags?.is_chat && !app.flags?.is_custom && !app.flags?.is_evaluator
}
const latestRevisions = await getLatestAppRevisions(page, apps)

let targetApp
if (!apps.length) {
targetApp = await createApp(page, type)
} else if (type) {
const app = apps.find(appMatchesType)
const app = apps.find((candidate) =>
appMatchesType(candidate, type, latestRevisions.get(candidate.id)),
)
if (!app) {
targetApp = await createApp(page, type)
} else {
Expand All @@ -455,6 +511,21 @@ export const getApp = async (page: Page, type: APP_TYPE = "completion") => {
return targetApp
}

export const archiveApp = async (page: Page, appId: string): Promise<void> => {
const archiveUrl = new URL(`${getApiURL(page)}/workflows/${appId}/archive`)
archiveUrl.searchParams.set("project_id", getProjectId(page))

const response = await page.request.post(archiveUrl.toString(), {data: {}})
if (!response.ok()) {
const body = await response.text().catch(() => "<unreadable>")
throw new Error(
`archiveApp('${appId}') failed: HTTP ${response.status()} ${response.statusText()}.\n` +
`URL: ${archiveUrl.toString()}\n` +
`Response body: ${body}`,
)
}
}

export const getAppById = async (page: Page, appId: string) => {
const appsResponse = waitForApiResponse<{workflows: ListAppsItem[]; count: number}>(page, {
route: "/workflows/query",
Expand Down Expand Up @@ -667,6 +738,9 @@ export const apiHelpers = () => {
getApp: async (type?: APP_TYPE): Promise<ListAppsItem> => {
return await getApp(page, type)
},
archiveApp: async (appId: string): Promise<void> => {
await archiveApp(page, appId)
},
getAppById: async (appId: string): Promise<ListAppsItem> => {
return await getAppById(page, appId)
},
Expand Down
6 changes: 4 additions & 2 deletions web/tests/tests/fixtures/base.fixture/apiHelpers/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {testset} from "../../../../../oss/src/lib/Types"

type APP_TYPE = "completion" | "chat" | "custom"
export type APP_TYPE = "completion" | "chat" | "custom"

interface ListAppsItem {
export interface ListAppsItem {
id: string
name: string
app_type: APP_TYPE
Expand All @@ -11,6 +11,7 @@ interface ListAppsItem {
is_custom?: boolean
is_application?: boolean
is_evaluator?: boolean
is_agent?: boolean
} | null
created_at: string | null
[key: string]: any
Expand Down Expand Up @@ -55,6 +56,7 @@ export interface ApiHelpers {
waitForApiResponse: <T>(options: ApiHandlerOptions<T>) => Promise<T>
createApp: (type?: APP_TYPE) => Promise<ListAppsItem>
getApp: (type?: APP_TYPE) => Promise<ListAppsItem>
archiveApp: (appId: string) => Promise<void>
getAppById: (appId: string) => Promise<ListAppsItem>
getTestsets: () => Promise<testset[]>
createTestset: (input: CreateTestsetInput) => Promise<CreatedTestset>
Expand Down
Loading