diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..01101f6c --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,77 @@ +name: E2E Tests + +on: + push: + tags: ["v*"] + workflow_dispatch: + +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 15 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: vectorflow_e2e + POSTGRES_PASSWORD: e2e_test_password + POSTGRES_DB: vectorflow_e2e + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + DATABASE_URL: postgresql://vectorflow_e2e:e2e_test_password@localhost:5432/vectorflow_e2e + NEXTAUTH_SECRET: e2e-test-secret-key-at-least-32-chars + NEXTAUTH_URL: http://localhost:3000 + ENCRYPTION_KEY: e2e-test-encryption-key-32chars!! + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run database migrations + run: npx prisma migrate deploy + + - name: Build application + run: pnpm build + + - name: Install Playwright Chromium + run: npx playwright install chromium --with-deps + + - name: Start server + run: pnpm start & + + - name: Wait for server + run: npx wait-on http://localhost:3000 --timeout 60000 + + - name: Run E2E tests + run: pnpm test:e2e + + - name: Upload test artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index d8ab7de8..30698f96 100644 --- a/.gitignore +++ b/.gitignore @@ -57,8 +57,6 @@ next-env.d.ts # worktrees .worktrees/ -# ── GSD baseline (auto-generated) ── -.gsd Thumbs.db *.swp *.swo @@ -82,3 +80,10 @@ vendor/ coverage/ .cache/ tmp/ + +# playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +e2e/.auth/ diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml new file mode 100644 index 00000000..b81538cf --- /dev/null +++ b/e2e/docker-compose.e2e.yml @@ -0,0 +1,16 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: vectorflow_e2e + POSTGRES_PASSWORD: e2e_test_password + POSTGRES_DB: vectorflow_e2e + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U vectorflow_e2e"] + interval: 5s + timeout: 3s + retries: 5 + tmpfs: + - /var/lib/postgresql/data diff --git a/e2e/fixtures/test.fixture.ts b/e2e/fixtures/test.fixture.ts new file mode 100644 index 00000000..2c96a84d --- /dev/null +++ b/e2e/fixtures/test.fixture.ts @@ -0,0 +1,50 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { test as base } from "@playwright/test"; +import { LoginPage } from "../pages/login.page"; +import { PipelinesPage } from "../pages/pipelines.page"; +import { PipelineEditorPage } from "../pages/pipeline-editor.page"; +import { FleetPage } from "../pages/fleet.page"; +import { AlertsPage } from "../pages/alerts.page"; +import { SidebarComponent } from "../pages/components/sidebar.component"; +import { ToastComponent } from "../pages/components/toast.component"; +import { DeployDialogComponent } from "../pages/components/deploy-dialog.component"; + +interface E2EFixtures { + loginPage: LoginPage; + pipelinesPage: PipelinesPage; + pipelineEditor: PipelineEditorPage; + fleetPage: FleetPage; + alertsPage: AlertsPage; + sidebar: SidebarComponent; + toast: ToastComponent; + deployDialog: DeployDialogComponent; +} + +export const test = base.extend({ + loginPage: async ({ page }, use) => { + await use(new LoginPage(page)); + }, + pipelinesPage: async ({ page }, use) => { + await use(new PipelinesPage(page)); + }, + pipelineEditor: async ({ page }, use) => { + await use(new PipelineEditorPage(page)); + }, + fleetPage: async ({ page }, use) => { + await use(new FleetPage(page)); + }, + alertsPage: async ({ page }, use) => { + await use(new AlertsPage(page)); + }, + sidebar: async ({ page }, use) => { + await use(new SidebarComponent(page)); + }, + toast: async ({ page }, use) => { + await use(new ToastComponent(page)); + }, + deployDialog: async ({ page }, use) => { + await use(new DeployDialogComponent(page)); + }, +}); + +export { expect } from "@playwright/test"; diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 00000000..29a8e551 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,39 @@ +import { test as setup } from "@playwright/test"; +import { PrismaClient } from "../src/generated/prisma"; +import { seed } from "./helpers/seed"; +import { cleanup } from "./helpers/cleanup"; +import { TEST_USER } from "./helpers/constants"; + +const authFile = "e2e/.auth/user.json"; + +setup("seed database and authenticate", async ({ page }) => { + const prisma = new PrismaClient(); + + try { + await cleanup(prisma); + const result = await seed(prisma); + + const fs = await import("fs/promises"); + await fs.mkdir("e2e/.auth", { recursive: true }); + await fs.writeFile( + "e2e/.auth/seed-result.json", + JSON.stringify(result, null, 2), + ); + } finally { + await prisma.$disconnect(); + } + + await page.goto("/login"); + await page.getByRole("button", { name: /sign in/i }).waitFor({ + state: "visible", + timeout: 15_000, + }); + + await page.getByRole("textbox", { name: /email/i }).fill(TEST_USER.email); + await page.locator('input[type="password"]').fill(TEST_USER.password); + await page.getByRole("button", { name: /sign in/i }).click(); + + await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 15_000 }); + + await page.context().storageState({ path: authFile }); +}); diff --git a/e2e/global-teardown.ts b/e2e/global-teardown.ts new file mode 100644 index 00000000..e4738ad7 --- /dev/null +++ b/e2e/global-teardown.ts @@ -0,0 +1,11 @@ +import { PrismaClient } from "../src/generated/prisma"; +import { cleanup } from "./helpers/cleanup"; + +export default async function globalTeardown(): Promise { + const prisma = new PrismaClient(); + try { + await cleanup(prisma); + } finally { + await prisma.$disconnect(); + } +} diff --git a/e2e/helpers/cleanup.ts b/e2e/helpers/cleanup.ts new file mode 100644 index 00000000..388ed196 --- /dev/null +++ b/e2e/helpers/cleanup.ts @@ -0,0 +1,90 @@ +import { PrismaClient } from "../../src/generated/prisma"; +import { TEST_USER, TEST_TEAM } from "./constants"; + +export async function cleanup(prisma: PrismaClient): Promise { + const user = await prisma.user.findUnique({ + where: { email: TEST_USER.email }, + }); + if (!user) return; + + const team = await prisma.team.findFirst({ + where: { name: TEST_TEAM.name }, + }); + + if (team) { + const alertRules = await prisma.alertRule.findMany({ + where: { teamId: team.id }, + select: { id: true }, + }); + const alertRuleIds = alertRules.map((r) => r.id); + + if (alertRuleIds.length > 0) { + await prisma.deliveryAttempt.deleteMany({ + where: { alertEvent: { alertRuleId: { in: alertRuleIds } } }, + }); + await prisma.alertEvent.deleteMany({ + where: { alertRuleId: { in: alertRuleIds } }, + }); + await prisma.alertRule.deleteMany({ + where: { id: { in: alertRuleIds } }, + }); + } + + const environments = await prisma.environment.findMany({ + where: { teamId: team.id }, + select: { id: true }, + }); + const envIds = environments.map((e) => e.id); + + if (envIds.length > 0) { + await prisma.notificationChannel.deleteMany({ + where: { environmentId: { in: envIds } }, + }); + + const pipelines = await prisma.pipeline.findMany({ + where: { environmentId: { in: envIds } }, + select: { id: true }, + }); + const pipelineIds = pipelines.map((p) => p.id); + + if (pipelineIds.length > 0) { + await prisma.pipelineEdge.deleteMany({ + where: { pipelineId: { in: pipelineIds } }, + }); + await prisma.pipelineNode.deleteMany({ + where: { pipelineId: { in: pipelineIds } }, + }); + await prisma.pipelineVersion.deleteMany({ + where: { pipelineId: { in: pipelineIds } }, + }); + await prisma.pipeline.deleteMany({ + where: { id: { in: pipelineIds } }, + }); + } + + await prisma.vectorNode.deleteMany({ + where: { environmentId: { in: envIds } }, + }); + + await prisma.team.update({ + where: { id: team.id }, + data: { defaultEnvironmentId: null }, + }); + + await prisma.environment.deleteMany({ + where: { id: { in: envIds } }, + }); + } + + await prisma.teamMember.deleteMany({ + where: { teamId: team.id }, + }); + await prisma.team.delete({ + where: { id: team.id }, + }); + } + + await prisma.user.delete({ + where: { id: user.id }, + }); +} diff --git a/e2e/helpers/constants.ts b/e2e/helpers/constants.ts new file mode 100644 index 00000000..f95392e7 --- /dev/null +++ b/e2e/helpers/constants.ts @@ -0,0 +1,40 @@ +export const TEST_USER = { + email: "e2e@test.local", + password: "TestPassword123!", + name: "E2E Test User", +} as const; + +export const TEST_TEAM = { + name: "E2E Test Team", +} as const; + +export const TEST_ENVIRONMENT = { + name: "e2e-test-env", +} as const; + +export const TEST_PIPELINE = { + name: "E2E Test Pipeline", + description: "Pipeline created by E2E seed script", +} as const; + +export const TEST_NODE = { + name: "e2e-node-01", + host: "e2e-host-01.local", + apiPort: 8686, +} as const; + +export const TEST_ALERT_RULE = { + name: "E2E Error Rate Alert", +} as const; + +export const SELECTORS = { + sidebar: { + nav: '[data-slot="sidebar"]', + menuButton: (title: string) => `a:has(span:text("${title}"))`, + }, + toast: { + container: '[data-sonner-toaster]', + success: '[data-type="success"]', + error: '[data-type="error"]', + }, +} as const; diff --git a/e2e/helpers/seed.ts b/e2e/helpers/seed.ts new file mode 100644 index 00000000..26fce02e --- /dev/null +++ b/e2e/helpers/seed.ts @@ -0,0 +1,210 @@ +import { PrismaClient } from "../../src/generated/prisma"; +import { hash } from "bcryptjs"; +import { TEST_USER, TEST_TEAM, TEST_ENVIRONMENT, TEST_PIPELINE, TEST_NODE, TEST_ALERT_RULE } from "./constants"; + +export interface SeedResult { + userId: string; + teamId: string; + environmentId: string; + pipelineId: string; + nodeId: string; + alertRuleId: string; + firingEventId: string; + resolvedEventId: string; + acknowledgedEventId: string; +} + +export async function seed(prisma: PrismaClient): Promise { + const passwordHash = await hash(TEST_USER.password, 10); + + const user = await prisma.user.create({ + data: { + email: TEST_USER.email, + name: TEST_USER.name, + passwordHash, + authMethod: "LOCAL", + totpEnabled: false, + isSuperAdmin: false, + mustChangePassword: false, + }, + }); + + const team = await prisma.team.create({ + data: { name: TEST_TEAM.name }, + }); + + await prisma.teamMember.create({ + data: { userId: user.id, teamId: team.id, role: "ADMIN" }, + }); + + const environment = await prisma.environment.create({ + data: { name: TEST_ENVIRONMENT.name, teamId: team.id, isSystem: false }, + }); + + await prisma.team.update({ + where: { id: team.id }, + data: { defaultEnvironmentId: environment.id }, + }); + + const pipeline = await prisma.pipeline.create({ + data: { + name: TEST_PIPELINE.name, + description: TEST_PIPELINE.description, + environmentId: environment.id, + isDraft: true, + createdById: user.id, + }, + }); + + const sourceNode = await prisma.pipelineNode.create({ + data: { + pipelineId: pipeline.id, + componentKey: "demo_logs_source", + displayName: "Demo Logs", + componentType: "demo_logs", + kind: "SOURCE", + config: { format: "syslog", interval: 1 }, + positionX: 100, + positionY: 200, + }, + }); + + const transformNode = await prisma.pipelineNode.create({ + data: { + pipelineId: pipeline.id, + componentKey: "remap_transform", + displayName: "Remap", + componentType: "remap", + kind: "TRANSFORM", + config: { source: ". = parse_syslog!(.message)" }, + positionX: 400, + positionY: 200, + }, + }); + + const sinkNode = await prisma.pipelineNode.create({ + data: { + pipelineId: pipeline.id, + componentKey: "blackhole_sink", + displayName: "Blackhole", + componentType: "blackhole", + kind: "SINK", + config: { print_interval_secs: 1 }, + positionX: 700, + positionY: 200, + }, + }); + + await prisma.pipelineEdge.createMany({ + data: [ + { pipelineId: pipeline.id, sourceNodeId: sourceNode.id, targetNodeId: transformNode.id }, + { pipelineId: pipeline.id, sourceNodeId: transformNode.id, targetNodeId: sinkNode.id }, + ], + }); + + await prisma.pipelineVersion.create({ + data: { + pipelineId: pipeline.id, + version: 1, + configYaml: "# E2E test pipeline config", + nodesSnapshot: [ + { id: sourceNode.id, componentKey: "demo_logs_source", kind: "SOURCE", componentType: "demo_logs", config: sourceNode.config, positionX: 100, positionY: 200 }, + { id: transformNode.id, componentKey: "remap_transform", kind: "TRANSFORM", componentType: "remap", config: transformNode.config, positionX: 400, positionY: 200 }, + { id: sinkNode.id, componentKey: "blackhole_sink", kind: "SINK", componentType: "blackhole", config: sinkNode.config, positionX: 700, positionY: 200 }, + ], + edgesSnapshot: [ + { sourceNodeId: sourceNode.id, targetNodeId: transformNode.id }, + { sourceNodeId: transformNode.id, targetNodeId: sinkNode.id }, + ], + createdById: user.id, + }, + }); + + const node = await prisma.vectorNode.create({ + data: { + name: TEST_NODE.name, + host: TEST_NODE.host, + apiPort: TEST_NODE.apiPort, + environmentId: environment.id, + status: "HEALTHY", + lastSeen: new Date(), + agentVersion: "1.0.0", + vectorVersion: "0.42.0", + os: "linux", + deploymentMode: "STANDALONE", + labels: { env: "e2e", region: "test" }, + }, + }); + + const alertRule = await prisma.alertRule.create({ + data: { + name: TEST_ALERT_RULE.name, + enabled: true, + environmentId: environment.id, + pipelineId: pipeline.id, + teamId: team.id, + metric: "error_rate", + condition: "gt", + threshold: 5.0, + durationSeconds: 60, + }, + }); + + await prisma.notificationChannel.create({ + data: { + environmentId: environment.id, + name: "E2E Slack Channel", + type: "slack", + config: { webhookUrl: "https://hooks.slack.example.com/e2e" }, + enabled: true, + }, + }); + + const firingEvent = await prisma.alertEvent.create({ + data: { + alertRuleId: alertRule.id, + nodeId: node.id, + status: "firing", + value: 12.5, + message: "Error rate exceeded threshold: 12.5% > 5.0%", + firedAt: new Date(), + }, + }); + + const resolvedEvent = await prisma.alertEvent.create({ + data: { + alertRuleId: alertRule.id, + nodeId: node.id, + status: "resolved", + value: 2.1, + message: "Error rate returned to normal: 2.1%", + firedAt: new Date(Date.now() - 3600_000), + resolvedAt: new Date(Date.now() - 1800_000), + }, + }); + + const acknowledgedEvent = await prisma.alertEvent.create({ + data: { + alertRuleId: alertRule.id, + nodeId: node.id, + status: "acknowledged", + value: 8.3, + message: "Error rate exceeded threshold: 8.3% > 5.0%", + firedAt: new Date(Date.now() - 7200_000), + acknowledgedAt: new Date(Date.now() - 6000_000), + acknowledgedBy: TEST_USER.email, + }, + }); + + return { + userId: user.id, + teamId: team.id, + environmentId: environment.id, + pipelineId: pipeline.id, + nodeId: node.id, + alertRuleId: alertRule.id, + firingEventId: firingEvent.id, + resolvedEventId: resolvedEvent.id, + acknowledgedEventId: acknowledgedEvent.id, + }; +} diff --git a/e2e/pages/alerts.page.ts b/e2e/pages/alerts.page.ts new file mode 100644 index 00000000..adc9be2e --- /dev/null +++ b/e2e/pages/alerts.page.ts @@ -0,0 +1,48 @@ +import { type Page, type Locator, expect } from "@playwright/test"; + +export class AlertsPage { + constructor(private page: Page) {} + + async goto(): Promise { + await this.page.goto("/alerts"); + await this.page.waitForLoadState("networkidle"); + } + + async switchToHistoryTab(): Promise { + await this.page.getByRole("tab", { name: /history/i }).click(); + await this.page.waitForLoadState("networkidle"); + } + + async expectAlertEventsVisible(): Promise { + const eventRows = this.page.locator("tr").filter({ hasText: /firing|resolved|acknowledged/i }); + await expect(eventRows.first()).toBeVisible({ timeout: 10_000 }); + } + + findFiringAlert(): Locator { + return this.page.locator("tr").filter({ hasText: /firing/i }).first(); + } + + async acknowledgeAlert(): Promise { + const firingRow = this.page.locator("tr").filter({ hasText: /firing/i }).first(); + + const ackButton = firingRow.getByRole("button", { name: /acknowledge/i }); + if (await ackButton.isVisible()) { + await ackButton.click(); + } else { + await firingRow.getByRole("button", { name: /open menu|more/i }).click(); + await this.page.getByRole("menuitem", { name: /acknowledge/i }).click(); + } + } + + async expectAlertAcknowledged(): Promise { + await this.page.waitForResponse( + (resp) => resp.url().includes("trpc") && resp.status() === 200, + { timeout: 5_000 } + ); + await this.page.waitForTimeout(500); + } + + getAlertStatusBadges(): Locator { + return this.page.locator("tr").filter({ hasText: /acknowledged/i }); + } +} diff --git a/e2e/pages/components/deploy-dialog.component.ts b/e2e/pages/components/deploy-dialog.component.ts new file mode 100644 index 00000000..e3b05ab5 --- /dev/null +++ b/e2e/pages/components/deploy-dialog.component.ts @@ -0,0 +1,32 @@ +import { type Page, type Locator, expect } from "@playwright/test"; + +export class DeployDialogComponent { + private readonly dialog: Locator; + + constructor(private page: Page) { + this.dialog = page.getByRole("dialog"); + } + + async expectOpen(): Promise { + await expect(this.dialog).toBeVisible(); + } + + async expectEnvironmentOption(envName: string): Promise { + await expect(this.dialog.getByText(envName)).toBeVisible(); + } + + async expectNodeListed(nodeName: string): Promise { + await expect(this.dialog.getByText(nodeName)).toBeVisible(); + } + + async clickDeploy(): Promise { + await this.dialog.getByRole("button", { name: /deploy/i }).click(); + } + + async waitForDeployComplete(): Promise { + await this.page.waitForResponse( + (resp) => resp.url().includes("trpc") && resp.status() === 200, + { timeout: 15_000 } + ); + } +} diff --git a/e2e/pages/components/sidebar.component.ts b/e2e/pages/components/sidebar.component.ts new file mode 100644 index 00000000..8897d4bb --- /dev/null +++ b/e2e/pages/components/sidebar.component.ts @@ -0,0 +1,21 @@ +import { type Page, type Locator } from "@playwright/test"; + +export class SidebarComponent { + private readonly sidebar: Locator; + + constructor(private page: Page) { + this.sidebar = page.locator('[data-slot="sidebar"]'); + } + + async navigateTo(title: string): Promise { + await this.sidebar.getByRole("link", { name: title }).click(); + } + + async expectVisible(): Promise { + await this.sidebar.waitFor({ state: "visible" }); + } + + getNavLink(title: string): Locator { + return this.sidebar.getByRole("link", { name: title }); + } +} diff --git a/e2e/pages/components/toast.component.ts b/e2e/pages/components/toast.component.ts new file mode 100644 index 00000000..e473ae42 --- /dev/null +++ b/e2e/pages/components/toast.component.ts @@ -0,0 +1,31 @@ +import { type Page, type Locator, expect } from "@playwright/test"; + +export class ToastComponent { + private readonly toaster: Locator; + + constructor(private page: Page) { + this.toaster = page.locator("[data-sonner-toaster]"); + } + + async expectSuccess(message?: string): Promise { + const toast = this.toaster.locator('[data-type="success"]'); + await expect(toast.first()).toBeVisible({ timeout: 5000 }); + if (message) { + await expect(toast.first()).toContainText(message); + } + } + + async expectError(message?: string): Promise { + const toast = this.toaster.locator('[data-type="error"]'); + await expect(toast.first()).toBeVisible({ timeout: 5000 }); + if (message) { + await expect(toast.first()).toContainText(message); + } + } + + async expectToastWithText(text: string): Promise { + await expect( + this.toaster.locator(`li:has-text("${text}")`).first() + ).toBeVisible({ timeout: 5000 }); + } +} diff --git a/e2e/pages/fleet.page.ts b/e2e/pages/fleet.page.ts new file mode 100644 index 00000000..df36325a --- /dev/null +++ b/e2e/pages/fleet.page.ts @@ -0,0 +1,44 @@ +import { type Page, expect } from "@playwright/test"; + +export class FleetPage { + constructor(private page: Page) {} + + async goto(): Promise { + await this.page.goto("/fleet"); + await this.page.waitForLoadState("networkidle"); + } + + async expectNodeInList(nodeName: string): Promise { + await expect(this.page.getByText(nodeName).first()).toBeVisible(); + } + + async expectNodeStatus(nodeName: string, status: string): Promise { + const row = this.page.locator("tr", { hasText: nodeName }); + await expect(row.getByText(status, { exact: false })).toBeVisible(); + } + + async openNodeDetail(nodeName: string): Promise { + await this.page.getByRole("link", { name: nodeName }).click(); + await this.page.waitForURL("**/fleet/**"); + } + + async expectNodeDetailInfo(fields: { + host?: string; + agentVersion?: string; + os?: string; + }): Promise { + if (fields.host) { + await expect(this.page.getByText(fields.host)).toBeVisible(); + } + if (fields.agentVersion) { + await expect(this.page.getByText(fields.agentVersion)).toBeVisible(); + } + if (fields.os) { + await expect(this.page.getByText(fields.os)).toBeVisible(); + } + } + + async navigateBackToFleet(): Promise { + await this.page.goto("/fleet"); + } +} diff --git a/e2e/pages/login.page.ts b/e2e/pages/login.page.ts new file mode 100644 index 00000000..4c949e4f --- /dev/null +++ b/e2e/pages/login.page.ts @@ -0,0 +1,44 @@ +import { type Page, type Locator, expect } from "@playwright/test"; + +export class LoginPage { + private readonly emailInput: Locator; + private readonly passwordInput: Locator; + private readonly submitButton: Locator; + private readonly errorContainer: Locator; + + constructor(private page: Page) { + this.emailInput = page.getByRole("textbox", { name: /email/i }); + this.passwordInput = page.locator('input[type="password"]'); + this.submitButton = page.getByRole("button", { name: /sign in/i }); + this.errorContainer = page.locator(".bg-destructive\\/10"); + } + + async goto(): Promise { + await this.page.goto("/login"); + await this.submitButton.waitFor({ state: "visible", timeout: 10_000 }); + } + + async login(email: string, password: string): Promise { + await this.emailInput.fill(email); + await this.passwordInput.fill(password); + await this.submitButton.click(); + } + + async expectRedirectedToDashboard(): Promise { + await this.page.waitForURL("**/*", { timeout: 10_000 }); + await expect(this.page).not.toHaveURL(/\/login/); + } + + async expectError(message?: string): Promise { + await expect(this.errorContainer.first()).toBeVisible(); + if (message) { + await expect(this.errorContainer.first()).toContainText(message); + } + } + + async logout(): Promise { + const userButton = this.page.locator('[data-slot="sidebar"] footer button').first(); + await userButton.click(); + await this.page.getByRole("menuitem", { name: /sign out/i }).click(); + } +} diff --git a/e2e/pages/pipeline-editor.page.ts b/e2e/pages/pipeline-editor.page.ts new file mode 100644 index 00000000..5d491ea2 --- /dev/null +++ b/e2e/pages/pipeline-editor.page.ts @@ -0,0 +1,78 @@ +import { type Page, type Locator, expect } from "@playwright/test"; + +export class PipelineEditorPage { + private readonly canvas: Locator; + + constructor(private page: Page) { + this.canvas = page.locator(".react-flow"); + } + + async goto(pipelineId: string): Promise { + await this.page.goto(`/pipelines/${pipelineId}`); + await this.canvas.waitFor({ state: "visible" }); + } + + async setName(name: string): Promise { + const nameInput = this.page.locator('input[name="name"], [data-testid="pipeline-name"]').first(); + if (await nameInput.isVisible()) { + await nameInput.clear(); + await nameInput.fill(name); + } + } + + async addNodeFromPalette( + kind: "source" | "transform" | "sink", + componentType: string, + ): Promise { + const palette = this.page.locator('[class*="palette"], [data-testid="component-palette"]').first(); + + if (!(await palette.isVisible().catch(() => false))) { + const toggleBtn = this.page.getByRole("button", { name: /add|components|palette/i }); + if (await toggleBtn.isVisible()) { + await toggleBtn.click(); + } + } + + const searchInput = palette.locator('input[placeholder*="Search"]'); + if (await searchInput.isVisible()) { + await searchInput.fill(componentType); + } + + const componentItem = palette.locator(`[draggable="true"]`, { + hasText: new RegExp(componentType, "i"), + }).first(); + + await componentItem.dragTo(this.canvas, { + targetPosition: { x: 400, y: 300 }, + }); + + if (await searchInput.isVisible()) { + await searchInput.clear(); + } + } + + async expectNodeCount(count: number): Promise { + const nodes = this.canvas.locator(".react-flow__node"); + await expect(nodes).toHaveCount(count); + } + + async save(): Promise { + const saveButton = this.page.getByRole("button", { name: /save/i }); + await saveButton.click(); + } + + async expectSaveSuccess(): Promise { + await this.page.waitForResponse( + (resp) => resp.url().includes("trpc") && resp.status() === 200, + { timeout: 10_000 } + ); + } + + async clickDeploy(): Promise { + await this.page.getByRole("button", { name: /deploy/i }).click(); + } + + getCanvasNodes(): Locator { + return this.canvas.locator(".react-flow__node"); + } +} diff --git a/e2e/pages/pipelines.page.ts b/e2e/pages/pipelines.page.ts new file mode 100644 index 00000000..da87e12f --- /dev/null +++ b/e2e/pages/pipelines.page.ts @@ -0,0 +1,40 @@ +import { type Page, expect } from "@playwright/test"; + +export class PipelinesPage { + constructor(private page: Page) {} + + async goto(): Promise { + await this.page.goto("/pipelines"); + await this.page.waitForLoadState("networkidle"); + } + + async clickNewPipeline(): Promise { + await this.page.getByRole("link", { name: /new pipeline/i }).click(); + await this.page.waitForURL("**/pipelines/new"); + } + + async expectPipelineInList(name: string): Promise { + await expect(this.page.getByRole("link", { name })).toBeVisible(); + } + + async expectPipelineNotInList(name: string): Promise { + await expect(this.page.getByRole("link", { name })).not.toBeVisible(); + } + + async openPipeline(name: string): Promise { + await this.page.getByRole("link", { name }).click(); + await this.page.waitForURL("**/pipelines/**"); + } + + async deletePipeline(name: string): Promise { + const row = this.page.locator("tr", { hasText: name }); + await row.getByRole("button", { name: /open menu|more/i }).click(); + await this.page.getByRole("menuitem", { name: /delete/i }).click(); + await this.page.getByRole("dialog").getByRole("button", { name: /delete|confirm/i }).click(); + } + + async expectDeploymentBadge(pipelineName: string): Promise { + const row = this.page.locator("tr", { hasText: pipelineName }); + await expect(row).not.toContainText("Draft"); + } +} diff --git a/e2e/tests/alerts.spec.ts b/e2e/tests/alerts.spec.ts new file mode 100644 index 00000000..88b724bb --- /dev/null +++ b/e2e/tests/alerts.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "../fixtures/test.fixture"; + +test.describe("Alert Management", () => { + test("should display alert events in history tab", async ({ + page, + alertsPage, + sidebar, + }) => { + await sidebar.navigateTo("Alerts"); + await page.waitForLoadState("networkidle"); + + await alertsPage.switchToHistoryTab(); + + await alertsPage.expectAlertEventsVisible(); + }); + + test("should acknowledge a firing alert", async ({ + page, + alertsPage, + sidebar, + }) => { + await sidebar.navigateTo("Alerts"); + await page.waitForLoadState("networkidle"); + + await alertsPage.switchToHistoryTab(); + await alertsPage.expectAlertEventsVisible(); + + await alertsPage.acknowledgeAlert(); + await alertsPage.expectAlertAcknowledged(); + + const ackBadges = alertsPage.getAlertStatusBadges(); + await expect(ackBadges.first()).toBeVisible(); + }); +}); diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 00000000..45e02040 --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -0,0 +1,44 @@ +import { test, expect } from "@playwright/test"; +import { LoginPage } from "../pages/login.page"; +import { SidebarComponent } from "../pages/components/sidebar.component"; +import { TEST_USER } from "../helpers/constants"; + +test.use({ storageState: { cookies: [], origins: [] } }); + +test.describe("Authentication", () => { + test("should log in with valid credentials and redirect to dashboard", async ({ + page, + }) => { + const loginPage = new LoginPage(page); + const sidebar = new SidebarComponent(page); + + await loginPage.goto(); + await loginPage.login(TEST_USER.email, TEST_USER.password); + await loginPage.expectRedirectedToDashboard(); + await sidebar.expectVisible(); + }); + + test("should show error for invalid credentials", async ({ page }) => { + const loginPage = new LoginPage(page); + + await loginPage.goto(); + await loginPage.login(TEST_USER.email, "WrongPassword!"); + await loginPage.expectError("Invalid email or password"); + }); + + test("should redirect unauthenticated users to login", async ({ page }) => { + await page.goto("/pipelines"); + await expect(page).toHaveURL(/\/login/); + }); + + test("should log out and redirect to login", async ({ page }) => { + const loginPage = new LoginPage(page); + + await loginPage.goto(); + await loginPage.login(TEST_USER.email, TEST_USER.password); + await loginPage.expectRedirectedToDashboard(); + + await loginPage.logout(); + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/e2e/tests/deploy.spec.ts b/e2e/tests/deploy.spec.ts new file mode 100644 index 00000000..a9d072e7 --- /dev/null +++ b/e2e/tests/deploy.spec.ts @@ -0,0 +1,35 @@ +import { test } from "../fixtures/test.fixture"; + +test.describe("Pipeline Deploy", () => { + test("should open deploy dialog and submit deployment", async ({ + pipelineEditor, + deployDialog, + }) => { + const fs = await import("fs/promises"); + const seedResult = JSON.parse( + await fs.readFile("e2e/.auth/seed-result.json", "utf-8"), + ); + + await pipelineEditor.goto(seedResult.pipelineId); + + await pipelineEditor.clickDeploy(); + + await deployDialog.expectOpen(); + + await deployDialog.expectEnvironmentOption("e2e-test-env"); + + await deployDialog.clickDeploy(); + + await deployDialog.waitForDeployComplete(); + }); + + test("should show deployment badge on pipeline list after deploy", async ({ + pipelinesPage, + sidebar, + }) => { + await sidebar.navigateTo("Pipelines"); + + await pipelinesPage.expectPipelineInList("E2E Test Pipeline"); + await pipelinesPage.expectDeploymentBadge("E2E Test Pipeline"); + }); +}); diff --git a/e2e/tests/fleet.spec.ts b/e2e/tests/fleet.spec.ts new file mode 100644 index 00000000..41f87248 --- /dev/null +++ b/e2e/tests/fleet.spec.ts @@ -0,0 +1,47 @@ +import { test } from "../fixtures/test.fixture"; + +test.describe("Fleet Management", () => { + test("should display fleet node list with correct status", async ({ + page, + fleetPage, + sidebar, + }) => { + await sidebar.navigateTo("Fleet"); + await page.waitForLoadState("networkidle"); + + await fleetPage.expectNodeInList("e2e-node-01"); + + await fleetPage.expectNodeStatus("e2e-node-01", "Healthy"); + }); + + test("should navigate to node detail page", async ({ + page, + fleetPage, + sidebar, + }) => { + await sidebar.navigateTo("Fleet"); + await page.waitForLoadState("networkidle"); + + await fleetPage.openNodeDetail("e2e-node-01"); + + await fleetPage.expectNodeDetailInfo({ + host: "e2e-host-01.local", + agentVersion: "1.0.0", + os: "linux", + }); + }); + + test("should navigate back to fleet list from node detail", async ({ + page, + fleetPage, + sidebar, + }) => { + await sidebar.navigateTo("Fleet"); + await page.waitForLoadState("networkidle"); + + await fleetPage.openNodeDetail("e2e-node-01"); + await fleetPage.navigateBackToFleet(); + + await fleetPage.expectNodeInList("e2e-node-01"); + }); +}); diff --git a/e2e/tests/pipeline-crud.spec.ts b/e2e/tests/pipeline-crud.spec.ts new file mode 100644 index 00000000..fdf24765 --- /dev/null +++ b/e2e/tests/pipeline-crud.spec.ts @@ -0,0 +1,55 @@ +import { test } from "../fixtures/test.fixture"; + +test.describe("Pipeline CRUD", () => { + test("should create a pipeline with source, transform, and sink nodes", async ({ + page, + pipelinesPage, + pipelineEditor, + sidebar, + }) => { + await sidebar.navigateTo("Pipelines"); + await page.waitForLoadState("networkidle"); + + await pipelinesPage.clickNewPipeline(); + + await pipelineEditor.addNodeFromPalette("source", "demo_logs"); + await pipelineEditor.expectNodeCount(1); + + await pipelineEditor.addNodeFromPalette("transform", "remap"); + await pipelineEditor.expectNodeCount(2); + + await pipelineEditor.addNodeFromPalette("sink", "blackhole"); + await pipelineEditor.expectNodeCount(3); + + await pipelineEditor.save(); + await pipelineEditor.expectSaveSuccess(); + }); + + test("should persist pipeline nodes after reload", async ({ + pipelineEditor, + }) => { + const fs = await import("fs/promises"); + const seedResult = JSON.parse( + await fs.readFile("e2e/.auth/seed-result.json", "utf-8"), + ); + + await pipelineEditor.goto(seedResult.pipelineId); + + await pipelineEditor.expectNodeCount(3); + }); + + test("should delete a pipeline from the list", async ({ + page, + pipelinesPage, + sidebar, + }) => { + await sidebar.navigateTo("Pipelines"); + await page.waitForLoadState("networkidle"); + + await pipelinesPage.expectPipelineInList("E2E Test Pipeline"); + + await pipelinesPage.deletePipeline("E2E Test Pipeline"); + + await pipelinesPage.expectPipelineNotInList("E2E Test Pipeline"); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..741517c9 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "moduleResolution": "node", + "module": "commonjs", + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "paths": { + "@/*": ["../src/*"] + } + }, + "include": [ + "../e2e/**/*.ts" + ], + "exclude": ["../node_modules"] +} diff --git a/package.json b/package.json index 1706bbcc..0fca9f7f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "start": "next start", "lint": "eslint", "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "postinstall": "prisma generate", "generate:openapi": "tsx scripts/generate-openapi.ts" }, @@ -75,6 +77,7 @@ "devDependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", "@next/bundle-analyzer": "^16.2.1", + "@playwright/test": "^1.59.0", "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -98,6 +101,7 @@ "typescript": "^5", "vitest": "^4.1.0", "vitest-axe": "^0.1.0", - "vitest-mock-extended": "^3.1.0" + "vitest-mock-extended": "^3.1.0", + "wait-on": "^9.0.4" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..876736e9 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e/tests", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: process.env.CI ? "github" : "html", + globalTeardown: "./e2e/global-teardown.ts", + use: { + baseURL: "http://localhost:3000", + trace: "on-first-retry", + screenshot: "only-on-failure", + actionTimeout: 10_000, + }, + timeout: 30_000, + projects: [ + { + name: "setup", + testDir: "./e2e", + testMatch: "global-setup.ts", + }, + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + storageState: "e2e/.auth/user.json", + }, + dependencies: ["setup"], + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0853d7c..12d21428 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,7 +41,7 @@ importers: version: 7.4.2 '@sentry/nextjs': specifier: ^10.47.0 - version: 10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.105.4(esbuild@0.27.4)) + version: 10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.105.4(esbuild@0.27.4)) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.3) @@ -98,10 +98,10 @@ importers: version: 5.1.6 next: specifier: 16.1.7 - version: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-auth: specifier: 5.0.0-beta.30 - version: 5.0.0-beta.30(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@8.0.4)(react@19.2.3) + version: 5.0.0-beta.30(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@8.0.4)(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -163,6 +163,9 @@ importers: '@next/bundle-analyzer': specifier: ^16.2.1 version: 16.2.1 + '@playwright/test': + specifier: ^1.59.0 + version: 1.59.0 '@tailwindcss/postcss': specifier: ^4 version: 4.2.1 @@ -235,6 +238,9 @@ importers: vitest-mock-extended: specifier: ^3.1.0 version: 3.1.0(typescript@5.9.3)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.35)(jsdom@29.0.1(@noble/hashes@2.0.1))(msw@2.12.10(@types/node@20.19.35)(typescript@5.9.3))(vite@8.0.1(@types/node@20.19.35)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))) + wait-on: + specifier: ^9.0.4 + version: 9.0.4 packages: @@ -914,6 +920,26 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@hapi/address@5.1.1': + resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==} + engines: {node: '>=14.0.0'} + + '@hapi/formula@3.0.2': + resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==} + + '@hapi/hoek@11.0.7': + resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==} + + '@hapi/pinpoint@2.0.1': + resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} + + '@hapi/tlds@1.1.6': + resolution: {integrity: sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==} + engines: {node: '>=14.0.0'} + + '@hapi/topo@6.0.2': + resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -1535,6 +1561,11 @@ packages: '@panva/hkdf@1.2.1': resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@playwright/test@1.59.0': + resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -3570,6 +3601,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -3582,6 +3616,9 @@ packages: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} + axios@1.14.0: + resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -3760,6 +3797,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} @@ -4008,6 +4049,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -4439,6 +4484,15 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -4447,6 +4501,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -4480,6 +4538,11 @@ packages: resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4938,6 +5001,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + joi@18.1.2: + resolution: {integrity: sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==} + engines: {node: '>= 20'} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -5695,6 +5762,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.59.0: + resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.0: + resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==} + engines: {node: '>=18'} + hasBin: true + pngjs@5.0.0: resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} engines: {node: '>=10.13.0'} @@ -5805,6 +5882,10 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -6047,6 +6128,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -6677,6 +6761,11 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + wait-on@9.0.4: + resolution: {integrity: sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==} + engines: {node: '>=20.0.0'} + hasBin: true + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -7850,6 +7939,22 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@hapi/address@5.1.1': + dependencies: + '@hapi/hoek': 11.0.7 + + '@hapi/formula@3.0.2': {} + + '@hapi/hoek@11.0.7': {} + + '@hapi/pinpoint@2.0.1': {} + + '@hapi/tlds@1.1.6': {} + + '@hapi/topo@6.0.2': + dependencies: + '@hapi/hoek': 11.0.7 + '@hono/node-server@1.19.9(hono@4.12.7)': dependencies: hono: 4.12.7 @@ -8490,6 +8595,10 @@ snapshots: '@panva/hkdf@1.2.1': {} + '@playwright/test@1.59.0': + dependencies: + playwright: 1.59.0 + '@polka/url@1.0.0-next.29': {} '@prisma/adapter-pg@7.4.2': @@ -9568,7 +9677,7 @@ snapshots: '@sentry/core@10.47.0': {} - '@sentry/nextjs@10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.105.4(esbuild@0.27.4))': + '@sentry/nextjs@10.47.0(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.105.4(esbuild@0.27.4))': dependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 @@ -9581,7 +9690,7 @@ snapshots: '@sentry/react': 10.47.0(react@19.2.3) '@sentry/vercel-edge': 10.47.0 '@sentry/webpack-plugin': 5.1.1(webpack@5.105.4(esbuild@0.27.4)) - next: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) rollup: 4.60.1 stacktrace-parser: 0.1.11 transitivePeerDependencies: @@ -10747,6 +10856,8 @@ snapshots: async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -10755,6 +10866,14 @@ snapshots: axe-core@4.11.1: {} + axios@1.14.0: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} balanced-match@1.0.2: {} @@ -10940,6 +11059,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@11.1.0: {} commander@14.0.3: {} @@ -11143,6 +11266,8 @@ snapshots: defu@6.1.4: {} + delayed-stream@1.0.0: {} + denque@2.1.0: {} depd@2.0.0: {} @@ -11768,6 +11893,8 @@ snapshots: flatted@3.4.2: {} + follow-redirects@1.15.11: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -11777,6 +11904,14 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + formdata-polyfill@4.0.10: dependencies: fetch-blob: 3.2.0 @@ -11802,6 +11937,9 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12236,6 +12374,16 @@ snapshots: jiti@2.6.1: {} + joi@18.1.2: + dependencies: + '@hapi/address': 5.1.1 + '@hapi/formula': 3.0.2 + '@hapi/hoek': 11.0.7 + '@hapi/pinpoint': 2.0.1 + '@hapi/tlds': 1.1.6 + '@hapi/topo': 6.0.2 + '@standard-schema/spec': 1.1.0 + jose@6.1.3: {} js-tokens@4.0.0: {} @@ -12604,10 +12752,10 @@ snapshots: neo-async@2.6.2: {} - next-auth@5.0.0-beta.30(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@8.0.4)(react@19.2.3): + next-auth@5.0.0-beta.30(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@8.0.4)(react@19.2.3): dependencies: '@auth/core': 0.41.0(nodemailer@8.0.4) - next: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 optionalDependencies: nodemailer: 8.0.4 @@ -12617,7 +12765,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.7 '@swc/helpers': 0.5.15 @@ -12637,6 +12785,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.1.7 '@next/swc-win32-x64-msvc': 16.1.7 '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.59.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -12911,6 +13060,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.59.0: {} + + playwright@1.59.0: + dependencies: + playwright-core: 1.59.0 + optionalDependencies: + fsevents: 2.3.2 + pngjs@5.0.0: {} possible-typed-array-names@1.1.0: {} @@ -13019,6 +13176,8 @@ snapshots: proxy-from-env@1.1.0: {} + proxy-from-env@2.1.0: {} + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -13373,6 +13532,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -14091,6 +14254,16 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + wait-on@9.0.4: + dependencies: + axios: 1.14.0 + joi: 18.1.2 + lodash: 4.17.23 + minimist: 1.2.8 + rxjs: 7.8.2 + transitivePeerDependencies: + - debug + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1