Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
89 changes: 53 additions & 36 deletions web/ee/tests/playwright/acceptance/members/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,6 @@ const lightFastTags = buildAcceptanceTags({
const createInviteEmail = (scope: string) =>
`${scope}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@agenta.test`

const waitForInviteResponse = async (page: any) => {
const response = await page.waitForResponse(
(res: any) =>
res.request().method() === "POST" &&
res.url().includes("/workspaces/") &&
res.url().includes("/invite?"),
{timeout: 15000},
)

if (!response.ok()) {
throw new Error(`Invite request failed (${response.status()}): ${await response.text()}`)
}
}

const waitForRemoveResponse = async (page: any) => {
const response = await page.waitForResponse(
(res: any) =>
Expand All @@ -64,6 +50,37 @@ const waitForRemoveResponse = async (page: any) => {
}
}

const openInviteMembersModal = async (page: any) => {
const inviteButton = page.getByRole("button", {name: "Invite Members"}).first()
await expect(inviteButton).toBeVisible({timeout: 20000})
await expect(inviteButton).toBeEnabled()

const inviteModal = page.getByRole("dialog", {name: "Invite Members"})
const emailInput = inviteModal.getByPlaceholder("member@organization.com")

for (let attempt = 0; attempt < 2; attempt++) {
await inviteButton.click()

if (
await inviteModal
.waitFor({state: "visible", timeout: 5000})
.then(() => true)
.catch(() => false)
) {
await expect(emailInput).toBeVisible({timeout: 20000})
return {inviteModal, emailInput}
}
}

await expect(emailInput).toBeVisible({timeout: 10000})
return {inviteModal, emailInput}
}
Comment on lines +53 to +99
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make the email input timeout consistent in both code paths.

The email input visibility check uses a 20-second timeout inside the retry loop (line 70) but only a 10-second timeout in the fallback path (line 75). This inconsistency could cause the fallback to fail prematurely if the modal opens on the second attempt but the email input takes 11-19 seconds to render.

🔧 Proposed fix to unify timeout
     }
 
-    await expect(emailInput).toBeVisible({timeout: 10000})
+    await expect(emailInput).toBeVisible({timeout: 20000})
     return {inviteModal, emailInput}
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const openInviteMembersModal = async (page: any) => {
const inviteButton = page.getByRole("button", {name: "Invite Members"}).first()
await expect(inviteButton).toBeVisible({timeout: 20000})
await expect(inviteButton).toBeEnabled()
const inviteModal = page.getByRole("dialog", {name: "Invite Members"})
const emailInput = inviteModal.getByPlaceholder("member@organization.com")
for (let attempt = 0; attempt < 2; attempt++) {
await inviteButton.click()
if (
await inviteModal
.waitFor({state: "visible", timeout: 5000})
.then(() => true)
.catch(() => false)
) {
await expect(emailInput).toBeVisible({timeout: 20000})
return {inviteModal, emailInput}
}
}
await expect(emailInput).toBeVisible({timeout: 10000})
return {inviteModal, emailInput}
}
const openInviteMembersModal = async (page: any) => {
const inviteButton = page.getByRole("button", {name: "Invite Members"}).first()
await expect(inviteButton).toBeVisible({timeout: 20000})
await expect(inviteButton).toBeEnabled()
const inviteModal = page.getByRole("dialog", {name: "Invite Members"})
const emailInput = inviteModal.getByPlaceholder("member@organization.com")
for (let attempt = 0; attempt < 2; attempt++) {
await inviteButton.click()
if (
await inviteModal
.waitFor({state: "visible", timeout: 5000})
.then(() => true)
.catch(() => false)
) {
await expect(emailInput).toBeVisible({timeout: 20000})
return {inviteModal, emailInput}
}
}
await expect(emailInput).toBeVisible({timeout: 20000})
return {inviteModal, emailInput}
}


const submitInviteMembersModal = async (inviteModal: any) => {
await inviteModal.locator("form").evaluate((form: HTMLFormElement) => form.requestSubmit())
await expect(inviteModal).not.toBeVisible({timeout: 30000})
}

/**
* Invite a member via the EE flow (email sent) and wait for their row to appear
* in the members table with "Invitation Pending" status.
Expand All @@ -76,23 +93,23 @@ const invitePendingMember = async (page: any, apiHelpers: any, uiHelpers: any):
await page.goto(`${basePath}/settings`, {waitUntil: "domcontentloaded"})
await uiHelpers.expectPath("/settings")

const inviteButton = page.getByRole("button", {name: "Invite Members"})
await expect(inviteButton).toBeVisible({timeout: 20000})
await inviteButton.click()

const inviteModal = page.getByRole("dialog", {name: "Invite Members"})
const emailInput = inviteModal.getByPlaceholder("member@organization.com")
const {inviteModal, emailInput} = await openInviteMembersModal(page)
// Wait for the email input rather than just the dialog — the InviteUsersModal
// is a dynamic() import, so the form body can lag behind the modal wrapper.
// Waiting for the input guarantees the chunk has fully rendered.
await expect(emailInput).toBeVisible({timeout: 20000})
// Click before fill: rc-component/dialog briefly unmounts while animatedVisible
// catches up (useEffect fires after first render), which makes the locator
// stale. A click forces Playwright to wait for the element to be fully
// interactive before fill attempts to interact.
await emailInput.click()
await emailInput.fill(testEmail)

await Promise.all([
waitForInviteResponse(page),
inviteModal.getByRole("button", {name: "Invite"}).click(),
])
await expect(inviteModal).not.toBeVisible({timeout: 15000})
// Submit the form and wait for the modal to close as the success signal.
// The InviteUsersModal only closes its onSuccess callback after the API
// returns successfully — so modal closure == invite accepted.
// Waiting for the network response by URL is fragile (URL-pattern drift,
// timing races between listener registration and the async form submit).
await submitInviteMembersModal(inviteModal)

// Wait for the pending row to appear in the refreshed table
await expect(page.getByText(testEmail)).toBeVisible({timeout: 15000})
Expand Down Expand Up @@ -125,13 +142,14 @@ const membersTests = () => {
await scenarios.when(
"the user clicks Invite Members, fills in an email address and selects a role",
async () => {
await page.getByRole("button", {name: "Invite Members"}).click()

const inviteModal = page.getByRole("dialog", {name: "Invite Members"})
const emailInput = inviteModal.getByPlaceholder("member@organization.com")
const {inviteModal, emailInput} = await openInviteMembersModal(page)
// Wait for the input directly — the InviteUsersModal is a dynamic()
// import so the form body can lag behind the modal wrapper appearing.
await expect(emailInput).toBeVisible({timeout: 20000})
// Click before fill: rc-component/dialog briefly unmounts the panel
// while animatedVisible settles (useEffect fires after first render),
// making the locator transiently stale. Clicking first ensures the
// element is fully interactive before fill runs.
await emailInput.click()
await emailInput.fill(testEmail)

// EE renders a role selector; keep the default selection
Expand All @@ -144,11 +162,10 @@ const membersTests = () => {

await scenarios.and("the user submits the invitation", async () => {
const inviteModal = page.getByRole("dialog", {name: "Invite Members"})
await Promise.all([
waitForInviteResponse(page),
inviteModal.getByRole("button", {name: "Invite"}).click(),
])
await expect(inviteModal).not.toBeVisible({timeout: 15000})
// Submit the form and wait for the modal to dismiss as the success signal.
// The InviteUsersModal only closes after the API returns successfully,
// so modal closure is equivalent to a successful invite response.
await submitInviteMembersModal(inviteModal)
})

await scenarios.then(
Expand Down
4 changes: 4 additions & 0 deletions web/ee/tests/playwright/acceptance/use-api/use-api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {test} from "@agenta/web-tests/tests/fixtures/base.fixture"
import useApiTests from "@agenta/oss/tests/playwright/10-use-api"

test.describe("Registry: use API snippets", useApiTests)
3 changes: 3 additions & 0 deletions web/oss/tests/playwright/10-use-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import useApiTests from "./acceptance/use-api"

export default useApiTests
3 changes: 3 additions & 0 deletions web/oss/tests/playwright/acceptance/evaluators/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,9 @@ const createHumanEvaluatorFromDrawer = async (
await nameInput.fill(evaluatorName)
await expect(nameInput).toHaveValue(evaluatorName)

const slugInput = drawer.locator('input[placeholder="Enter a unique slug"]').first()
await expect(slugInput).toHaveValue(evaluatorName, {timeout: 5000})

// Fill in the feedback name (the first metric row)
const feedbackNameInput = drawer
.locator(`input[placeholder="${HUMAN_EVALUATOR_FEEDBACK_NAME_PLACEHOLDER}"]`)
Expand Down
40 changes: 40 additions & 0 deletions web/oss/tests/playwright/acceptance/features/use-api.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Tests: use-api/use-api.spec.ts -> use-api/index.ts
# RTM IDs: WEB-ACC-USEAPI-001, WEB-ACC-USEAPI-002
# Tags: @scope:deployment @coverage:light @path:happy @speed:fast
#
# Implementation notes:
# - Variant mode: navigate to /apps/{id}/variants, click "Use API" (primary button)
# → DeploymentsDrawer opens in mode="variant" rendering VariantUseApiContent
# → Fetch Prompt/Config snippet uses application_variant_ref (variant-keyed endpoint)
# → Invoke LLM snippet uses axios.post to the variant invocation URL
# - Deployment mode: navigate to /apps/{id}/variants?tab=deployments&selectedEnvName=development
# → click "Use API" (primary button in the deployments tab header)
# → DeploymentsDrawer opens in mode="deployment" rendering UseApiContent
# → Fetch Prompt/Config snippet uses environment_ref (environment-keyed endpoint)
# → Invoke LLM snippet uses axios.post to the environment invocation URL
# - Both tests assert TypeScript tab only (Python and cURL are separate language concerns).

Feature: Registry Use API — TypeScript snippets
As a user
I want to view TypeScript code snippets for calling my app via API
So that I can integrate Agenta into my TypeScript project

Background:
Given the user is authenticated
And a completion app with at least one variant exists

@light @happy @scope:deployment @speed:fast
Scenario: Variant TypeScript snippet shows application_variant_ref in Fetch section
Given the user is on the Variants registry page
When the user opens the Use API drawer
And the user selects the TypeScript tab
Then the Fetch Prompt/Config section displays the variant TypeScript snippet
And the Invoke LLM section displays a TypeScript axios snippet

@light @happy @scope:deployment @speed:fast
Scenario: Deployment TypeScript snippet shows environment_ref in Fetch section
Given the user is on the Deployments registry page for the Development environment
When the user opens the Use API drawer
And the user selects the TypeScript tab
Then the Fetch Prompt/Config section displays the deployment TypeScript snippet
And the Invoke LLM section displays a TypeScript axios snippet
92 changes: 24 additions & 68 deletions web/oss/tests/playwright/acceptance/prompt-registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,8 @@ import {expectAuthenticatedSession} from "../utils/auth"
import {createScenarios} from "../utils/scenarios"
import {buildAcceptanceTags} from "../utils/tags"

interface WorkflowRevision {
id: string
workflow_id?: string | null
version?: number | null
}

interface WorkflowRevisionsResponse {
workflow_revisions: WorkflowRevision[]
count?: number
}

interface PromptRegistryApiHelpers {
getApp: (slug: string) => Promise<{id: string}>
waitForApiResponse: <T>(options: {route: string; method: string}) => Promise<T>
}

interface PromptRegistryUiHelpers {
Expand Down Expand Up @@ -62,67 +50,41 @@ const getCompletionAppId = async (apiHelpers: {
const openWorkflowRevisionsPage = async (
page: Page,
uiHelpers: PromptRegistryUiHelpers,
apiHelpers: PromptRegistryApiHelpers,
appId: string,
) => {
const basePath = getProjectScopedBasePath(page)
const revisionsResponsePromise = apiHelpers.waitForApiResponse<WorkflowRevisionsResponse>({
route: "/api/workflows/revisions/query",
method: "POST",
})

await page.goto(`${basePath}/apps/${appId}/variants`, {
waitUntil: "domcontentloaded",
})
await uiHelpers.expectPath(`/apps/${appId}/variants`)

return await revisionsResponsePromise
const revisionsRadio = page.getByRole("radio", {name: "Revisions"})
const revisionsControl = page
.locator(".ant-radio-button-wrapper")
.filter({hasText: "Revisions"})
.first()
await expect(revisionsControl).toBeVisible({timeout: 15000})
if (!(await revisionsRadio.isChecked())) {
await revisionsControl.click()
}
Comment on lines +62 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard revisionsRadio against strict-mode violations.

revisionsControl is scoped with .first(), but revisionsRadio is not. getByRole's name option matches by substring (case-insensitive) by default, so if more than one element resolves, revisionsRadio.isChecked() will throw a strict-mode error. Add .first() for consistency with revisionsControl.

🛡️ Proposed fix
-    const revisionsRadio = page.getByRole("radio", {name: "Revisions"})
+    const revisionsRadio = page.getByRole("radio", {name: "Revisions"}).first()

}

const openFirstPublishedWorkflowRevision = async (
page: Page,
revisionsResponse: WorkflowRevisionsResponse,
) => {
const revisions = revisionsResponse.workflow_revisions.filter(
(revision) => (revision.version ?? 0) > 0,
)

test.skip(revisions.length === 0, "No workflow revisions found in registry")

// The app may accumulate revisions across test runs, and the table uses
// virtual scrolling — so a specific revision ID from the API response may
// not be rendered if it is scrolled out of the viewport. Instead poll for
// ANY visible published revision row and click whichever appears first.
const publishedRevisionIds = new Set(revisions.map((r) => r.id))
let foundRevisionId: string | null = null

await expect
.poll(
async () => {
const rows = page.locator("[data-row-key]")
const count = await rows.count()
for (let i = 0; i < count; i++) {
const row = rows.nth(i)
const key = await row.getAttribute("data-row-key").catch(() => null)
if (
key &&
publishedRevisionIds.has(key) &&
(await row.isVisible().catch(() => false))
) {
foundRevisionId = key
return true
}
}
return false
},
{timeout: 30000},
)
.toBe(true)
const openFirstPublishedWorkflowRevision = async (page: Page, appId: string) => {
const versionLabel = page.getByText(/^v[1-9]\d*$/).first()
await expect(versionLabel).toBeVisible({timeout: 30000})
await versionLabel.click()

const row = page.locator(`[data-row-key="${foundRevisionId}"]`).first()
await row.click()
await page.waitForURL(
(url) =>
url.pathname.endsWith(`/apps/${appId}/variants`) &&
Boolean(url.searchParams.get("revisionId")),
{timeout: 15000},
)

return foundRevisionId!
const revisionId = new URL(page.url()).searchParams.get("revisionId")
expect(revisionId).toBeTruthy()
return revisionId!
}

const expectWorkflowRevisionDrawer = async (page: Page, appId: string, revisionId: string) => {
Expand Down Expand Up @@ -176,7 +138,6 @@ const promptRegistryTests = () => {
async ({page, uiHelpers, apiHelpers}) => {
let appId = ""
let revisionId = ""
let revisionsResponse: WorkflowRevisionsResponse | null = null
let workflowRevisionDrawer: ReturnType<typeof page.locator> | null = null

await scenarios.given("the user is authenticated", async () => {
Expand All @@ -190,19 +151,14 @@ const promptRegistryTests = () => {
await scenarios.and(
"the user is on the workflow revisions page for that app",
async () => {
revisionsResponse = await openWorkflowRevisionsPage(
page,
uiHelpers,
apiHelpers,
appId,
)
await openWorkflowRevisionsPage(page, uiHelpers, appId)
},
)

await scenarios.when(
"the user opens the first published workflow revision",
async () => {
revisionId = await openFirstPublishedWorkflowRevision(page, revisionsResponse!)
revisionId = await openFirstPublishedWorkflowRevision(page, appId)
},
)

Expand Down
Loading
Loading