diff --git a/.gitignore b/.gitignore index 2ac8edacc..f2624c084 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ # testing /coverage +*-screenshots/ # production /dist diff --git a/docs/ai-gateway/getting-started.mdx b/docs/ai-gateway/getting-started.mdx index 21e317bbf..b6e555fcc 100644 --- a/docs/ai-gateway/getting-started.mdx +++ b/docs/ai-gateway/getting-started.mdx @@ -16,10 +16,11 @@ initial configuration to making your first LLM request through Zuplo. ## Step 1: Create an AI Gateway Project 1. Log into your Zuplo account -2. Click **Create New Project** -3. Select the **AI Gateway** template -4. Give your project a name (for example, "MyCompany AI Gateway") -5. Click **Create Project** +2. Navigate to **Projects** +3. Click **New Project** +4. Click **AI or MCP Gateway** at the bottom of the dialog +5. Give your project a name (for example, "MyCompany AI Gateway") +6. Click **Create Project** Your AI Gateway project will be created in seconds. You'll notice the interface includes Apps, Teams, and a setup guide to help you get started. @@ -60,10 +61,11 @@ you're starting solo, you'll need at least one team. 1. Click **Create Team** 2. Name your team (for example, "Root" or your company name) 3. Choose an icon for easy identification -4. Set organization-wide limits (optional) by clicking on **Settings**: - - **Daily Budget**: Maximum spend per day (for example, $1,000) +4. Click **Create Team** +5. Set organization-wide limits (optional) by selecting the **Usage & Limits** + tab: + - **Budget Limit**: Maximum spend per day (for example, $1,000) - **Rate Limits**: Request limits if needed -5. Click **Create** ### Creating Sub-Teams (Optional) @@ -90,15 +92,16 @@ Gateway. Each app gets its own unique URL and API key. Support Bot") - **Team**: Select which team owns this app - **Provider**: Choose your LLM provider (for example, OpenAI) - - **Model**: Select the specific model (for example, GPT-4o) for completions - and/or embeddings + - **Completions**: Select the model for chat completions (for example, + GPT-4o) + - **Embeddings**: Select the model for embeddings (optional) 3. Set application-level budgets: - **Daily Limit**: (for example, $1/day for a hackathon project) - **Monthly Limit**: (for example, $10/month) 4. Enable **Semantic Caching** (optional): - Caches similar prompts to reduce costs and improve performance - Best for applications with repeated queries -5. Click **Create** +5. Click **Create App** ### Access Your App Credentials diff --git a/docs/ai-gateway/getting-started.test.ts b/docs/ai-gateway/getting-started.test.ts new file mode 100644 index 000000000..aa2d52ca7 --- /dev/null +++ b/docs/ai-gateway/getting-started.test.ts @@ -0,0 +1,234 @@ +/** + * Verify docs/ai-gateway/getting-started.mdx against the real portal. + * + * Run: npx tsx docs/ai-gateway/getting-started.test.ts + */ +import { z } from "zod"; +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "getting-started", + async ({ stagehand, page, snap, report }) => { + const { pass, fail, warn } = report; + + // === Step 1: Create AI Gateway Project === + console.log("=== Step 1: Create AI Gateway Project ==="); + await snap("01-home"); + + // Doc: "Navigate to Projects" → "Click New Project" + const nav = await stagehand.extract( + "Extract the top-level navigation tab labels in the header", + z.object({ tabs: z.array(z.string()) }), + ); + nav.tabs.some((t) => /projects/i.test(t)) + ? pass("1.2", 'Navigate to "Projects"') + : fail("1.2", 'Navigate to "Projects"', `Tabs: ${nav.tabs.join(", ")}`); + + await stagehand.act('Click the "New Project" button'); + await page.waitForTimeout(2000); + await page.keyboard.press("Escape"); // dismiss notification overlay + await page.waitForTimeout(300); + if ( + !(await page + .locator('[role="dialog"]') + .isVisible() + .catch(() => false)) + ) { + await stagehand.act('Click "New Project"'); + await page.waitForTimeout(2000); + } + await snap("02-new-project-dialog"); + + // Doc: "Click AI or MCP Gateway at the bottom of the dialog" + // Use observe to find the link — it's more reliable for clickable elements + const aiMcpLinks = await stagehand.observe( + 'Find a link or button that mentions "AI", "MCP", or "Gateway" at the bottom of the New Project dialog. It might say "AI or MCP Gateway" or similar.', + ); + console.log( + ` AI/MCP links found: ${aiMcpLinks.map((l) => l.description).join(", ") || "none"}`, + ); + + // Also extract the full dialog content for context + const dialog = await stagehand.extract( + "List the template options and any links at the bottom of the New Project dialog", + z.object({ + templates: z.array(z.string()), + bottomLinks: z.array(z.string()), + }), + ); + console.log(` Templates: ${dialog.templates.join(", ")}`); + console.log(` Bottom links: ${dialog.bottomLinks.join(", ")}`); + + const allLinks = [ + ...aiMcpLinks.map((l) => l.description), + ...dialog.bottomLinks, + ]; + allLinks.some((l) => /ai|mcp/i.test(l)) + ? pass("1.4", '"AI or MCP Gateway" link found') + : fail( + "1.4", + '"AI or MCP Gateway" link', + `Links found: ${allLinks.join(", ")}`, + ); + + await stagehand.act( + 'Click "AI or MCP Gateway" at the bottom of the dialog', + ); + await page.waitForTimeout(5000); + await snap("03-ai-gateway-flow"); + + // Doc: "Give your project a name" → "Click Create Project" + const testName = `verify-${Date.now().toString(36)}`; + await stagehand.act(`Type "${testName}" into the project name field`); + pass("1.5", "Name the project"); + await stagehand.act('Click "Create Project"'); + await page.waitForTimeout(8000); + await snap("04-project-created"); + + // Verify nav tabs: Project | Apps | Teams | Settings + const projectNav = await stagehand.extract( + "Extract the project navigation tabs in the header", + z.object({ tabs: z.array(z.string()) }), + ); + console.log(` AI Gateway tabs: ${projectNav.tabs.join(", ")}`); + projectNav.tabs.some((t) => /^apps$/i.test(t)) && + projectNav.tabs.some((t) => /^teams$/i.test(t)) + ? pass("nav", "Has Apps and Teams tabs") + : fail( + "nav", + "Apps and Teams tabs", + `Tabs: ${projectNav.tabs.join(", ")}`, + ); + await snap("05-nav-tabs"); + + // === Step 2: Configure Providers === + console.log("\n=== Step 2: Configure Providers ==="); + let providerBtns = await stagehand.observe('Find "Add Provider" button'); + if (providerBtns.length === 0) { + await stagehand.act('Click "Settings" in the navigation'); + await page.waitForTimeout(2000); + await stagehand.act('Click "AI Providers"'); + await page.waitForTimeout(2000); + providerBtns = await stagehand.observe('Find "Add Provider" button'); + } + providerBtns.length > 0 + ? pass("2.1", '"Add Provider" button found') + : fail("2.1", '"Add Provider" button', "Not found"); + + if (providerBtns.length > 0) { + await stagehand.act('Click "Add Provider"'); + await page.waitForTimeout(2000); + await snap("06-add-provider"); + + const prov = await stagehand.extract( + "What provider options are shown? What form fields exist?", + z.object({ + providers: z.array(z.string()), + fields: z.array(z.string()), + }), + ); + prov.providers.some((p) => /openai/i.test(p)) + ? pass("2.2", "OpenAI provider option") + : warn( + "2.2", + "OpenAI option", + `Providers: ${prov.providers.join(", ")}`, + ); + + await page.keyboard.press("Escape"); + await page.waitForTimeout(500); + } + + // === Step 3: Create a Team === + console.log("\n=== Step 3: Create a Team ==="); + await stagehand.act('Click "Teams" in the navigation'); + await page.waitForTimeout(2000); + await snap("07-teams-page"); + + await stagehand.act('Click "Create Team"'); + await page.waitForTimeout(2000); + await snap("08-create-team-dialog"); + + const teamForm = await stagehand.extract( + "Describe the Create Team form. Fields? Icon picker? Submit button text?", + z.object({ + fields: z.array(z.string()), + hasIconPicker: z.boolean(), + submitButton: z.string(), + }), + ); + teamForm.hasIconPicker + ? pass("3.3", "Icon picker present") + : warn("3.3", "Icon picker", "Not detected"); + /create team/i.test(teamForm.submitButton) + ? pass("3.5", `Submit button: "${teamForm.submitButton}"`) + : warn( + "3.5", + 'Click "Create Team"', + `Button: "${teamForm.submitButton}"`, + ); + + await stagehand.act('Type "Docs Verify Team" into the team name field'); + await stagehand.act("Click the submit button to create the team"); + await page.waitForTimeout(3000); + await snap("09-team-created"); + + // Check team detail tabs + const teamTabs = await stagehand.extract( + "What tabs are in the team detail view? (Overview, Usage & Limits, etc.)", + z.object({ tabs: z.array(z.string()) }), + ); + teamTabs.tabs.some((t) => /usage.*limits/i.test(t)) + ? pass("3.limits", "Usage & Limits tab exists") + : fail( + "3.limits", + "Usage & Limits tab", + `Tabs: ${teamTabs.tabs.join(", ")}`, + ); + await snap("10-team-detail"); + + // === Step 4: Create an App === + console.log("\n=== Step 4: Create an App ==="); + await stagehand.act('Click "Apps" in the navigation'); + await page.waitForTimeout(2000); + await stagehand.act('Click "Create App"'); + await page.waitForTimeout(2000); + await snap("11-create-app"); + + const appForm = await stagehand.extract( + "List ALL form field labels. Look for Name, Team, Provider, Completions, Embeddings, Budget, Semantic Caching.", + z.object({ + fields: z.array(z.string()), + hasSemanticCaching: z.boolean(), + hasBudgetFields: z.boolean(), + }), + ); + console.log(` App fields: ${appForm.fields.join(", ")}`); + + for (const name of ["Name", "Team", "Provider"]) { + appForm.fields.some((f) => new RegExp(name, "i").test(f)) + ? pass(`4.${name}`, `"${name}" field`) + : fail( + `4.${name}`, + `"${name}" field`, + `Not in: ${appForm.fields.join(", ")}`, + ); + } + // Doc says Completions/Embeddings (not "Model") + appForm.fields.some((f) => /completions/i.test(f)) + ? pass("4.completions", "Completions model selector") + : warn( + "4.completions", + "Completions field", + `Fields: ${appForm.fields.join(", ")}`, + ); + appForm.hasBudgetFields + ? pass("4.budgets", "Budget fields present") + : warn("4.budgets", "Budget fields", "Not detected"); + appForm.hasSemanticCaching + ? pass("4.caching", "Semantic Caching toggle") + : fail("4.caching", "Semantic Caching", "Not found"); + await snap("12-app-form"); + }, + import.meta.filename, +); diff --git a/docs/ai-gateway/managing-apps.test.ts b/docs/ai-gateway/managing-apps.test.ts new file mode 100644 index 000000000..7e3ac14ec --- /dev/null +++ b/docs/ai-gateway/managing-apps.test.ts @@ -0,0 +1,77 @@ +/** + * Verify docs/ai-gateway/managing-apps.mdx against the real portal. + * + * Run: npx tsx docs/ai-gateway/managing-apps.test.ts + */ +import { z } from "zod"; +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "managing-apps", + async ({ stagehand, page, snap, report }) => { + const { pass, fail, warn } = report; + + // Navigate to AI Gateway project + const projects = await stagehand.extract( + "List project names and types", + z.object({ + projects: z.array(z.object({ name: z.string(), type: z.string() })), + }), + ); + const aiProject = projects.projects.find((p) => /ai/i.test(p.type)); + if (aiProject) await stagehand.act(`Click on "${aiProject.name}"`); + else await stagehand.act("Click on the first project"); + await page.waitForTimeout(3000); + + // Navigate to Apps tab + console.log("=== Apps page ==="); + await stagehand.act('Click "Apps" in the navigation'); + await page.waitForTimeout(2000); + await snap("01-apps-page"); + + // Doc: Apps have API Keys + const appsBtns = await stagehand.observe('Find "Create App" button'); + appsBtns.length > 0 + ? pass("create-app-btn", '"Create App" button exists') + : fail("create-app-btn", '"Create App" button', "Not found"); + + // Click Create App to inspect the form + if (appsBtns.length > 0) { + await stagehand.act('Click "Create App"'); + await page.waitForTimeout(2000); + await snap("02-create-app-form"); + + const appForm = await stagehand.extract( + "List ALL form fields/labels on the Create App page. Include budget fields, advanced features, semantic caching.", + z.object({ + fields: z.array(z.string()), + hasSemanticCaching: z.boolean(), + sections: z + .array(z.string()) + .describe("Section headings on the page"), + }), + ); + console.log(` Fields: ${appForm.fields.join(", ")}`); + console.log(` Sections: ${appForm.sections.join(", ")}`); + + // Doc: Each App has its own API Key + // Doc: Apps are owned by a team + appForm.fields.some((f) => /team/i.test(f)) + ? pass("team-field", "Team selector in app form") + : warn( + "team-field", + "Team field", + `Fields: ${appForm.fields.join(", ")}`, + ); + + appForm.hasSemanticCaching + ? pass("semantic-caching", "Semantic Caching toggle") + : warn("semantic-caching", "Semantic Caching", "Not found"); + + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.waitForTimeout(500); + await snap("03-create-app-scrolled"); + } + }, + import.meta.filename, +); diff --git a/docs/ai-gateway/managing-providers.test.ts b/docs/ai-gateway/managing-providers.test.ts new file mode 100644 index 000000000..eea41061b --- /dev/null +++ b/docs/ai-gateway/managing-providers.test.ts @@ -0,0 +1,94 @@ +/** + * Verify docs/ai-gateway/managing-providers.mdx against the real portal. + * + * Run: npx tsx docs/ai-gateway/managing-providers.test.ts + */ +import { z } from "zod"; +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "managing-providers", + async ({ stagehand, page, snap, report }) => { + const { pass, fail, warn } = report; + + // Navigate to an AI Gateway project + console.log("=== Navigate to AI Gateway project ==="); + await snap("01-home"); + + // Find an AI Gateway project or create one + const projects = await stagehand.extract( + "List the projects shown. Include their names and types (API Gateway, AI Gateway, etc.)", + z.object({ + projects: z.array(z.object({ name: z.string(), type: z.string() })), + }), + ); + const aiProject = projects.projects.find((p) => /ai/i.test(p.type)); + + if (aiProject) { + await stagehand.act(`Click on the project "${aiProject.name}"`); + } else { + warn( + "setup", + "AI Gateway project", + "No AI Gateway project found — using first project", + ); + await stagehand.act("Click on the first project"); + } + await page.waitForTimeout(3000); + + // Navigate to Settings > AI Providers + console.log("\n=== AI Providers page ==="); + await stagehand.act('Click "Settings" in the navigation'); + await page.waitForTimeout(2000); + await stagehand.act('Click "AI Providers" in the sidebar'); + await page.waitForTimeout(2000); + await snap("02-ai-providers"); + + // Doc: "Click Add Provider" + const addBtn = await stagehand.observe('Find "Add Provider" button'); + addBtn.length > 0 + ? pass("add-provider", '"Add Provider" button exists') + : fail("add-provider", '"Add Provider" button', "Not found"); + + if (addBtn.length > 0) { + await stagehand.act('Click "Add Provider"'); + await page.waitForTimeout(2000); + await snap("03-add-provider-dialog"); + + // Extract provider form structure + const form = await stagehand.extract( + "Describe the Add Provider form. What provider options are available (OpenAI, Anthropic, Gemini, etc.)? What fields exist (name, API key, URL, models)?", + z.object({ + providers: z.array(z.string()), + fields: z.array(z.string()), + }), + ); + console.log(` Providers: ${form.providers.join(", ")}`); + console.log(` Fields: ${form.fields.join(", ")}`); + + // Doc mentions: OpenAI, Google Gemini + form.providers.some((p) => /openai/i.test(p)) + ? pass("provider-openai", "OpenAI available") + : warn( + "provider-openai", + "OpenAI", + `Providers: ${form.providers.join(", ")}`, + ); + + // Doc: "Enter a name", "paste API key", "select models", "Click Create" + form.fields.some((f) => /name/i.test(f)) + ? pass("field-name", "Name field") + : warn("field-name", "Name field", `Fields: ${form.fields.join(", ")}`); + form.fields.some((f) => /api.key|key/i.test(f)) + ? pass("field-key", "API Key field") + : warn( + "field-key", + "API Key field", + `Fields: ${form.fields.join(", ")}`, + ); + + await page.keyboard.press("Escape"); + } + }, + import.meta.filename, +); diff --git a/docs/ai-gateway/managing-teams.test.ts b/docs/ai-gateway/managing-teams.test.ts new file mode 100644 index 000000000..1bd0e004e --- /dev/null +++ b/docs/ai-gateway/managing-teams.test.ts @@ -0,0 +1,95 @@ +/** + * Verify docs/ai-gateway/managing-teams.mdx against the real portal. + * + * Run: npx tsx docs/ai-gateway/managing-teams.test.ts + */ +import { z } from "zod"; +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "managing-teams", + async ({ stagehand, page, snap, report }) => { + const { pass, fail, warn } = report; + + // Navigate to an AI Gateway project + const projects = await stagehand.extract( + "List project names and types", + z.object({ + projects: z.array(z.object({ name: z.string(), type: z.string() })), + }), + ); + const aiProject = projects.projects.find((p) => /ai/i.test(p.type)); + if (aiProject) await stagehand.act(`Click on "${aiProject.name}"`); + else await stagehand.act("Click on the first project"); + await page.waitForTimeout(3000); + + // Navigate to Teams tab + console.log("=== Teams page ==="); + await stagehand.act('Click "Teams" in the navigation'); + await page.waitForTimeout(2000); + await snap("01-teams-page"); + + // Doc: "Create Team" button + const teamBtns = await stagehand.observe('Find "Create Team" button'); + teamBtns.length > 0 + ? pass("create-team-btn", '"Create Team" button exists') + : fail("create-team-btn", '"Create Team" button', "Not found"); + + // Click into a team if one exists + const teams = await stagehand.extract( + "List any team names shown in the sidebar or main content", + z.object({ teams: z.array(z.string()) }), + ); + + if (teams.teams.length > 0) { + await stagehand.act(`Click on the team "${teams.teams[0]}"`); + await page.waitForTimeout(2000); + await snap("02-team-detail"); + + // Check team detail tabs + const teamTabs = await stagehand.extract( + "What tabs are in the team detail view?", + z.object({ tabs: z.array(z.string()) }), + ); + console.log(` Team tabs: ${teamTabs.tabs.join(", ")}`); + + for (const expected of [ + "Overview", + "Usage & Limits", + "Members", + "Settings", + ]) { + teamTabs.tabs.some((t) => + t.toLowerCase().includes(expected.toLowerCase()), + ) + ? pass(`tab-${expected}`, `"${expected}" tab exists`) + : warn( + `tab-${expected}`, + `"${expected}" tab`, + `Tabs: ${teamTabs.tabs.join(", ")}`, + ); + } + + // Doc mentions RBAC link + const settingsInfo = await stagehand.extract( + "Is there a mention of RBAC, roles, or permissions on this page?", + z.object({ hasRbac: z.boolean(), context: z.string() }), + ); + settingsInfo.hasRbac + ? pass("rbac", "RBAC/permissions mentioned") + : warn("rbac", "RBAC mention", settingsInfo.context); + + // Check "Create Sub-Team" button + const subTeamBtns = await stagehand.observe( + 'Find "Create Sub-Team" button', + ); + subTeamBtns.length > 0 + ? pass("sub-team", '"Create Sub-Team" button exists') + : warn("sub-team", '"Create Sub-Team"', "Not found"); + await snap("03-team-detail-final"); + } else { + warn("teams", "Existing teams", "No teams found to inspect"); + } + }, + import.meta.filename, +); diff --git a/docs/ai-gateway/usage-limits.test.ts b/docs/ai-gateway/usage-limits.test.ts new file mode 100644 index 000000000..7ebd74888 --- /dev/null +++ b/docs/ai-gateway/usage-limits.test.ts @@ -0,0 +1,120 @@ +/** + * Verify docs/ai-gateway/usage-limits.mdx against the real portal. + * + * Run: npx tsx docs/ai-gateway/usage-limits.test.ts + */ +import { z } from "zod"; +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "usage-limits", + async ({ stagehand, page, snap, report }) => { + const { pass, fail, warn } = report; + + // Navigate to AI Gateway project + const projects = await stagehand.extract( + "List project names and types", + z.object({ + projects: z.array(z.object({ name: z.string(), type: z.string() })), + }), + ); + const aiProject = projects.projects.find((p) => /ai/i.test(p.type)); + if (aiProject) await stagehand.act(`Click on "${aiProject.name}"`); + else await stagehand.act("Click on the first project"); + await page.waitForTimeout(3000); + + // === Verify Teams/Apps tabs exist === + console.log("=== Verify navigation ==="); + const nav = await stagehand.extract( + "Extract the project navigation tabs", + z.object({ tabs: z.array(z.string()) }), + ); + nav.tabs.some((t) => /teams/i.test(t)) + ? pass("nav-teams", '"Teams" tab exists') + : fail("nav-teams", '"Teams" tab', `Tabs: ${nav.tabs.join(", ")}`); + nav.tabs.some((t) => /apps/i.test(t)) + ? pass("nav-apps", '"Apps" tab exists') + : fail("nav-apps", '"Apps" tab', `Tabs: ${nav.tabs.join(", ")}`); + + // === Check team Usage & Limits === + console.log("\n=== Team Usage & Limits ==="); + await stagehand.act('Click "Teams" in the navigation'); + await page.waitForTimeout(2000); + + const teams = await stagehand.extract( + "List any team names shown", + z.object({ teams: z.array(z.string()) }), + ); + + if (teams.teams.length > 0) { + await stagehand.act(`Click on the team "${teams.teams[0]}"`); + await page.waitForTimeout(2000); + + // Doc: "Select the Usage & Limits tab and configure the Daily Budget" + const usageLimits = await stagehand.observe( + 'Find a tab or link that says "Usage & Limits"', + ); + usageLimits.length > 0 + ? pass("team-ul-tab", '"Usage & Limits" tab in team detail') + : fail("team-ul-tab", '"Usage & Limits" tab', "Not found"); + + if (usageLimits.length > 0) { + await stagehand.act('Click "Usage & Limits"'); + await page.waitForTimeout(1000); + await snap("01-team-usage-limits"); + + const ulFields = await stagehand.extract( + "What budget/limit fields are shown? Look for Budget Limit, Tokens Limit, Requests Limit, daily/monthly, enforce/warn.", + z.object({ + fields: z.array(z.string()), + hasDaily: z.boolean(), + hasMonthly: z.boolean(), + }), + ); + console.log(` Usage & Limits fields: ${ulFields.fields.join(", ")}`); + ulFields.hasDaily + ? pass("daily-budget", "Daily budget configurable") + : warn( + "daily-budget", + "Daily budget", + `Fields: ${ulFields.fields.join(", ")}`, + ); + } + } else { + warn("teams", "No teams exist", "Cannot verify team usage limits"); + } + + // === Check project-level Usage Limits (Settings) === + console.log("\n=== Project-level Usage Limits ==="); + await stagehand.act('Click "Settings" in the navigation'); + await page.waitForTimeout(2000); + await snap("02-settings-page"); + + const settingsSections = await stagehand.extract( + "List the sidebar sections in Settings", + z.object({ sections: z.array(z.string()) }), + ); + console.log(` Settings sections: ${settingsSections.sections.join(", ")}`); + + settingsSections.sections.some((s) => + /usage.*limits|limits.*thresholds/i.test(s), + ) + ? pass("settings-ul", "Usage Limits section in Settings") + : warn( + "settings-ul", + "Usage Limits in Settings", + `Sections: ${settingsSections.sections.join(", ")}`, + ); + + // Click into Usage Limits & Thresholds + const ulLink = await stagehand.observe( + 'Find "Usage Limits" or "Usage Limits & Thresholds" in sidebar', + ); + if (ulLink.length > 0) { + await stagehand.act('Click "Usage Limits & Thresholds" in the sidebar'); + await page.waitForTimeout(1000); + await snap("03-project-usage-limits"); + } + }, + import.meta.filename, +); diff --git a/docs/articles/accounts/billing.mdx b/docs/articles/accounts/billing.mdx index c0521f960..40563e2f8 100644 --- a/docs/articles/accounts/billing.mdx +++ b/docs/articles/accounts/billing.mdx @@ -3,8 +3,8 @@ title: Zuplo Billing sidebar_label: Billing --- -The "Upgrade & Billing" page in the Zuplo dashboard allows you to subscribe to a -Zuplo plan, update your payment method, and view your billing history. +The **Billing** page in the Zuplo dashboard allows you to subscribe to a Zuplo +plan, update your payment method, and view your billing history. Zuplo uses Stripe to process payments. When you subscribe to a Zuplo plan, you will be redirected to the Stripe checkout page to enter your payment @@ -13,9 +13,9 @@ Zuplo dashboard. ## Update Payment Method -When you navigate to the "Upgrade & Billing" page, you will see a link to manage -your existing subscription. Clicking this link will take you to the Stripe -checkout page where you can update your payment method. +When you navigate to the **Billing** page, you will see a link to manage your +existing subscription. Clicking this link will take you to the Stripe checkout +page where you can update your payment method. :::note{title="Enterprise Plans"} diff --git a/docs/articles/accounts/delete-account.mdx b/docs/articles/accounts/delete-account.mdx index 80ae2c099..c2b828375 100644 --- a/docs/articles/accounts/delete-account.mdx +++ b/docs/articles/accounts/delete-account.mdx @@ -9,7 +9,7 @@ sure you want to delete your account, follow these steps: 1. Remove any custom domains from your projects. You can do this by going to the - **Custom Domain** tab in your project settings and removing any custom + **Custom Domains** section in your project settings and removing any custom domains. 1. Delete all your projects. You can do this by going to the **General** tab in your each project settings and clicking the **Delete Project** button. diff --git a/docs/articles/accounts/managing-project-members.mdx b/docs/articles/accounts/managing-project-members.mdx index 26c9efec6..ab9afb267 100644 --- a/docs/articles/accounts/managing-project-members.mdx +++ b/docs/articles/accounts/managing-project-members.mdx @@ -10,8 +10,8 @@ project level roles in order to grant them access to specific project resources. ## Add Project Member To manage project members, navigate to the project settings page and click the -**Members** tab, which shows a list of all members in the project and their -roles. +**Members & Access** section, which shows a list of all members in the project +and their roles. ![Project Members](../../../public/media/managing-project-members/image-1.png) diff --git a/docs/articles/api-key-api.mdx b/docs/articles/api-key-api.mdx index 394aea167..b619965bd 100644 --- a/docs/articles/api-key-api.mdx +++ b/docs/articles/api-key-api.mdx @@ -74,7 +74,7 @@ inside Zuplo) ```bash # Your Zuplo Account Name export ACCOUNT_NAME=my-account -# Your bucket API URL (Found in Settings > Project Information) +# Your bucket API URL (Found in Settings > General) export BUCKET_NAME=my-bucket # Your Zuplo API Key (Found in Settings > Zuplo API Keys) export ZAPI_KEY=zpka_YOUR_API_KEY diff --git a/docs/articles/custom-ci-cd-github.mdx b/docs/articles/custom-ci-cd-github.mdx index 41cf8161a..86b3d8514 100644 --- a/docs/articles/custom-ci-cd-github.mdx +++ b/docs/articles/custom-ci-cd-github.mdx @@ -71,8 +71,8 @@ use throughout your workflow. to prevent double deployments. 2. **Add your API key** — Store your Zuplo API key as a GitHub secret named - `ZUPLO_API_KEY`. Find your key in the Zuplo portal under **Settings** > **API - Keys**. + `ZUPLO_API_KEY`. Find your key in the Zuplo portal under your account + **Settings** > **API Keys**. ## Examples diff --git a/docs/articles/custom-domains.mdx b/docs/articles/custom-domains.mdx index 3ca7370e3..3e84dd88f 100644 --- a/docs/articles/custom-domains.mdx +++ b/docs/articles/custom-domains.mdx @@ -5,7 +5,7 @@ sidebar_label: Overview This guide will walk you through the process of setting up a custom domain for your project's edge deployment environment. You can manage all domain settings -related to a project in the Custom Domains section of the Settings tab of your +related to a project in the **Custom Domains** section of **Settings** for your project. Custom Domains are available on [Builder plans and above](https://zuplo.com/pricing). @@ -38,9 +38,9 @@ for your Zuplo project. ### 1. Navigate to your project's Custom Domain Settings -Go to your project in the Zuplo portal and open to the **Settings** tab (1), -then select **Custom Domain** (2) and click the **Add New Custom Domain** button -to open the `New Custom Domain` configuration modal. +Go to your project in the Zuplo portal, click **Settings** (1), then select +**Custom Domains** (2) and click the **Add New Custom Domain** button to open +the `New Custom Domain` configuration modal. ![Custom Domain](../../public/media/custom-domains/image.png) diff --git a/docs/articles/custom-domains.test.ts b/docs/articles/custom-domains.test.ts new file mode 100644 index 000000000..1065d3ddf --- /dev/null +++ b/docs/articles/custom-domains.test.ts @@ -0,0 +1,54 @@ +/** + * Verify docs/articles/custom-domains.mdx — Settings > Custom Domains. + * + * Run: npx tsx docs/articles/custom-domains.test.ts + */ +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "custom-domains", + async ({ stagehand, page, snap, getSettingsLinks, report }) => { + const { pass, fail, warn } = report; + + await stagehand.act("Click on the first project"); + await page.waitForTimeout(3000); + + console.log("=== Settings > Custom Domains ==="); + await stagehand.act('Click "Settings" in the navigation'); + await page.waitForTimeout(2000); + await snap("01-settings-page"); + + const links = await getSettingsLinks(); + console.log(` Sidebar links: ${links.join(", ")}`); + + links.some((s) => /custom domains/i.test(s)) + ? pass("custom-domains", '"Custom Domains" section (plural)') + : links.some((s) => /custom domain$/i.test(s)) + ? fail( + "custom-domains", + '"Custom Domains" (plural)', + "Found singular 'Custom Domain'", + ) + : fail( + "custom-domains", + '"Custom Domains"', + `Links: ${links.join(", ")}`, + ); + + await stagehand.act('Click "Custom Domains" in the sidebar'); + await page.waitForTimeout(2000); + await snap("02-custom-domains-page"); + + const addBtn = await stagehand.observe( + 'Find "Add New Custom Domain" button', + ); + addBtn.length > 0 + ? pass("add-domain-btn", '"Add New Custom Domain" button') + : warn( + "add-domain-btn", + '"Add New Custom Domain"', + "Not found (may require paid plan)", + ); + }, + import.meta.filename, +); diff --git a/docs/articles/environment-variables.mdx b/docs/articles/environment-variables.mdx index edb0d826f..a2d4183c7 100644 --- a/docs/articles/environment-variables.mdx +++ b/docs/articles/environment-variables.mdx @@ -26,8 +26,8 @@ code, see the ## Environment Variable Editor -To set environment variables in your project, open the **Settings** tab and then -select the **Environment Variables** section. +To set environment variables in your project, click **Settings** and then select +**Environment Variables**. To create a new variable, click **Add new variable**. diff --git a/docs/articles/environment-variables.test.ts b/docs/articles/environment-variables.test.ts new file mode 100644 index 000000000..db52fcd0e --- /dev/null +++ b/docs/articles/environment-variables.test.ts @@ -0,0 +1,42 @@ +/** + * Verify docs/articles/environment-variables.mdx — Settings > Environment Variables. + * + * Run: npx tsx docs/articles/environment-variables.test.ts + */ +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "environment-variables", + async ({ stagehand, page, snap, getSettingsLinks, report }) => { + const { pass, fail } = report; + + await stagehand.act("Click on the first project"); + await page.waitForTimeout(3000); + + console.log("=== Settings > Environment Variables ==="); + await stagehand.act('Click "Settings" in the navigation'); + await page.waitForTimeout(2000); + await snap("01-settings"); + + const links = await getSettingsLinks(); + console.log(` Sidebar links: ${links.join(", ")}`); + + links.some((s) => /environment variables/i.test(s)) + ? pass("env-vars-section", '"Environment Variables" in Settings sidebar') + : fail( + "env-vars-section", + '"Environment Variables"', + `Links: ${links.join(", ")}`, + ); + + await stagehand.act('Click "Environment Variables" in the sidebar'); + await page.waitForTimeout(2000); + await snap("02-env-vars-page"); + + const addBtn = await stagehand.observe('Find "Add new variable" button'); + addBtn.length > 0 + ? pass("add-var-btn", '"Add new variable" button') + : fail("add-var-btn", '"Add new variable"', "Not found"); + }, + import.meta.filename, +); diff --git a/docs/articles/rename-or-move-project.test.ts b/docs/articles/rename-or-move-project.test.ts new file mode 100644 index 000000000..12226d6e2 --- /dev/null +++ b/docs/articles/rename-or-move-project.test.ts @@ -0,0 +1,41 @@ +/** + * Verify docs/articles/rename-or-move-project.mdx — Settings > General. + * + * Run: npx tsx docs/articles/rename-or-move-project.test.ts + */ +import { z } from "zod"; +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "rename-or-move-project", + async ({ stagehand, page, snap, getSettingsLinks, report }) => { + const { pass, fail } = report; + + await stagehand.act("Click on the first project"); + await page.waitForTimeout(3000); + + console.log("=== Settings > General ==="); + await stagehand.act('Click "Settings" in the navigation'); + await page.waitForTimeout(2000); + await snap("01-settings"); + + const links = await getSettingsLinks(); + console.log(` Sidebar links: ${links.join(", ")}`); + + links.some((l) => /^general$/i.test(l)) + ? pass("general", '"General" found in Settings sidebar') + : fail("general", '"General" in sidebar', `Links: ${links.join(", ")}`); + + await stagehand.act('Click "General" in the Settings sidebar'); + await page.waitForTimeout(2000); + await snap("02-general-page"); + + const heading = await stagehand.extract( + "What is the main content heading on this page?", + z.object({ heading: z.string() }), + ); + console.log(` Page heading: "${heading.heading}"`); + pass("heading", `Page heading: "${heading.heading}"`); + }, + import.meta.filename, +); diff --git a/docs/articles/source-control-setup-github.mdx b/docs/articles/source-control-setup-github.mdx index 78da34ec5..d02268be3 100644 --- a/docs/articles/source-control-setup-github.mdx +++ b/docs/articles/source-control-setup-github.mdx @@ -18,10 +18,10 @@ new Zuplo project. 1. Connect to GitHub - Go to your project in the Zuplo portal and open to the **Settings** tab, then - select **Source Control**. If your project isn't already connected to GitHub - click the **Connect to GitHub** button and follow the auth flow. You'll need - to grant permissions for any GitHub organizations you want to work with. + Go to your project in the Zuplo portal, click **Settings**, then select + **Source Control**. If your project isn't already connected to GitHub click + the **Connect to GitHub** button and follow the auth flow. You'll need to + grant permissions for any GitHub organizations you want to work with. ![Connect GitHub](../../public/media/step-4-deploying-to-the-edge/image-1.png) diff --git a/docs/articles/source-control-setup-github.test.ts b/docs/articles/source-control-setup-github.test.ts new file mode 100644 index 000000000..6cb9ba25e --- /dev/null +++ b/docs/articles/source-control-setup-github.test.ts @@ -0,0 +1,42 @@ +/** + * Verify docs/articles/source-control-setup-github.mdx — Settings > Source Control. + * + * Run: npx tsx docs/articles/source-control-setup-github.test.ts + */ +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "source-control-github", + async ({ stagehand, page, snap, getSettingsLinks, report }) => { + const { pass, fail } = report; + + await stagehand.act("Click on the first project"); + await page.waitForTimeout(3000); + + console.log("=== Settings > Source Control ==="); + await stagehand.act('Click "Settings" in the navigation'); + await page.waitForTimeout(2000); + + const links = await getSettingsLinks(); + console.log(` Sidebar links: ${links.join(", ")}`); + + links.some((s) => /source control/i.test(s)) + ? pass("source-control", '"Source Control" in Settings sidebar') + : fail( + "source-control", + '"Source Control"', + `Links: ${links.join(", ")}`, + ); + + await stagehand.act('Click "Source Control" in the sidebar'); + await page.waitForTimeout(2000); + await snap("01-source-control"); + + const ghBtn = await stagehand.observe('Find a "Connect to GitHub" button'); + ghBtn.length > 0 + ? pass("connect-github", '"Connect to GitHub" button found') + : pass("connect-github-alt", "GitHub may already be connected"); + await snap("02-source-control-detail"); + }, + import.meta.filename, +); diff --git a/docs/articles/step-1-setup-basic-gateway.mdx b/docs/articles/step-1-setup-basic-gateway.mdx index fddcdc986..b638e219d 100644 --- a/docs/articles/step-1-setup-basic-gateway.mdx +++ b/docs/articles/step-1-setup-basic-gateway.mdx @@ -93,7 +93,7 @@ Note - Zuplo also supports building and running your API locally. To learn more ![BASE_URL from Environment](../../public/media/step-1-setup-basic-gateway/image-8.png) - Navigate to your project's **Settings** tab (1) via the navigation bar. Next, + Navigate to your project's **Settings** (1) via the navigation bar. Next, click **Environment Variables** (2) under Project Settings. ![Click Environment Variables](../../public/media/step-1-setup-basic-gateway/set-env-var.png) diff --git a/docs/articles/step-1-setup-basic-gateway.test.ts b/docs/articles/step-1-setup-basic-gateway.test.ts new file mode 100644 index 000000000..44b72c225 --- /dev/null +++ b/docs/articles/step-1-setup-basic-gateway.test.ts @@ -0,0 +1,50 @@ +/** + * Verify docs/articles/step-1-setup-basic-gateway.mdx — project creation + env vars. + * + * Run: npx tsx docs/articles/step-1-setup-basic-gateway.test.ts + */ +import { z } from "zod"; +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "step-1-setup-basic-gateway", + async ({ stagehand, page, snap, getSettingsLinks, report }) => { + const { pass, fail } = report; + + console.log("=== Project creation ==="); + await snap("01-home"); + + const newProjBtns = await stagehand.observe('Find "New Project" button'); + newProjBtns.length > 0 + ? pass("new-project", '"New Project" button exists') + : fail("new-project", '"New Project" button', "Not found"); + + console.log("\n=== Settings > Environment Variables ==="); + await stagehand.act("Click on the first project"); + await page.waitForTimeout(3000); + + const nav = await stagehand.extract( + "Extract the project navigation tabs", + z.object({ tabs: z.array(z.string()) }), + ); + nav.tabs.some((t) => /settings/i.test(t)) + ? pass("settings-nav", '"Settings" in project nav') + : fail("settings-nav", '"Settings"', `Tabs: ${nav.tabs.join(", ")}`); + + await stagehand.act('Click "Settings" in the navigation'); + await page.waitForTimeout(2000); + await snap("02-settings"); + + const links = await getSettingsLinks(); + console.log(` Sidebar links: ${links.join(", ")}`); + + links.some((s) => /environment variables/i.test(s)) + ? pass("env-vars", '"Environment Variables" in sidebar') + : fail( + "env-vars", + '"Environment Variables"', + `Links: ${links.join(", ")}`, + ); + }, + import.meta.filename, +); diff --git a/docs/articles/step-4-deploying-to-the-edge.mdx b/docs/articles/step-4-deploying-to-the-edge.mdx index 1bda0e9de..6e668db89 100644 --- a/docs/articles/step-4-deploying-to-the-edge.mdx +++ b/docs/articles/step-4-deploying-to-the-edge.mdx @@ -30,11 +30,10 @@ Let's get started: 1. Authorize to GitHub - Next, go to your project in the Zuplo portal and open to the **Settings** - tab, then select **Source Control**. If your project isn't already connected - to GitHub click the **Connect to GitHub** button and follow the auth flow. - You'll need to grant permissions for any GitHub organizations you want to - work with. + Next, go to your project in the Zuplo portal, click **Settings**, then select + **Source Control**. If your project isn't already connected to GitHub click + the **Connect to GitHub** button and follow the auth flow. You'll need to + grant permissions for any GitHub organizations you want to work with. ![Connect GitHub](../../public/media/step-4-deploying-to-the-edge/image-1.png) diff --git a/docs/articles/step-4-deploying-to-the-edge.test.ts b/docs/articles/step-4-deploying-to-the-edge.test.ts new file mode 100644 index 000000000..8d0682862 --- /dev/null +++ b/docs/articles/step-4-deploying-to-the-edge.test.ts @@ -0,0 +1,44 @@ +/** + * Verify docs/articles/step-4-deploying-to-the-edge.mdx — Settings > Source Control. + * + * Run: npx tsx docs/articles/step-4-deploying-to-the-edge.test.ts + */ +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "step-4-deploying", + async ({ stagehand, page, snap, getSettingsLinks, report }) => { + const { pass, fail } = report; + + await stagehand.act("Click on the first project"); + await page.waitForTimeout(3000); + + console.log("=== Settings > Source Control ==="); + await stagehand.act('Click "Settings" in the navigation'); + await page.waitForTimeout(2000); + + const links = await getSettingsLinks(); + console.log(` Sidebar links: ${links.join(", ")}`); + + links.some((s) => /source control/i.test(s)) + ? pass("source-control", '"Source Control" in sidebar') + : fail( + "source-control", + '"Source Control"', + `Links: ${links.join(", ")}`, + ); + + await stagehand.act('Click "Source Control" in the sidebar'); + await page.waitForTimeout(2000); + await snap("01-source-control"); + + const connectBtn = await stagehand.observe( + 'Find "Connect to GitHub" button', + ); + connectBtn.length > 0 + ? pass("connect-github", '"Connect to GitHub" button found') + : pass("github-connected", "GitHub may already be connected"); + await snap("02-source-control-detail"); + }, + import.meta.filename, +); diff --git a/docs/articles/testing-graphql.mdx b/docs/articles/testing-graphql.mdx index fa9f4b975..77ac04b6c 100644 --- a/docs/articles/testing-graphql.mdx +++ b/docs/articles/testing-graphql.mdx @@ -16,7 +16,8 @@ the following methods: The fastest way to test your GraphQL endpoint using the Zuplo Portal is via the API Test Console. -1. Navigate to the API test console tab and create a new "Code Test" +1. Navigate to **Code** and open the API test console, then create a new "Code + Test" 2. Fill in the method, path, and headers you need. Leave the `content-type` as `application/json` 3. Convert your GraphQL Query and GraphQL Variables into a JSON body. You can diff --git a/docs/handlers/mcp-server.mdx b/docs/handlers/mcp-server.mdx index eb6922350..815a9d94d 100644 --- a/docs/handlers/mcp-server.mdx +++ b/docs/handlers/mcp-server.mdx @@ -31,7 +31,7 @@ policies for the MCP server handler. ## Setup via Portal -Open the **Route Designer** by navigating to the **Files** tab, then click +Open the **Route Designer** by navigating to the **Code** tab, then click **routes.oas.json**. For any route definition, select **MCP Server** from the **Request Handlers** drop-down. Set the method to **POST**. diff --git a/docs/handlers/openapi.test.ts b/docs/handlers/openapi.test.ts new file mode 100644 index 000000000..51a3b53a3 --- /dev/null +++ b/docs/handlers/openapi.test.ts @@ -0,0 +1,151 @@ +/** + * Verify docs/handlers/openapi.mdx — "Code tab" and Route Designer references. + * + * Run: npx tsx docs/handlers/openapi.test.ts + */ +import { z } from "zod"; +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "openapi-handler", + async ({ stagehand, page, snap, report }) => { + const { pass, fail } = report; + + // Navigate to an API Gateway project + const projects = await stagehand.extract( + "List project names and types", + z.object({ + projects: z.array(z.object({ name: z.string(), type: z.string() })), + }), + ); + const apiProject = projects.projects.find((p) => + /api.gateway/i.test(p.type), + ); + if (apiProject) await stagehand.act(`Click on "${apiProject.name}"`); + else await stagehand.act("Click on the first project"); + await page.waitForTimeout(3000); + + // Doc: "navigating to the Code tab then click routes.oas.json" + console.log("=== Verify Code tab and Route Designer ==="); + const nav = await stagehand.extract( + "Extract the project navigation tabs in the header", + z.object({ tabs: z.array(z.string()) }), + ); + console.log(` Nav tabs: ${nav.tabs.join(", ")}`); + + nav.tabs.some((t) => /^code$/i.test(t)) + ? pass("code-tab", '"Code" tab exists in project nav') + : fail("code-tab", '"Code" tab', `Tabs: ${nav.tabs.join(", ")}`); + + await stagehand.act('Click "Code" in the navigation'); + await page.waitForTimeout(2000); + await snap("01-code-tab"); + + // Check for routes.oas.json in the file tree + const filesVisible = await stagehand.observe( + 'Find "routes.oas.json" in the file tree or sidebar', + ); + filesVisible.length > 0 + ? pass("routes-file", "routes.oas.json visible in Code tab") + : fail("routes-file", "routes.oas.json", "Not found in file tree"); + + if (filesVisible.length > 0) { + await stagehand.act('Click "routes.oas.json"'); + await page.waitForTimeout(2000); + await snap("02-route-designer"); + + // Click on a route to see handler options + await stagehand.act("Click on the first route in the route list"); + await page.waitForTimeout(1000); + await snap("03-route-selected"); + + // The handler is shown as a dropdown labeled "Handler" with a value + // like "URL Rewrite". We need to click the dropdown to open it. + // Use Playwright to find and click the actual select/dropdown element + // since Stagehand may not reliably open custom dropdowns. + const handlerSelect = page.locator( + 'select:near(:text("Handler")), [role="combobox"]:near(:text("Handler")), [data-slot="select-trigger"]:near(:text("Handler"))', + ); + + if ( + await handlerSelect + .first() + .isVisible({ timeout: 3000 }) + .catch(() => false) + ) { + await handlerSelect.first().click(); + await page.waitForTimeout(1000); + await snap("04-handler-dropdown-open"); + + // Extract all visible options + const options = await page + .locator( + '[role="option"], option, [role="menuitem"], [data-slot="select-item"]', + ) + .allTextContents(); + const handlerOptions = options.map((t) => t.trim()).filter(Boolean); + console.log( + ` Handler options (Playwright): ${handlerOptions.join(", ")}`, + ); + + if (handlerOptions.length === 0) { + // Fallback: try Stagehand extract on the open dropdown + const extracted = await stagehand.extract( + "List ALL options visible in the currently open dropdown menu for the Request Handler", + z.object({ handlers: z.array(z.string()) }), + ); + console.log( + ` Handler options (Stagehand): ${extracted.handlers.join(", ")}`, + ); + handlerOptions.push(...extracted.handlers); + } + + for (const handler of [ + "OpenAPI Spec", + "URL Forward", + "URL Rewrite", + "Redirect", + "AWS Lambda", + ]) { + const found = handlerOptions.some((h) => + new RegExp(handler.replace(/\s+/g, "."), "i").test(h), + ); + found + ? pass(`handler-${handler}`, `"${handler}" in dropdown`) + : fail( + `handler-${handler}`, + `"${handler}"`, + `Not in: ${handlerOptions.join(", ")}`, + ); + } + + // Close dropdown + await page.keyboard.press("Escape"); + } else { + // Try Stagehand as fallback + await stagehand.act( + 'Click on the Handler dropdown that currently shows "URL Rewrite" or "URL Forward" to open the list of handler options', + ); + await page.waitForTimeout(1000); + await snap("04-handler-dropdown-open"); + + const handlerOptions = await stagehand.extract( + "List ALL handler options visible in the open dropdown", + z.object({ handlers: z.array(z.string()) }), + ); + console.log(` Handlers: ${handlerOptions.handlers.join(", ")}`); + + handlerOptions.handlers.some((h) => /openapi|open.api/i.test(h)) + ? pass("openapi-handler", '"OpenAPI Spec" in handler dropdown') + : fail( + "openapi-handler", + '"OpenAPI Spec"', + `Handlers: ${handlerOptions.handlers.join(", ")}`, + ); + } + + await snap("05-handlers-verified"); + } + }, + import.meta.filename, +); diff --git a/docs/handlers/redirect.test.ts b/docs/handlers/redirect.test.ts new file mode 100644 index 000000000..0912b5b72 --- /dev/null +++ b/docs/handlers/redirect.test.ts @@ -0,0 +1,90 @@ +/** + * Verify docs/handlers/redirect.mdx — Code tab and Redirect handler in dropdown. + * + * Run: npx tsx docs/handlers/redirect.test.ts + */ +import { z } from "zod"; +import { portalTest } from "../../scripts/lib/portal-test.ts"; + +await portalTest( + "redirect-handler", + async ({ stagehand, page, snap, report }) => { + const { pass, fail } = report; + + const projects = await stagehand.extract( + "List project names and types", + z.object({ + projects: z.array(z.object({ name: z.string(), type: z.string() })), + }), + ); + const apiProject = projects.projects.find((p) => + /api.gateway/i.test(p.type), + ); + if (apiProject) await stagehand.act(`Click on "${apiProject.name}"`); + else await stagehand.act("Click on the first project"); + await page.waitForTimeout(3000); + + console.log("=== Verify Redirect handler in Route Designer ==="); + await stagehand.act('Click "Code" in the navigation'); + await page.waitForTimeout(2000); + await stagehand.act('Click "routes.oas.json"'); + await page.waitForTimeout(2000); + await stagehand.act("Click on the first route in the route list"); + await page.waitForTimeout(1000); + await snap("01-route-selected"); + + // Open handler dropdown using Playwright for reliability + const handlerSelect = page.locator( + 'select:near(:text("Handler")), [role="combobox"]:near(:text("Handler")), [data-slot="select-trigger"]:near(:text("Handler"))', + ); + + if ( + await handlerSelect + .first() + .isVisible({ timeout: 3000 }) + .catch(() => false) + ) { + await handlerSelect.first().click(); + await page.waitForTimeout(1000); + await snap("02-handler-dropdown-open"); + + const options = await page + .locator( + '[role="option"], option, [role="menuitem"], [data-slot="select-item"]', + ) + .allTextContents(); + const handlerOptions = options.map((t) => t.trim()).filter(Boolean); + console.log(` Handler options: ${handlerOptions.join(", ")}`); + + handlerOptions.some((h) => /redirect/i.test(h)) + ? pass("redirect-handler", '"Redirect" in handler dropdown') + : fail( + "redirect-handler", + '"Redirect"', + `Options: ${handlerOptions.join(", ")}`, + ); + + await page.keyboard.press("Escape"); + } else { + // Fallback: use stagehand + await stagehand.act("Click on the Handler dropdown to open it"); + await page.waitForTimeout(1000); + await snap("02-handler-dropdown-open"); + + const handlers = await stagehand.extract( + "List ALL handler options in the open dropdown", + z.object({ handlers: z.array(z.string()) }), + ); + handlers.handlers.some((h) => /redirect/i.test(h)) + ? pass("redirect-handler", '"Redirect" in dropdown') + : fail( + "redirect-handler", + '"Redirect"', + `Options: ${handlers.handlers.join(", ")}`, + ); + } + + await snap("03-verified"); + }, + import.meta.filename, +); diff --git a/docs/handlers/url-forward.mdx b/docs/handlers/url-forward.mdx index 8c11cae22..2a9ac15a3 100644 --- a/docs/handlers/url-forward.mdx +++ b/docs/handlers/url-forward.mdx @@ -29,7 +29,7 @@ By default, query parameters are forwarded automatically. ## Setup via Portal The Forward Handler can be added to any route using the Route Designer. Open the -**Route Designer** by navigating to the **Files** tab then click +**Route Designer** by navigating to the **Code** tab then click **routes.oas.json**. Inside any route, select **URL Forward** from the **Request Handlers** drop-down. diff --git a/scripts/lib/portal-test.ts b/scripts/lib/portal-test.ts new file mode 100644 index 000000000..3cf5e5e4d --- /dev/null +++ b/scripts/lib/portal-test.ts @@ -0,0 +1,216 @@ +/** + * Shared test harness for verifying docs against the real Zuplo portal. + * + * Launches Stagehand (LOCAL) + Playwright via CDP, authenticates to the + * portal, and provides helpers for writing doc verification tests. + * + * Usage in a test file: + * import { portalTest } from "../../scripts/lib/portal-test.ts"; + * await portalTest("doc-name", async ({ stagehand, page }) => { ... }); + */ +import { Stagehand } from "@browserbasehq/stagehand"; +import { chromium, type Page } from "playwright-core"; +import { config } from "dotenv"; +import { existsSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +const ROOT = resolve(import.meta.dirname, "../.."); +const PORTAL_ENV = resolve(ROOT, "../portal/.env.agent-test"); + +if (!existsSync(PORTAL_ENV)) { + console.error(`Missing ${PORTAL_ENV}`); + console.error("Run: cd ~/zuplo/portal && bash scripts/setup-agent-env.sh"); + process.exit(1); +} + +config({ path: PORTAL_ENV }); +config({ path: resolve(ROOT, ".env") }); + +const AUTH0_DOMAIN = process.env.AUTH0_DOMAIN ?? ""; +const AUTH0_CLIENT_ID = process.env.AUTH0_CLIENT_ID ?? ""; +const AUTH0_CLIENT_SECRET = process.env.AUTH0_CLIENT_SECRET ?? ""; +const AUTH0_USERNAME = process.env.AUTH0_USERNAME ?? ""; +const AUTH0_PASSWORD = process.env.AUTH0_PASSWORD ?? ""; +const PORTAL_URL = process.env.PORTAL_URL ?? "https://portal.zuplo.com"; + +async function getTokens() { + const res = await fetch(`${AUTH0_DOMAIN.replace(/\/$/, "")}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "http://auth0.com/oauth/grant-type/password-realm", + client_id: AUTH0_CLIENT_ID, + client_secret: AUTH0_CLIENT_SECRET, + username: AUTH0_USERNAME, + password: AUTH0_PASSWORD, + audience: "https://api.zuplo.com/", + scope: "openid profile email", + realm: "Agent-Database", + }), + }); + if (!res.ok) + throw new Error(`Auth0 ROPC failed (${res.status}): ${await res.text()}`); + return res.json() as Promise<{ access_token: string; id_token: string }>; +} + +// ── Finding reporter ────────────────────────────────────────────── + +export interface Finding { + step: string; + status: "PASS" | "FAIL" | "WARN"; + doc: string; + actual: string; +} + +export function createReporter() { + const findings: Finding[] = []; + + function pass(step: string, doc: string, actual?: string) { + findings.push({ step, status: "PASS", doc, actual: actual ?? doc }); + console.log(` [PASS] ${step}: ${doc}`); + } + function fail(step: string, doc: string, actual: string) { + findings.push({ step, status: "FAIL", doc, actual }); + console.log(` [FAIL] ${step}: ${doc}`); + console.log(` Actual: ${actual}`); + } + function warn(step: string, doc: string, actual: string) { + findings.push({ step, status: "WARN", doc, actual }); + console.log(` [WARN] ${step}: ${doc}`); + console.log(` Actual: ${actual}`); + } + + function summary() { + const p = findings.filter((f) => f.status === "PASS").length; + const fl = findings.filter((f) => f.status === "FAIL").length; + const w = findings.filter((f) => f.status === "WARN").length; + console.log("\n========================================"); + console.log(" VERIFICATION SUMMARY"); + console.log("========================================"); + console.log(` PASS: ${p} | FAIL: ${fl} | WARN: ${w}\n`); + if (fl > 0) { + console.log("--- FAILURES ---"); + for (const x of findings.filter((x) => x.status === "FAIL")) + console.log( + ` [${x.step}] Doc: ${x.doc}\n Got: ${x.actual}\n`, + ); + } + if (w > 0) { + console.log("--- WARNINGS ---"); + for (const x of findings.filter((x) => x.status === "WARN")) + console.log( + ` [${x.step}] Doc: ${x.doc}\n Got: ${x.actual}\n`, + ); + } + return { pass: p, fail: fl, warn: w }; + } + + return { findings, pass, fail, warn, summary }; +} + +// ── Test harness ────────────────────────────────────────────────── + +export interface TestContext { + stagehand: Stagehand; + /** Playwright page — use for screenshots, keyboard, evaluate, locator */ + page: Page; + /** Screenshot helper: snap("step-name") */ + snap: (name: string) => Promise; + /** Extract settings sidebar link texts using Playwright */ + getSettingsLinks: () => Promise; + /** Finding reporter */ + report: ReturnType; + /** Portal base URL */ + portalUrl: string; +} + +/** + * Run a portal doc verification test. + * + * @param name Test name (used for screenshot subfolder) + * @param testFn Async function receiving the test context + * @param callerPath Pass `import.meta.filename` so screenshots go next to the test + */ +export async function portalTest( + name: string, + testFn: (ctx: TestContext) => Promise, + callerPath?: string, +) { + // Screenshot dir next to the calling test file + const screenshotDir = callerPath + ? resolve(dirname(callerPath), `${name}-screenshots`) + : resolve(ROOT, `docs/${name}-screenshots`); + mkdirSync(screenshotDir, { recursive: true }); + + console.log(`Getting tokens...`); + const tokens = await getTokens(); + console.log(`Launching Stagehand...`); + + const stagehand = new Stagehand({ + env: "LOCAL", + model: "anthropic/claude-haiku-4-5-20251001", + headless: true, + verbose: 0, + }); + await stagehand.init(); + + // Connect Playwright via CDP for full page API + const pwBrowser = await chromium.connectOverCDP(stagehand.connectURL()); + const pwContext = pwBrowser.contexts()[0]; + const page = pwContext.pages()[0]; + + // Auth: inject token via addInitScript before portal can redirect + const tokenJson = JSON.stringify({ + access_token: tokens.access_token, + id_token: tokens.id_token, + }); + await pwContext.addInitScript({ + content: `try { if (location.hostname.includes('portal.zuplo.com') || location.hostname.includes('zuplosite.com')) localStorage.setItem('ZUPLO_AGENT_TOKEN', ${JSON.stringify(tokenJson)}); } catch(e) {}`, + }); + await page.goto(PORTAL_URL, { waitUntil: "networkidle", timeout: 60000 }); + await page.waitForTimeout(3000); + console.log(`Authenticated: ${page.url()}\n`); + + const snap = async (stepName: string) => { + const path = resolve(screenshotDir, `${stepName}.png`); + await page.screenshot({ path, fullPage: true }); + console.log(` [screenshot] ${stepName}.png`); + return path; + }; + + /** + * Extract settings sidebar links using Playwright. + * Stagehand returns IDs for custom sidebar components, + * so we use Playwright to get the actual link text. + */ + const getSettingsLinks = async (): Promise => { + const allLinks = await page.locator("a").allTextContents(); + return allLinks + .map((t) => t.trim()) + .filter( + (t) => + t && + !t.match(/^\d/) && + t.length < 50 && + /general|api key|environment|members|source|custom|billing|zuplo|usage|ai provider|security|audit/i.test( + t, + ), + ); + }; + + const report = createReporter(); + + try { + await testFn({ + stagehand, + page, + snap, + getSettingsLinks, + report, + portalUrl: PORTAL_URL, + }); + report.summary(); + } finally { + await stagehand.close(); + } +}